From 8bc57bc7ad23d822ff3096fd20f3ed14b2616742 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 13:38:11 +0200 Subject: [PATCH] Text: Justify All balances the lines with the every-line composer, as After Effects With Justify All After Effects stretches the paragraph's last line too, and its every-line composer breaks the paragraph so that line is not a lone word: a 400 px box that the other justifications break into 6 / 6 / 1 words breaks 4 / 4 / 4 (AE 2026, EXPORTER read-back t6). EffectCraft's composer left the last line's slack out of its cost, so "twelve" stood alone on a stretched line; it now counts that slack for Justify All and every word starts within 1 px of AE's (what remains in that box is the known box-text baseline offset). Other justifications and the single-line composer are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 24 ++++++++++++++++++++++-- docs/fidelity/README.md | 9 +++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 0ec5c7347..d8bdee4fd 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -761,7 +761,10 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar let opps: Vec = unicode_linebreak::linebreaks(text).map(|(p, _)| p).collect(); let avail = |line: usize| avail_for(line == 0).unwrap_or(f32::MAX); if para.every_line && opps.len() > 1 && opps.len() <= 1500 { - // Minimise the summed squared slack of every line but the last. + // Minimise the summed squared slack of every line but the last; with Justify All + // the last line is stretched too, so its slack counts (After Effects then balances + // the lines: 4 / 4 / 4 words where the other modes break 6 / 6 / 1). + let last_counts = para.align == Align::Justify && para.justify_last == Align::Justify; let nodes: Vec = std::iter::once(0).chain(opps.iter().copied()).collect(); let m = nodes.len(); let mut best = vec![(f32::INFINITY, 0usize); m]; @@ -781,7 +784,7 @@ fn paragraph(text: &str, base: usize, runs: &[(Range, TextStyle)], primar let slack = maxw - w; let cost = if over { 1e12 + w - } else if j == m - 1 { + } else if j == m - 1 && !last_counts { 0.0 } else { slack * slack @@ -1464,6 +1467,23 @@ mod tests { assert!(every.lines.iter().all(|ln| ln.width <= 320.0 + 0.5)); } + /// After Effects 2026 (EXPORTER t6): with Justify All and the every-line composer, a box that + /// the other justifications break into lines of 6, 6 and 1 words breaks 4 / 4 / 4, so the + /// stretched last line is not a lone word; word positions then match AE to 1 px. + #[test] + fn justify_all_balances_the_lines_with_the_every_line_composer() { + let text = "one two three four five six seven eight nine ten eleven twelve"; + let s = st(30.0); + let words = |l: &Layout| -> Vec { l.lines.iter().map(|ln| text[ln.range.clone()].split_whitespace().count()).collect() }; + let p = |last: Align| ParagraphStyle { width: Some(450.0), align: Align::Justify, justify_last: last, every_line: true, ..Default::default() }; + let left = layout_rich_uncached(text, &[(0..text.len(), s.clone())], &[p(Align::Left)]); + let all = layout_rich_uncached(text, &[(0..text.len(), s)], &[p(Align::Justify)]); + let (wl, wa) = (words(&left), words(&all)); + assert!(wl.last().copied().unwrap_or(0) < *wl.first().unwrap_or(&0), "{wl:?}"); + let (lo, hi) = (wa.iter().min().copied().unwrap_or(0), wa.iter().max().copied().unwrap_or(0)); + assert!(hi - lo <= 1, "Justify All balances the lines: {wa:?} (left-justified: {wl:?})"); + } + #[test] fn hanging_punctuation_overhangs_the_box() { let s = st(40.0); diff --git a/docs/fidelity/README.md b/docs/fidelity/README.md index 5afafee41..6c59acd43 100644 --- a/docs/fidelity/README.md +++ b/docs/fidelity/README.md @@ -71,6 +71,15 @@ After the fix, all ten tangents equal AE's to the printed 0.001 px, and the two test `spatial_auto_bezier_tangents_follow_after_effects` checks the rule on other original keys, including a timing change. AE's output stays local; only the script and these scores are tracked. +## Justify All with the every-line composer + +Measured on 19 October 2026 with After Effects 2026 (26.2) on box text (EXPORTER read-back t6): +with Justify All, AE stretches the paragraph's last line too and breaks the lines so that it is not +a lone word (4 / 4 / 4 words where the other justifications break 6 / 6 / 1). EffectCraft's +every-line composer ignored the last line's slack and left "twelve" alone on a stretched line; it now +counts that slack for Justify All, and every word starts within 1 px of AE's. Regression test: +`justify_all_balances_the_lines_with_the_every_line_composer`. + ## Still unmeasured Rendered frame comparisons now have a deterministic `effectcraft_effects::fidelity::compare_frames`