collective

Browse / github.com/storytold/wordcraft / patch

Layout: line height of an inline picture in multiple line spacing, as in Word

Layout: line height of an inline picture in multiple line spacing, as in Word When a picture is taller than the text on its line and the paragraph uses multiple spacing (1.15, 1.5, 2 …), Word adds a fixed space below the picture. The picture itself is never scaled by the multiple. The engine sized such lines differently, so the pages broke differently from Word. How Word sizes the line. I measured this in Word for Mac 16.93 and Word for Windows and got the same result in both, within 0.15pt: - if the line also has text: the picture, then the text's descent, then (m − 1) × the text's natural height; - if the line has only pictures: the picture, then (m − 1) × the natura

Status
open
Score
0.00
Adopters
0 other operators
Author
dfgodvan (imported from pull request #4436, open)
Base
4b8b06fe03f93c9fc81783ee3e5b01977be89efc
Size
+142 −10 in 5 files
Review
pending
Shared
2026-10-28
Id
b74f3b1a988d9aeccae91385

How it went

No other operator has reported using it yet.

Diff

raw · crates/layout/src/flow.rs crates/layout/src/para/lines.rs crates/layout/src/para/model.rs crates/layout/src/tests.rs docs/layout-parity.md

diff --git a/crates/layout/src/flow.rs b/crates/layout/src/flow.rs--- a/crates/layout/src/flow.rs+++ b/crates/layout/src/flow.rs@@ -333,8 +333,8 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:                 wordcraft_doc::props::LineSpacing::Multiple(m)                     if m > 1.0 && at_margin && pb.notes.is_empty() && new_notes.is_empty() && line_notes.is_empty() =>                 {-                    // `baseline` is from the paragraph's top; the text ends a descent below it.-                    (l.baseline - l.top + l.text_desc).clamp(0.0, l.height)+                    // The text (or a taller picture) ends `leading` above the line's bottom.+                    (l.height - l.leading).clamp(0.0, l.height)                 }                 _ => l.height,             };diff --git a/crates/layout/src/para/lines.rs b/crates/layout/src/para/lines.rs--- a/crates/layout/src/para/lines.rs+++ b/crates/layout/src/para/lines.rs@@ -436,6 +436,8 @@ pub(super) fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, l         // The tallest picture on the line (it sits on the baseline).         let mut obj_asc = 0.0f32;         let mut any = false;+        // The line's text alone, without its pictures' runs: the multiple's leading below a picture+        let (mut tasc, mut tdesc) = (0.0f32, 0.0f32);         let dropped = pl.drop_cap.map_or(0, |d| d.0);         for (k, c) in pl.clusters.get(c0..c1).into_iter().flatten().enumerate() {             // Spaces and tabs never make a line taller, whatever their size: Word sizes a line by@@ -461,6 +463,8 @@ pub(super) fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, l                 } else {                     // `obj_h` is set on text clusters only under ruby text, which the line grows to fit.                     let (a, d) = st.line_extent();+                    tasc = tasc.max(a);+                    tdesc = tdesc.max(d);                     (a.max(c.obj_h + st.shift), d)                 };                 asc = asc.max(a);@@ -497,11 +501,34 @@ pub(super) fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, l         // no rule tried so far (none, only the text's descent) matched both; it needs Word probes.         let natural = asc + desc;         let pictures = if obj_asc > asc { obj_asc + desc } else { 0.0 };+        // Below a picture taller than the text Word puts the multiple's leading, (m - 1) x the+        // natural height, of the line's text after its descent, or of the paragraph mark's font+        // alone on a line of only pictures, never of a picture's own run. Word for Mac 16.93,+        // a 2cm picture: next to 14pt Times New Roman text at double spacing 3.0 + 16.1pt below it,+        // under a default 11pt Cambria mark 12.9pt, in a 20pt Arial run under an 8pt Times New+        // Roman mark 9.2pt; at 1.15 spacing 0.15 x 12.9pt, less than the descent. Word for+        // Windows: a 472pt picture in 1.5-spaced 12pt text takes 478.3pt. (height, leading)+        let multiple_pictures = match rp.line_spacing {+            LineSpacing::Multiple(m) if text_pictures > 0.0 && m > 1.0 => {+                let (below, leading) = if tasc + tdesc > 0.0 {+                    let leading = (m - 1.0) * (tasc + tdesc);+                    (tdesc + leading, leading)+                } else {+                    let natural = pl.styles.get(mark_style as usize).map_or(text_natural, |st| {+                        let (a, d) = st.line_extent();+                        a + d+                    });+                    ((m - 1.0) * natural, (m - 1.0) * natural)+                };+                Some((obj_asc + below, leading))+            }+            _ => None,+        };         // Document grid: the line takes whole line pitches, its text centred in them.         let (height, centred) = match (rp.line_spacing, grid) {             (LineSpacing::Multiple(m), Some(g)) => ((g.snap_height(natural) * m).max(g.snap_height(pictures)), true),             (LineSpacing::AtLeast(v), Some(g)) => (g.snap_height(natural.max(pictures)).max(v), true),-            (LineSpacing::Multiple(m), None) => ((text_natural * m + label_extra).max(text_pictures), false),+            (LineSpacing::Multiple(m), None) => ((text_natural * m + label_extra).max(multiple_pictures.map_or(text_pictures, |p| p.0)), false),             (LineSpacing::AtLeast(v), None) => (natural.max(text_pictures).max(v), false),             (LineSpacing::Exactly(v), _) => (v, false),         };@@ -509,7 +536,18 @@ pub(super) fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, l             let up = asc.max(obj_asc);             top + ((height - up - desc) / 2.0).max(0.0) + up         } else {-            top + baseline_offset(rp.line_spacing, asc, height, desc).max(obj_asc.min(height - desc))+            // A picture that sets the line's height under multiple spacing stands at its top: the+            // room below it may be less than the descent.+            let floor = if multiple_pictures.is_some_and(|p| height <= p.0) { obj_asc } else { obj_asc.min(height - desc) };+            top + baseline_offset(rp.line_spacing, asc, height, desc).max(floor)+        };+        // Below a picture that sets the line's height the leading is the multiple's, as below text:+        // Word keeps such a line on the page when the picture fits above the bottom margin (with+        // 1.7pt to spare, under the descent, in the same document).+        let leading = if let Some((h, lead)) = multiple_pictures.filter(|p| !centred && height <= p.0) {+            lead.min(top + h - baseline).max(0.0)+        } else {+            (top + height - baseline - text_desc).max(0.0)         };          // Alignment / justification (trailing spaces hang).@@ -601,6 +639,7 @@ pub(super) fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, l             baseline,             text_asc,             text_desc,+            leading,             c0,             c1,             xs,@@ -643,12 +682,14 @@ pub(super) fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, l                 };                 let x0 = rp.indent_left;                 let (x0, left, right) = if rtl_para { (width - x0, width - base_right, width - x0) } else { (x0, x0, base_right) };+                let below = if grid.is_some() { h - desc } else { baseline_offset(rp.line_spacing, asc, h, desc) };                 lines.push(Line {                     top,                     height: h,-                    baseline: top + if grid.is_some() { h - desc } else { baseline_offset(rp.line_spacing, asc, h, desc) },+                    baseline: top + below,                     text_asc: asc,                     text_desc: desc,+                    leading: (h - below - desc).max(0.0),                     c0: n,                     c1: n,                     xs: vec![x0],diff --git a/crates/layout/src/para/model.rs b/crates/layout/src/para/model.rs--- a/crates/layout/src/para/model.rs+++ b/crates/layout/src/para/model.rs@@ -158,6 +158,9 @@ pub struct Line {     /// The text's own ascent and descent on this line, without line-spacing leading (the caret's extent).     pub text_asc: f32,     pub text_desc: f32,+    /// Room at the bottom of the line below its text or picture: multiple spacing's extra+    /// leading, which Word lets a page's last line run into the bottom margin.+    pub leading: f32,     /// Cluster index range.     pub c0: usize,     pub c1: usize,diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs--- a/crates/layout/src/tests.rs+++ b/crates/layout/src/tests.rs@@ -4814,12 +4814,97 @@ fn line_spacing_does_not_scale_pictures() {     let l = lay(&d);     let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };     let line = &para.lines[0];-    let desc = para.styles[0].descent;-    // Word: the picture plus the font's descent, not (picture + descent) x 1.15.-    assert!((line.height - (144.0 + desc)).abs() < 0.01, "line {} for a 144pt picture (descent {desc})", line.height);+    let st = &para.styles[0];+    // Word: the picture plus 0.15 of the paragraph mark's natural height, not (picture + descent)+    // x 1.15, and not the descent either (Word for Mac 16.93: 1.9pt under a 2cm picture, 11pt Cambria).+    let want = 144.0 + 0.15 * (st.ascent + st.descent);+    assert!((line.height - want).abs() < 0.01, "line {} for a 144pt picture, want {want}", line.height);     assert!((line.baseline - line.top - 144.0).abs() < 0.01, "the picture stands on the baseline"); } +#[test]+fn a_picture_line_in_multiple_spacing_takes_the_leading_of_its_text_or_the_paragraph_mark() {+    // Word for Mac 16.93, a 2cm picture at double spacing: alone on its line it gets the+    // paragraph mark's natural height below it, whatever its own run's size (20pt run, 8pt mark:+    // 9.2pt); next to text, the text's descent and natural height (20pt Arial: 4.2 + 23pt).+    use wordcraft_doc::CharProps;+    use wordcraft_doc::props::LineSpacing;+    let pic = 56.7;+    let size = |pt: f32| CharProps { size: Some(pt), ..Default::default() };+    let para = |m: f32, mark: f32, picture_run: Option<f32>, text: Option<f32>| {+        let mut p = wordcraft_doc::Paragraph::with_text(if text.is_some() { "x" } else { "" }, size(text.unwrap_or(mark)));+        p.mark = size(mark);+        p.props.line_spacing = Some(LineSpacing::Multiple(m));+        p.props.space_before = Some(0.0);+        p.props.space_after = Some(0.0);+        if let Some(run) = picture_run {+            p.insert_object(0, picture(40.0, pic, Default::default()), &size(run)).unwrap();+        }+        wordcraft_doc::para_block(p)+    };+    let mut d = Document::from_text("");+    d.body = vec![+        para(1.0, 8.0, None, Some(8.0)),+        para(1.0, 8.0, None, Some(20.0)),+        para(2.0, 8.0, Some(20.0), None),+        para(2.0, 8.0, Some(14.0), Some(20.0)),+    ];+    let l = lay(&d);+    let lines: Vec<crate::para::Line> =+        l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { para, l0, .. } = i { Some(para.lines[*l0].clone()) } else { None }).collect();+    let (small, big) = (&lines[0], &lines[1]);+    let alone = lines[2].height;+    assert!((alone - (pic + small.height)).abs() < 0.01, "alone: {alone}, want {pic} + the 8pt mark's {}", small.height);+    let beside = lines[3].height;+    let want = pic + big.text_desc + big.height;+    assert!((beside - want).abs() < 0.01, "next to 20pt text: {beside}, want {want}");+    assert!((lines[3].baseline - lines[3].top - pic).abs() < 0.01, "the picture stands at the line top");+}++#[test]+fn a_picture_in_one_and_a_half_spacing_takes_the_texts_leading_and_fits_at_the_page_bottom() {+    // Word for Windows (a 472pt picture in 1.5-spaced 12pt text): the line is the picture plus+    // half the text's natural height, and at the foot of a page the picture only has to fit above+    // the bottom margin: it stayed with 1.7pt to spare, less than the descent.+    use wordcraft_doc::props::LineSpacing;+    let pic = 400.0;+    let doc = |spare: f32| {+        let mut d = Document::from_text("Text");+        let s = d.last_section.clone();+        let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();+        p.props.line_spacing = Some(LineSpacing::Exactly(20.0));+        p.props.space_before = Some(0.0);+        p.props.space_after = Some(s.page_h - s.margin_bottom - s.margin_top - 20.0 - pic - spare);+        let mut q = wordcraft_doc::Paragraph::with_text("", Default::default());+        q.props.line_spacing = Some(LineSpacing::Multiple(1.5));+        q.props.space_before = Some(0.0);+        q.insert_object(0, picture(100.0, pic, Default::default()), &Default::default()).unwrap();+        d.body.push(wordcraft_doc::para_block(q));+        d+    };+    let l = lay(&doc(1.0));+    let lines = |l: &DocLayout, page: usize| {+        l.pages[page]+            .items+            .iter()+            .filter_map(|i| if let Placed::Lines { para, l0, .. } = i { Some((para.clone(), *l0)) } else { None })+            .collect::<Vec<_>>()+    };+    let on_first = lines(&l, 0);+    let Some((para, l0)) = on_first.iter().find(|(p, _)| !p.lines.is_empty() && p.lines[0].text_asc >= pic) else {+        panic!("the picture should stay on page 1 ({} pages)", l.pages.len())+    };+    let line = &para.lines[*l0];+    let st = &para.styles[para.clusters[0].style as usize];+    assert!(st.descent > 1.0, "the spare room is under the descent");+    let want = pic + 0.5 * (st.ascent + st.descent);+    assert!((line.height - want).abs() < 0.01, "line {} for a {pic}pt picture, want {want}", line.height);+    assert!((line.baseline - line.top - pic).abs() < 0.01, "the picture stands at the line top");+    // A picture that ends past the margin still goes to the next page.+    let l = lay(&doc(-0.5));+    assert!(!lines(&l, 0).iter().any(|(p, _)| p.lines.first().is_some_and(|x| x.text_asc >= pic)), "the picture should move on");+}+ #[test] fn multiple_line_spacing_adds_its_leading_below_the_text() {     use wordcraft_doc::props::LineSpacing;@@ -4868,8 +4953,10 @@ fn list_label_does_not_inflate_an_inline_picture_floor() {         let l = lay(&d);         let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };         let line = &para.lines[0];-        let desc = para.styles[para.clusters[0].style as usize].descent;-        assert!((line.height - (144.0 + desc)).abs() < 0.01, "{spacing:?}: {}", line.height);+        let st = &para.styles[para.clusters[0].style as usize];+        // Under the picture: the descent, or the text's own multiple leading when larger.+        let below = if let LineSpacing::Multiple(m) = spacing { st.descent.max((m - 1.0) * (st.ascent + st.descent)) } else { st.descent };+        assert!((line.height - (144.0 + below)).abs() < 0.01, "{spacing:?}: {}", line.height);         assert!((line.baseline - line.top - 144.0).abs() < 0.01);     } }diff --git a/docs/layout-parity.md b/docs/layout-parity.md--- a/docs/layout-parity.md+++ b/docs/layout-parity.md@@ -266,6 +266,7 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim.  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | trivial | A line set by an inline picture in multiple spacing above single takes the text's leading, (m - 1) x its natural height, below the picture instead of the descent (when larger), and at the foot of a page only the picture has to fit above the bottom margin, as for text. Word for Windows put a 472pt picture in 1.5-spaced 12pt text on the page with 1.7pt to spare and spaced the next line 6.3pt below it; the 64-page document now has 64 pages, not 65 (`layout::tests::a_picture_in_one_and_a_half_spacing_takes_the_texts_leading_and_fits_at_the_page_bottom`) | | 2026-10-25 | trivial | Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`) | | 2026-10-24 | trivial | Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; original emoji regression retained, new mixed-style/bundled-font LTR/bidi controls pending managed validation | | 2026-10-24 | trivial | Exact-spacing table contact uses the specified line height in row selection and word skipping; large/small mark regressions in modes 11/12/14/15 (#3977); shape estimates unchanged |