Files
figcraft/crates/engine/src/text_metrics_local_tests.rs
T
BFlat 07681afebe M6: .fig import keeps the file's saved line breaks while the text is unedited (#1236) (#1519)
* M6: .fig import keeps the file's saved line breaks while the text and its box are unedited (#1236)

The line breaker already keeps trailing spaces and the last glyph's letter spacing out of the fit test and doesn't round box widths; imported text wrapped a word early or mid-word because local fonts measure slightly differently from the file's. The import keeps the saved line starts (derivedTextData) on the text with the box width and a stable key of its layout content; layout follows them while both match and no line overflows its box by more than 3% (+1 px).

* M6: local check that imported text wraps at the line breaks its file saved (#1236)

Runs only when FIGCRAFT_FIG_SAMPLES names a folder of .fig files: each fixed-width text with saved breaks wraps there once imported, or falls back by the overflow slack; prints the counts.
2026-10-29 17:04:29 +07:00

91 lines
4.9 KiB
Rust

//! Text box sizes measured against documents re-saved by other software. Local only: runs when
//! `FIGCRAFT_FIG_SAMPLES` names a folder of `.fig` files, and only for text whose exact font is
//! installed. The rules themselves are tested on built-in fonts in `figcraft_text`.
use figcraft_doc::{LineHeight, Node, NodeKind, TextAutoResize};
/// `n` with its percent line height read as a share of the font's own line height (the import
/// reads every percent as a share of the font size; files can mean either).
fn of_own_line_height(n: &Node) -> Option<Node> {
let mut m = n.clone();
let NodeKind::Text(t) = &mut m.kind else { return None };
let LineHeight::Percent(p) = t.style.line_height else { return None };
let f = figcraft_text::find_face(&t.style.font_family, &t.style.font_style)?;
t.style.line_height = LineHeight::Percent(p * (f.ascent + f.descent + f.line_gap.max(0.0)) / f.upem);
Some(m)
}
/// The chars that start a wrapped line (not those after a hard break) in `t` laid out `w` wide.
fn soft_starts(t: &figcraft_doc::TextData, w: f64) -> Vec<usize> {
let chars: Vec<char> = t.characters.chars().collect();
let l = figcraft_text::layout(t, Some(w), None);
l.lines
.iter()
.skip(1)
.map(|ln| ln.char_start)
.filter(|s| !s.checked_sub(1).and_then(|i| chars.get(i)).is_some_and(|c| matches!(c, '\n' | '\u{2028}')))
.collect()
}
/// Each fixed-width text with the line breaks its file saved (one-line texts too) wraps there once imported, unless a
/// line would overflow its box by more than the allowed slack in the fonts at hand (then it wraps
/// as it would without them). Prints the counts (`--nocapture`).
#[test]
fn local_samples_wrap_at_their_saved_line_breaks() {
let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return };
let Ok(dir) = std::fs::read_dir(dir) else { return };
figcraft_text::scan_system_fonts();
let (mut matched, mut fell_back, mut differed_before, mut wrapped) = (0, 0, 0, 0);
let mut problems = Vec::new();
for p in dir.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "fig")) {
let Ok((doc, _)) = figcraft_format::fig::import(&std::fs::read(&p).unwrap(), "sample") else { continue };
for n in doc.nodes() {
let NodeKind::Text(t) = &n.kind else { continue };
let Some(saved) = t.saved_breaks.as_deref() else { continue };
if t.auto_resize == TextAutoResize::WidthAndHeight {
continue;
}
wrapped += usize::from(!saved.starts.is_empty());
let plain = figcraft_doc::TextData { saved_breaks: None, ..t.clone() };
let (own, hinted) = (soft_starts(&plain, n.width), soft_starts(t, n.width));
differed_before += usize::from(own != saved.starts);
if t.saved_breaks_for(n.width).is_none() {
problems.push(format!("{}: {:?}: saved breaks no longer apply after import", p.display(), t.characters));
} else if hinted == saved.starts {
matched += 1;
} else if hinted == own {
fell_back += 1;
} else {
problems.push(format!("{}: {:?}: {hinted:?} != saved {:?}", p.display(), t.characters, saved.starts));
}
}
}
eprintln!(
"saved line breaks ({wrapped} of the texts wrap): {matched} matched, {fell_back} fell back, {} other; {differed_before} wrapped differently without them",
problems.len()
);
assert!(problems.is_empty(), "{problems:#?}");
}
/// Each text box that sizes itself comes out at the size stored in the file, to the pixel.
#[test]
fn local_samples_keep_their_text_box_sizes() {
let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return };
let Ok(dir) = std::fs::read_dir(dir) else { return };
figcraft_text::scan_system_fonts();
for p in dir.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "fig")) {
let Ok((doc, _)) = figcraft_format::fig::import(&std::fs::read(&p).unwrap(), "sample") else { continue };
for n in doc.nodes() {
let NodeKind::Text(t) = &n.kind else { continue };
// Stored sizes are whole pixels once the text was laid out by the software that saved it.
let laid_out = n.height.fract() == 0.0 && (t.auto_resize != TextAutoResize::WidthAndHeight || n.width.fract() == 0.0);
let own_font = t.spans().iter().all(|(_, _, s)| {
figcraft_text::find_face(&s.font_family, &s.font_style).is_some_and(|f| f.family == s.font_family && f.style == s.font_style)
});
let Some((w, h)) = figcraft_text::auto_size(n).filter(|_| laid_out && own_font) else { continue };
let h = if h == n.height { h } else { of_own_line_height(n).and_then(|m| figcraft_text::auto_size(&m)).map_or(h, |s| s.1) };
assert_eq!((w, h), (n.width, n.height), "{}: {:?}", p.display(), t.characters);
}
}
}