mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 19:27:21 -06:00
Area Type Options gain a fit: none, autoHeight (Illustrator's Auto Size:
the frame's height follows the text after every edit, resolved in
refresh_bounds inside the edit so it shares the edit's undo step) and
shrinkText {minPercent} (beyond Illustrator: overflowing text scales its
sizes, leading and baseline shifts down at layout time by the largest
factor, in 0.1 pt steps of the first run, that fits). The Auto Size New
Area Type preference now applies. text.areaOptions and document.inspect
report overflow and the shrink factor; the Area Type Options dialog
offers fit and first baseline as dropdowns.
164 lines
6.7 KiB
Rust
164 lines
6.7 KiB
Rust
//! Area type fitting: Shrink Text to Fit.
|
|
|
|
use super::*;
|
|
use kurbo::Shape;
|
|
use vectorcraft_doc::{AreaFit, CharStyle, TextKind};
|
|
use vectorcraft_geom::PathData;
|
|
|
|
const COPY: &str = "Typography is the craft of arranging type to make written language legible, readable and appealing when displayed. \
|
|
The arrangement of type involves selecting typefaces, point sizes, line lengths, line spacing and letter spacing, and adjusting the space \
|
|
between pairs of letters.";
|
|
|
|
fn db() -> &'static FontDb {
|
|
FontDb::global()
|
|
}
|
|
|
|
fn area(text: &str, size: f64, frame: Rect, fit: AreaFit) -> TextObject {
|
|
let mut t = TextObject::point(Point::ZERO, text, CharStyle { size, ..CharStyle::default() });
|
|
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
|
|
t.xf = Affine::IDENTITY;
|
|
t.area.fit = fit;
|
|
t
|
|
}
|
|
|
|
fn shrink(min_percent: f64) -> AreaFit {
|
|
AreaFit::ShrinkText { min_percent }
|
|
}
|
|
|
|
/// `t` laid out without fitting at `size` (every run).
|
|
fn at_size(t: &TextObject, size: f64) -> TextLayout {
|
|
let mut p = t.clone();
|
|
p.area.fit = AreaFit::None;
|
|
for r in &mut p.runs {
|
|
r.style.size = size;
|
|
}
|
|
layout(db(), &p)
|
|
}
|
|
|
|
#[test]
|
|
fn shrink_text_makes_overflowing_text_fit() {
|
|
let t = area(COPY, 14.0, Rect::new(0.0, 0.0, 200.0, 90.0), shrink(30.0));
|
|
assert!(at_size(&t, 14.0).overflow, "the text overflows at full size");
|
|
let l = layout(db(), &t);
|
|
assert!(!l.overflow, "shrunk to fit");
|
|
assert!(l.fit_scale < 1.0 && l.fit_scale >= 0.3, "{}", l.fit_scale);
|
|
assert!(l.glyphs.len() > 100);
|
|
// The first run's scaled size is a whole number of tenths of a point.
|
|
let size = 14.0 * l.fit_scale;
|
|
assert!((size * 10.0 - (size * 10.0).round()).abs() < 1e-6, "{size}");
|
|
// Maximal: a tenth of a point larger overflows; and the layout is the one at that size.
|
|
assert!(at_size(&t, size + 0.1).overflow, "{size} + 0.1 pt still fits");
|
|
let same = at_size(&t, size);
|
|
assert_eq!((same.lines.len(), same.glyphs.len(), same.overflow), (l.lines.len(), l.glyphs.len(), false));
|
|
// Deterministic.
|
|
assert_eq!(layout(db(), &t).fit_scale, l.fit_scale);
|
|
}
|
|
|
|
#[test]
|
|
fn shrink_text_respects_its_minimum() {
|
|
let t = area(COPY, 14.0, Rect::new(0.0, 0.0, 120.0, 40.0), shrink(90.0));
|
|
let l = layout(db(), &t);
|
|
assert!(l.overflow, "still overflows at 90 %");
|
|
assert!((l.fit_scale - 0.9).abs() < 1e-9, "{}", l.fit_scale);
|
|
// Out-of-range minimums are clamped (10 %).
|
|
let l = layout(db(), &area(COPY, 14.0, Rect::new(0.0, 0.0, 120.0, 40.0), shrink(0.0)));
|
|
assert!(l.fit_scale >= 0.1 - 1e-9, "{}", l.fit_scale);
|
|
}
|
|
|
|
#[test]
|
|
fn shrink_text_is_a_no_op_when_the_text_fits() {
|
|
let t = area("Short text", 14.0, Rect::new(0.0, 0.0, 200.0, 90.0), shrink(50.0));
|
|
let l = layout(db(), &t);
|
|
let plain = at_size(&t, 14.0);
|
|
assert!(!l.overflow);
|
|
assert_eq!(l.fit_scale, 1.0);
|
|
let o = |l: &TextLayout| l.glyphs.iter().map(|g| g.origin).collect::<Vec<_>>();
|
|
assert_eq!(o(&l), o(&plain));
|
|
}
|
|
|
|
#[test]
|
|
fn shrink_text_fills_columns() {
|
|
let mut t = area(COPY, 14.0, Rect::new(0.0, 0.0, 300.0, 70.0), shrink(20.0));
|
|
t.area.columns = 2;
|
|
t.area.gutter = 12.0;
|
|
let l = layout(db(), &t);
|
|
assert!(!l.overflow);
|
|
assert!(l.fit_scale < 1.0);
|
|
assert_eq!(l.frames.len(), 2);
|
|
let second = l.frames[1];
|
|
assert!(l.glyphs.iter().any(|g| g.origin.x >= second.x0), "text flows into the second column");
|
|
let size = 14.0 * l.fit_scale;
|
|
assert!(at_size(&t, size + 0.1).overflow, "maximal across columns too");
|
|
}
|
|
|
|
#[test]
|
|
fn shrink_text_scales_leading_and_baseline_shift_not_paragraph_spacing() {
|
|
let mut t = area(©.repeat(2), 12.0, Rect::new(0.0, 0.0, 200.0, 200.0), shrink(10.0));
|
|
for r in &mut t.runs {
|
|
r.style.leading = Some(20.0);
|
|
}
|
|
let l = layout(db(), &t);
|
|
assert!(!l.overflow && l.fit_scale < 1.0);
|
|
let gap = l.lines[2].baseline - l.lines[1].baseline;
|
|
assert!((gap - 20.0 * l.fit_scale).abs() < 1e-6, "{gap} vs {}", 20.0 * l.fit_scale);
|
|
}
|
|
|
|
#[test]
|
|
fn point_type_and_type_on_a_path_ignore_fit() {
|
|
let mut t = TextObject::point(Point::ZERO, COPY, CharStyle::default());
|
|
t.area.fit = shrink(10.0);
|
|
assert_eq!(layout(db(), &t).fit_scale, 1.0);
|
|
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new((0.0, 0.0), (50.0, 0.0)).to_path(0.1)), start: 0.0, end: None };
|
|
let l = layout(db(), &t);
|
|
assert!(l.overflow, "text runs past the path end");
|
|
assert_eq!(l.fit_scale, 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn shrink_text_guards_degenerate_sizes() {
|
|
for size in [0.0, 0.01, f64::NAN, 1296.0] {
|
|
let l = layout(db(), &area(COPY, size, Rect::new(0.0, 0.0, 50.0, 20.0), shrink(10.0)));
|
|
assert!(l.fit_scale.is_finite() && l.fit_scale > 0.0 && l.fit_scale <= 1.0, "{size}: {}", l.fit_scale);
|
|
}
|
|
// A zero-height frame.
|
|
let l = layout(db(), &area(COPY, 12.0, Rect::new(0.0, 0.0, 50.0, 0.0), shrink(10.0)));
|
|
assert!(l.overflow);
|
|
}
|
|
|
|
#[test]
|
|
fn shrink_text_follows_top_to_top_leading_and_character_alignment() {
|
|
use vectorcraft_doc::{CharAlign, LeadingModel, TextRun};
|
|
// Mixed sizes aligned on the em box centre, leading measured from em box top to top.
|
|
let mut t = area(COPY, 14.0, Rect::new(0.0, 0.0, 200.0, 90.0), shrink(20.0));
|
|
t.para.leading_model = LeadingModel::EmBoxTop;
|
|
let big = CharStyle { size: 28.0, leading: Some(32.0), char_align: CharAlign::EmBoxCenter, ..CharStyle::default() };
|
|
t.runs.insert(0, TextRun { text: "Big ".into(), style: big });
|
|
for r in &mut t.runs {
|
|
r.style.char_align = CharAlign::EmBoxCenter;
|
|
}
|
|
let l = layout(db(), &t);
|
|
assert!(!l.overflow, "shrunk to fit under the top-to-top model");
|
|
assert!(l.fit_scale < 1.0);
|
|
// The shrunk layout is the same text laid out at the scaled sizes and leading.
|
|
let mut manual = t.clone();
|
|
manual.area.fit = AreaFit::None;
|
|
for r in &mut manual.runs {
|
|
r.style.size *= l.fit_scale;
|
|
r.style.leading = r.style.leading.map(|v| v * l.fit_scale);
|
|
}
|
|
let m = layout(db(), &manual);
|
|
let o = |l: &TextLayout| l.glyphs.iter().map(|g| (g.origin.x, g.origin.y)).collect::<Vec<_>>();
|
|
assert_eq!(o(&l), o(&m));
|
|
// First line still touches the frame top (top-to-top: em box top at y = 0, scaled).
|
|
let first = l.lines.first().map(|li| li.baseline).unwrap_or(0.0);
|
|
assert!(first > 0.0 && first <= 28.0 * l.fit_scale + 1e-6, "{first}");
|
|
// Maximal: the next 0.1 pt step of the first run overflows.
|
|
let step = 0.1 / 28.0;
|
|
let mut bigger = manual.clone();
|
|
for (r, orig) in bigger.runs.iter_mut().zip(&t.runs) {
|
|
r.style.size = orig.style.size * (l.fit_scale + step);
|
|
r.style.leading = orig.style.leading.map(|v| v * (l.fit_scale + step));
|
|
}
|
|
assert!(layout(db(), &bigger).overflow);
|
|
}
|