From e6d78a71d05c015d39c87ce2b6cbeaeb4e184a09 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 22 Oct 2026 16:09:27 +0500 Subject: [PATCH] A right-to-left paragraph's first-line indent is at its right edge (#1448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-line indent was added to the left indent whatever the paragraph's direction, so a right-to-left paragraph's first line moved the wrong way. With the hanging indent a numbered list uses — right indent 10 mm, first line −10 mm — the first line went 10 mm outside the frame on the left while the wrapped lines sat where it should have been. It is the paragraph's start-side indent, which is the right one in a right-to-left paragraph; the drop-cap case beside it already made that distinction. Measured on a 200 pt column with that hanging indent: first line right edge 200 (the frame's edge, as InDesign sets it) wrapped right edge 171.65 (10 mm in) every line left edge >= 0 (nothing outside the frame) and the same paragraph left to right is unchanged, first line at 0 and wrapped lines 10 mm in. The test checks both directions; the right-to-left half fails without the change. 359 composition tests pass unchanged. --- crates/compose/src/lib.rs | 8 ++++++- crates/compose/src/tests.rs | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 89ea89bd..aaaee080 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1674,10 +1674,16 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C } // A drop cap sits at the start-side indent; the first-line indent doesn't move it. let beside = drop_cap.as_ref().filter(|dc| line_no < dc.lines && col_first_line == 0).map(DropCap::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), Some(w) => (pp.left_indent + w, pp.right_indent), - None => (pp.left_indent + if line_no == 0 { pp.first_line_indent } else { 0.0 }, pp.right_indent), + // The first-line indent is at the paragraph's start side, which is the right + // one in a right-to-left paragraph: applied on the left it moved the line the + // wrong way, out of the frame for the hanging indent a numbered list uses + // (#1448). + None if rtl => (pp.left_indent, pp.right_indent + first_line), + None => (pp.left_indent + first_line, pp.right_indent), }; let lx0 = x0 + ind_l; let lx1 = x1 - ind_r; diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 15c412ad..57a545b0 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -7310,3 +7310,45 @@ fn rule_above_offset_is_measured_from_the_baseline() { assert!((rule.height() - 1.5).abs() < 1e-6); assert!(rule.y0 > first.baseline, "the rule stays below the row above: {rule:?}, its baseline {}", first.baseline); } + +/// A right-to-left paragraph's first-line indent belongs at its start side, the right. Applied on +/// the left it moved the first line the wrong way — out of the frame for the hanging indent a +/// numbered list uses (right indent 10 mm, first line −10 mm), while the wrapped lines sat where +/// the first line should have been (#1448). +#[test] +fn an_rtl_first_line_indent_is_at_the_right_edge() { + const MM: f64 = 72.0 / 25.4; + // Latin text in a right-to-left paragraph: the direction is the paragraph's, and it is the + // placement that is being measured, so no Arabic font has to be present. + let text = LOREM; + let rect = Rect::new(0.0, 0.0, 200.0, 300.0); + let para = |dir, align, first, left, right| ParaAttrs { + direction: Some(dir), + align: Some(align), + first_line_indent: Some(first), + left_indent: Some(left), + right_indent: Some(right), + ..Default::default() + }; + // The hanging indent of a numbered list, right to left. + let (d, sid, _) = doc_with(text, rect, para(designcraft_doc::TextDirection::RightToLeft, Align::Right, -10.0 * MM, 0.0, 10.0 * MM)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert!(lines.len() >= 2, "the text wraps: {}", lines.len()); + let right_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x + g.adv).fold(f64::MIN, f64::max); + let left_edge = |l: &Line| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x).fold(f64::MAX, f64::min); + // The first line reaches the frame's right edge; the wrapped lines stop 10 mm short of it. + assert!((right_edge(lines[0]) - rect.x1).abs() < 1.0, "first line at the edge: {}", right_edge(lines[0])); + assert!((right_edge(lines[1]) - (rect.x1 - 10.0 * MM)).abs() < 1.0, "wrapped line indented: {}", right_edge(lines[1])); + // And nothing leaves the frame on the left, which is where the first line used to go. + for (i, l) in lines.iter().enumerate() { + assert!(left_edge(l) >= rect.x0 - 0.01, "line {i} starts inside the frame: {}", left_edge(l)); + } + + // The same paragraph left to right is unchanged: the first line hangs on the left. + let (d, sid, _) = doc_with(text, rect, para(designcraft_doc::TextDirection::LeftToRight, Align::Left, -10.0 * MM, 10.0 * MM, 0.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines = all_lines(&cs); + assert!((left_edge(lines[0]) - rect.x0).abs() < 1.0, "left to right, first line at the edge: {}", left_edge(lines[0])); + assert!((left_edge(lines[1]) - (rect.x0 + 10.0 * MM)).abs() < 1.0, "left to right, wrapped line indented: {}", left_edge(lines[1])); +}