M7: area type auto size and shrink text to fit

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.
This commit is contained in:
haven
2026-10-08 14:48:25 -07:00
parent a89f914119
commit 674aef95b0
18 changed files with 871 additions and 35 deletions
+75 -1
View File
@@ -128,14 +128,88 @@ pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout {
first_baseline: a.first_baseline,
first_baseline_min: a.first_baseline_min,
vertical_align: a.vertical_align,
fit: a.fit,
..LayoutOptions::default()
};
layout_with(db, t, &opts)
}
/// Most layout passes Shrink Text to Fit runs past the first (bisection plus the final pass).
const SHRINK_PASSES: usize = 11;
/// Lay out a text object with explicit options (area type rows/columns, inset, first baseline,
/// composer, OpenType features).
/// fit, composer, OpenType features).
pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout {
let first = layout_once(db, t, opts);
match (opts.fit.min_scale(), &t.kind) {
(Some(min), TextKind::Area { .. }) if first.overflow => shrink_to_fit(db, t, opts, min).unwrap_or(first),
_ => first,
}
}
/// Shrink Text to Fit for text that overflows at full size: the largest scale in `min..1` at which
/// it fits, searched by bisection over the first run's size in steps of 0.1 pt (so its scaled size
/// is a whole number of tenths of a point, the same every time). Down at `min` it lays out there,
/// still overflowing. None (keep the full-size layout) when the text has no usable size.
fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> Option<TextLayout> {
let size = t.runs.first().map(|r| r.style.size).filter(|s| s.is_finite() && *s >= 0.1)?;
if !(min.is_finite() && min < 1.0) {
return None;
}
let tenths = size * 10.0;
// Candidate sizes k/10 pt for k in lo..hi: `hi` (full size, rounded down) is known to overflow
// unless it is below the full size; `lo` is the smallest allowed.
let mut lo = (tenths * min).ceil().max(1.0);
let mut hi = tenths.floor();
if hi < lo {
hi = lo;
}
let at = |k: f64| -> TextLayout {
let f = (k / tenths).clamp(min, 1.0);
let mut l = layout_once(db, &scaled(t, f), opts);
l.fit_scale = f;
l
};
let mut passes = 0;
let mut best: Option<TextLayout> = None;
// `hi` itself may fit when the size isn't a whole number of tenths.
if hi < tenths && hi > lo {
let l = at(hi);
passes += 1;
if !l.overflow {
return Some(l);
}
}
// Invariant: everything at `hi` or above overflows; `lo` fits or is the floor.
while hi - lo > 1.0 && passes + 1 < SHRINK_PASSES {
let mid = ((lo + hi) * 0.5).floor();
let l = at(mid);
passes += 1;
if l.overflow {
hi = mid;
} else {
lo = mid;
best = Some(l);
}
}
// `best` is the layout at `lo` once `lo` has moved; at the floor it still needs laying out.
Some(best.unwrap_or_else(|| at(lo)))
}
/// `t` with every run's size, explicit leading and baseline shift scaled by `f` (auto leading
/// follows the size; paragraph spacing stays).
fn scaled(t: &TextObject, f: f64) -> TextObject {
let mut s = t.clone();
for r in &mut s.runs {
r.style.size *= f;
r.style.leading = r.style.leading.map(|l| l * f);
r.style.baseline_shift *= f;
}
s
}
/// One layout pass, without fitting.
fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout {
let text = t.plain_text();
let mut runs = Vec::with_capacity(t.runs.len());
let mut off = 0;
+27 -2
View File
@@ -31,7 +31,7 @@ use kurbo::{Affine, BezPath, Point, Rect, Vec2};
pub use layout::{layout, layout_with};
pub use vectorcraft_doc::TextObject;
pub use vectorcraft_doc::{FirstBaseline, VerticalAlign};
pub use vectorcraft_doc::{AreaFit, FirstBaseline, VerticalAlign};
/// Paragraph composer; stored per text object in [`vectorcraft_doc::ParaStyle::composer`].
pub use vectorcraft_doc::Composer;
@@ -52,6 +52,9 @@ pub struct LayoutOptions {
pub first_baseline_min: f64,
/// Vertical alignment of the lines in each row/column (Area Type Options "Align").
pub vertical_align: VerticalAlign,
/// Area type only: Shrink Text to Fit scales overflowing text down at layout time (see
/// [`TextLayout::fit_scale`]); the other fits are the engine's business and lay out as `None`.
pub fit: AreaFit,
/// Overrides the object's paragraph composer (`None` = use `ParaStyle::composer`).
pub composer: Option<Composer>,
pub features: OtFeatures,
@@ -67,6 +70,7 @@ impl Default for LayoutOptions {
first_baseline: FirstBaseline::Ascent,
first_baseline_min: 0.0,
vertical_align: VerticalAlign::Top,
fit: AreaFit::None,
composer: None,
features: OtFeatures::default(),
}
@@ -131,7 +135,7 @@ pub struct LineInfo {
pub region: usize,
}
#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug)]
pub struct TextLayout {
/// Lines retain inline/block coordinates; glyph geometry is in physical text space.
pub vertical: bool,
@@ -149,6 +153,25 @@ pub struct TextLayout {
pub on_path: bool,
/// Area type: the frame cells text flowed into (one per row/column).
pub frames: Vec<Rect>,
/// Shrink Text to Fit: the factor the text's sizes, leading and baseline shifts were scaled
/// by to fit its frame (1.0 when the text is not shrunk).
pub fit_scale: f64,
}
impl Default for TextLayout {
fn default() -> Self {
Self {
vertical: false,
line_xf: Affine::IDENTITY,
glyphs: Vec::new(),
lines: Vec::new(),
bounds: Rect::ZERO,
overflow: false,
on_path: false,
frames: Vec::new(),
fit_scale: 1.0,
}
}
}
impl TextLayout {
@@ -450,6 +473,8 @@ mod tests_bidi;
#[cfg(test)]
mod tests_embed;
#[cfg(test)]
mod tests_fit;
#[cfg(test)]
mod tests_scripts;
#[cfg(test)]
mod tests_snap;
+163
View File
@@ -0,0 +1,163 @@
//! 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(&COPY.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);
}