From d0271a562ffaf8b9293715a97520c9cfaee3fc85 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:59:37 +0100 Subject: [PATCH] Text: Indent to Here beside a drop cap isn't indented by the drop cap again (#2080) The lines after an Indent to Here start at the character, which already sits past the drop cap, but the lines beside the drop cap still added its width on top. They no longer do. Adds tests for the drop cap, for Indent to Here after a tab (the usual hanging number) and for a right-to-left paragraph, and documents how the indent behaves beside a text wrap. --- crates/compose/src/lib.rs | 13 ++++++--- crates/compose/src/tests.rs | 54 +++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 9d383d270..0e32881d9 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1817,6 +1817,8 @@ fn place_sub_objects( } } +/// Sets one paragraph's lines, column after column and frame after frame. It stops when the +/// paragraph ends, when the frames run out, or when an earlier paragraph has to be set again. fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLines<'_>) -> LinePlacement { let PlacementShared { doc, @@ -1916,6 +1918,9 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi }); } let pp = indented.as_ref().unwrap_or(para_props); + // The Indent to Here character sits past the drop cap, so the lines that start at it + // aren't indented by the drop cap again. + let beside_cap = drop_cap.filter(|_| indented.is_none()); let f = &frames[cur.fi]; let base = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]; let col = cur.clip(base); @@ -1941,7 +1946,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi props: pp, slots: &slots, column: col, - drop_cap, + drop_cap: beside_cap, line_no, col_first_line, rtl, @@ -2066,7 +2071,7 @@ fn place_paragraph_lines(shared: PlacementShared<'_, '_>, paragraph: ParagraphLi let col_w = notes.area(&cols[cur.fi], cur.col).map_or(0.0, |r| r.width()); let note_column = NoteColumn { frame_index: cur.fi, column_index: cur.col, width: col_w, frame: f }; // A drop cap sits at the start-side indent; the first-line indent doesn't move it. - let beside = drop_cap.filter(|dc| line_no < dc.lines && col_first_line == 0).map(|dc| dc.indent()); + let beside = beside_cap.filter(|dc| line_no < dc.lines && col_first_line == 0).map(|dc| dc.indent()); let first_line = if line_no == 0 { pp.first_line_indent } else { 0.0 }; let (ind_l, ind_r) = match beside { Some(w) if rtl => (pp.left_indent, pp.right_indent + w), @@ -4963,7 +4968,9 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: & /// The paragraph settings for the lines after `line` when it holds an Indent to Here character /// (the last one, if several): the start-side indent reaches the character, measured from the -/// start edge of the line's segment `x0..x1`. `None` when there is none or the indent is unchanged. +/// start edge of the line's segment `x0..x1`. Like the paragraph's own indents it then applies +/// from each later line's segment, so beside a text wrap it starts at the wrap's edge. `None` +/// when there is none or the indent is unchanged. fn indent_here( line: &[Glyph], placed: &[PlacedGlyph], diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3aff83ae6..7867d2ec8 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1934,6 +1934,60 @@ fn indent_to_here_starts_the_paragraphs_later_lines_at_the_character() { assert!((start - 78.0).abs() < 0.01, "plain second line starts at {start}"); } +/// Where each line after the first starts, and the Indent to Here character's x on the first. +fn indent_to_here_starts(text: &str, para: ParaAttrs) -> (f64, Vec) { + let (d, sid, _) = doc_with(text, Rect::new(72.0, 36.0, 272.0, 600.0), para); + let cs = compose_story(&d, sid, &Default::default()); + let lines = all_lines(&cs); + assert!(lines.len() > 4, "the text wraps"); + let byte = text.find('\u{e005}').unwrap(); + let here = lines[0].glyphs.iter().find(|g| g.byte == byte && g.len > 0).unwrap().x; + let starts = lines.iter().skip(1).map(|l| l.glyphs.iter().find(|g| g.visible).unwrap().x).collect(); + (here, starts) +} + +#[test] +fn indent_to_here_after_a_tab_hangs_the_text_after_the_number() { + // The usual hanging number: "1.", a tab to the next stop, then Indent to Here. + let tabs = vec![designcraft_doc::TabStop { position: 24.0, align: TabAlign::Left, leader: String::new(), align_on: String::new() }]; + let (here, starts) = indent_to_here_starts(&format!("1.\t\u{e005}{LOREM}"), ParaAttrs { tabs: Some(tabs), ..Default::default() }); + assert!((here - (72.0 + 24.0)).abs() < 0.01, "the character sits at the tab stop: {here}"); + for (i, start) in starts.iter().enumerate() { + assert!((start - here).abs() < 0.01, "line {} starts at {start}, not at {here}", i + 1); + } +} + +#[test] +fn indent_to_here_beside_a_drop_cap_is_not_indented_by_the_drop_cap_again() { + // A three-line drop cap, then Indent to Here on the first line: the lines beside the drop + // cap and the lines below it all start at the character, none further in. + let (here, starts) = indent_to_here_starts(&format!("Drop \u{e005}{LOREM}"), drop_cap(3, 1)); + for (i, start) in starts.iter().enumerate() { + assert!((start - here).abs() < 0.01, "line {} starts at {start}, not at {here}", i + 1); + } +} + +#[test] +fn indent_to_here_in_a_right_to_left_paragraph_indents_from_the_right() { + // Hebrew words (the bidi classes decide the order; no Hebrew font has to be present): the + // lines after the first end where the character sits, measured from the right edge. + let words = "שלום עולם ".repeat(40); + let text = format!("שלום \u{e005}{words}"); + let para = ParaAttrs { direction: Some(designcraft_doc::TextDirection::RightToLeft), align: Some(Align::Right), ..Default::default() }; + let (d, sid, _) = doc_with(&text, Rect::new(72.0, 36.0, 272.0, 600.0), para); + let cs = compose_story(&d, sid, &Default::default()); + let lines = all_lines(&cs); + assert!(lines.len() > 3, "the text wraps"); + let byte = text.find('\u{e005}').unwrap(); + let here = lines[0].glyphs.iter().find(|g| g.byte == byte && g.len > 0).unwrap().x; + assert!(here < 272.0 - 1.0, "the character follows the first word: {here}"); + let right_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x + g.adv).fold(f64::MIN, f64::max); + for (i, line) in lines.iter().enumerate().skip(1) { + let end = right_edge(line); + assert!((end - here).abs() < 0.01, "line {i} ends at {end}, not at {here}"); + } +} + #[test] fn right_indent_tab_leaders_follow_stop_precedence_without_moving_text() { let text = "Intro\u{e006}12";