From 53a83139d9df41dbef63c57e28e10b42c5040da4 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 02:13:18 +0000 Subject: [PATCH] Footnotes: the first line wraps at the column edge after the number Line breaking gave tabs no width: a tab only reached its stop when the line was laid out, so a line holding a tab ran past the measure by the tab's advance. A footnote whose separator is a tab overflowed its first line by the gap between the number and the tab stop, and body lines with tabs did the same. The greedy breaker now asks for each tab's advance at its position on the line, computed by the same tab_advance helper line layout uses. --- crates/compose/src/breaker.rs | 18 +++++++--- crates/compose/src/lib.rs | 62 +++++++++++++++++++++-------------- crates/compose/src/tests.rs | 40 ++++++++++++++++++++++ 3 files changed, 92 insertions(+), 28 deletions(-) diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 3b08e1050..7646aaba2 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -390,7 +390,7 @@ pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: & return b; } } - greedy(glyphs, hyph_after, sp, width) + greedy(glyphs, hyph_after, sp, width, &|_, _, _| 0.0) } /// Upper bound on the hyphen-count states tracked per breakpoint. @@ -628,7 +628,16 @@ fn emergency_split(glyphs: &[Glyph], start: usize, i: usize) -> usize { } /// Greedy first-fit breaking (single-line composer; also used for paragraphs with tabs). -pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn Fn(usize) -> f64) -> Vec { +/// +/// `tab(line, x, i)` is the advance of the tab glyph `i` when it starts `x` from the start of +/// line `line`: tabs have no width until they reach their stop, which depends on where they sit. +pub fn greedy( + glyphs: &[Glyph], + hyph_after: &[bool], + sp: &Spacing, + width: &dyn Fn(usize) -> f64, + tab: &dyn Fn(usize, f64, usize) -> f64, +) -> Vec { let n = glyphs.len(); let mut out = Vec::new(); let mut start = 0; @@ -664,7 +673,8 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F continue; } let hang_r = right_hang(g, sp); - if x + g.adv - hang_r > w + shrink + let adv = if g.ch == '\t' { tab(line, x, i) } else { g.adv }; + if x + adv - hang_r > w + shrink && i > start && (last_ok.is_some() || glyphs.get(i - 1).is_none_or(|p| p.break_after != Some(false) && !p.no_break)) { @@ -679,7 +689,7 @@ pub fn greedy(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &dyn F }); break; } - x += g.adv; + x += adv; if sp.justify { shrink += sp.box_elastic(g).1.iter().sum::(); } diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 5a3056d8f..51bd406e2 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -613,7 +613,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C apply_desired_spacing(&mut gl, &pp); let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); let breaks = if pp.composer == Composer::SingleLine || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 { - breaker::greedy(&gl, &hy, &spacing, &width) + let tab = |j: usize, x: f64, i: usize| { + let ind = pp.left_indent + if j == 0 { pp.first_line_indent } else { 0.0 }; + tab_advance(&pp.tabs, ind + x, gl.get(i + 1..).unwrap_or_default()).0 + }; + breaker::greedy(&gl, &hy, &spacing, &width, &tab) } else if pp.balance_ragged && !spacing.justify { breaker::balanced(&gl, &hy, &spacing, &width) } else { @@ -745,7 +749,13 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let rest = &glyphs[g0..]; let rest_h = &hyph_after[g0..]; let breaks: Vec = if pp.composer == Composer::SingleLine || has_tabs || rest.len() > 4000 { - breaker::greedy(rest, rest_h, &spacing, &width) + // Where each line starts relative to the tab origin, as `layout_line` places it. + let tab = |j: usize, x: f64, i: usize| { + let x0 = slots.get(j).map_or(col.x0, |s| s.0); + let ind = pp.left_indent + if line_no + j == 0 { pp.first_line_indent } else { 0.0 }; + tab_advance(&pp.tabs, x0 + ind + x - col.x0, rest.get(i + 1..).unwrap_or_default()).0 + }; + breaker::greedy(rest, rest_h, &spacing, &width, &tab) } else if pp.balance_ragged && !spacing.justify { breaker::balanced(rest, rest_h, &spacing, &width) } else { @@ -1531,6 +1541,29 @@ pub fn hj_severity(ratio: f64, min: f64, max: f64) -> u8 { } } +/// Advance of a tab that starts `abs` from the tab origin and is followed by `after`, and the tab +/// stop it reaches (none: the next default stop, left aligned). Right, centre and character +/// alignment look at the text up to the next tab or forced break. +fn tab_advance<'a>(tabs: &'a [designcraft_doc::TabStop], abs: f64, after: &[Glyph]) -> (f64, Option<&'a designcraft_doc::TabStop>) { + let stop = tabs.iter().find(|t| t.position > abs + 0.01); + let (pos, align) = match stop { + Some(t) => (t.position, t.align), + None => (((abs / DEFAULT_TAB).floor() + 1.0) * DEFAULT_TAB, TabAlign::Left), + }; + let seg = after.iter().take_while(|g| g.ch != '\t' && !breaker::is_forced(g.ch)); + let gap = pos - abs; + let w = match align { + TabAlign::Left => gap, + TabAlign::Right => gap - seg.map(|g| g.adv).sum::(), + TabAlign::Center => gap - seg.map(|g| g.adv).sum::() / 2.0, + TabAlign::Char => { + let ch = stop.and_then(|t| t.align_on.chars().next()).unwrap_or('.'); + gap - seg.take_while(|g| g.ch != ch).map(|g| g.adv).sum::() + } + }; + (w.max(0.0), stop) +} + /// Position glyphs `s..e` within `[x0, x1]`; returns (glyphs, end x, word-space ratio). /// /// Justified lines distribute the difference to the measure in priority order: word spaces up to @@ -1570,32 +1603,13 @@ fn layout_line( let mut leaders: Vec<(usize, String)> = Vec::new(); while i < line.len() { if line[i].ch == '\t' { - let abs = x0 + x - tab_origin; - let stop = pp.tabs.iter().find(|t| t.position > abs + 0.01).cloned(); - if let Some(t) = &stop + let (w, stop) = tab_advance(&pp.tabs, x0 + x - tab_origin, line.get(i + 1..).unwrap_or_default()); + if let Some(t) = stop && !t.leader.is_empty() { leaders.push((i, t.leader.clone())); } - let (pos, align) = match &stop { - Some(t) => (t.position, t.align), - None => (((abs / DEFAULT_TAB).floor() + 1.0) * DEFAULT_TAB, TabAlign::Left), - }; - // Width of the text after this tab up to the next tab / line end. - let seg_end = line[i + 1..].iter().position(|g| g.ch == '\t').map_or(line.len(), |p| i + 1 + p); - let seg_w: f64 = line[i + 1..seg_end].iter().map(|g| g.adv).sum(); - let target = pos + tab_origin - x0; - let w = match align { - TabAlign::Left => target - x, - TabAlign::Right => target - x - seg_w, - TabAlign::Center => target - x - seg_w / 2.0, - TabAlign::Char => { - let ch = stop.as_ref().and_then(|t| t.align_on.chars().next()).unwrap_or('.'); - let before: f64 = line[i + 1..seg_end].iter().take_while(|g| g.ch != ch).map(|g| g.adv).sum(); - target - x - before - } - }; - line[i].adv = w.max(0.0); + line[i].adv = w; } else if line[i].ch == story::RIGHT_INDENT_TAB { let rest: f64 = line[i + 1..].iter().map(|g| g.adv).sum(); line[i].adv = (measure - x - rest).max(0.0); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 549e7bccf..a99095e0b 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -777,6 +777,46 @@ fn footnotes_sit_at_the_column_bottom_and_push_text() { assert!(note_caret(&cs, n.id, 0).is_some()); } +#[test] +fn footnote_first_line_wraps_at_the_column_edge_after_its_number() { + // InDesign sets the footnote number and separator as part of the first line: that line + // wraps at the same right edge as the others, whatever the separator. A tab separator + // reaches the footnote style's tab stop. + let tab_at = |position: f64| { + let stop = designcraft_doc::TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: String::new() }; + ParaAttrs { tabs: Some(vec![stop]), ..Default::default() } + }; + let right_edge = |l: &Line, source: &str| { + let space = |g: &&PlacedGlyph| g.len > 0 && source.get(g.byte..).and_then(|s| s.chars().next()).is_some_and(char::is_whitespace); + l.glyphs.iter().filter(|g| g.visible && !space(g)).map(|g| g.x + g.adv).fold(0.0, f64::max) + }; + let cases = + [("\t", tab_at(100.0)), ("\t", ParaAttrs::default()), (" ", ParaAttrs::default()), (".\u{2003}\u{2003}\u{2003}", ParaAttrs::default())]; + for (sep, para) in cases { + let (mut d, sid, _) = doc_with("Short text.", Rect::new(36.0, 36.0, 300.0, 400.0), ParaAttrs::default()); + d.footnote_options.separator = sep.into(); + d.footnote_options.start_at = 1234; + d.story_mut(sid).unwrap().insert_note(5, &LOREM.repeat(2), ParaFormat { para, ..Default::default() }); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let n = &cs.frames[0].notes[0]; + let width = n.rect.width(); + let lines = &n.text.frames[0].lines; + assert!(lines.len() >= 3, "{sep:?}: {} lines", lines.len()); + for (i, l) in lines.iter().enumerate() { + let right = right_edge(l, &n.source); + assert!(right <= width + 0.01, "{sep:?}: line {i} ends at {right}, past the column's {width}"); + } + assert!(lines[0].glyphs.first().is_some_and(|g| g.len == 0 && g.x < 1.0), "{sep:?}: the number leads line 0"); + } + // The same holds for a tab in body text. + let text = format!("Term\t{LOREM}"); + let (d, sid, _) = doc_with(&text, Rect::new(36.0, 36.0, 300.0, 400.0), tab_at(100.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let l = &cs.frames[0].lines[0]; + let edge = cs.frames[0].columns[0].x1; + assert!(right_edge(l, &text) <= edge + 0.01, "body line 0 ends at {}, past {edge}", right_edge(l, &text)); +} + #[test] fn footnote_line_at_column_top_still_sets() { // A note taller than the frame can't push its reference line forever.