From 56c975465c68041c339a3ecdee7ee5c4b8df8f9a Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:12:23 -0700 Subject: [PATCH 1/4] M7: inline art follows vertically aligned lines align_vertically moved lines and glyphs but not TextLayout::inlines, so inline symbols in a centred or bottom-aligned area stayed where top alignment put them. The reflow path for non-rectangular frames also kept the previous pass's inlines, drawing each symbol twice. --- crates/text/src/layout.rs | 18 ++++++++++++++++-- crates/text/src/tests_inline.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 98e8809b9..60c2e96d7 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -595,6 +595,15 @@ fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, re g.origin.y += d; } } + // Inline art moves with its glyph's line. + for i in &mut cx.out.inlines { + let d = cx.out.glyphs.get(i.glyph).and_then(|g| offs.get(g.line)).copied().unwrap_or(0.0); + if d != 0.0 { + let m = Affine::translate((0.0, d)); + i.xf = m * i.xf; + i.bounds = m.transform_rect_bbox(i.bounds); + } + } return; } let laid_out = |out: &TextLayout| out.lines.last().map_or(0, |l| l.end); @@ -624,12 +633,17 @@ fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, re return; } let before = laid_out(&cx.out); - let prev = (std::mem::take(&mut cx.out.glyphs), std::mem::take(&mut cx.out.lines), std::mem::replace(&mut cx.out.overflow, false)); + let prev = ( + std::mem::take(&mut cx.out.glyphs), + std::mem::take(&mut cx.out.lines), + std::mem::take(&mut cx.out.inlines), + std::mem::replace(&mut cx.out.overflow, false), + ); flow(cx, paras, t, Some(regions)); if laid_out(&cx.out) < before { // Text no longer fits (lines got narrower, or flow around a wrap object): undo the // pass and try a smaller step. - (cx.out.glyphs, cx.out.lines, cx.out.overflow) = prev; + (cx.out.glyphs, cx.out.lines, cx.out.inlines, cx.out.overflow) = prev; for (r, (shift, gap)) in regions.iter_mut().zip(saved) { r.shift = shift; r.gap = gap; diff --git a/crates/text/src/tests_inline.rs b/crates/text/src/tests_inline.rs index 6a435ade9..495555381 100644 --- a/crates/text/src/tests_inline.rs +++ b/crates/text/src/tests_inline.rs @@ -296,3 +296,33 @@ fn top_to_top_leading_spaces_inline_art_by_its_run() { let (tall0, tall1) = lines(3.0); assert!((small0 - tall0).abs() < 1e-6 && (small1 - tall1).abs() < 1e-6, "{small0} {small1} / {tall0} {tall1}"); } + +#[test] +fn inline_art_moves_with_vertically_aligned_lines() { + use vectorcraft_doc::text::{AreaOptions, VerticalAlign}; + // A rectangle (lines shift) and a triangle (text flows again lower down). + let rect = Rect::new(0.0, 0.0, 400.0, 400.0).to_path(0.1); + let mut tri = kurbo::BezPath::new(); + tri.move_to((0.0, 0.0)); + tri.line_to((800.0, 0.0)); + tri.line_to((400.0, 400.0)); + tri.close_path(); + for frame in [rect, tri] { + for align in [VerticalAlign::Center, VerticalAlign::Bottom] { + let mut t = text("A", art(1.0), "B", 20.0); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame) }; + t.area = AreaOptions { vertical_align: align, ..AreaOptions::default() }; + let l = layout(db(), &t); + assert_eq!(l.inlines.len(), 1, "{align:?}"); + let g = inline_glyph(&l); + let line = &l.lines[g.line]; + assert!(line.baseline > 100.0, "{align:?}: not aligned, baseline {}", line.baseline); + // Still centred on the cap height above its (moved) baseline. + let face = db().face("Source Sans 3", "Regular").unwrap(); + let cap = face.cap_height * 20.0 / face.upem; + let b = l.inlines[0].bounds; + assert!((b.center().y - (line.baseline - cap * 0.5)).abs() < 1e-6, "{align:?}: {b:?} baseline {}", line.baseline); + assert!((b.x0 - g.origin.x).abs() < 1e-6, "{align:?}: {b:?}"); + } + } +} From abdc26d8d1c69ef6a3373332d98fb6414702d591 Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:25:56 -0700 Subject: [PATCH 2/4] M7: Shrink Text to Fit scales inline art's baseline shift The fit scaled each run's size, leading and baseline shift but not an inline graphic's own shift (points), so shrunk symbols sat off the cap-height centre. --- crates/text/src/layout.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 60c2e96d7..ba73c2109 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -206,14 +206,17 @@ fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> 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). +/// `t` with every run's size, explicit leading and baseline shift scaled by `f`, inline art's own +/// shift included (auto leading and the art's size follow 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; + if let Some(a) = &mut r.inline { + a.baseline_shift *= f; + } } s } From 53e39884fcbf758c3716312bf05301f3edc98a7c Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:25:56 -0700 Subject: [PATCH 3/4] M7: Auto Size measures vertically aligned area type from the top auto_height lays the text out in a very tall probe frame; with Center, Bottom or Justify the lines moved down inside it and the frame grew to tens of thousands of points. --- crates/engine/src/cmd/typecmd.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 392f1d80e..f51db4bec 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -147,6 +147,8 @@ fn auto_height(t: &mut TextObject) -> bool { let col_w = if cols > 1 { ((b.width() - t.area.gutter.max(0.0) * (cols - 1) as f64) / cols as f64).max(1.0) } else { b.width() }; with_size(&mut probe, col_w, tall); probe.area.columns = 1; + // Measured from the top: aligned in the tall probe the lines would sit far below. + probe.area.vertical_align = vectorcraft_doc::VerticalAlign::Top; let lay = vectorcraft_text::layout(db, &probe); let inset = t.area.inset.max(0.0); let Some(bottom) = lay.lines.iter().map(|l| l.baseline + l.descent).filter(|y| y.is_finite()).reduce(f64::max) else { return false }; From 992f6a0a4dd6e2f645515220a2a2abd91b4e2b70 Mon Sep 17 00:00:00 2001 From: haven <54509200+Haven-King@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:25:56 -0700 Subject: [PATCH 4/4] M7: tests for the text engine features combined Layout (vectorcraft-text tests_combos): every pair of ligatures under tracking, the paragraph composer, vertical alignment, Shrink Text to Fit, per-paragraph attributes and inline graphics, and all six at once. Commands (vectorcraft-engine tests_textcombos): Auto Size and Shrink Text with inline art, paragraphs, composer and alignment; editing and undo across them; a save round trip with everything on. --- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_textcombos.rs | 310 ++++++++++++++ crates/text/src/lib.rs | 2 + crates/text/src/tests_combos.rs | 574 ++++++++++++++++++++++++++ 4 files changed, 888 insertions(+) create mode 100644 crates/engine/src/tests_textcombos.rs create mode 100644 crates/text/src/tests_combos.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bdc9063c9..cc8184df3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1586,6 +1586,8 @@ mod tests_swatchlib; #[cfg(test)] mod tests_targeting; #[cfg(test)] +mod tests_textcombos; +#[cfg(test)] mod tests_textedit; #[cfg(test)] mod tests_textimport; diff --git a/crates/engine/src/tests_textcombos.rs b/crates/engine/src/tests_textcombos.rs new file mode 100644 index 000000000..5b3c89b47 --- /dev/null +++ b/crates/engine/src/tests_textcombos.rs @@ -0,0 +1,310 @@ +//! The text engine's newer features combined through commands: Auto Size and Shrink Text to Fit +//! with inline graphics, per-paragraph attributes, the paragraph composer and vertical +//! alignment; editing and undo across them; and a save round trip with all of them on. +//! (`vectorcraft_text`'s `tests_combos` covers the layout side.) + +use serde_json::{Value, json}; +use vectorcraft_doc::text::INLINE_CHAR; +use vectorcraft_doc::{AreaFit, Composer, Justify, NodeKind, TextKind, TextObject, VerticalAlign}; +use vectorcraft_geom::Rect; + +use super::*; + +const STORY: &str = "The quick brown fox jumps over the lazy dog. A second sentence follows the first one here. Then a third one ends it."; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +/// A symbol `name`: a circle `d` points across, its instance deleted. +fn circle_symbol(s: &mut Session, name: &str, d: f64) { + s.execute("shape.ellipse", &json!({"x": 600, "y": 500, "width": d, "height": d})).unwrap(); + s.execute("symbol.new", &json!({"name": name})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); +} + +/// Area type at (40, 40), `w` × `h`, size 12, with `extra` area options; selected. +fn area(s: &mut Session, w: f64, h: f64, text: &str, extra: Value) -> NodeId { + let mut a = json!({"width": w, "height": h}); + if let (Some(a), Some(e)) = (a.as_object_mut(), extra.as_object()) { + a.extend(e.clone()); + } + let id = NodeId(s.execute("text.create", &json!({"x": 40, "y": 40, "size": 12, "area": a, "text": text})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + id +} + +fn text(s: &Session, id: NodeId) -> TextObject { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + k => panic!("not text: {k:?}"), + } +} + +fn frame(s: &Session, id: NodeId) -> Rect { + match text(s, id).kind { + TextKind::Area { frame } => frame.bounds().unwrap(), + k => panic!("not area type: {k:?}"), + } +} + +fn lay(t: &TextObject) -> vectorcraft_text::TextLayout { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t) +} + +/// Auto Size's frame is tight: the text fits, and a point less overflows. +fn assert_tight(s: &Session, id: NodeId, what: &str) { + let t = text(s, id); + assert!(!lay(&t).overflow, "{what}: the frame holds the text"); + let b = frame(s, id); + let mut less = t.clone(); + less.area.fit = AreaFit::None; + less.kind = TextKind::Area { + frame: vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::Shape::to_path(&Rect::new(b.x0, b.y0, b.x1, b.y1 - 1.0), 0.1)), + }; + assert!(lay(&less).overflow, "{what}: a point less overflows ({b:?})"); +} + +fn undo(s: &mut Session) { + s.execute("edit.undo", &json!({})).unwrap(); +} + +fn redo(s: &mut Session) { + s.execute("edit.redo", &json!({})).unwrap(); +} + +fn query(s: &mut Session) -> Value { + s.execute("text.areaOptions", &json!({})).unwrap() +} + +#[test] +fn auto_height_x_inline_graphics() { + let mut s = session(); + circle_symbol(&mut s, "Tap", 10.0); + let id = area(&mut s, 200.0, 300.0, "Tap: add one mana.", json!({"fit": "autoHeight"})); + let one = frame(&s, id); + assert_tight(&s, id, "one line"); + // Art taller than the line makes the line, and so the frame, taller: in the same undo step. + let n = s.doc().unwrap().history.undo.len(); + s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap", "scale": 3})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), n + 1); + let tall = frame(&s, id); + assert!(tall.height() > one.height() + 5.0, "{one:?} → {tall:?}"); + assert_tight(&s, id, "tall art"); + // Enough art to wrap: another line. + for _ in 0..12 { + s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap", "scale": 1})).unwrap(); + } + assert!(lay(&text(&s, id)).lines.len() > 1); + assert_tight(&s, id, "wrapped art"); + // Deleting the art shrinks it back; undo restores each step exactly. + let wrapped = frame(&s, id); + let all = text(&s, id).plain_text().rfind(INLINE_CHAR).unwrap() + INLINE_CHAR.len_utf8(); + s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": all})).unwrap(); + assert_eq!(text(&s, id).plain_text(), "Tap: add one mana."); + assert_eq!(frame(&s, id), one); + undo(&mut s); + assert_eq!(frame(&s, id), wrapped); + for _ in 0..13 { + undo(&mut s); + } + assert_eq!(frame(&s, id), one); + redo(&mut s); + assert_eq!(frame(&s, id), tall); +} + +#[test] +fn auto_height_x_paragraph_attrs() { + let mut s = session(); + let id = area(&mut s, 200.0, 300.0, "One\nTwo", json!({"fit": "autoHeight"})); + let h0 = frame(&s, id).height(); + // Space before on the second paragraph only: the frame grows by exactly that. + s.execute("text.setFormat", &json!({"ids": [id.0], "spaceBefore": 30, "start": 4, "end": 4})).unwrap(); + let t = text(&s, id); + assert_eq!((t.para_at(0).space_before, t.para_at(1).space_before), (0.0, 30.0)); + let h1 = frame(&s, id).height(); + assert!((h1 - h0 - 30.0).abs() < 1e-6, "{h0} → {h1}"); + assert_tight(&s, id, "space before"); + // Return in the spaced paragraph: the new one inherits the spacing, the frame follows. + s.execute("text.editRange", &json!({"id": id.0, "start": 7, "end": 7, "insert": "\nThree"})).unwrap(); + let t = text(&s, id); + assert_eq!(t.paragraph_styles().iter().map(|p| p.space_before).collect::>(), [0.0, 30.0, 30.0]); + assert!(frame(&s, id).height() > h1 + 30.0); + assert_tight(&s, id, "three paragraphs"); + // Merging paragraphs: the first one's attributes win, the frame shrinks. + s.execute("text.editRange", &json!({"id": id.0, "start": 3, "end": 4})).unwrap(); + let t = text(&s, id); + assert_eq!(t.paragraph_styles().iter().map(|p| p.space_before).collect::>(), [0.0, 30.0]); + assert_tight(&s, id, "merged"); + undo(&mut s); + undo(&mut s); + assert!((frame(&s, id).height() - h1).abs() < 1e-9); + undo(&mut s); + assert!((frame(&s, id).height() - h0).abs() < 1e-9); + assert!(text(&s, id).paras.is_empty(), "back to one style for all"); +} + +#[test] +fn auto_height_x_composer() { + // Switching the composer of one paragraph rebreaks it; Auto Size follows either way. + let mut s = session(); + let story = format!("{STORY} {STORY}\n{STORY}"); + let id = area(&mut s, 130.0, 100.0, &story, json!({"fit": "autoHeight"})); + assert_tight(&s, id, "every-line"); + for composer in ["singleLine", "everyLine", "singleLine"] { + s.execute("text.setFormat", &json!({"ids": [id.0], "composer": composer, "start": 0, "end": 0})).unwrap(); + let t = text(&s, id); + assert_eq!(t.para_at(1).composer, Composer::EveryLine, "the second paragraph keeps its own"); + assert_tight(&s, id, composer); + } + s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "singleLine"})).unwrap(); + s.execute("text.setStyle", &json!({"id": id.0, "justify": "justifyAll"})).unwrap(); + assert!(text(&s, id).paragraph_styles().iter().all(|p| p.composer == Composer::SingleLine && p.justify == Justify::JustifyAll)); + assert_tight(&s, id, "justified"); +} + +#[test] +fn auto_height_x_vertical_align() { + // An Auto Size frame has no space to spare: every alignment sets the lines as Top does, and + // the frame is as tall as with Top. + let mut s = session(); + let id = area(&mut s, 160.0, 100.0, STORY, json!({"fit": "autoHeight"})); + let top_frame = frame(&s, id); + let top = lay(&text(&s, id)); + for a in ["center", "bottom", "justify"] { + s.execute("text.areaOptions", &json!({"verticalAlign": a})).unwrap(); + assert_eq!(frame(&s, id), top_frame, "{a}"); + let l = lay(&text(&s, id)); + for (li, l0) in l.lines.iter().zip(&top.lines) { + assert!((li.baseline - l0.baseline).abs() < 1e-6, "{a}: {} vs {}", li.baseline, l0.baseline); + } + assert_tight(&s, id, a); + } + // Editing with the alignment on keeps the frame tight. + s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": 0, "insert": format!("{STORY} ")})).unwrap(); + assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Justify); + assert_tight(&s, id, "longer, justified"); +} + +#[test] +fn shrink_text_x_inline_graphics_and_paragraphs() { + let mut s = session(); + circle_symbol(&mut s, "Dot", 8.0); + let id = area(&mut s, 160.0, 60.0, "Short rules.\nFlavor.", json!({"fit": "shrinkText", "fitMinPercent": 20})); + assert_eq!(query(&mut s)["fitScale"], 1.0); + // Inline art and per-paragraph spacing push it over; shrinking makes it fit. + s.execute("text.setFormat", &json!({"ids": [id.0], "spaceBefore": 12, "start": 13, "end": 13})).unwrap(); + for i in 0..10 { + s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Dot", "scale": 1.0 + i as f64 * 0.2})).unwrap(); + s.execute("text.editRange", &json!({"id": id.0, "start": 0, "end": 0, "insert": "Add "})).unwrap(); + } + let q = query(&mut s); + assert_eq!(q["overflow"], false, "{q}"); + let f = q["fitScale"].as_f64().unwrap(); + assert!((0.2..1.0).contains(&f), "{f}"); + let t = text(&s, id); + assert_eq!(t.paragraph_count(), 2); + assert_eq!(t.para_at(1).space_before, 12.0, "inserting in the first paragraph keeps the second's attributes"); + assert_eq!(t.runs[0].style.size, 12.0, "the stored size stays"); + let l = lay(&t); + assert_eq!(l.inlines.len(), 10); + assert!(l.inlines.iter().all(|i| i.bounds.height() < 12.0 * 2.8 * f + 1e-6)); + // Undo walks the fit back up. + for _ in 0..20 { + undo(&mut s); + } + assert_eq!(query(&mut s)["fitScale"], 1.0); +} + +#[test] +fn paragraph_attrs_x_inline_editing() { + // An inline graphic opening a paragraph belongs to it: inserting it keeps the paragraph's + // attributes, deleting it with the paragraph break merges into the paragraph before. + let mut s = session(); + circle_symbol(&mut s, "Tap", 10.0); + let id = area(&mut s, 300.0, 200.0, "Rules\nFlavor", json!({})); + s.execute("text.setStyle", &json!({"id": id.0, "justify": "center", "start": 6, "end": 6})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "singleLine", "start": 6, "end": 6})).unwrap(); + s.execute("text.insertInline", &json!({"id": id.0, "at": 6, "symbol": "Tap"})).unwrap(); + let t = text(&s, id); + assert_eq!(t.plain_text(), format!("Rules\n{INLINE_CHAR}Flavor")); + assert_eq!((t.para_at(0).justify, t.para_at(1).justify), (Justify::Auto, Justify::Center)); + assert_eq!(t.para_at(1).composer, Composer::SingleLine); + // The art's line is centred. + let l = lay(&t); + let g = &l.glyphs[l.inlines[0].glyph]; + let line = &l.lines[g.line]; + assert!(((line.x0 + line.x1) * 0.5 - 150.0).abs() < 1e-6, "centred in the 300 pt frame: {line:?}"); + assert!((l.inlines[0].bounds.x0 - g.origin.x).abs() < 1e-6); + // Return just before the art: the new paragraph (holding the art) continues the centred one. + s.execute("text.editRange", &json!({"id": id.0, "start": 6, "end": 6, "insert": "Text\n"})).unwrap(); + let t = text(&s, id); + assert_eq!(t.paragraph_styles().iter().map(|p| p.justify).collect::>(), [Justify::Auto, Justify::Center, Justify::Center]); + undo(&mut s); + // Delete the break and the art together: one paragraph, the first one's attributes. + let end = 6 + INLINE_CHAR.len_utf8(); + s.execute("text.editRange", &json!({"id": id.0, "start": 5, "end": end})).unwrap(); + let t = text(&s, id); + assert_eq!(t.plain_text(), "RulesFlavor"); + assert!(t.paras.is_empty() && t.para.justify == Justify::Auto && t.para.composer == Composer::EveryLine); + assert!(t.runs.iter().all(|r| r.inline.is_none())); + undo(&mut s); + let t = text(&s, id); + assert_eq!(t.plain_text(), format!("Rules\n{INLINE_CHAR}Flavor")); + assert_eq!(t.para_at(1).justify, Justify::Center); + assert_eq!(t.runs.iter().filter(|r| r.inline.is_some()).count(), 1); +} + +#[test] +fn vertical_align_x_inline_graphics_in_the_document() { + // The art the renderer draws (the layout's inline placements) moves with centred lines. + let mut s = session(); + circle_symbol(&mut s, "Tap", 10.0); + let id = area(&mut s, 300.0, 200.0, "Add one mana.", json!({})); + s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap"})).unwrap(); + let top = lay(&text(&s, id)); + s.execute("text.areaOptions", &json!({"verticalAlign": "center"})).unwrap(); + let l = lay(&text(&s, id)); + let d = l.lines[0].baseline - top.lines[0].baseline; + assert!(d > 50.0, "{d}"); + assert!((l.inlines[0].bounds.y0 - top.inlines[0].bounds.y0 - d).abs() < 1e-6, "{:?} vs {:?}", l.inlines[0].bounds, top.inlines[0].bounds); + // The cached bounds (hit testing, selection) include the moved art. + let cached = text(&s, id).cached_bounds.unwrap(); + assert!(cached.contains(l.inlines[0].bounds.center()), "{cached:?}"); +} + +#[test] +fn every_feature_saves_and_reopens() { + let mut s = session(); + circle_symbol(&mut s, "Tap", 10.0); + let id = area( + &mut s, + 220.0, + 70.0, + &format!(": Add one mana of any color. {STORY}\n{STORY}"), + json!({"verticalAlign": "center", "fit": "shrinkText", "fitMinPercent": 30}), + ); + s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Tap", "scale": 0.8, "shift": 0.5})).unwrap(); + let flavor = text(&s, id).plain_text().find('\n').unwrap() + 1; + s.execute("text.setFormat", &json!({"ids": [id.0], "spaceBefore": 8, "composer": "singleLine", "start": flavor, "end": flavor})).unwrap(); + s.execute("text.setStyle", &json!({"id": id.0, "justify": "justifyAll", "start": flavor, "end": flavor})).unwrap(); + s.execute("text.setStyle", &json!({"id": id.0, "tracking": 20})).unwrap(); + let t = text(&s, id); + let l = lay(&t); + assert!(!l.overflow && l.fit_scale < 1.0, "{}", l.fit_scale); + assert_eq!(l.inlines.len(), 1); + let doc = s.doc().unwrap().doc.clone(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap(); + let NodeKind::Text(t2) = &back.node(id).unwrap().kind else { panic!("not text") }; + let mut t2 = (**t2).clone(); + // Resolved art bounds and layout caches aren't saved: resolve them as opening does. + back.resolve_text_inline(&mut t2); + assert_eq!(t2.runs, t.runs); + assert_eq!(t2.paragraph_styles(), t.paragraph_styles()); + assert_eq!(t2.area, t.area); + let l2 = lay(&t2); + assert_eq!((l2.fit_scale, l2.lines.len()), (l.fit_scale, l.lines.len())); + assert_eq!(l2.inlines.iter().map(|i| i.bounds).collect::>(), l.inlines.iter().map(|i| i.bounds).collect::>()); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 33c1360f7..cf5ed57ce 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -502,6 +502,8 @@ mod tests; #[cfg(test)] mod tests_bidi; #[cfg(test)] +mod tests_combos; +#[cfg(test)] mod tests_embed; #[cfg(test)] mod tests_fit; diff --git a/crates/text/src/tests_combos.rs b/crates/text/src/tests_combos.rs new file mode 100644 index 000000000..0453058b4 --- /dev/null +++ b/crates/text/src/tests_combos.rs @@ -0,0 +1,574 @@ +//! The text engine's newer features combined, two at a time and all together: ligatures under +//! tracking, the paragraph composer, area type vertical alignment, Shrink Text to Fit, +//! per-paragraph attributes and inline graphics. Each feature has its own tests; these check that +//! one doesn't undo or skip another (as vertical alignment once left inline art behind). + +use super::*; +use kurbo::Shape; +use vectorcraft_doc::{AreaFit, CharStyle, Composer, InlineArt, Justify, TextKind, TextRun, VerticalAlign}; +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."; + +/// Words full of `fi` (Source Serif 4 ligates it). +const FI: &str = "fine fish finish first in fifty fields of fireflies, a fitting finale for the final fiddler"; + +/// Two lines that single-line and every-line composers break differently (see +/// `every_line_composer_balances_ragged_lines`). +const RAG: &str = "Destroy all creatures with toughness X or less."; + +fn db() -> &'static FontDb { + FontDb::global() +} + +fn sans(size: f64) -> CharStyle { + CharStyle { size, ..CharStyle::default() } +} + +fn serif(size: f64, tracking: f64) -> CharStyle { + CharStyle { font_family: "Source Serif 4".into(), size, tracking, ..CharStyle::default() } +} + +fn run(text: &str, style: CharStyle) -> TextRun { + TextRun::new(text, style) +} + +/// A square symbol, `scale` × the run's size tall, raised `shift` points. +fn art(scale: f64, shift: f64) -> InlineArt { + InlineArt { scale, baseline_shift: shift, bounds: Some(Rect::new(-5.0, -5.0, 5.0, 5.0)), ..InlineArt::new("sym") } +} + +fn sym(scale: f64, shift: f64, style: CharStyle) -> TextRun { + TextRun::inline(art(scale, shift), style) +} + +/// Area type in `frame` holding `runs`. +fn area(runs: Vec, frame: Rect) -> TextObject { + let mut t = TextObject::point(Point::ZERO, "", sans(12.0)); + t.runs = runs; + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) }; + t.xf = Affine::IDENTITY; + t +} + +/// `t` as Shrink Text to Fit lays it out at factor `f`, without fitting: sizes, explicit leading +/// and baseline shifts scaled (inline art's own shift too), paragraph spacing kept. +fn scaled_by(t: &TextObject, f: f64) -> TextObject { + let mut s = t.clone(); + s.area.fit = AreaFit::None; + 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; + if let Some(a) = &mut r.inline { + a.baseline_shift *= f; + } + } + s +} + +fn line_texts(t: &TextObject, l: &TextLayout) -> Vec { + let text = t.plain_text(); + l.lines.iter().map(|li| text[li.start..li.end].trim_end().to_string()).collect() +} + +/// Ligature clusters: glyphs standing for more than one character. +fn ligatures(l: &TextLayout) -> usize { + l.glyphs.iter().filter(|g| g.len > 1 && !g.outline.elements().is_empty()).count() +} + +/// Every inline graphic drawn once, where its glyph is: left edge on the glyph's origin, the +/// scaled height, centred half the (scaled) cap height above the glyph's baseline plus its own +/// (scaled) shift. +fn assert_inlines_on_their_glyphs(t: &TextObject, l: &TextLayout, what: &str) { + let want = t.runs.iter().filter(|r| r.inline.is_some()).count(); + assert_eq!(l.inlines.len(), want, "{what}: one placement per inline run"); + let mut glyphs: Vec = l.inlines.iter().map(|i| i.glyph).collect(); + glyphs.dedup(); + assert_eq!(glyphs.len(), want, "{what}: no inline drawn twice"); + for i in &l.inlines { + let g = &l.glyphs[i.glyph]; + assert_eq!(g.run, i.run, "{what}"); + let r = &t.runs[i.run]; + let a = r.inline.as_ref().unwrap(); + let size = r.style.size * l.fit_scale; + let face = db().face(&r.style.font_family, &r.style.font_style).unwrap(); + let cap = face.cap_height * size / face.upem; + let b = i.bounds; + assert!((b.height() - a.scale * size).abs() < 1e-6, "{what}: height {} vs {}", b.height(), a.scale * size); + assert!((b.x0 - g.origin.x).abs() < 1e-6, "{what}: {b:?} vs origin {:?}", g.origin); + let centre = g.origin.y - cap * 0.5 - a.baseline_shift * l.fit_scale; + assert!((b.center().y - centre).abs() < 1e-6, "{what}: centre {} vs {centre} (line {})", b.center().y, g.line); + assert!(l.lines[g.line].glyph_start <= i.glyph && i.glyph < l.lines[g.line].glyph_end, "{what}"); + assert!(l.bounds.contains(b.center()), "{what}: layout bounds {:?} miss {b:?}", l.bounds); + } +} + +/// Space left below the last line of a one-cell frame. +fn slack(l: &TextLayout, frame: Rect) -> f64 { + let last = l.lines.last().unwrap(); + frame.y1 - (last.baseline + last.descent) +} + +fn baselines(l: &TextLayout) -> Vec { + l.lines.iter().map(|li| li.baseline).collect() +} + +// ---------- ligatures under tracking × … ---------- + +#[test] +fn ligatures_x_composer() { + // Justified (where every-line spreads word space) and ragged, with tracking inside the limits + // (kept) and letterspaced (dropped), whichever composer breaks the lines. + for justify in [Justify::Left, Justify::JustifyLeft] { + for (tracking, kept) in [(0.0, true), (20.0, true), (100.0, false)] { + let mut counts = vec![]; + for composer in [Composer::SingleLine, Composer::EveryLine] { + let mut t = area(vec![run(&FI.repeat(3), serif(12.0, tracking))], Rect::new(0.0, 0.0, 170.0, 1000.0)); + t.para.justify = justify; + t.para.composer = composer; + let l = layout(db(), &t); + assert!(l.lines.len() > 3, "several lines to compose"); + counts.push(ligatures(&l)); + } + assert_eq!(counts[0], counts[1], "{justify:?} {tracking}: composers shape alike"); + assert_eq!(counts[0] > 0, kept, "{justify:?} {tracking}: {counts:?}"); + } + } +} + +#[test] +fn ligatures_x_vertical_align() { + let frame = Rect::new(0.0, 0.0, 200.0, 400.0); + for tracking in [20.0, 100.0] { + let mut t = area(vec![run(FI, serif(12.0, tracking))], frame); + let top = layout(db(), &t); + for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] { + t.area.vertical_align = a; + let l = layout(db(), &t); + let gids = |l: &TextLayout| l.glyphs.iter().map(|g| (g.gid, g.byte)).collect::>(); + assert_eq!(gids(&l), gids(&top), "{tracking} {a:?}: moving lines doesn't reshape them"); + assert!(l.lines[0].baseline > top.lines[0].baseline + 1.0 || a == VerticalAlign::Justify, "{a:?}"); + } + } +} + +#[test] +fn ligatures_x_shrink_text() { + // Tracking is in 1/1000 em, so scaling the size doesn't move it across the limits. + let frame = Rect::new(0.0, 0.0, 150.0, 60.0); + for (tracking, features, kept) in [(40.0, vec![], true), (100.0, vec![], false), (100.0, vec!["liga".to_string()], true)] { + let st = CharStyle { features, ..serif(14.0, tracking) }; + let mut t = area(vec![run(&FI.repeat(2), st)], frame); + t.area.fit = AreaFit::ShrinkText { min_percent: 10.0 }; + let l = layout(db(), &t); + assert!(!l.overflow && l.fit_scale < 1.0, "{tracking}: {}", l.fit_scale); + assert_eq!(ligatures(&l) > 0, kept, "{tracking}"); + assert_eq!(ligatures(&l), ligatures(&layout(db(), &scaled_by(&t, l.fit_scale))), "{tracking}"); + } +} + +#[test] +fn ligatures_x_paragraph_attrs() { + // Paragraph attributes don't touch shaping: a centred, indented, every-line paragraph keeps + // its ligatures and a letterspaced run in the next paragraph loses them. + let mut t = area(vec![run(&format!("{FI}\n"), serif(12.0, 10.0)), run(FI, serif(12.0, 120.0))], Rect::new(0.0, 0.0, 240.0, 600.0)); + let mut second = t.para.clone(); + second.justify = Justify::Center; + second.left_indent = 20.0; + second.space_before = 12.0; + t.set_paragraph_styles(vec![t.para.clone(), second]); + let l = layout(db(), &t); + let split = FI.len() + 1; + let ligs = |first: bool| l.glyphs.iter().filter(|g| g.len > 1 && (g.byte < split) == first).count(); + assert!(ligs(true) > 0, "tracking 10 keeps them"); + assert_eq!(ligs(false), 0, "tracking 120 drops them"); +} + +#[test] +fn ligatures_x_inline_graphics() { + // An inline graphic between ligated words neither breaks their ligatures nor takes them: its + // own letterspaced run tracks the art without touching its neighbours. + let st = serif(14.0, 0.0); + let runs = vec![run("fine fish ", st.clone()), sym(1.0, 0.0, serif(14.0, 200.0)), run(" first fifty", st.clone())]; + let t = area(runs, Rect::new(0.0, 0.0, 400.0, 100.0)); + let l = layout(db(), &t); + let without = area(vec![run("fine fish ", st.clone()), run(" first fifty", st.clone())], Rect::new(0.0, 0.0, 400.0, 100.0)); + assert_eq!(ligatures(&l), ligatures(&layout(db(), &without)), "the same ligatures as without the art"); + assert!(ligatures(&l) >= 4, "fi in fine, fish, first and fifty"); + assert_inlines_on_their_glyphs(&t, &l, "between ligatures"); + let g = &l.glyphs[l.inlines[0].glyph]; + assert!((g.advance - (14.0 + 200.0 / 1000.0 * 14.0)).abs() < 1e-6, "art width plus its tracking: {}", g.advance); +} + +// ---------- composer × … ---------- + +#[test] +fn composer_x_vertical_align() { + let w = natural_width(RAG, 12.0) * 0.9; + let frame = Rect::new(0.0, 0.0, w, 200.0); + for composer in [Composer::SingleLine, Composer::EveryLine] { + let mut t = area(vec![run(RAG, sans(12.0))], frame); + t.para.composer = composer; + let top = layout(db(), &t); + for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] { + t.area.vertical_align = a; + let l = layout(db(), &t); + assert_eq!(line_texts(&t, &l), line_texts(&t, &top), "{composer:?} {a:?}: the same breaks"); + let want = match a { + VerticalAlign::Center => slack(&top, frame) * 0.5, + _ => slack(&top, frame), + }; + let moved = l.lines.last().unwrap().baseline - top.lines.last().unwrap().baseline; + assert!((moved - want).abs() < 1e-6, "{composer:?} {a:?}: last line moved {moved}, want {want}"); + } + } + // And the two composers still differ there. + let breaks = |c: Composer| { + let mut t = area(vec![run(RAG, sans(12.0))], frame); + t.para.composer = c; + t.area.vertical_align = VerticalAlign::Center; + line_texts(&t, &layout(db(), &t)) + }; + assert_ne!(breaks(Composer::SingleLine), breaks(Composer::EveryLine)); +} + +#[test] +fn composer_x_shrink_text() { + // Each composer shrinks to the largest size at which its own breaks fit. + for justify in [Justify::Left, Justify::JustifyLeft] { + for composer in [Composer::SingleLine, Composer::EveryLine] { + let mut t = area(vec![run(COPY, sans(14.0))], Rect::new(0.0, 0.0, 180.0, 90.0)); + t.para.composer = composer; + t.para.justify = justify; + t.area.fit = AreaFit::ShrinkText { min_percent: 20.0 }; + let l = layout(db(), &t); + assert!(!l.overflow && l.fit_scale < 1.0, "{justify:?} {composer:?}"); + let size = 14.0 * l.fit_scale; + assert!(layout(db(), &scaled_by(&t, (size + 0.1) / 14.0)).overflow, "{justify:?} {composer:?}: maximal at {size}"); + let same = layout(db(), &scaled_by(&t, l.fit_scale)); + assert_eq!(line_texts(&t, &same), line_texts(&t, &l), "{justify:?} {composer:?}"); + } + } +} + +#[test] +fn composer_x_paragraph_attrs() { + // Each paragraph breaks with its own composer. + let w = natural_width("Destroy all creatures with toughness X or", 12.0) + 2.0; + let mut t = area(vec![run(&format!("{RAG}\n{RAG}\n{RAG}"), sans(12.0))], Rect::new(0.0, 0.0, w, 400.0)); + let p = |c: Composer| vectorcraft_doc::ParaStyle { composer: c, ..t.para.clone() }; + t.set_paragraph_styles(vec![p(Composer::SingleLine), p(Composer::EveryLine), p(Composer::SingleLine)]); + let l = layout(db(), &t); + let greedy = ["Destroy all creatures with toughness X or", "less."]; + let every = ["Destroy all creatures with toughness X", "or less."]; + let want: Vec<&str> = [greedy, every, greedy].concat(); + assert_eq!(line_texts(&t, &l), want); + // The object-wide override still wins over every paragraph. + let forced = layout_with(db(), &t, &LayoutOptions { composer: Some(Composer::SingleLine), ..Default::default() }); + assert_eq!(line_texts(&t, &forced), [greedy, greedy, greedy].concat()); +} + +#[test] +fn composer_x_inline_graphics() { + // An inline graphic is a word's worth of box to the composers: each one breaks around it as + // around the letter it replaces, and the art follows its glyph to whichever line it lands on. + let st = sans(12.0); + let runs = |x: TextRun| vec![run("Destroy all creatures with toughness ", st.clone()), x, run(" or less.", st.clone())]; + let with_art = runs(sym(0.7, 0.0, st.clone())); + let mut probe = area(with_art.clone(), Rect::new(0.0, 0.0, 10_000.0, 100.0)); + probe.runs.truncate(2); + probe.runs.push(run(" or", st.clone())); + let w = layout(db(), &probe).lines[0].x1 + 2.0; + let sym_text = INLINE.to_string(); + for (composer, first, second) in [ + (Composer::SingleLine, format!("Destroy all creatures with toughness {sym_text} or"), "less."), + (Composer::EveryLine, format!("Destroy all creatures with toughness {sym_text}"), "or less."), + ] { + let mut t = area(with_art.clone(), Rect::new(0.0, 0.0, w, 200.0)); + t.para.composer = composer; + let l = layout(db(), &t); + assert_eq!(line_texts(&t, &l), [first.as_str(), second], "{composer:?}"); + assert_inlines_on_their_glyphs(&t, &l, &format!("{composer:?}")); + } +} + +const INLINE: char = vectorcraft_doc::text::INLINE_CHAR; + +// ---------- vertical alignment × … ---------- + +#[test] +fn vertical_align_x_shrink_text() { + let frame = Rect::new(0.0, 0.0, 200.0, 100.0); + let mut t = area(vec![run(©.repeat(2), sans(14.0))], frame); + t.area.fit = AreaFit::ShrinkText { min_percent: 10.0 }; + let top = layout(db(), &t); + assert!(!top.overflow && top.fit_scale < 1.0); + for a in [VerticalAlign::Center, VerticalAlign::Bottom] { + t.area.vertical_align = a; + let l = layout(db(), &t); + assert_eq!(l.fit_scale, top.fit_scale, "{a:?}: aligning doesn't change the fit"); + assert_eq!(line_texts(&t, &l), line_texts(&t, &top), "{a:?}"); + let want = if a == VerticalAlign::Center { slack(&top, frame) * 0.5 } else { slack(&top, frame) }; + for (b, b0) in baselines(&l).iter().zip(baselines(&top)) { + assert!((b - b0 - want).abs() < 1e-6, "{a:?}: {b} vs {b0} + {want}"); + } + if a == VerticalAlign::Bottom { + assert!(slack(&l, frame).abs() < 1e-6, "the shrunk block sits on the bottom"); + } + } + // Still overflowing at the minimum: aligned like any full frame (the same lines, moved by + // less than a line, inside the frame), at the same scale. + let frame = Rect::new(0.0, 0.0, 120.0, 40.0); + let mut t = area(vec![run(©.repeat(4), sans(14.0))], frame); + t.area.fit = AreaFit::ShrinkText { min_percent: 90.0 }; + let top = layout(db(), &t); + assert!(top.overflow && (top.fit_scale - 0.9).abs() < 1e-9); + for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] { + t.area.vertical_align = a; + let l = layout(db(), &t); + assert!(l.overflow && l.fit_scale == top.fit_scale, "{a:?}"); + assert_eq!(line_texts(&t, &l), line_texts(&t, &top), "{a:?}"); + let first = &top.lines[0]; + assert!(l.lines[0].baseline - first.baseline < first.ascent + first.descent, "{a:?}"); + assert!(slack(&l, frame) >= -1e-6, "{a:?}"); + } +} + +#[test] +fn vertical_align_x_paragraph_attrs() { + // Per-paragraph spacing and alignment survive the move: lines keep their x and their + // paragraph gaps, and the block lands where the alignment puts it. + let frame = Rect::new(0.0, 0.0, 260.0, 500.0); + let mut t = area(vec![run(&format!("{COPY}\n{COPY}\n{COPY}"), sans(11.0))], frame); + let mut mid = t.para.clone(); + mid.justify = Justify::Center; + mid.space_before = 18.0; + mid.left_indent = 24.0; + let mut last = t.para.clone(); + last.justify = Justify::Right; + last.space_before = 6.0; + t.set_paragraph_styles(vec![t.para.clone(), mid, last]); + let top = layout(db(), &t); + for a in [VerticalAlign::Center, VerticalAlign::Bottom] { + t.area.vertical_align = a; + let l = layout(db(), &t); + assert_eq!(line_texts(&t, &l), line_texts(&t, &top)); + let d = l.lines[0].baseline - top.lines[0].baseline; + assert!(d > 1.0, "{a:?}"); + for (li, l0) in l.lines.iter().zip(&top.lines) { + assert!((li.baseline - l0.baseline - d).abs() < 1e-6, "{a:?}: every line moves alike"); + assert!((li.x0 - l0.x0).abs() < 1e-6 && (li.x1 - l0.x1).abs() < 1e-6, "{a:?}: x stays"); + } + for (g, g0) in l.glyphs.iter().zip(&top.glyphs) { + assert!((g.origin.x - g0.origin.x).abs() < 1e-6 && (g.origin.y - g0.origin.y - d).abs() < 1e-6); + } + } + // Justify: spacing is shared out on top of each paragraph's own space before, never less. + t.area.vertical_align = VerticalAlign::Justify; + let l = layout(db(), &t); + assert!(slack(&l, frame).abs() < 1e-6, "justified to the bottom"); + for (pair, pair0) in l.lines.windows(2).zip(top.lines.windows(2)) { + assert!(pair[1].baseline - pair[0].baseline >= pair0[1].baseline - pair0[0].baseline - 1e-6); + } +} + +#[test] +fn vertical_align_x_inline_graphics() { + // Inline art moves with its line however the lines are aligned, in one cell or several, in a + // rectangle or a frame the text flows into again. + let st = sans(12.0); + let mut runs = vec![]; + for i in 0..6 { + runs.push(run(&format!("{} ", ©[i * 20..i * 20 + 20]), st.clone())); + runs.push(sym(1.0 + i as f64 * 0.1, i as f64 - 2.0, st.clone())); + if i == 2 { + runs.push(run("\n", st.clone())); + } + } + let mut tri = BezPath::new(); + tri.move_to((0.0, 0.0)); + tri.line_to((600.0, 0.0)); + tri.line_to((300.0, 400.0)); + tri.close_path(); + for (name, frame, columns) in [ + ("rect", Rect::new(0.0, 0.0, 160.0, 400.0).to_path(0.1), 1), + ("columns", Rect::new(0.0, 0.0, 400.0, 300.0).to_path(0.1), 2), + ("triangle", tri, 1), + ] { + for a in VerticalAlign::ALL { + let mut t = area(runs.clone(), Rect::ZERO); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame) }; + t.area.columns = columns; + t.area.vertical_align = a; + let l = layout(db(), &t); + assert!(!l.overflow, "{name} {a:?}"); + assert_inlines_on_their_glyphs(&t, &l, &format!("{name} {a:?}")); + } + } +} + +// ---------- Shrink Text to Fit × … ---------- + +#[test] +fn shrink_text_x_paragraph_attrs() { + // Shrinking scales the type, not each paragraph's spacing and indents: the fitted layout is + // the unfitted one at the scaled size. + let mut t = area(vec![run(&format!("{COPY}\n{COPY}"), sans(13.0))], Rect::new(0.0, 0.0, 220.0, 160.0)); + let mut second = t.para.clone(); + second.space_before = 14.0; + second.left_indent = 30.0; + second.first_line_indent = 12.0; + second.justify = Justify::JustifyLeft; + second.composer = Composer::SingleLine; + t.set_paragraph_styles(vec![t.para.clone(), second]); + t.area.fit = AreaFit::ShrinkText { min_percent: 10.0 }; + let l = layout(db(), &t); + assert!(!l.overflow && l.fit_scale < 1.0); + let same = layout(db(), &scaled_by(&t, l.fit_scale)); + assert_eq!(baselines(&l), baselines(&same)); + assert_eq!(line_texts(&t, &l), line_texts(&t, &same)); + // The second paragraph's indents are unscaled points. + let split = COPY.len() + 1; + let first_of_second = l.lines.iter().find(|li| li.start == split).unwrap(); + assert!((first_of_second.x0 - 42.0).abs() < 1e-6, "{}", first_of_second.x0); + assert!(l.lines.iter().filter(|li| li.start > split).all(|li| (li.x0 - 30.0).abs() < 1e-6)); + // A paragraph's space before alone can make the text overflow; shrinking still finds a fit. + let mut tall = t.clone(); + tall.edit_paras(Some(1..2), |p| p.space_before = 100.0); + let l = layout(db(), &tall); + assert!(!l.overflow && l.fit_scale < layout(db(), &t).fit_scale); +} + +#[test] +fn shrink_text_x_inline_graphics() { + // Inline art shrinks with the type, its shift included, and stays centred on the cap height. + let st = sans(14.0); + let mut runs = vec![]; + for i in 0..8 { + runs.push(run(©[i * 25..i * 25 + 25], st.clone())); + runs.push(sym(1.2, 1.5, st.clone())); + } + let mut t = area(runs, Rect::new(0.0, 0.0, 200.0, 80.0)); + t.area.fit = AreaFit::ShrinkText { min_percent: 10.0 }; + let l = layout(db(), &t); + assert!(!l.overflow && l.fit_scale < 1.0, "{}", l.fit_scale); + assert_inlines_on_their_glyphs(&t, &l, "shrunk"); + let same = layout(db(), &scaled_by(&t, l.fit_scale)); + for (i, i0) in l.inlines.iter().zip(&same.inlines) { + assert!((i.bounds.y0 - i0.bounds.y0).abs() < 1e-6 && (i.bounds.x0 - i0.bounds.x0).abs() < 1e-6, "{:?} vs {:?}", i.bounds, i0.bounds); + } + // Large art makes lines taller; shrinking still fits it. + for r in &mut t.runs { + if let Some(a) = &mut r.inline { + a.scale = 3.0; + } + } + let big = layout(db(), &t); + assert!(!big.overflow && big.fit_scale < l.fit_scale); + assert_inlines_on_their_glyphs(&t, &big, "shrunk, big art"); +} + +// ---------- paragraph attributes × inline graphics ---------- + +#[test] +fn paragraph_attrs_x_inline_graphics() { + // Inline art in paragraphs of each alignment and indent sits on its glyph, and an inline + // graphic opening a paragraph takes that paragraph's first-line indent. + let st = sans(12.0); + let runs = vec![ + run("Left aligned ", st.clone()), + sym(1.0, 0.0, st.clone()), + run(" text.\n", st.clone()), + sym(1.0, 0.0, st.clone()), + run(" centred with art first\nright ", st.clone()), + sym(1.5, -1.0, st.clone()), + run(" aligned\nfull justified text with ", st.clone()), + sym(1.0, 0.0, st.clone()), + run(" in the middle of a long line that wraps", st.clone()), + ]; + let mut t = area(runs, Rect::new(0.0, 0.0, 200.0, 400.0)); + let base = t.para.clone(); + let with = |j: Justify, first: f64| vectorcraft_doc::ParaStyle { justify: j, first_line_indent: first, left_indent: 6.0, ..base.clone() }; + t.set_paragraph_styles(vec![base.clone(), with(Justify::Center, 0.0), with(Justify::Right, 0.0), with(Justify::JustifyLeft, 18.0)]); + let l = layout(db(), &t); + assert_inlines_on_their_glyphs(&t, &l, "paragraphs"); + let opening = &l.glyphs[l.inlines[1].glyph]; + let line = &l.lines[opening.line]; + assert!((opening.origin.x - line.x0).abs() < 1e-6, "the art opens its line"); + assert!(((line.x0 + line.x1) * 0.5 - (6.0 + 200.0) * 0.5).abs() < 1e-6, "the centred paragraph: {line:?}"); + let right = &l.lines[l.glyphs[l.inlines[2].glyph].line]; + assert!((right.x1 - 200.0).abs() < 1e-6, "{right:?}"); + let full = l.lines.iter().find(|li| li.start == t.plain_text().find("full").unwrap()).unwrap(); + assert!((full.x0 - 24.0).abs() < 1e-6, "left indent + first-line indent: {full:?}"); +} + +// ---------- all of them ---------- + +/// One area type object with everything on: two paragraphs with their own attributes (the first +/// ragged every-line with inline symbols and lightly tracked ligatures, the second single-line, +/// justified and spaced away), centred vertically, shrunk to fit. +#[test] +fn every_feature_at_once() { + let rules = serif(14.0, 15.0); + let flavor = CharStyle { font_style: "Italic".into(), ..serif(14.0, 0.0) }; + let mut runs = vec![]; + for (i, words) in + ["Sacrifice a fine fish", "First strike. When this finishes fighting, draw a card", "Fifty fireflies fill the field"].iter().enumerate() + { + runs.push(sym(0.8, 0.0, rules.clone())); + runs.push(run(&format!(": {words}. "), rules.clone())); + if i == 1 { + runs.push(sym(0.8, 0.5, rules.clone())); + runs.push(run(" Then fight again.", rules.clone())); + } + } + runs.push(run("\nThe finest fishers find their fortune in the fjords, if they first fix their nets.", flavor)); + let frame = Rect::new(0.0, 0.0, 240.0, 110.0); + let mut t = area(runs, frame); + let mut flav = t.para.clone(); + flav.space_before = 10.0; + flav.justify = Justify::JustifyLeft; + flav.composer = Composer::SingleLine; + t.para.composer = Composer::EveryLine; + t.set_paragraph_styles(vec![t.para.clone(), flav]); + t.area.fit = AreaFit::ShrinkText { min_percent: 30.0 }; + + let top = layout(db(), &t); + t.area.vertical_align = VerticalAlign::Center; + let l = layout(db(), &t); + assert!(!l.overflow && l.fit_scale < 1.0, "{}", l.fit_scale); + assert_eq!(l.fit_scale, top.fit_scale); + // Shrunk: the same as laying out the scaled text, then centring it. + let mut unfit = scaled_by(&t, l.fit_scale); + unfit.area.vertical_align = VerticalAlign::Top; + let plain = layout(db(), &unfit); + assert_eq!(line_texts(&t, &l), line_texts(&t, &plain)); + let d = slack(&plain, frame) * 0.5; + assert!(d > 0.0); + for (b, b0) in baselines(&l).iter().zip(baselines(&plain)) { + assert!((b - b0 - d).abs() < 1e-6, "centred: {b} vs {b0} + {d}"); + } + // Every symbol on its glyph; ligatures kept at tracking 15. + assert_inlines_on_their_glyphs(&t, &l, "everything"); + assert_eq!(l.inlines.len(), 4); + assert!(ligatures(&l) >= 6, "{}", ligatures(&l)); + // The flavor paragraph: justified (all but its last line fill the measure) and spaced away. + let split = t.plain_text().find('\n').unwrap() + 1; + let flavor_lines: Vec<&LineInfo> = l.lines.iter().filter(|li| li.start >= split).collect(); + assert!(flavor_lines.len() >= 2); + for li in &flavor_lines[..flavor_lines.len() - 1] { + assert!((li.x1 - li.x0 - 240.0).abs() < 0.5, "justified: {li:?}"); + } + // Deterministic. + let again = layout(db(), &t); + assert_eq!(baselines(&again), baselines(&l)); + assert_eq!(again.inlines.iter().map(|i| i.bounds).collect::>(), l.inlines.iter().map(|i| i.bounds).collect::>()); +} + +/// The natural width of `s` set in one line at `size`. +fn natural_width(s: &str, size: f64) -> f64 { + let l = layout(db(), &TextObject::point(Point::ZERO, s, sans(size))); + l.lines[0].x1 - l.lines[0].x0 +}