diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 6084745b7..2445b90a1 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -447,6 +447,7 @@ impl DocLayout { let first = para.lines.get(*l0)?; let l = para.lines.get(li)?; let cx = para.x_of(li, pos.off)?; + let cx = para.caret_limit(li).map_or(cx, |m| cx.min(m)); // In the lines' own frame (turned text turns it onto the page). let top = l.top - first.top; // Caret spans the text height (ascent + descent), not the full line spacing. diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index b35661d42..3e36cd4fb 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -2545,6 +2545,21 @@ impl ParaLayout { } l.xs.last().copied() } + /// The farthest right a caret goes on left-to-right line `li`: spaces at the end of a line + /// hang past its right edge, but the caret after them stays at the edge, as in Word (#2146); + /// text that overflows the line still carries it. `None` on bidirectional lines. + pub fn caret_limit(&self, li: usize) -> Option { + let l = self.lines.get(li)?; + if !l.vis.is_empty() { + return None; + } + let ink = (l.c0..l.c1).rev().find(|&k| self.clusters.get(k).is_some_and(|c| c.kind != ClKind::Space)); + let end = match ink { + Some(k) => l.xs.get(k + 1 - l.c0).copied(), + None => l.xs.first().copied(), + }; + Some(end.unwrap_or(l.left).max(l.right)) + } /// The line holding offset `off` (a position at a wrap point belongs to the next line). pub fn line_of(&self, off: usize) -> usize { for (i, l) in self.lines.iter().enumerate() { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index a8ad28337..28fc46c77 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1336,6 +1336,39 @@ fn headers_and_page_fields() { assert!(c.top > 700.0); } +/// Spaces typed at the end of a line hang past the right margin (they don't wrap), but the caret +/// after them stays at the margin, as in Word, instead of running off the page (#2146). A letter +/// typed after them wraps to the next line. Same in the body and in a header. +#[test] +fn caret_after_hanging_spaces_stays_at_the_margin() { + let spaces = format!("a{}", " ".repeat(200)); + for header in [false, true] { + for text in [spaces.clone(), format!("{spaces}b")] { + let mut d = Document::new(); + let p = wordcraft_doc::Paragraph::with_text(&text, Default::default()); + let pos = if header { + let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(p)]); + d.last_section.headers.default = Some(id); + Pos { story: StoryRef::Part(id), path: Path::top(0), off: text.len() } + } else { + d.body = vec![wordcraft_doc::para_block(p)]; + Pos::body(0, text.len()) + }; + let right = d.last_section.page_w - d.last_section.margin_right; + let l = lay(&d); + let items = if header { &l.pages[0].header } else { &l.pages[0].items }; + let lines = items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { Some(para.lines.len()) } else { None }).unwrap(); + let c = l.caret(&pos).unwrap(); + let typed = text.ends_with('b'); + assert_eq!(lines, if typed { 2 } else { 1 }, "header {header}, letter typed {typed}"); + assert!(c.x <= right + 0.5, "header {header}, letter typed {typed}: caret at {} past the margin at {right}", c.x); + if !typed { + assert!(c.x >= right - 0.5, "header {header}: the caret sits at the margin, after the spaces ({})", c.x); + } + } + } +} + #[test] fn tall_first_page_header_pushes_body_down() { let mut d = Document::from_text(&"para\n".repeat(120)); diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 946816a4a..8fdadea67 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -21,6 +21,7 @@ font can't match Word's metrics. | 2026-10-16 | trivial | Split-cell content retains its resolved top margin and border band on continuation pages, matching original Word Mac16.113.2 controls. Synthetic regressions cover margins, borders, line spacing and image frames. | | Line breaking (UAX #14 with Word's exceptions: no break after `/`) | ✅ | `layout::tests::no_line_break_right_after_a_slash` | — | | Justification, Word 2013+ space shrinking (compat mode 15) | ✅ | #109 | — | +| Spaces at the end of a line hang past the right margin instead of wrapping; the caret after them stays at the margin, in the body and in headers and footers | ✅ | `layout::tests::caret_after_hanging_spaces_stays_at_the_margin` (#2146): a letter typed after them wraps to the next line | — | | Complex scripts (Myanmar, Khmer, Indic): shaped per script run with OpenType fallback fonts, one cluster per syllable, Myanmar wraps between syllables | 🟡 | `layout::tests_complex` (#604). Khmer, Thai and Lao still wrap only at spaces (need a dictionary) | 4–8 | | Kashida justification (Arabic) | ❌ | no kashida in `crates/layout` | 3–5 | | Distributed / Thai distributed alignment | 🟡 | read; layout untested | 1–2 | @@ -184,6 +185,7 @@ Theme-inherited dash styles, line caps, ink and open freeform strokes are not co | 2026-10-16 | trivial | Exact OpenType full names (name ID4) select installed static faces after family names; variable-instance aliases remain unsupported | | 2026-10-16 | trivial | Symbol (PCL6) uses the existing Symbol mapping and Symbol face/fallback despite stale Roman font metadata; stored codes/fonts stay unchanged | | 2026-10-16 | trivial | Prefer a covering Times New Roman face for an uncovered black-circle glyph; retain normal label-ascent and other fallback rules | +| 2026-10-15 | trivial | The caret after spaces hanging past the right margin stays at the margin instead of running off the page (#2146) | | 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. |