mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 03:46:50 -06:00
The .fig export now writes a numbered list's start on each of its lines, so a reader that takes it per line or per list counts the same. Bullets no longer keep a start. The field is named start_offset (it holds the offset: 1 shows "2."). get_node reports listStartOffsets. The parity doc lists what's still not read from .fig lists. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
296 lines
15 KiB
Rust
296 lines
15 KiB
Rust
//! Text behaviour suite, the tail: lists (numbering and nesting, list spacing, hanging lists),
|
|
//! hanging punctuation and decoration styles, written from the public documentation of the target
|
|
//! app's text settings. Each case gives the rule and a one-line derivation; where the
|
|
//! documentation is silent the case says so and pins today's behaviour.
|
|
//!
|
|
//! Numbering and decoration shapes are font-free; layout geometry reads the default face and skips
|
|
//! on a machine without fonts.
|
|
|
|
use figcraft_doc::{
|
|
DecorationLength, DecorationStyle, ListStyle, ParaList, TextAlignH, TextAutoResize, TextData, TextDecoration, color::Color, color::Paint,
|
|
};
|
|
use figcraft_geom::{Rect, Shape};
|
|
|
|
use crate::layout::list_markers;
|
|
use crate::{Decoration, TextLayout, db, layout, scan_system_fonts};
|
|
|
|
const EPS: f64 = 1e-6;
|
|
|
|
/// A 20 px text in the default face, or None (skip) without fonts.
|
|
fn text(s: &str) -> Option<TextData> {
|
|
scan_system_fonts();
|
|
let f = db().write().ok()?.default_face()?;
|
|
let mut t = TextData::new(s);
|
|
(t.style.font_family, t.style.font_style) = (f.family.clone(), f.style.clone());
|
|
t.style.font_size = 20.0;
|
|
Some(t)
|
|
}
|
|
|
|
fn laid(t: &TextData) -> Option<TextLayout> {
|
|
Some(layout(t, Some(400.0), None)).filter(|l| !l.glyphs.is_empty())
|
|
}
|
|
|
|
/// The x of each line's first text glyph and of its marker (glyphs covering no char).
|
|
fn starts(l: &TextLayout) -> Vec<(f64, Option<f64>)> {
|
|
l.lines
|
|
.iter()
|
|
.map(|ln| {
|
|
let gl = l.glyphs.get(ln.glyph_start..ln.glyph_end).unwrap_or(&[]);
|
|
let text = gl.iter().find(|g| g.char_end > g.char_start).map_or(f64::NAN, |g| g.x);
|
|
(text, gl.iter().find(|g| g.char_end == g.char_start).map(|g| g.x))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn pl(list: ListStyle, indent: u8) -> ParaList {
|
|
ParaList { list, indent, start_offset: 0 }
|
|
}
|
|
|
|
fn from(list: ListStyle, indent: u8, start_offset: u32) -> ParaList {
|
|
ParaList { list, indent, start_offset }
|
|
}
|
|
|
|
#[test]
|
|
fn numbered_lists_count_from_their_start() {
|
|
use ListStyle::{Bulleted as B, None as P, Numbered as N};
|
|
let cases: Vec<(Vec<ParaList>, Vec<&str>, &str)> = vec![
|
|
(vec![from(N, 0, 1)], vec!["2."], "a one-item list started at 2 (#686)"),
|
|
(vec![from(N, 0, 3), pl(N, 0), pl(N, 0)], vec!["4.", "5.", "6."], "the items after the first count on from it"),
|
|
(vec![from(N, 0, 3), from(N, 0, 3), from(N, 0, 3)], vec!["4.", "5.", "6."], "items that go on counting ignore their own start"),
|
|
(vec![from(N, 0, 1), pl(P, 0), from(N, 0, 1)], vec!["2.", "", "2."], "a list after a plain paragraph starts from its own start"),
|
|
(vec![pl(N, 0), from(N, 1, 1), pl(N, 1)], vec!["1.", "b.", "c."], "a nested level starts from its first item's start"),
|
|
(vec![from(B, 0, 4), pl(B, 0)], vec!["\u{2022}", "\u{2022}"], "bullets have no number"),
|
|
(vec![from(N, 0, 4_000), pl(N, 2)], vec!["4001.", "i."], "big starts"),
|
|
];
|
|
for (lists, want, why) in cases {
|
|
assert_eq!(list_markers(&lists), want, "{why}");
|
|
}
|
|
// The largest start never overflows, on 32-bit targets too.
|
|
let max = list_markers(&[from(N, 0, u32::MAX), pl(N, 0), pl(N, 0)]);
|
|
assert_eq!(max.len(), 3);
|
|
assert!(max.iter().all(|m| m.starts_with("429496729")), "{max:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn list_numbering_by_level() {
|
|
use ListStyle::{Bulleted as B, None as P, Numbered as N};
|
|
let many: Vec<ParaList> = std::iter::once(pl(N, 0)).chain(std::iter::repeat_n(pl(N, 1), 27)).collect();
|
|
// (paragraphs, markers, derivation)
|
|
let cases: Vec<(Vec<ParaList>, Vec<&str>, &str)> = vec![
|
|
(vec![pl(N, 0), pl(N, 0), pl(N, 0)], vec!["1.", "2.", "3."], "a numbered list counts its items"),
|
|
(
|
|
vec![pl(N, 0), pl(N, 1), pl(N, 1), pl(N, 0)],
|
|
vec!["1.", "a.", "b.", "2."],
|
|
"nested items count on their own level; the outer count goes on",
|
|
),
|
|
(
|
|
vec![pl(N, 0), pl(N, 1), pl(N, 2), pl(N, 3)],
|
|
vec!["1.", "a.", "i.", "1."],
|
|
"each level numbers its own way (silent on which: numbers, letters, roman numerals, round again)",
|
|
),
|
|
(
|
|
vec![pl(B, 0), pl(B, 1), pl(B, 2), pl(B, 3)],
|
|
vec!["\u{2022}", "\u{25E6}", "\u{25AA}", "\u{2022}"],
|
|
"silent: bullets change shape per level, round again",
|
|
),
|
|
(vec![pl(N, 0), pl(N, 0), pl(P, 0), pl(N, 0)], vec!["1.", "2.", "", "1."], "silent: a plain paragraph ends a list, the next starts at 1"),
|
|
(vec![pl(N, 0), pl(B, 0), pl(N, 0)], vec!["1.", "\u{2022}", "1."], "silent: the other kind of list at the same level starts the count again"),
|
|
(vec![pl(N, 0), pl(N, 1), pl(N, 0), pl(N, 1)], vec!["1.", "a.", "2.", "a."], "a shallower item starts the deeper count again"),
|
|
(vec![pl(N, 2), pl(N, 2), pl(N, 2), pl(N, 2)], vec!["i.", "ii.", "iii.", "iv."], "roman numerals at the third level"),
|
|
];
|
|
for (lists, want, why) in cases {
|
|
assert_eq!(list_markers(&lists), want, "{why}");
|
|
}
|
|
assert_eq!(list_markers(&many).last().map(String::as_str), Some("aa."), "silent: letters go on past z as aa");
|
|
}
|
|
|
|
#[test]
|
|
fn nested_items_indent_by_one_gutter() {
|
|
// Tab nests an item: each level moves the item and its marker in by one marker gutter (bullets:
|
|
// one and a half ems, 30 px at 20 px); a hanging list puts the outer gutter outside the box so
|
|
// item text lines up with plain paragraphs.
|
|
let Some(mut t) = text("One\nTwo\nThree") else { return };
|
|
t.paragraphs = vec![pl(ListStyle::Bulleted, 0), pl(ListStyle::Bulleted, 1), pl(ListStyle::Bulleted, 2)];
|
|
let Some(l) = laid(&t) else { return };
|
|
// (hanging, text x per level, marker x per level, derivation)
|
|
for (hanging, text_x, marker_x, why) in [
|
|
(false, [30.0, 60.0, 90.0], [0.0, 30.0, 60.0], "inside the box: level n's text at (n + 1) gutters"),
|
|
(true, [0.0, 30.0, 60.0], [-30.0, 0.0, 30.0], "hanging list: one gutter less, the outer bullet left of the box"),
|
|
] {
|
|
t.hanging_list = hanging;
|
|
let l = if hanging { laid(&t).unwrap() } else { l.clone() };
|
|
for (i, (x, m)) in starts(&l).into_iter().enumerate() {
|
|
assert!((x - text_x[i]).abs() < EPS, "{why}: line {i} text at {x}");
|
|
assert!(m.is_some_and(|m| (m - marker_x[i]).abs() < EPS), "{why}: line {i} marker at {m:?}");
|
|
}
|
|
assert_eq!(l.ink_bounds().is_some_and(|b| b.x0 <= -30.0), hanging, "{why}: exports reach the hanging bullet");
|
|
}
|
|
// A plain paragraph next to a hanging list starts where the items' text does.
|
|
t.paragraphs.push(pl(ListStyle::None, 0));
|
|
t.characters.push_str("\nPlain");
|
|
let l = laid(&t).unwrap();
|
|
assert!(starts(&l)[3].0.abs() < EPS && starts(&l)[0].0.abs() < EPS, "hanging list: items line up with plain text");
|
|
}
|
|
|
|
#[test]
|
|
fn list_spacing_between_items_only() {
|
|
// List spacing is the space between items of a list; paragraphs around it keep the paragraph
|
|
// spacing (silent on both set at once: between two items the list spacing replaces it).
|
|
let Some(mut t) = text("Intro\nA\nB\nOutro") else { return };
|
|
t.style.line_height = figcraft_doc::LineHeight::Pixels(30.0);
|
|
t.paragraphs = vec![pl(ListStyle::None, 0), pl(ListStyle::Bulleted, 0), pl(ListStyle::Bulleted, 0), pl(ListStyle::None, 0)];
|
|
(t.paragraph_spacing, t.list_spacing) = (10.0, 4.0);
|
|
let Some(l) = laid(&t) else { return };
|
|
let tops: Vec<f64> = l.lines.iter().map(|ln| ln.top).collect();
|
|
assert_eq!(tops, [0.0, 40.0, 74.0, 114.0], "plain→item +10, item→item +4, item→plain +10");
|
|
assert!((l.height - 144.0).abs() < EPS);
|
|
}
|
|
|
|
#[test]
|
|
fn hanging_punctuation_outside_the_aligned_edge() {
|
|
// Hanging punctuation: opening quotes and brackets at a line's start sit left of a left-aligned
|
|
// (or justified) box; closing punctuation at its end right of a right-aligned (or justified)
|
|
// one, so the letters line up. Silent on centred text and right-to-left paragraphs: no hang.
|
|
let Some(mut t) = text("\u{201C}Quote\u{201D} here.") else { return };
|
|
t.auto_resize = TextAutoResize::Height;
|
|
let Some(plain) = laid(&t) else { return };
|
|
let open = plain.glyphs[0].advance;
|
|
// Kerning may draw a glyph off its pen position (an x offset, e.g. Helvetica Neue's "re"), so
|
|
// each hanging line is checked against the same line set flush: every glyph moves by exactly
|
|
// the hanging mark's advance, which puts the letters' pen edge on the box edge.
|
|
let moved = |a: &TextLayout, b: &TextLayout, by: f64| {
|
|
a.glyphs.len() == b.glyphs.len() && a.glyphs.iter().zip(&b.glyphs).all(|(a, b)| (a.x - b.x - by).abs() < EPS)
|
|
};
|
|
t.hanging_punctuation = true;
|
|
let l = laid(&t).unwrap();
|
|
assert!(plain.glyphs[0].x.abs() < EPS && moved(&l, &plain, -open), "left: the quote hangs, Q starts the line");
|
|
t.align_h = TextAlignH::Right;
|
|
t.hanging_punctuation = false;
|
|
let flush = laid(&t).unwrap();
|
|
t.hanging_punctuation = true;
|
|
let l = laid(&t).unwrap();
|
|
let stop = l.glyphs.last().unwrap().advance;
|
|
let edge = flush.lines[0].x + flush.lines[0].width;
|
|
assert!((edge - 400.0).abs() < EPS && moved(&l, &flush, stop), "right: the full stop hangs past the edge");
|
|
t.align_h = TextAlignH::Center;
|
|
let (a, b) = (laid(&t).unwrap(), {
|
|
t.hanging_punctuation = false;
|
|
laid(&t).unwrap()
|
|
});
|
|
assert!((a.glyphs[0].x - b.glyphs[0].x).abs() < EPS, "centred: nothing hangs");
|
|
// Hanging punctuation takes no room in the box: a line that fits only without its opening
|
|
// quote stays on one line.
|
|
let mut t = text("\u{201C}ab cd").unwrap();
|
|
t.auto_resize = TextAutoResize::Height;
|
|
let w = layout(&t, None, None).width;
|
|
let room = w - plain.glyphs[0].advance + 0.01;
|
|
assert_eq!(layout(&t, Some(room), None).lines.len(), 2, "without hanging it wraps");
|
|
t.hanging_punctuation = true;
|
|
assert_eq!(layout(&t, Some(room), None).lines.len(), 1, "with hanging it fits");
|
|
}
|
|
|
|
/// (decoration, look change, top edge, height, derivation)
|
|
type Case<'a> = (TextDecoration, &'a dyn Fn(&mut figcraft_doc::DecorationLook), Option<f64>, f64, &'a str);
|
|
|
|
#[test]
|
|
fn decoration_style_thickness_offset_and_colour() {
|
|
let Some(mut t) = text("Hello") else { return };
|
|
t.style.decoration = TextDecoration::Underline;
|
|
let Some(base) = laid(&t) else { return };
|
|
let baseline = base.lines[0].baseline;
|
|
let red = Color::new(1.0, 0.0, 0.0, 1.0);
|
|
let set = |t: &mut TextData, d: TextDecoration, f: &dyn Fn(&mut figcraft_doc::DecorationLook)| {
|
|
t.style.decoration = d;
|
|
t.style.decoration_look = None;
|
|
t.style.set_look(f);
|
|
};
|
|
let auto = base.decorations[0].rect;
|
|
let cases: [Case; 5] = [
|
|
(TextDecoration::Underline, &|l| l.thickness = DecorationLength::Pixels(3.0), None, 3.0, "thickness in px"),
|
|
(TextDecoration::Underline, &|l| l.thickness = DecorationLength::Percent(10.0), None, 2.0, "thickness in % of the font size: 10% of 20"),
|
|
(
|
|
TextDecoration::Underline,
|
|
&|l| l.offset = DecorationLength::Pixels(4.0),
|
|
Some(baseline + 4.0),
|
|
auto.height(),
|
|
"offset: baseline to the line's top",
|
|
),
|
|
(
|
|
TextDecoration::Underline,
|
|
&|l| l.offset = DecorationLength::Percent(-10.0),
|
|
Some(baseline - 2.0),
|
|
auto.height(),
|
|
"a negative offset lifts it above the baseline",
|
|
),
|
|
(
|
|
TextDecoration::Strikethrough,
|
|
&|l| l.offset = DecorationLength::Pixels(4.0),
|
|
None,
|
|
auto.height(),
|
|
"silent: strikethrough ignores the offset",
|
|
),
|
|
];
|
|
for (d, f, top, h, why) in cases {
|
|
set(&mut t, d, f);
|
|
let r = laid(&t).unwrap().decorations[0].rect;
|
|
assert!((r.height() - h).abs() < EPS, "{why}: height {}", r.height());
|
|
if let Some(y) = top {
|
|
assert!((r.y0 - y).abs() < EPS, "{why}: top {}", r.y0);
|
|
}
|
|
}
|
|
let mut strike = t.clone();
|
|
strike.style.decoration_look = None;
|
|
let plain_strike = laid(&strike).unwrap().decorations[0].rect;
|
|
assert!((laid(&t).unwrap().decorations[0].rect.y0 - plain_strike.y0).abs() < EPS, "strikethrough stays at the font's position");
|
|
// Wavy swings either side; dotted is a row of dots; both inside their rect.
|
|
for style in [DecorationStyle::Wavy, DecorationStyle::Dotted] {
|
|
set(&mut t, TextDecoration::Underline, &|l| l.style = style);
|
|
let d = laid(&t).unwrap().decorations[0].clone();
|
|
assert_eq!(d.kind, style);
|
|
let b = d.path().bounding_box();
|
|
assert!(b.x0 >= d.rect.x0 - EPS && b.x1 <= d.rect.x1 + EPS && b.y0 >= d.rect.y0 - EPS && b.y1 <= d.rect.y1 + EPS, "{style:?} inside {b:?}");
|
|
assert!(style != DecorationStyle::Wavy || d.rect.height() > d.thickness, "a wave is taller than its thickness");
|
|
}
|
|
// Colour: the line paints with its own colour, the glyphs keep theirs.
|
|
set(&mut t, TextDecoration::Underline, &|l| l.color = Some(red));
|
|
let l = laid(&t).unwrap();
|
|
let d = &l.decorations[0];
|
|
assert!(l.glyphs.iter().all(|g| g.style != d.style));
|
|
assert_eq!(l.styles[d.style].fills, Some(vec![Paint::solid(red)]));
|
|
assert_eq!(l.styles[l.glyphs[0].style].fills, None);
|
|
}
|
|
|
|
#[test]
|
|
fn skip_ink_breaks_under_descenders() {
|
|
// Skip ink: an underline stops short of descenders that cross it (silent on the gap: one line
|
|
// thickness, at least 1 px, each side).
|
|
let Some(mut t) = text("gypsy") else { return };
|
|
t.style.decoration = TextDecoration::Underline;
|
|
let Some(l) = laid(&t) else { return };
|
|
assert_eq!(l.decorations.len(), 1, "off: one line");
|
|
t.style.set_look(|l| l.skip_ink = true);
|
|
let l = laid(&t).unwrap();
|
|
assert!(l.decorations.len() > 1, "on: broken under g, y and p");
|
|
let t2 = TextData { characters: "mmm".into(), ..t.clone() };
|
|
let l2 = laid(&t2).unwrap();
|
|
// Only when the line and its gap clear the baseline: some fonts put the underline so close
|
|
// that it touches every glyph's foot, and then it breaks under those too.
|
|
let clear = l2.decorations.first().zip(l2.lines.first()).is_some_and(|(d, ln)| d.rect.y0 - d.thickness.max(1.0) / 2.0 > ln.baseline);
|
|
if clear {
|
|
assert_eq!(l2.decorations.len(), 1, "no descenders: one line");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn decoration_shapes() {
|
|
// Font-free: the path of each style for a 100 px line, 2 px thick.
|
|
let d = |kind| Decoration { rect: Rect::new(0.0, 10.0, 100.0, 12.0), style: 0, kind, thickness: 2.0 };
|
|
assert_eq!(d(DecorationStyle::Solid).path().bounding_box(), Rect::new(0.0, 10.0, 100.0, 12.0), "solid: the bar");
|
|
let dots = d(DecorationStyle::Dotted).path();
|
|
let count = dots.elements().iter().filter(|e| matches!(e, figcraft_geom::kurbo::PathEl::MoveTo(_))).count();
|
|
assert_eq!(count, 25, "dotted: a 2 px dot every 4 px");
|
|
let wave = Decoration { rect: Rect::new(0.0, 7.0, 100.0, 15.0), ..d(DecorationStyle::Wavy) };
|
|
let b = wave.path().bounding_box();
|
|
assert!((b.x0, b.x1) == (0.0, 100.0) && b.y0 >= 7.0 - EPS && b.y1 <= 15.0 + EPS, "wavy: across, within the band {b:?}");
|
|
}
|