diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 51bd406e2..92adf47a8 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -615,7 +615,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let breaks = if pp.composer == Composer::SingleLine || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 { 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 + 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 { @@ -753,7 +753,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C 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 + 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 { @@ -1541,13 +1541,18 @@ 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); +/// 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)); @@ -1603,7 +1608,7 @@ fn layout_line( let mut leaders: Vec<(usize, String)> = Vec::new(); while i < line.len() { if line[i].ch == '\t' { - let (w, stop) = tab_advance(&pp.tabs, x0 + x - tab_origin, line.get(i + 1..).unwrap_or_default()); + 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() { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index a99095e0b..ee1227e40 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1917,3 +1917,59 @@ fn vertical_lines_fit_the_em_box() { assert!((l.baseline - col.y0 - ascent).abs() < 1e-9, "{family}: {} {}", l.baseline, col.y0); } } + +#[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}"); + } + } + } +}