From 99af9f513fffed3d59e879212627643a0cf8e92a Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:03:25 -0400 Subject: [PATCH] Text: a line with a tab wraps at the column edge (#282) * 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. * Lists: the first line after a bullet or number wraps at the column edge In a hanging indent (a negative first-line indent), the tab after a bullet or number went to the next default tab stop, past the left indent: the first line's text started late and pushed the line past the column edge. The left indent is now a left tab stop of its own. A tab before it reaches it unless an explicit stop comes first, so the first line's text lines up with the lines below. --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/compose/src/breaker.rs | 18 +++++-- crates/compose/src/lib.rs | 67 +++++++++++++++--------- crates/compose/src/tests.rs | 96 +++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 28 deletions(-) diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 3b08e10..7646aab 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 6e745d8..f4a6e89 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -657,7 +657,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, pp.left_indent, 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 { @@ -836,7 +840,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, pp.left_indent, 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 { @@ -1866,6 +1876,34 @@ 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 +/// explicit tab stop it reaches (none: the left indent or the next default stop, left aligned). +/// Right, centre and character alignment look at the text up to the next tab or forced break. +/// +/// The left indent is a left stop of its own: in a hanging indent, the tab after a bullet or +/// number reaches it unless an explicit stop comes first. +fn tab_advance<'a>(tabs: &'a [designcraft_doc::TabStop], left_indent: f64, abs: f64, after: &[Glyph]) -> (f64, Option<&'a designcraft_doc::TabStop>) { + let indent_ahead = left_indent > abs + 0.01; + let stop = tabs.iter().find(|t| t.position > abs + 0.01).filter(|t| !indent_ahead || t.position <= left_indent); + let (pos, align) = match stop { + Some(t) => (t.position, t.align), + None if indent_ahead => (left_indent, TabAlign::Left), + 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 @@ -1905,32 +1943,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, pp.left_indent, 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 1f11dcd..36de5be 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -815,6 +815,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. @@ -1937,6 +1977,62 @@ fn vertical_lines_fit_the_em_box() { } } +#[test] +fn list_first_line_wraps_at_the_column_edge_after_its_label() { + use designcraft_doc::{ListType, TabStop}; + // A hanging indent (left 18, first line −18): the label sits at the column start, a tab + // after it reaches the left indent (an implicit stop when no explicit stop comes first), and + // the first line wraps at the same right edge as the others. + 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 text_start = |l: &Line| l.glyphs.iter().find(|g| g.len > 0 && g.visible).map_or(f64::NAN, |g| g.x); + let stop = |position: f64| TabStop { position, align: TabAlign::Left, leader: String::new(), align_on: String::new() }; + let text = format!("{LOREM}\n{LOREM}"); + // (list, separator, explicit tab stops, where line 0's text starts past the column start: + // None = right after the label, wherever that is). + let cases = [ + (ListType::Bullets, "\t", None, Some(18.0)), + (ListType::Numbers, "\t", None, Some(18.0)), + (ListType::Numbers, "\t", Some(vec![stop(12.0)]), Some(12.0)), + (ListType::Numbers, "\t", Some(vec![stop(30.0)]), Some(18.0)), + (ListType::Bullets, " ", None, None), + (ListType::Numbers, "\u{2003}", None, None), + ]; + for (list, sep, tabs, first_at) in cases { + let para = ParaAttrs { + list_type: Some(list), + list_separator: Some(sep.into()), + left_indent: Some(18.0), + first_line_indent: Some(-18.0), + tabs: tabs.clone(), + ..Default::default() + }; + let (d, sid, _) = doc_with(&text, Rect::new(36.0, 36.0, 300.0, 400.0), para); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let col = cs.frames[0].columns[0]; + let lines = &cs.frames[0].lines; + let what = format!("{list:?} {sep:?} {tabs:?}"); + assert!(lines.len() >= 6, "{what}: {} lines", lines.len()); + for (i, l) in lines.iter().enumerate() { + let right = right_edge(l, &text); + assert!(right <= col.x1 + 0.01, "{what}: line {i} ends at {right}, past the column's {}", col.x1); + let start = text_start(l) - col.x0; + if !l.first_in_para { + assert!((start - 18.0).abs() < 0.01, "{what}: line {i} text starts at {start}, not the left indent"); + } else if let Some(at) = first_at { + assert!((start - at).abs() < 0.01, "{what}: line {i} text starts at {start}, not {at}"); + } else { + assert!(start > 0.0, "{what}: line {i} text starts at {start}"); + } + if l.first_in_para { + assert!(l.glyphs.first().is_some_and(|g| g.len == 0 && (g.x - col.x0).abs() < 0.01), "{what}: the label leads line {i}"); + } + } + } +} + fn ruled(text: &str, rect: Rect, columns: u32, edit: impl FnOnce(&mut Document, StoryId)) -> (Document, ItemId, Vec) { let (mut d, sid, fid) = doc_with(text, rect, ParaAttrs::default()); {