fix(text): fitHeadline sees wrapping in a frame one line tall

The layout drops lines that overflow an area frame vertically, so counting
the first paragraph's laid-out lines cannot see a wrapped second line
when the frame holds only one. The bisection then found every tracking
"one line" and ran to its 2000 ceiling, which spread the line far past the
frame (most glyphs clipped away) while the command reported success.

one_line now asks whether the first line reaches the end of the first
paragraph, which holds regardless of dropped overflow lines. Regression
test: area 240x50 at size 36 previously returned tracking 1999.9, now
fills the width (avail - w < 5) with the paragraph on one line.
This commit is contained in:
Giang Nguyen 2026-10-12 18:22:59 +02:00 committed by bflatastic
parent 9f0a91b638
commit 2b34554ac8
1 changed files with 27 additions and 5 deletions

View File

@ -1022,6 +1022,7 @@ mod area_tests {
}
/// Width of the first line and of the space it can fill, with `tracking` on the first paragraph.
/// The third value is where the first line ends in the plain text.
fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> {
let mut probe = t.clone();
let para_end = probe.plain_text().find('\n').unwrap_or(usize::MAX);
@ -1031,7 +1032,7 @@ fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> {
let line = lay.lines.first()?;
let para = t.para_at(0);
let avail = cell.width() - 2.0 * t.area.inset - para.left_indent - para.right_indent;
Some((line.x1 - line.x0, avail, lay.lines.iter().filter(|l| l.start < para_end).count()))
Some((line.x1 - line.x0, avail, line.end))
}
fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
@ -1044,8 +1045,11 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
if !matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) {
continue;
}
// Largest tracking that keeps the first paragraph on one line (bisection).
let one_line = |tr: f64| headline_fit(t, tr).is_some_and(|(_, _, lines)| lines == 1);
// Largest tracking that keeps the first paragraph on one line (bisection). The layout
// drops lines that overflow the frame, so a frame one line tall hides the wrapped rest:
// "one line" means the first line reaches the end of the first paragraph.
let para_end = t.plain_text().find('\n').unwrap_or(t.plain_text().len());
let one_line = |tr: f64| headline_fit(t, tr).is_some_and(|(_, _, end)| end >= para_end);
let (mut lo, mut hi) = (-200.0, 2000.0);
if !one_line(lo) {
continue;
@ -1087,9 +1091,27 @@ mod headline_tests {
let tr = s.execute("text.fitHeadline", &json!({})).unwrap()["tracking"].as_f64().unwrap();
assert!(tr > 100.0, "a short word spreads out: {tr}");
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!() };
let (w, avail, lines) = headline_fit(t, tr).unwrap();
assert_eq!(lines, 1);
let (w, avail, end) = headline_fit(t, tr).unwrap();
assert_eq!(end, 8);
assert!(avail - w < 30.0, "{w} of {avail}");
assert_eq!(t.runs.last().unwrap().style.tracking, 0.0, "the body keeps its tracking");
}
#[test]
fn fit_headline_fills_even_when_the_frame_holds_one_line() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s
.execute("text.create", &json!({"x": 10, "y": 10, "text": "NEON DRIVE", "size": 36, "area": {"width": 240, "height": 50}}))
.unwrap()["id"]
.as_u64()
.unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let tr = s.execute("text.fitHeadline", &json!({})).unwrap()["tracking"].as_f64().unwrap();
assert!(tr < 1500.0, "a frame one line tall must not push the tracking to the ceiling: {tr}");
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!() };
let (w, avail, end) = headline_fit(t, tr).unwrap();
assert_eq!(end, 10);
assert!(avail - w < 5.0, "{w} of {avail}");
}
}