//! 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 }