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()); {