From 7563ebb314e32e39d155a37da067602ebe380623 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:23:25 +0100 Subject: [PATCH] Layout: a no-break space is as wide as a space, not as the font's own U+00A0 glyph (#4210) * Layout: a no-break space is as wide as a space, not as the font's own U+00A0 glyph (fixes #4192) * Layout parity: a no-break space is as wide as a space (#4192) --- crates/layout/src/lib.rs | 2 + crates/layout/src/para.rs | 4 ++ crates/layout/src/tests_nbsp.rs | 65 +++++++++++++++++++++++++++++++++ docs/layout-parity.md | 3 +- 4 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 crates/layout/src/tests_nbsp.rs diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index f7d4b9b341..0d30535386 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -4939,6 +4939,8 @@ mod tests_grid; #[cfg(test)] mod tests_ligature; #[cfg(test)] +mod tests_nbsp; +#[cfg(test)] mod tests_numeral; #[cfg(test)] mod tests_pages; diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 16e3d34be1..d2c62fc953 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -1066,6 +1066,10 @@ impl<'a> Builder<'a> { let d = show(c); if d != c { d + } else if c == '\u{a0}' { + // Word sets a no-break space as wide as a space; some fonts draw their own + // U+00A0 glyph wider. Only the shaping changes: line breaking reads the text. + ' ' } else if upper { upper_char(c) } else { diff --git a/crates/layout/src/tests_nbsp.rs b/crates/layout/src/tests_nbsp.rs new file mode 100644 index 0000000000..b8dbc9aad7 --- /dev/null +++ b/crates/layout/src/tests_nbsp.rs @@ -0,0 +1,65 @@ +//! A no-break space is laid out as wide as an ordinary space, whatever the font's own U+00A0 +//! glyph measures (Lucida Bright draws it twice as wide). + +use super::*; + +/// Inter with its U+00A0 glyph twice as wide as its space, under the family name "Wnbsp". +/// Built from the bundled font so the test needs no installed font. +fn wide_nbsp_family() -> &'static str { + const FAMILY: &str = "Wnbsp"; + let inter = wordcraft_fonts::word::resolve("Inter", false, false).face; + let mut data = inter.data().to_vec(); + let gid = inter.glyph_for('\u{a0}') as usize; + assert_ne!(gid, inter.glyph_for(' ') as usize, "Inter keeps a glyph of its own for U+00A0"); + let u16_at = |d: &[u8], o: usize| u16::from_be_bytes([d[o], d[o + 1]]) as usize; + let u32_at = |d: &[u8], o: usize| u32::from_be_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]) as usize; + let table = |d: &[u8], tag: &[u8; 4]| { + (0..u16_at(d, 4)).map(|i| 12 + 16 * i).find(|&r| &d[r..r + 4] == tag).map(|r| (u32_at(d, r + 8), u32_at(d, r + 12))).unwrap() + }; + let (hmtx, _) = table(&data, b"hmtx"); + assert!(gid < u16_at(&data, table(&data, b"hhea").0 + 34), "the glyph has a full metrics record"); + let advance = u16_at(&data, hmtx + 4 * gid); + data[hmtx + 4 * gid..hmtx + 4 * gid + 2].copy_from_slice(&((advance * 2) as u16).to_be_bytes()); + // The family name, in the name table's UTF-16 and single-byte records alike. + let (name, len) = table(&data, b"name"); + let wide = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(wide("Inter"), wide(FAMILY)), (b"Inter".to_vec(), FAMILY.as_bytes().to_vec())] { + for i in name..name + len - from.len() { + if data[i..i + from.len()] == from[..] { + data[i..i + from.len()].copy_from_slice(&to); + } + } + } + wordcraft_fonts::FontDb::global().add_font(data); + FAMILY +} + +/// The width of the first line of `text` set in the wide-nbsp font. +fn line_width(text: &str) -> f32 { + let chr = wordcraft_doc::CharProps { font: Some(wide_nbsp_family().into()), size: Some(12.0), ..Default::default() }; + let d = Document { body: vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, chr))], ..Document::new() }; + let mut c = LayoutCache::new(); + let l = layout(&d, &mut c, &LayoutOptions::default()); + for p in &l.pages { + for it in &p.items { + if let Placed::Lines { para, .. } = it { + let xs = ¶.lines[0].xs; + return xs[xs.len() - 1] - xs[0]; + } + } + } + panic!("paragraph not laid out"); +} + +#[test] +fn a_no_break_space_is_as_wide_as_a_space() { + let face = wordcraft_fonts::word::resolve(wide_nbsp_family(), false, false).face; + assert_eq!(face.family, wide_nbsp_family(), "the test font is installed under its own name"); + assert!( + face.advance(face.glyph_for('\u{a0}')) > 1.9 * face.advance(face.glyph_for(' ')), + "the font's own U+00A0 glyph is twice as wide as its space" + ); + let nbsp = line_width(&format!("|{}|", "\u{a0}".repeat(10))); + let space = line_width(&format!("|{}|", " ".repeat(10))); + assert!((nbsp - space).abs() < 0.01, "ten no-break spaces measure {nbsp} pt, ten spaces {space} pt"); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 493ec6a677..c443e7ca7d 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-25 · **Change:** trivial (a bookmark or other zero-width marker is no line-break opportunity, #4193; previously 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`); previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously 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: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; 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-25 · **Change:** trivial (a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously 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`); previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously 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: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; 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, @@ -264,6 +264,7 @@ Installed static fonts also resolve by their OpenType PostScript name (name ID 6 | 2026-10-21 | trivial | A word with no break opportunity in it, laid out beside a floating object, no longer lays out quadratically: every word is measured once, before the line is broken (#3412) | | 2026-10-23 | trivial | An empty paragraph whose mark is hidden is laid out as nothing, like a deleted one: no room, no list number (still counted, as in Word) and no shading, in the body and in table cells, headers and text boxes (#3982) | | 2026-10-25 | trivial | A bookmark, comment anchor or other zero-width marker is no line-break opportunity: a word that doesn't fit after one no longer leaves a blank line, and one inside a word no longer splits it (#4193) | +| 2026-10-25 | trivial | A no-break space is shaped as a space, so it is as wide as one even in fonts whose own U+00A0 glyph is wider; it still holds the line together (#4192). `layout::tests_nbsp::a_no_break_space_is_as_wide_as_a_space` | | 2026-10-22 | trivial | A table row that another row follows fits on a page only with room for the border below it, as checked in Word for Mac: a 60-row table with 3 pt borders kept one row more than Word on page 1 for bottom margins of 78.2–81.0 pt (`layout::tests::a_row_leaves_room_for_the_border_below_it`) | | 2026-10-16 | trivial | Exact PostScript names resolve installed static faces without changing family lookup | | 2026-10-21 | trivial | Pin the font input of four geometric layout controls; assertions and renderer font policy unchanged. |