mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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.
This commit is contained in:
1 parent
53e39884fc
commit
992f6a0a4d
4 files changed
+888
No files matched your search
@@ -1586,6 +1586,8 @@ mod tests_swatchlib;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_targeting;
|
mod tests_targeting;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_textcombos;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_textedit;
|
mod tests_textedit;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_textimport;
|
mod tests_textimport;
|
||||||
|
|||||||
@@ -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::<Vec<_>>(), [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::<Vec<_>>(), [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::<Vec<_>>(), [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::<Vec<_>>(), l.inlines.iter().map(|i| i.bounds).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
@@ -502,6 +502,8 @@ mod tests;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_bidi;
|
mod tests_bidi;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_combos;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_embed;
|
mod tests_embed;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_fit;
|
mod tests_fit;
|
||||||
|
|||||||
@@ -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<TextRun>, 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<String> {
|
||||||
|
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<usize> = 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<f64> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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::<Vec<_>>(), l.inlines.iter().map(|i| i.bounds).collect::<Vec<_>>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user