From 57dfacfc0797472b35f812ae90552220bd28818f Mon Sep 17 00:00:00 2001 From: Fin Warman Date: Sun, 18 Oct 2026 15:26:01 +0100 Subject: [PATCH] Layout: preserve equation baselines under exact line spacing (#2435) * Layout: preserve equation baselines under exact line spacing (task 243) * layout-parity: keep the cell-margin inheritance note on the Tables row --------- Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 ++ crates/layout/src/para.rs | 5 ++-- crates/layout/src/tests.rs | 53 ++++++++++++++++++++++++++++++++++++++ docs/gaps.md | 3 +++ docs/layout-parity.md | 5 +++- 5 files changed, 65 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 31432af1b..84a70a36c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -162,6 +162,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. | | 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture | | 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | +| 2026-10-15 | Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. | |---|---| | 2026-10-15 | The font family caches are bounded (4096 families for text, 1024 for math, names over 256 bytes aren't remembered), so a document naming thousands of missing fonts can't grow memory; equation fonts are looked up again after a font is added or removed instead of keeping the old choice (#1354) | | 2026-10-15 | AutoCorrect recognizes `-->` as `→` (#2223), respecting replacement settings and undo without changing paragraph-start arrow bullets. | @@ -506,6 +507,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-15 | trivial | Progress log: plain-text paste keeps the paragraph formatting where it lands | | 2026-10-14 | trivial | Progress log: Add Font imports every selected font file (#1789) | | 2026-10-15 | trivial | Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. | +| 2026-10-15 | trivial | Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. | | 2026-10-14 | trivial | Progress log: row table border exceptions | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 35be42a93..5aafa4be4 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -2227,8 +2227,9 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio // and descent, and grows above the baseline to fit a taller picture. let (a, d) = if matches!(c.kind, ClKind::Object(_)) && c.obj_h > 0.0 { if pl.maths.iter().any(|(k, _)| c.kind == ClKind::Object(*k)) { - // An equation is text: its ascent and descent count like the text's. - (c.obj_h, c.obj_d) + // Exact spacing keeps the run's baseline; tall math may overflow the + // fixed line box without moving adjacent text up by its denominator. + if matches!(rp.line_spacing, LineSpacing::Exactly(_)) { (st.ascent, st.descent) } else { (c.obj_h, c.obj_d) } } else { obj_asc = obj_asc.max(c.obj_h); (st.ascent, st.descent) diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 0edbcbb56..b8dc7fe8f 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -8170,3 +8170,56 @@ fn partial_margins_inherit() { assert!((height(2.75, 2.75) - 16.5).abs() < 0.01); assert!((height(6.0, 8.0) - 25.0).abs() < 0.01); } + +/// Exact line pitch keeps the text baseline even when an equation has a deep denominator. +#[test] +fn exact_math_keeps_run_baseline() { + use wordcraft_doc::props::LineSpacing; + for display in [false, true] { + for spacing in [LineSpacing::Exactly(12.0), LineSpacing::AtLeast(12.0), LineSpacing::Multiple(1.0)] { + let mut baseline = Vec::new(); + let mut following = Vec::new(); + for equation in [None, Some("a/(b/c)")] { + let chr = wordcraft_doc::CharProps { font: Some(wordcraft_doc::styles::BODY_FONT.into()), size: Some(12.0), ..Default::default() }; + let mut p = wordcraft_doc::Paragraph::new(); + p.props.line_spacing = Some(spacing); + match equation { + None => { + p.insert_text(0, "x", &chr).unwrap(); + } + Some(text) => p + .insert_object( + 0, + InlineObject::Equation { + linear: text.into(), + display, + math: wordcraft_doc::math::Math { nodes: wordcraft_doc::math::parse_linear(text), ..Default::default() }, + }, + &chr, + ) + .unwrap(), + } + let mut d = Document::from_text("After"); + d.body.insert(0, wordcraft_doc::para_block(p)); + let l = lay(&d); + let (y, para) = l.pages[0] + .items + .iter() + .find_map(|it| match it { + Placed::Lines { path, y, para, .. } if *path == Path::top(0) => Some((*y, para)), + _ => None, + }) + .unwrap(); + baseline.push(y + para.lines[0].baseline); + following.push(para_at(&l, &[1])[0].1); + } + match spacing { + LineSpacing::Exactly(_) => { + assert!((baseline[0] - baseline[1]).abs() < 0.01, "display={display}: {baseline:?}"); + assert!((following[0] - following[1]).abs() < 0.01, "fixed pitch: {following:?}"); + } + _ => assert!(following[1] > following[0], "minimum pitch grows for math: {following:?}"), + } + } + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 4ac0b802f..f6bf6822b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -22,6 +22,8 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. Other pagination gaps remain open. +Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. Other pagination gaps remain open. + ## Ranked | # | Gap | B | Evidence | Impact | Hours | Doc | @@ -110,6 +112,7 @@ The Language dialog reads effective character formatting, including document def | 2026-10-14 | trivial | Gap 19: RTF export writes sections (ODT export landed too) | | 2026-10-14 | trivial | #19 narrowed: ODT export done (master page and page layout per section, columned `text:section`s) | | 2026-10-15 | trivial | Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. | +| 2026-10-15 | trivial | Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. | | 2026-10-14 | trivial | Row table border exceptions fixed; other row table exceptions remain unsupported | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 18073655f..82f072ce2 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -14,6 +14,8 @@ font can't match Word's metrics. Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. Synthetic control: `layout::tests::section_start_summed_spacing`. +Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. Synthetic control: `layout::tests::exact_math_keeps_run_baseline`. + ## Checklist ✅ matches Word as far as our tests show · 🟡 implemented, differs or untested against Word · ❌ missing @@ -53,7 +55,7 @@ Additive paragraph spacing retains the full before-space after a section page br | Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — | | Tables: row border exceptions (`w:tblPrEx/w:tblBorders`) | ✅ | `row_borders_stay_on_row`, `row_nil_overrides_style`: explicit cell borders win; row exceptions precede table styles and table borders | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | -| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — | +| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; omitted cell-margin edges inherit current table defaults; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — | | Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; other overlap rules untested | 2–3 | | Nested tables | 🟡 | render; polish missing | 2–4 | @@ -214,6 +216,7 @@ Behind-text pictures in table cells participate in centre and bottom vertical al | 2026-10-15 | trivial | Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. | | 2026-10-15 | trivial | Removed the 12 pt line-width floor in narrow cells and float spans; synthetic wrapping and oversized-glyph controls | | 2026-10-15 | trivial | Partial cell margins retain omitted edges through DOCX save and inherit current table defaults during layout. Original import/write and row-height regressions. | +| 2026-10-15 | trivial | Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. | | 2026-10-14 | trivial | Row table border exceptions draw without leaking into other rows | | 2026-10-14 | trivial | Consistent cell widths override a stale table grid | | 2026-10-14 | trivial | Paragraphs inside complex-field instructions take no flow height; empty result paragraphs keep their line. |