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] 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 bdc9063c..cc8184df 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 00000000..5b3c89b4 --- /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 33c1360f..cf5ed57c 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 00000000..0453058b --- /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 +}