From 5c0a62a73669a7bb53a1a470e8dd83bebde8dca4 Mon Sep 17 00:00:00 2001 From: naxvog Date: Sun, 8 Nov 2026 05:18:09 +0100 Subject: [PATCH 01/12] Layout: no line break between a full stop and an opening parenthesis (#5437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UAX #14 allows a break between a full stop (IS) and an opening parenthesis (OP), so `break_positions` (crates/layout/src/para/paragraph.rs) let "29.05.2023г.(оформление ЗУ)" and "2023.(Example detail)" wrap after the full stop. Word keeps such a token whole and wraps it at the space before, as the docxide-pdf dot-open-token* Word references show. The break between the two is now dropped (before the kinsoku pass, so break-anywhere wrapping still applies). Test: layout::tests::a_full_stop_before_a_parenthesis_is_no_break. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/layout/src/para/paragraph.rs | 3 +++ crates/layout/src/tests.rs | 30 +++++++++++++++++++++++++++++ docs/gaps.md | 2 ++ docs/layout-parity.md | 3 ++- 5 files changed, 39 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index 249f287f99..6634121a2c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -237,6 +237,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Find and Replace: a keyboard apostrophe also finds typographic single quotes, so smart-quoted typing stays searchable (#5388). Regex, wildcards, literal searches and numeric character codes remain exact. Original UTF-8 match ranges and replacement spelling are preserved; readiness unchanged. - 2026-11-03: Security: XLSX import finds each sheet's worksheet part through an index of the workbook's relationships, built once, instead of searching every relationship for every sheet. Readiness unchanged. - 2026-11-03: Layout: a row of a nested table marked Can't Split isn't split with its outer row any more. The outer row moves to the next page whole when that row comes first in it, as Word does (docxide-pdf `nested-first-row-*-cant1`), and otherwise splits above it (inferred, not yet checked in Word); on a fresh page a nested row taller than the room still splits rather than running into the margin. Readiness unchanged. +- 2026-11-03: Layout: no line break between a full stop and an opening parenthesis. UAX #14 allows one there, Word doesn't: `29.05.2023г.(оформление ЗУ)` and `2023.(Example detail)` now wrap whole at the space before, as in the docxide-pdf `dot-open-token*` Word references. Readiness unchanged. - 2026-11-03: Security: pasting text boxes charges each copied story for its formatting too (run, mark, paragraph and section properties), not only its text and objects, so a story with huge style or font names can't copy past the paste's 32 MiB text box budget. Readiness unchanged. - 2026-11-03: Security: `.doc` import keeps one width, shading, alignment and merge override per table cell, the last one winning as before, instead of a copy every time a paragraph's properties repeat the sprm, so a shared PAPX can't pile up copies on every paragraph mark. Readiness unchanged. - 2026-11-03: Security: `.doc` import attaches each section to the paragraph that ends it through an index of paragraph ends, instead of scanning every paragraph for every section, so a file with a thousand sections that end nowhere can't multiply the work over its paragraphs. Readiness unchanged. @@ -914,6 +915,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. | | 2026-11-03 | trivial | Progress log: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288) | | 2026-11-03 | trivial | Progress log: a nested table's Can't Split row moves on with its outer row | +| 2026-11-03 | trivial | Progress log: no line break between a full stop and an opening parenthesis | | 2026-11-03 | trivial | Progress log: Replace and Remove Table of Contents with Word's title above a section break (#4041) | | 2026-11-03 | trivial | Progress log: language-packs-2 published and pinned, 75 Language packs | | 2026-11-02 | trivial | Progress log: language-packs-1 published and pinned, 61 Language packs (CP4.4) | diff --git a/crates/layout/src/para/paragraph.rs b/crates/layout/src/para/paragraph.rs index f844e541ab..1d024219d7 100644 --- a/crates/layout/src/para/paragraph.rs +++ b/crates/layout/src/para/paragraph.rs @@ -864,6 +864,9 @@ fn break_positions(text: &str, rp: &wordcraft_doc::resolve::ResolvedPara) -> Vec found.sort_unstable(); found.dedup(); } + // UAX #14 breaks between a full stop and an opening parenthesis; Word keeps + // "2023.(Example" whole and wraps it at the space before. + found.retain(|&i| !(text.get(..i).is_some_and(|t| t.ends_with('.')) && text.get(i..).is_some_and(|t| t.starts_with('(')))); // Asian typography: kinsoku and breaking Latin words anywhere. crate::kinsoku::apply(text, &mut found, rp.kinsoku, rp.word_wrap); found diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 2b1c4eb15b..4acda0f5fe 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -13679,6 +13679,36 @@ fn a_hyphen_before_a_slash_is_a_break_opportunity_like_word() { panic!("no width in the sweep wrapped the paragraph"); } +/// docxide-pdf `dot-open-token*`: UAX #14 allows a break between a full stop and an opening +/// parenthesis, Word doesn't. The first width that wraps `Deed 2023.(Example` breaks at the space. +#[test] +fn a_full_stop_before_a_parenthesis_is_no_break() { + let text = "Deed 2023.(Example"; + for m in 100..=540 { + let mut d = Document::from_text(text); + d.last_section.margin_right = m as f32; + let l = lay(&d); + let lines: Vec = l.pages[0] + .items + .iter() + .filter_map(|it| { + if let Placed::Lines { para, l0, l1, .. } = it { + Some(para.lines[*l0..*l1].iter().map(|line| text[line.start..line.stop].to_string()).collect::>()) + } else { + None + } + }) + .flatten() + .collect(); + if lines.len() < 2 { + continue; // still fits on one line at this width + } + assert_eq!(lines, ["Deed ", "2023.(Example"], "the first wrap breaks at the space"); + return; + } + panic!("no width in the sweep wrapped the paragraph"); +} + #[test] fn layout_cache_invalidates_on_changed_filename() { let mut doc = Document::new(); diff --git a/docs/gaps.md b/docs/gaps.md index cae60f28b0..e4ed791d1b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -87,6 +87,7 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren | 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963) 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, so a TOC entry's page number keeps its leader (#1840). Page break before now applies at a continuous section's mid-page top (#5287); the later-column case remains unmeasured in Word. | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963); a hyphen before a slash and a letter is also a break opportunity (#3408), while a hyphen before a slash and a digit stays whole 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, so a TOC entry's page number keeps its leader (#1840). | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963); a nested table's Can't Split row moves on with its outer row when the row splits across pages (#5456) 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, so a TOC entry's page number keeps its leader (#1840). | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | +| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options only partially covered; no page-by-page comparison harness | B | SECTIONPAGES now counts shared continuous-section pages in body and header/footer fields; legacy same-style cell spacing preserved for measured modes 11/12/14, with mode 15 and non-cell controls; Contextual spacing corrected in body/header probes and repeated table-cell runs; [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps; non-overlapping body floating tables clear header tables; stray labels on page-break-only list paragraphs fixed multi-paragraph field-code blank lines fixed; centred numbered heading labels after leading page breaks fixed (#2964); Arial Unicode MS no longer selects monospace substitutes; a slash opening a word travels whole to the next line (#2963); a hyphen before a slash and a letter is also a break opportunity (#3408), while a hyphen before a slash and a digit stays whole; a full stop followed by an opening parenthesis stays on one line (#5437) 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, so a TOC entry's page number keeps its leader (#1840). | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) | | 2 | **Word's native ink is not drawn from its strokes**; OLE objects show only their picture | B | Every native ink form is preserved: drawing-level ink with its optional picture fallback, direct run-level content parts, and ink members of groups and drawing canvases (kept in their place among the members, drawn from their fallback picture). Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Pen notes from Word show only as their picture and can't be edited; OLE objects can't be opened | 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | | 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; Track Changes Options has the Moves rows (Moved from / Moved to mark and colour) and `w:doNotTrackMoves` (Track moves off: a cut and paste is a deletion and an insertion) round-trips; still missing: drag-and-drop moves (no text drag yet). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes (table, row, cell and section property changes now record in WordCraft too, #41 follow-up). Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); print-only N-up and booklets are available; driver tray/options remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | @@ -232,6 +233,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-10-17 | trivial | UI/UX gaps: copies put RTF on the clipboard (Windows, macOS, Wayland; not X11), and paste reads RTF on macOS and X11 too | | 2026-11-03 | trivial | Find smart-quoted text with a keyboard apostrophe (#5388); exact search modes and replacement spelling preserved. | | 2026-11-03 | trivial | #4 narrowed: a nested table's Can't Split row moves on with its outer row when the row splits across pages (#5456); Word shows this for a nested row first in its outer row, the split above a later one is inferred and not yet checked in Word; readiness estimates unchanged | +| 2026-11-03 | trivial | #4 narrowed: a full stop followed by an opening parenthesis stays on one line, as in Word (#5437) | | 2026-11-03 | minor | #11: Arabic interface with mirrored shell (#1195); key languages missing are now hi and id (plus pt-PT); Hebrew UI still open | | 2026-11-03 | trivial | #8 (CP4.3): one language list: Set Proofing Language, Review › Language and the status bar offer the Language pack languages (Bulgarian was in none); every published pack named in English; Valencian is `ca-ES-valencia`; gap 8 names built-in proofing | | 2026-11-03 | trivial | #8 (CP4.3): the missing-pack bar offers a language before the list of packs is fetched (a fresh install, or a list cached for an older pin) and gets it on Install; with downloading on, a missing or stale list is fetched once in the background; the caret's typing language counts; the spelling context menu's heading is in the interface language | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 8f57f85b0a..0034e76638 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -25,7 +25,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Hint-reliant CJK glyph outlines (DFKai-SB / 標楷體) | 🟡 | Required TrueType interpreter runs at units-per-em size; local Windows glyph-bounds regression and synthetic DOCX page/window comparison pass. Other platforms and hint-reliant families remain unmeasured; no native Word comparison. A font asking for a twilight zone over 1,024 points or a stack over 4,096 values, or a glyph past 256 KiB of hinting buffer, is drawn unhinted; on desktop, once at least three of a face's hinted draws have taken over 50 ms and those slow draws are at least 1/16 of its hinted draws, its later glyphs are drawn unhinted (#3692). | — | | Subpoint table grid columns | 🟡 | Preserve positive grid widths rather than imposing a text-box minimum; ECMA-376 §17.4.16. Native Word for Mac 16.113.2 build26092012 confirms narrow trailing grid units. `layout::tests::subpoint_grid_keeps_widths`, `tiny_grid_scales_to_width`. Tiny totals normalize without overflowing the scale factor. Zero and invalid widths retain their fallback. Border painting remains separate. | n/a | | Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_bookmark_is_no_line_break_opportunity; the paragraph's kinsoku (JIS X 4051 §4 行頭禁則) lists the small kana (っゃゕヶ, the katakana phonetic extensions ㇰ…ㇿ, the halfwidth small kana ァ…ッ) and the prolonged sound mark ー/ー as non-starters — CJ/NS in UAX #14, where the break before them is only a tailoring choice — as JIS X 4051 §4 specifies, inferred from the standard and the synthetic tests named here rather than checked in Word. The rule covers ordinary break opportunities only: a line too narrow for any of them takes the emergency break in `para::break_lines`, which can still start with one, as the module doc records. `layout::tests::a_line_never_starts_with_a_small_kana_or_the_prolonged_sound_mark`, `layout::kinsoku::tests::no_start_holds_every_small_kana_and_the_prolonged_sound_mark` | — | -| Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit, or before a slash and a letter (`Dataprotection-` / `/Secrecyviolation` in the #3408 report's Word PDF; `fitness-/1589` stays whole in the arabic_rice_benefits_article reference)) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_hyphen_before_a_slash_is_a_break_opportunity_like_word`, `layout::tests::a_bookmark_is_no_line_break_opportunity` | — | +| Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit, or before a slash and a letter (`Dataprotection-` / `/Secrecyviolation` in the #3408 report's Word PDF; `fitness-/1589` stays whole in the arabic_rice_benefits_article reference), no break between `.` and `(`) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_hyphen_before_a_slash_is_a_break_opportunity_like_word`, `layout::tests::a_full_stop_before_a_parenthesis_is_no_break`, `layout::tests::a_bookmark_is_no_line_break_opportunity` | — | | A break before a slash that starts a word (" /word" wraps whole; not after a no-break space) | 🟡 | `layout::tests::a_word_after_a_slash_wraps_whole`, `layout::tests::a_no_break_space_before_a_slash_still_holds`; behaviour from the #2963 report, not compared with Word yet | — | | Narrow table cells | ✅ | `layout::tests::narrow_cells_keep_their_width`: usable widths below 12 pt wrap without expansion; oversized glyphs still make progress | 0 | | Justification, Word 2013+ space shrinking (compat mode 15) | ✅ | #109; spaces before a line's first character (paragraph indented with spaces, after a line break or a tab, a bookmark, hidden text or a floating object's anchor before them) keep their width in justified and distributed lines, `layout::tests::leading_spaces_keep_their_width_in_justified_lines` | — | @@ -325,6 +325,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-11-03 | trivial | A line breaks after a hyphen before a slash and a letter ("Dataprotection-" / "/Secrecyviolation"), not before a slash and a digit ("fitness-/1589" stays whole, as in Word), so auto-fit table columns no longer widen to the whole compound (#3408) | | 2026-11-03 | trivial | A section's closing paragraph with a hidden mark and only an in-line picture (or a note reference) is not an empty hidden mark: the picture keeps its room, and a continuous section after it starts below it, not over it; not observed in Word (#5288) | | 2026-11-03 | trivial | A row of a nested table marked Can't Split isn't split with its outer row: the outer row moves on whole when that row comes first in it, and otherwise splits above it (inferred); on a fresh page a nested row taller than the room still splits (docxide-pdf `nested-first-row-*-cant1`) | +| 2026-11-03 | trivial | No line break between a full stop and an opening parenthesis: `2023.(Example` wraps whole at the space before, as in the docxide-pdf `dot-open-token*` Word references | | 2026-11-01 | trivial | Columns: the line between them runs from the section's top on the page to the bottom of its tallest column; a continuous section mid-page gets its line (#5178) | | 2026-10-31 | trivial | A table row that would leave a cell empty on a page that already holds something (its inline picture or first line runs past the room) moves on whole instead of leaving the other cells' text beside the empty cell; an at-least row grown by its picture no longer splits off the picture (#4272) | | 2026-11-01 | trivial | A cell aligned to the middle or bottom of a row split across pages is aligned within the part left on the page when its text fits there, and starts at the top of the cell otherwise, as measured in Word | From 4259dd1b2e02dcb310bb98ecc6b6d53c38bf1f6c Mon Sep 17 00:00:00 2001 From: naxvog Date: Sun, 8 Nov 2026 05:31:43 +0100 Subject: [PATCH 02/12] Layout: before Word 2013 layout a floating table moved below another keeps its own left edge (#5442) A floating table with tblOverlap=never that is moved below another floating table steps in to its distance from the text's left edge only in Word 2013 layout (compatibility mode 15). clear_of_float_tables read the mode from the legacy shift, the first cell's left margin before mode 15, being zero, so a mode 12 table whose first cell has no left margin was taken for Word 2013 layout and moved 9 pt right (docxide displaced-anchor-*-compat12-overlap0-*: Word 72 pt, WordCraft 81 pt). Pass the mode itself. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/layout/src/boxes.rs | 20 +++++++++------- crates/layout/src/table_flow.rs | 8 ++++++- crates/layout/src/tests.rs | 42 +++++++++++++++++++++++++++++++++ docs/layout-parity.md | 3 +++ 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6634121a2c..dbbd79d65e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -238,6 +238,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Security: XLSX import finds each sheet's worksheet part through an index of the workbook's relationships, built once, instead of searching every relationship for every sheet. Readiness unchanged. - 2026-11-03: Layout: a row of a nested table marked Can't Split isn't split with its outer row any more. The outer row moves to the next page whole when that row comes first in it, as Word does (docxide-pdf `nested-first-row-*-cant1`), and otherwise splits above it (inferred, not yet checked in Word); on a fresh page a nested row taller than the room still splits rather than running into the margin. Readiness unchanged. - 2026-11-03: Layout: no line break between a full stop and an opening parenthesis. UAX #14 allows one there, Word doesn't: `29.05.2023г.(оформление ЗУ)` and `2023.(Example detail)` now wrap whole at the space before, as in the docxide-pdf `dot-open-token*` Word references. Readiness unchanged. +- 2026-11-03: Layout: before Word 2013 layout a floating table that may not overlap another and is moved below it keeps its own left edge, as Word places it, also when its first cell has no left margin; the layout took a zero margin for Word 2013 layout and stepped the table in by its distance from the text. Readiness unchanged. - 2026-11-03: Security: pasting text boxes charges each copied story for its formatting too (run, mark, paragraph and section properties), not only its text and objects, so a story with huge style or font names can't copy past the paste's 32 MiB text box budget. Readiness unchanged. - 2026-11-03: Security: `.doc` import keeps one width, shading, alignment and merge override per table cell, the last one winning as before, instead of a copy every time a paragraph's properties repeat the sprm, so a shared PAPX can't pile up copies on every paragraph mark. Readiness unchanged. - 2026-11-03: Security: `.doc` import attaches each section to the paragraph that ends it through an index of paragraph ends, instead of scanning every paragraph for every section, so a file with a thousand sections that end nowhere can't multiply the work over its paragraphs. Readiness unchanged. @@ -848,6 +849,7 @@ MATH glyphs that reference the same construction now share its immutable variant > 2026-11-07 · Progress log: VML text boxes keep their text direction (#5193). > > 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged. +> 2026-11-03 · Before Word 2013 layout a floating table moved below another keeps its own left edge, also when its first cell has no left margin; readiness unchanged. > > 2026-10-30 · Technical synchronization: retain the Proofing pane and command/selection/undo contracts on main `3b7f7ed`; new-SHA validation pending, readiness unchanged. > diff --git a/crates/layout/src/boxes.rs b/crates/layout/src/boxes.rs index c535016f05..0229beebe3 100644 --- a/crates/layout/src/boxes.rs +++ b/crates/layout/src/boxes.rs @@ -159,7 +159,7 @@ fn layout_table_block(input: TableBlockContext<'_, '_, '_>) -> bool { let layout = table::layout_table(ctx, story, t, path, width, depth + 1); let (tx, ty) = match t.props.float { Some(float) => { - let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 }; + let legacy = (ctx.doc.settings.compat_mode < 15).then(|| table::first_cell_left_margin(ctx, t)); let (rect, area) = box_float_table_rect(frame, width, *y, &layout, &float, legacy, float_tables); exclusions.push((area, false, None, WrapText::BothSides, true)); float_tables.push(area); @@ -464,14 +464,15 @@ pub(crate) struct BoxLayout { /// Where floating table `tl` goes in a box `width` wide whose next paragraph starts at `y`, and /// the area text keeps clear of. Positions relative to the page or its margins need the box's -/// `frame`; without one (a table cell) they're relative to the box. +/// `frame`; without one (a table cell) they're relative to the box. `legacy` is the first cell's +/// left margin before Word 2013 layout, `None` in it. fn box_float_table_rect( frame: Option, width: f32, y: f32, tl: &table::TableLayout, f: &TableFloat, - legacy: f32, + legacy: Option, float_tables: &[Rect], ) -> (Rect, Rect) { let h = tl.rows.iter().map(|r| r.height).sum::().clamp(0.0, 100_000.0); @@ -507,18 +508,19 @@ fn box_float_table_rect( }; // Nor is there an area to align in: the table starts where the text is. let v_align = if frame.is_none() && matches!(f.v_rel, Anchor::Page | Anchor::Margin) { None } else { f.v_align }; - let x = align_in(h_area, f.x, w, PageFrame::h_align(frame.as_ref(), f.h_align)) - if f.h_align.is_none() { legacy } else { 0.0 }; - let r = clear_of_float_tables(Rect::new(x, align_in(v_area, y_off, h, v_align), w, h), f, legacy, h_area.0, float_tables); + let x = align_in(h_area, f.x, w, PageFrame::h_align(frame.as_ref(), f.h_align)) - if f.h_align.is_none() { legacy.unwrap_or(0.0) } else { 0.0 }; + let r = clear_of_float_tables(Rect::new(x, align_in(v_area, y_off, h, v_align), w, h), f, legacy.is_none(), h_area.0, float_tables); let [dl, dt, dr, db] = f.dist_from_text(); (r, r.inset(-dl, -dt, -dr, -db)) } /// Floating table `f` at `r`, moved below the floating tables in its way when it may not -/// overlap them. In Word 2013+ layout one placed by an offset keeps its distance from the text's -/// left edge (`text_left`). -pub(crate) fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, text_left: f32, others: &[Rect]) -> Rect { +/// overlap them. In Word 2013+ layout (`modern`) one placed by an offset keeps its distance from +/// the text's left edge (`text_left`); before it, it keeps its own left edge, also when its first +/// cell has no left margin. +pub(crate) fn clear_of_float_tables(mut r: Rect, f: &TableFloat, modern: bool, text_left: f32, others: &[Rect]) -> Rect { let [dl, dt, dr, db] = f.dist_from_text(); - let keeps_text_distance = legacy == 0.0 && f.h_align.is_none() && f.x >= 0.0 && matches!(f.h_rel, Anchor::Margin | Anchor::Column); + let keeps_text_distance = modern && f.h_align.is_none() && f.x >= 0.0 && matches!(f.h_rel, Anchor::Margin | Anchor::Column); for _ in 0..64 { let area = r.inset(-dl, -dt, -dr, -db); let Some(below) = others.iter().filter(|o| !f.overlap && o.intersects(&area)).map(|o| o.bottom()).reduce(f32::max) else { break }; diff --git a/crates/layout/src/table_flow.rs b/crates/layout/src/table_flow.rs index 88757e3368..538e9eb746 100644 --- a/crates/layout/src/table_flow.rs +++ b/crates/layout/src/table_flow.rs @@ -349,7 +349,13 @@ fn place_floating_table(ctx: &mut Ctx, pb: &mut PageBuilder, tl: &table::TableLa let x = float_table_x(pb, w, f, legacy, ctx.mirror); let mut obstacles = if f.overlap { Vec::new() } else { header_table_areas(ctx, pb) }; obstacles.extend_from_slice(&pb.float_tables); - let r = clear_of_float_tables(Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h), f, legacy, h_area.0, &obstacles); + let r = clear_of_float_tables( + Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h), + f, + ctx.doc.settings.compat_mode >= 15, + h_area.0, + &obstacles, + ); // Like Word, a table that doesn't fit below the text before it moves to the next page with // its anchor: one placed relative to the paragraph when it would run off the page, one // placed on the page or margin when it would also cover the text already on the page. diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 4acda0f5fe..784c4a7a69 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -7014,6 +7014,48 @@ fn floating_tables_take_no_room_and_text_wraps_beside_them() { assert!(bx + bleft > nx + 200.0, "body text at {}, right of the narrow table", bx + bleft); } +/// Before Word 2013 layout a floating table moved below another keeps its own left edge, also +/// when its first cell has no left margin (docxide `displaced-anchor-*-compat12-overlap0-*`), in +/// the body and in a table cell. +#[test] +fn a_floating_table_moved_below_another_keeps_its_edge_before_word_2013_layout() { + use wordcraft_doc::para::Anchor; + use wordcraft_doc::props::TableFloat; + let narrow_x = |mode: u32, in_cell: bool| { + let mut d = Document::from_text("Body."); + d.settings.compat_mode = mode; + let table = |w: f32, text: &str, dist: [f32; 4]| { + let mut t = Table::new(1, 1, w); + t.props.cell_margins = Some([0.0; 4]); + t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))]; + t.props.float = Some(TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, dist, overlap: false, ..Default::default() }); + t + }; + let floats = [table(300.0, "Wide", [0.0; 4]), table(100.0, "Narrow", [9.0, 0.0, 9.0, 0.0])]; + if in_cell { + let mut outer = Table::new(1, 1, 468.0); + outer.rows[0].cells[0].blocks = floats.into_iter().map(|t| std::sync::Arc::new(wordcraft_doc::Block::Table(t))).collect(); + outer.rows[0].cells[0].blocks.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Cell.", Default::default()))); + d.insert_block(StoryRef::Body, &Path::top(0), wordcraft_doc::Block::Table(outer)).unwrap(); + } else { + for (i, t) in floats.into_iter().enumerate() { + d.insert_block(StoryRef::Body, &Path::top(i), wordcraft_doc::Block::Table(t)).unwrap(); + } + } + let l = lay(&d); + let xs: Vec<(f32, f32)> = + l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { x, y, .. } = i { Some((*x, *y)) } else { None }).collect(); + let [(wx, wy), (nx, ny), ..] = xs.as_slice() else { panic!("{xs:?}") }; + assert!(*ny > *wy + 10.0, "mode {mode}, in cell {in_cell}: narrow at {ny}, below the wide table at {wy}: {xs:?}"); + nx - wx + }; + for in_cell in [false, true] { + let (old, new) = (narrow_x(12, in_cell), narrow_x(15, in_cell)); + assert!(old.abs() < 0.01, "mode 12, in cell {in_cell}: narrow table {old} right of the wide one"); + assert!((new - 9.0).abs() < 0.01, "mode 15, in cell {in_cell}: narrow table {new} right of the wide one"); + } +} + /// A margin-anchored floating table is placed from the anchor page's margin even when a body /// paragraph keeps trailing empty lines: all 24 preceding paragraphs stay on page one and every /// table row is drawn exactly once there. diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 0034e76638..bb08fdadee 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -69,6 +69,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | 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; a table with its own preferred width keeps it, however far past the margin, and only an automatic-width table over 1.5× the text width is squeezed to the margins (Word squeezes it as soon as it is wider) (#1721); 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; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; other overlap rules untested | 2–3 | | 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; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); the same for a click within 2pt of a cell whose text is turned comes from the issue and synthetic tests, not a check by hand (#4181); other overlap rules untested | 2–3 | +| 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; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; one with `tblOverlap=never` moved below another keeps its distance from the text's left edge only in Word 2013 layout (mode 15), before it its own left edge, also when its first cell has no left margin (docxide `displaced-anchor-*`); other overlap rules untested | 2–3 | | Nested tables | 🟡 | render; polish missing | 2–4 | | Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136; in a header or footer table cell, a float not laid out in its cell (`layoutInCell="0"`, VML `o:allowincell="f"`, or any VML shape in a header or footer: user-checked in Word for Mac) and placed relative to the page or margins is placed on the page, drawn with the header behind or in front of its text, on every page: Word's picture watermark in a header that starts with a table is centred behind the body text (#1775, `layout::tests::header_cell_float_placed_on_the_page`). Other cell floats are still placed relative to their cell. A float anchored in a line of a long paragraph that goes onto a later page is drawn on that page (observed in Word for Mac: a picture dragged onto a page that only continues a paragraph stays there, saved with its anchor on that page and its offset from the paragraph's top there), and that page's lines are broken again around it (the #1374 relay, so not past its limit); relative to the paragraph, it is measured from where the paragraph continues there; text added on the pages before leaves it where it is. One anchored right after a page break stays on the page before (a cover page, #1632). The page is decided before the lines are placed; a float whose anchor only goes to the next page because the float itself narrows the lines stays on the first page, with the text wrapping around it; on a later page, a float anchored on a line moved to a later page by footnotes or widow control is drawn on that page, and one whose own wrap pushes its line on stays with the text wrapping around it. Not yet: a float anchored in a later column of the same page is still placed with the paragraph's first piece, as before (#4250, `layout::tests::a_float_anchored_on_a_later_page_of_its_paragraph_is_drawn_on_that_page` and the tests after it) | — | | Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136; in a header or footer table cell, a float not laid out in its cell (`layoutInCell="0"`, VML `o:allowincell="f"`, or any VML shape in a header or footer: user-checked in Word for Mac) and placed relative to the page or margins is placed on the page, drawn with the header behind or in front of its text, on every page: Word's picture watermark in a header that starts with a table is centred behind the body text (#1775, `layout::tests::header_cell_float_placed_on_the_page`). Other cell floats are still placed relative to their cell. A float anchored in a line of a long paragraph that goes onto a later page is drawn on that page (observed in Word for Mac: a picture dragged onto a page that only continues a paragraph stays there, saved with its anchor on that page and its offset from the paragraph's top there), and that page's lines are broken again around it (the #1374 relay, so not past its limit); relative to the paragraph, it is measured from where the paragraph continues there; text added on the pages before leaves it where it is. One anchored right after a page break, with nothing after it that takes room (other anchors, bookmarks, comment or field marks), stays on the page before (a cover page, #1632); with anything after it that takes room (text, a space or tab, an inline picture, another break), it is on the new page with it (observed in Word for Mac: a shape dragged beside the first line after a page break inside a paragraph is saved anchored right after the break, positioned from the paragraph's top on the new page, and stays there, #4262, `layout::tests::a_float_anchored_before_text_after_a_page_break_is_drawn_on_the_next_page`). The page is decided before the lines are placed; a float whose anchor only goes to the next page because the float itself narrows the lines stays on the first page, with the text wrapping around it; on a later page, a float anchored on a line moved to a later page by footnotes or widow control is drawn on that page, and one whose own wrap pushes its line on stays with the text wrapping around it. Not yet: a float anchored in a later column of the same page is still placed with the paragraph's first piece, as before (#4250, `layout::tests::a_float_anchored_on_a_later_page_of_its_paragraph_is_drawn_on_that_page` and the tests after it) | — | @@ -286,6 +287,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc > 2026-11-01 · Text in a turned text box is drawn turned with it, about its centre, on screen and in PNG and PDF export; flipped across, upside down; flipped side to side, still readable (as Word draws it). Clicks and the caret inside a turned box still go by its upright text (`layout::tests::a_turned_text_box_turns_its_text_with_it`). > 2026-11-01 · An in-line picture, shape or chart is raised or lowered by its run's position (Word's `w:position`, Font › Advanced › Position): lowered, it reaches below the baseline and the line grows down to hold it (at most until its top is on the baseline); raised, it stands clear of the baseline. It used to stand on the baseline whatever the position (`layout::tests::an_inline_picture_moves_with_its_runs_position`). +> 2026-11-03 · Before Word 2013 layout a floating table that may not overlap another and is moved below it keeps its own left edge instead of stepping in by its distance from the text, also when its first cell has no left margin (`layout::tests::a_floating_table_moved_below_another_keeps_its_edge_before_word_2013_layout`). > 2026-11-01 · Paragraph side borders lie beyond their distance from the text, as the top and bottom ones do; top and bottom reach the corners and the shading fills out to the outer edges (#5036) (`layout::tests::side_borders_lie_beyond_their_distance_from_the_text`). @@ -326,6 +328,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-11-03 | trivial | A section's closing paragraph with a hidden mark and only an in-line picture (or a note reference) is not an empty hidden mark: the picture keeps its room, and a continuous section after it starts below it, not over it; not observed in Word (#5288) | | 2026-11-03 | trivial | A row of a nested table marked Can't Split isn't split with its outer row: the outer row moves on whole when that row comes first in it, and otherwise splits above it (inferred); on a fresh page a nested row taller than the room still splits (docxide-pdf `nested-first-row-*-cant1`) | | 2026-11-03 | trivial | No line break between a full stop and an opening parenthesis: `2023.(Example` wraps whole at the space before, as in the docxide-pdf `dot-open-token*` Word references | +| 2026-11-03 | trivial | Before Word 2013 layout a floating table moved below another keeps its own left edge, also when its first cell has no left margin (the mode was read from that margin being zero); readiness estimates unchanged | | 2026-11-01 | trivial | Columns: the line between them runs from the section's top on the page to the bottom of its tallest column; a continuous section mid-page gets its line (#5178) | | 2026-10-31 | trivial | A table row that would leave a cell empty on a page that already holds something (its inline picture or first line runs past the room) moves on whole instead of leaving the other cells' text beside the empty cell; an at-least row grown by its picture no longer splits off the picture (#4272) | | 2026-11-01 | trivial | A cell aligned to the middle or bottom of a row split across pages is aligned within the part left on the page when its text fits there, and starts at the top of the cell otherwise, as measured in Word | From 5dfb3acf1511e14d4f3c04975579063859625078 Mon Sep 17 00:00:00 2001 From: Kevin Joseph <47004376+kevinj24fr@users.noreply.github.com> Date: Sun, 8 Nov 2026 05:40:51 +0100 Subject: [PATCH 03/12] picture.change: keep the width and take the new picture's aspect ratio (#5761) * picture.change: keep the width and take the new picture's aspect ratio Change Picture swapped only the media key, so a replacement of another shape was stretched into the old frame (a revised figure came out ~2% taller in a real manuscript). Keep the display width, set the height from the new picture's pixel size, and drop the old crop, which described the old picture. Regression test included. * docs: progress log and parity entries for picture-change-aspect --------- Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/engine/src/cmd/objects/specs.rs | 22 +++++++++++---- crates/engine/src/tests.rs | 38 ++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index dbbd79d65e..047b50e138 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,6 +164,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-01: Shapes: a line's arrowheads are as long and as wide as Word draws them: 2, 3 or 5 times the line's width for small, medium and large (a line under 2 points as a 2-point one). Their kind, width and length are read from DOCX (DrawingML and VML) and saved back; before, every arrowhead was one size and saved as a medium triangle. - 2026-11-07: DOCX: a VML text box keeps its text direction: `mso-layout-flow-alt:bottom-to-top` reads as Rotate all text 270° whatever `layout-flow` says, and otherwise `layout-flow:vertical` (or `vertical-ideographic`) as 90°, so the turned text box in a compatibility-mode document's header is drawn turned, as in Word. (#5193) +- 2026-11-06: Change Picture keeps the picture's width and takes the new picture's aspect ratio, dropping the old crop, instead of stretching the new picture into the old frame (a revised figure came out about 2% taller in a real manuscript) (#5761). Readiness estimates unchanged. - 2026-11-06: Windows print submissions run in separate helper processes (#5710). A helper acknowledges cancellation before it stops, so a driver stalled in a cancelled job cannot hold another job’s provider lock. A private temporary PDF and bounded settings messages cross the process boundary; normal shutdown reaps helpers and removes their inputs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Jobs already sending still complete. Readiness estimates unchanged. - 2026-11-06: File › Print: before sending starts, Cancel printing stops a waiting job, frees the Print page and keeps the settings; a successfully cancelled job is never sent. Cancellation remains available after the last document closes. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. Readiness estimates unchanged. - 2026-11-06: File › Print on Windows no longer waits on printer drivers to list printers (#5710). The list used to wait until each printer's driver said whether it prints on both sides, and a networked printer asleep in power save could keep it empty with Refresh unavailable; now discovery asks no driver (the Windows spooler still supplies the list), and printing still checks the job's options with the driver. The reporter's sleeping printer has not been retested yet. Readiness estimates unchanged. @@ -870,6 +871,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-10-31 | trivial | Progress log: font menu entries are drawn from shared textures (#5105) | | 2026-11-02 | trivial | Progress log: a linked picture shows and follows its link; DOCX `a:hlinkClick` (#5267) | | 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open | +| 2026-11-06 | trivial | Progress log: Change Picture keeps the width and takes the new picture's aspect ratio (#5761) | | 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) | | 2026-11-06 | trivial | Windows print submissions isolated per helper process; cancelled driver work cannot block a retry (#5710) | | 2026-11-06 | trivial | Progress log: print cancellation before sending, including without a document, and the Windows repeated-conversion limitation (#5710) | diff --git a/crates/engine/src/cmd/objects/specs.rs b/crates/engine/src/cmd/objects/specs.rs index dfc3cc76a7..11967ed4e5 100644 --- a/crates/engine/src/cmd/objects/specs.rs +++ b/crates/engine/src/cmd/objects/specs.rs @@ -132,14 +132,26 @@ pub fn specs() -> Vec { } else { return Err(CmdError::Params("`path` or `data` required".into())); }; - wordcraft_geom::picture::probe(&bytes).map_err(|e| match e { - wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()), - _ => CmdError::Failed("not a supported image".into()), - })?; + let (px_w, px_h) = wordcraft_geom::picture::probe(&bytes) + .map(|p| (p.width, p.height)) + .map_err(|e| match e { + wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()), + _ => CmdError::Failed("not a supported image".into()), + })?; let key = s.doc.add_media(bytes.clone(), crate::cmd::insert::image_ext(&bytes)); with_obj(s, |o| { - if let InlineObject::Image { media, .. } = o { + if let InlineObject::Image { media, w, h, crop, .. } = o { *media = key.clone(); + // The replacement keeps the picture's width and takes its own aspect ratio, so a + // picture of another shape isn't stretched into the old frame. The old crop + // described the old picture, so it is dropped. + if px_w > 0 && px_h > 0 && w.is_finite() && *w > 0.0 { + let nh = *w * px_h as f32 / px_w as f32; + if nh.is_finite() && nh > 0.0 { + *h = nh; + } + } + *crop = [0.0; 4]; } }) }) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2718ef8de6..3b5822dced 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10817,6 +10817,44 @@ fn picture_change_keeps_the_replacement_format() { let _ = std::fs::remove_dir_all(&dir); } +/// Change Picture keeps the width and takes the replacement's aspect ratio: swapping a figure for +/// a revised one of a different shape must not stretch it into the old frame. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn picture_change_keeps_width_and_takes_new_aspect() { + let dir = scratch_dir("picture-change-aspect"); + let mut wide = Vec::new(); + image::RgbImage::from_pixel(40, 20, image::Rgb([200, 30, 30])).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap(); + let mut tall = Vec::new(); + image::RgbImage::from_pixel(30, 60, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut tall), image::ImageFormat::Png).unwrap(); + let (first, second) = (dir.join("wide.png"), dir.join("tall.png")); + std::fs::write(&first, &wide).unwrap(); + std::fs::write(&second, &tall).unwrap(); + + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "insert.picture", json!({"path": first.to_string_lossy()})); + let size = |s: &Session| { + let mut out = (0.0f32, 0.0f32, [1.0f32; 4]); + s.doc.objects_in_reading_order(&mut |o| { + if let wordcraft_doc::para::InlineObject::Image { w, h, crop, .. } = o { + out = (*w, *h, *crop); + } + }); + out + }; + let (w0, h0, _) = size(&s); + assert!((w0 / h0 - 2.0).abs() < 1e-3, "inserted at its own 2:1 shape: {w0} x {h0}"); + run(&mut s, "picture.crop", json!({"left": 0.1})); + let (w_cropped, _, crop) = size(&s); + assert!(crop[0] > 0.0, "cropped before the change"); + run(&mut s, "picture.change", json!({"path": second.to_string_lossy()})); + let (w1, h1, crop) = size(&s); + assert!((w1 - w_cropped).abs() < 1e-3, "the width is kept: {w_cropped} -> {w1}"); + assert!((h1 / w1 - 2.0).abs() < 1e-3, "the height follows the new 1:2 picture: {w1} x {h1}"); + assert_eq!(crop, [0.0; 4], "the old picture's crop is dropped"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn json_open_repairs_hostile_run_lengths() { use wordcraft_doc::{Block, Document}; From 3291be0fbf02ecc7eafcf8473c6f2d83e3bd20cd Mon Sep 17 00:00:00 2001 From: naxvog Date: Sun, 8 Nov 2026 05:45:17 +0100 Subject: [PATCH 04/12] Layout: an empty paragraph after a leading floating table takes no space before (#5440) Word drops the space before of an empty paragraph that follows a document's leading floating table: its PDFs of docxide-pdf's leading-empty-before-float-0 and -12 put the text after the empty anchor (beside the table) at the same place for 0 pt and 12 pt before. Flow::gap_before (crates/layout/src/flow.rs) kept it: a floating table takes no room in the flow and clears `pb.prev`, so the paragraph after it was laid out like a document's first paragraph, with its full space before. It now drops it for body block 1 when block 0 was placed floating on this page (the page's float_tables), the page is fresh and the paragraph holds nothing but markers (bookmarks, comment and permission ranges). It doesn't check that the paragraph sits beside the table, so below a full-width leading float an empty paragraph loses its space before too; that case is untested. Later pages and columns, and paragraphs with text, keep their space before. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/layout/src/flow.rs | 11 ++++++ crates/layout/src/tests.rs | 1 + crates/layout/src/tests/float_top.rs | 53 ++++++++++++++++++++++++++++ docs/layout-parity.md | 2 ++ 5 files changed, 69 insertions(+) create mode 100644 crates/layout/src/tests/float_top.rs diff --git a/ROADMAP.md b/ROADMAP.md index 047b50e138..5e09190eea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -240,6 +240,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: Layout: a row of a nested table marked Can't Split isn't split with its outer row any more. The outer row moves to the next page whole when that row comes first in it, as Word does (docxide-pdf `nested-first-row-*-cant1`), and otherwise splits above it (inferred, not yet checked in Word); on a fresh page a nested row taller than the room still splits rather than running into the margin. Readiness unchanged. - 2026-11-03: Layout: no line break between a full stop and an opening parenthesis. UAX #14 allows one there, Word doesn't: `29.05.2023г.(оформление ЗУ)` and `2023.(Example detail)` now wrap whole at the space before, as in the docxide-pdf `dot-open-token*` Word references. Readiness unchanged. - 2026-11-03: Layout: before Word 2013 layout a floating table that may not overlap another and is moved below it keeps its own left edge, as Word places it, also when its first cell has no left margin; the layout took a zero margin for Word 2013 layout and stepped the table in by its distance from the text. Readiness unchanged. +- 2026-11-03: Layout: an empty paragraph after a document's leading floating table takes no space before, as in Word's PDFs of docxide-pdf's `leading-empty-before-float` cases (beside the table); below a full-width one it loses it too, which is untested. Readiness unchanged. - 2026-11-03: Security: pasting text boxes charges each copied story for its formatting too (run, mark, paragraph and section properties), not only its text and objects, so a story with huge style or font names can't copy past the paste's 32 MiB text box budget. Readiness unchanged. - 2026-11-03: Security: `.doc` import keeps one width, shading, alignment and merge override per table cell, the last one winning as before, instead of a copy every time a paragraph's properties repeat the sprm, so a shared PAPX can't pile up copies on every paragraph mark. Readiness unchanged. - 2026-11-03: Security: `.doc` import attaches each section to the paragraph that ends it through an index of paragraph ends, instead of scanning every paragraph for every section, so a file with a thousand sections that end nowhere can't multiply the work over its paragraphs. Readiness unchanged. @@ -920,6 +921,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-03 | trivial | Progress log: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288) | | 2026-11-03 | trivial | Progress log: a nested table's Can't Split row moves on with its outer row | | 2026-11-03 | trivial | Progress log: no line break between a full stop and an opening parenthesis | +| 2026-11-03 | trivial | Progress log: an empty paragraph after a document's leading floating table takes no space before | | 2026-11-03 | trivial | Progress log: Replace and Remove Table of Contents with Word's title above a section break (#4041) | | 2026-11-03 | trivial | Progress log: language-packs-2 published and pinned, 75 Language packs | | 2026-11-02 | trivial | Progress log: language-packs-1 published and pinned, 61 Language packs (CP4.4) | diff --git a/crates/layout/src/flow.rs b/crates/layout/src/flow.rs index 5fd30ff997..233add4ef0 100644 --- a/crates/layout/src/flow.rs +++ b/crates/layout/src/flow.rs @@ -281,6 +281,17 @@ impl<'a> Flow<'a> { if self.pl.rp.before_auto && self.block == 0 { before = 0.0; } + // An empty paragraph after a document's leading table, placed floating on this page, has no + // space before (Word's PDFs: 0 pt and 12 pt before put the text after it at the same place). + // Only that shape is observed: later pages and columns keep theirs. + if self.block == 1 + && pb.fresh() + && !pb.web + && pb.pages.last().is_some_and(|pg| pg.float_tables.contains(&Path::top(0))) + && only_markers(self.p, 0..self.p.text.len()) + { + before = 0.0; + } before } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 784c4a7a69..cc5df29f05 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -9,6 +9,7 @@ mod bidi; mod budget; mod cell_floats; mod complex; +mod float_top; mod grid; mod ligature; pub(crate) mod linear; diff --git a/crates/layout/src/tests/float_top.rs b/crates/layout/src/tests/float_top.rs new file mode 100644 index 0000000000..fb555de219 --- /dev/null +++ b/crates/layout/src/tests/float_top.rs @@ -0,0 +1,53 @@ +//! An empty paragraph after a document's leading floating table takes no space before. Word's PDFs +//! of docxide-pdf's `leading-empty-before-float-0` and `-12` put the text after the empty anchor at +//! the same place for 0 pt and 12 pt before. + +use super::lay; +use crate::{Arc, Placed, StoryRef}; +use wordcraft_doc::para::{Anchor, InlineObject}; +use wordcraft_doc::props::TableFloat; +use wordcraft_doc::{Block, Document, Paragraph, Table, para_block}; + +/// Where the lines of body block `block` start on page one. +fn line_y(d: &Document, block: u32) -> f32 { + lay(d).pages[0] + .items + .iter() + .find_map(|i| match i { + Placed::Lines { story: StoryRef::Body, path, y, .. } if path.0 == [block] => Some(*y), + _ => None, + }) + .unwrap() +} + +/// `table` (a floating one) or nothing, then a paragraph of `text` with `before`, then "After". +fn document(table: bool, text: &str, before: f32) -> Document { + let mut p = Paragraph::with_text(text, Default::default()); + // "#" stands for a bookmark, a zero-width marker. + if text == "#" { + p = Paragraph::new(); + p.insert_object(0, InlineObject::BookmarkStart { name: "_GoBack".into() }, &Default::default()).unwrap(); + } + p.props.space_before = Some(before); + let mut d = Document::new(); + d.body = vec![para_block(p), para_block(Paragraph::with_text("After", Default::default()))]; + if table { + let mut t = Table::new(1, 1, 200.0); + t.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Table", Default::default()))]; + t.props.float = Some(TableFloat { h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, ..Default::default() }); + d.body.insert(0, Arc::new(Block::Table(t))); + } + d +} + +#[test] +fn an_empty_paragraph_after_a_floating_table_at_the_page_top_takes_no_space_before() { + let empty = line_y(&document(true, "", 12.0), 2); + let plain = line_y(&document(false, "", 0.0), 1); + assert!((empty - plain).abs() < 0.01, "after the empty anchor at {empty}, with no table nor space before at {plain}"); + // A bookmark alone doesn't make it a paragraph with text. + assert!((line_y(&document(true, "#", 12.0), 2) - plain).abs() < 0.01); + // One with text keeps it, and so does an empty first paragraph with no table before it. + assert!(line_y(&document(true, "Text", 12.0), 2) > empty + 11.99); + assert!(line_y(&document(false, "", 12.0), 1) > plain + 11.99); +} diff --git a/docs/layout-parity.md b/docs/layout-parity.md index bb08fdadee..942c87e8af 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -70,6 +70,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | 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; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; other overlap rules untested | 2–3 | | 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; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); the same for a click within 2pt of a cell whose text is turned comes from the issue and synthetic tests, not a check by hand (#4181); other overlap rules untested | 2–3 | | 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; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; one with `tblOverlap=never` moved below another keeps its distance from the text's left edge only in Word 2013 layout (mode 15), before it its own left edge, also when its first cell has no left margin (docxide `displaced-anchor-*`); other overlap rules untested | 2–3 | +| 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; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; other overlap rules untested; an empty paragraph (or one of bookmarks and other markers only) after a document's leading floating table takes no space before (docxide-pdf's `leading-empty-before-float` Word PDFs, the paragraph beside the table); below a full-width one it loses it too, untested; a paragraph with text below a full-width one keeps it (`connector-paragraph-anchor-0`/`-240`); one with text beside a narrow one, and later pages and columns, are untested and keep it | 2–3 | | Nested tables | 🟡 | render; polish missing | 2–4 | | Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136; in a header or footer table cell, a float not laid out in its cell (`layoutInCell="0"`, VML `o:allowincell="f"`, or any VML shape in a header or footer: user-checked in Word for Mac) and placed relative to the page or margins is placed on the page, drawn with the header behind or in front of its text, on every page: Word's picture watermark in a header that starts with a table is centred behind the body text (#1775, `layout::tests::header_cell_float_placed_on_the_page`). Other cell floats are still placed relative to their cell. A float anchored in a line of a long paragraph that goes onto a later page is drawn on that page (observed in Word for Mac: a picture dragged onto a page that only continues a paragraph stays there, saved with its anchor on that page and its offset from the paragraph's top there), and that page's lines are broken again around it (the #1374 relay, so not past its limit); relative to the paragraph, it is measured from where the paragraph continues there; text added on the pages before leaves it where it is. One anchored right after a page break stays on the page before (a cover page, #1632). The page is decided before the lines are placed; a float whose anchor only goes to the next page because the float itself narrows the lines stays on the first page, with the text wrapping around it; on a later page, a float anchored on a line moved to a later page by footnotes or widow control is drawn on that page, and one whose own wrap pushes its line on stays with the text wrapping around it. Not yet: a float anchored in a later column of the same page is still placed with the paragraph's first piece, as before (#4250, `layout::tests::a_float_anchored_on_a_later_page_of_its_paragraph_is_drawn_on_that_page` and the tests after it) | — | | Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136; in a header or footer table cell, a float not laid out in its cell (`layoutInCell="0"`, VML `o:allowincell="f"`, or any VML shape in a header or footer: user-checked in Word for Mac) and placed relative to the page or margins is placed on the page, drawn with the header behind or in front of its text, on every page: Word's picture watermark in a header that starts with a table is centred behind the body text (#1775, `layout::tests::header_cell_float_placed_on_the_page`). Other cell floats are still placed relative to their cell. A float anchored in a line of a long paragraph that goes onto a later page is drawn on that page (observed in Word for Mac: a picture dragged onto a page that only continues a paragraph stays there, saved with its anchor on that page and its offset from the paragraph's top there), and that page's lines are broken again around it (the #1374 relay, so not past its limit); relative to the paragraph, it is measured from where the paragraph continues there; text added on the pages before leaves it where it is. One anchored right after a page break, with nothing after it that takes room (other anchors, bookmarks, comment or field marks), stays on the page before (a cover page, #1632); with anything after it that takes room (text, a space or tab, an inline picture, another break), it is on the new page with it (observed in Word for Mac: a shape dragged beside the first line after a page break inside a paragraph is saved anchored right after the break, positioned from the paragraph's top on the new page, and stays there, #4262, `layout::tests::a_float_anchored_before_text_after_a_page_break_is_drawn_on_the_next_page`). The page is decided before the lines are placed; a float whose anchor only goes to the next page because the float itself narrows the lines stays on the first page, with the text wrapping around it; on a later page, a float anchored on a line moved to a later page by footnotes or widow control is drawn on that page, and one whose own wrap pushes its line on stays with the text wrapping around it. Not yet: a float anchored in a later column of the same page is still placed with the paragraph's first piece, as before (#4250, `layout::tests::a_float_anchored_on_a_later_page_of_its_paragraph_is_drawn_on_that_page` and the tests after it) | — | @@ -329,6 +330,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-11-03 | trivial | A row of a nested table marked Can't Split isn't split with its outer row: the outer row moves on whole when that row comes first in it, and otherwise splits above it (inferred); on a fresh page a nested row taller than the room still splits (docxide-pdf `nested-first-row-*-cant1`) | | 2026-11-03 | trivial | No line break between a full stop and an opening parenthesis: `2023.(Example` wraps whole at the space before, as in the docxide-pdf `dot-open-token*` Word references | | 2026-11-03 | trivial | Before Word 2013 layout a floating table moved below another keeps its own left edge, also when its first cell has no left margin (the mode was read from that margin being zero); readiness estimates unchanged | +| 2026-11-03 | trivial | An empty paragraph after a document's leading floating table takes no space before, as Word's PDFs of docxide-pdf's `leading-empty-before-float` cases show (the paragraph beside the table); below a full-width one it loses it too, untested; paragraphs with text, later pages and columns keep it (`layout::tests::float_top`) | | 2026-11-01 | trivial | Columns: the line between them runs from the section's top on the page to the bottom of its tallest column; a continuous section mid-page gets its line (#5178) | | 2026-10-31 | trivial | A table row that would leave a cell empty on a page that already holds something (its inline picture or first line runs past the room) moves on whole instead of leaving the other cells' text beside the empty cell; an at-least row grown by its picture no longer splits off the picture (#4272) | | 2026-11-01 | trivial | A cell aligned to the middle or bottom of a row split across pages is aligned within the part left on the page when its text fits there, and starts at the top of the cell otherwise, as measured in Word | From 52934ef11073786d677980ffc79692cfbea65921 Mon Sep 17 00:00:00 2001 From: Senura Dissanayake Date: Sun, 8 Nov 2026 05:45:35 +0100 Subject: [PATCH 05/12] docbin: brace and bracket shapes are unfilled unless the file fills them (#5943) (#5945) A .doc bracket or brace AutoShape (MSOSPT 85-88, 185, 186) is drawn as its box, and with no fFilled property it got the default white fill, an opaque block over the text behind it. Word's shapetypes for these shapes are unfilled by default (VML filled="f"), so they now get a fill only when fFilled is set. Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/docbin/src/drawing.rs | 6 ++++- crates/docbin/src/tests/drawing.rs | 35 ++++++++++++++++++++++++++++++ docs/file-format-parity.md | 3 ++- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5e09190eea..64000e722f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -385,6 +385,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-31 | A picture or shape anchored in a line of a long paragraph on a later page is drawn on that page, as in Word, instead of the page the paragraph starts on; one dropped on such a page stays where it was dropped (#4250) | | 2026-10-31 | A picture or shape anchored in a line of a long paragraph on a later page is drawn on that page, as in Word, instead of the page the paragraph starts on; one dropped on such a page stays where it was dropped (#4250) | | 2026-10-31 | A picture or shape dropped beside the first line after a page break inside a paragraph stays on the new page, as in Word, instead of going back to the page before the break (#4262) | +| 2026-11-07 | A brace or bracket shape in a `.doc` file is drawn without a fill unless the file gives it one, as in Word, instead of as a white box that hides the text behind it (#5943) | | 2026-10-27 | Connector rerouting indexes laid-out object positions once per batch, preserving page hints, first-match order and same-page glue; synthetic equivalence, geometry and scaling regressions (#3631); routing policy and readiness estimates unchanged | | 2026-10-28 | Markdown import bounds nested undefined reference-label work within the existing link allowance; literal source and normal reference matching are preserved (#4658). | | 2026-10-23 | A command that changes nothing is not an edit: it adds no undo step, marks no unsaved changes and forces no relayout, so the redo stack survives it and five hundred of them can no longer push the user's own edits out of the undo history. | @@ -905,6 +906,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-04 | trivial | Progress log: ODT element audit (gap 19) | | 2026-11-04 | trivial | Progress log: ODT checked against 220 files from other applications (gap 19) | | 2026-11-04 | trivial | Progress log: OpenDocument files shown in Microsoft Word style or LibreOffice style (gap 19) | +| 2026-11-07 | trivial | Progress log: `.doc` brace and bracket shapes unfilled by default (#5943) | | 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. | | 2026-11-01 | trivial | Progress log: SEQ, STYLEREF and REF fields show fresh results in output | | 2026-10-29 | trivial | Progress log: Print Markup balloon anchors use one object walk per paragraph (#3614); readiness estimates unchanged | diff --git a/crates/docbin/src/drawing.rs b/crates/docbin/src/drawing.rs index 4fe3b62a6a..33f45de345 100644 --- a/crates/docbin/src/drawing.rs +++ b/crates/docbin/src/drawing.rs @@ -211,7 +211,11 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) - kind = ShapeKind::TextBox; } let line = kind == ShapeKind::Line; - let fill = if line || p.filled == Some(false) { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) }; + // Brackets and braces (85 to 88, bracket and brace pairs 185, 186) are unfilled unless + // `fFilled` says otherwise, as Word's VML shapetypes for them say (`filled="f"`). They are + // drawn as their box here, which a default fill would make an opaque block over the text. + let unfilled = p.filled == Some(false) || (matches!(sp.spt, 85..=88 | 185 | 186) && p.filled != Some(true)); + let fill = if line || unfilled { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) }; let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) }; // Line widths are EMUs; Word's default is 0.75 pt. let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0)); diff --git a/crates/docbin/src/tests/drawing.rs b/crates/docbin/src/tests/drawing.rs index ae9ec9e3fe..dc18c3d372 100644 --- a/crates/docbin/src/tests/drawing.rs +++ b/crates/docbin/src/tests/drawing.rs @@ -183,6 +183,41 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() { assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside")); } +#[test] +fn braces_and_brackets_are_unfilled_unless_filled_is_set() { + // A right brace (88) in front of the text with no fill properties, and a left bracket (85) + // whose fFilled is set: only the bracket is filled. A plain rectangle (1) keeps its default + // white fill. + let brace = sp(&[fsp(88, 1026, 0xA00)]); + let bracket = sp(&[fsp(85, 1027, 0xA00), fopt(&[(0x01BF, (1 << 20) | (1 << 4))], &[])]); + let rect = sp(&[fsp(1, 1028, 0xA00)]); + let art = art_content(&[], &[brace, bracket, rect]); + let flags = spa_flags(0, 0, 3, false); + let spas = + plc(&[1, 2, 3, 5], &[spa(1026, [0, 0, 240, 1140], flags), spa(1027, [0, 0, 240, 1140], flags), spa(1028, [0, 0, 240, 1140], flags)].concat()); + let text = "A\u{8}\u{8}\u{8}B\r"; + let ccp = text.chars().count() as u32; + let spec = StructSpec { text, extra_pairs: vec![(40, spas), (50, art)], ..structured_spec(text, ccp) }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + let fills: Vec<_> = p0 + .objects + .iter() + .map(|o| match o { + InlineObject::Shape { fill, stroke, w, h, .. } => (fill.clone(), *stroke, *w, *h), + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!( + fills, + [ + (None, Some(Rgb::BLACK), 12.0, 57.0), + (Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0), + (Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0), + ] + ); +} + #[test] fn text_boxes_pointing_at_the_same_story_text_read_it_once() { // Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4dc5e6d178..cd74be849e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -74,7 +74,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs`, `a_template_the_platform_hands_the_app_starts_a_new_document` | Macros and signatures kept on save (#172); macros never run. A template (`.dotx`, `.dotm`, `.dot`) the platform hands the app (file manager, command line, Finder) starts a new, untitled document attached to it, as in Word; File › Open edits the template itself (#3433) | | `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | `strict_namespace_and_bad_numbers`, `strict_line_heights_with_a_unit_are_read_as_point_multiples`, `strict_drawings` | Writing Strict is rare; unit-suffixed measures (`13.90pt`) read as points (#2329); shapes, text boxes, groups and canvases (`wsp`, `wgp`, `wpc` in the Strict drawing namespace, a text box's `wne:txbxContent` or `txbxContent`) read as their Word 2010 names and are saved as Transitional (#3548). Charts and SmartArt in Strict namespaces are not mapped | | `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run | -| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). Hostile files are bounded: a sector table longer than the file is refused before parsing (#3642); formatting and link runs are read once per change, not per character (#3692, #3708); copies of alt text, link targets and drawn objects share a 64 MB budget (#3709, #3710), and what it leaves out is logged once a kind per document; picture decoding shares a 512 MB budget and aliased store entries decode once (#3711, #3712). The default fonts are the stylesheet's standard fonts (`rgftcStandardChpStsh` and `ftcBi` in the `Stshi`, [MS-DOC] §2.9.273): Aptos in a Word 365 file, whose font table still starts with Times New Roman; a header too short for them, or an index past the font table, keeps the table's first font (#5066). Runs' East Asian and complex-script fonts (`sprmCRgFtc1`, `sprmCFtcBi`) are read. | +| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]); brackets and braces are drawn as their box, unfilled unless the file fills them (#5943). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). Hostile files are bounded: a sector table longer than the file is refused before parsing (#3642); formatting and link runs are read once per change, not per character (#3692, #3708); copies of alt text, link targets and drawn objects share a 64 MB budget (#3709, #3710), and what it leaves out is logged once a kind per document; picture decoding shares a 512 MB budget and aliased store entries decode once (#3711, #3712). The default fonts are the stylesheet's standard fonts (`rgftcStandardChpStsh` and `ftcBi` in the `Stshi`, [MS-DOC] §2.9.273): Aptos in a Word 365 file, whose font table still starts with Times New Roman; a header too short for them, or an index past the font table, keeps the table's first font (#5066). Runs' East Asian and complex-script fonts (`sprmCRgFtc1`, `sprmCFtcBi`) are read. | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, macOS, Wayland, X11) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). On the system clipboard: Copy and Cut write RTF (Windows, macOS, Wayland; not X11), except for picture- and shape-only copies, which keep to WordCraft's own copy, and Paste reads it on all four. Thin: comments, notes, RTL | @@ -360,6 +360,7 @@ For field results, the engine parser treats a backslash in quoted arguments as e | 2026-11-04 | trivial | ODT: a named section inside a table's cell (StarOffice's specification template hides some there) is written as read around the cell's paragraphs, closed in the cell; one starting or ending at a paragraph with twins elsewhere (an empty one, as in LibreOffice's agenda templates) is kept too, by that paragraph's own identity, and opened once whatever is copied. Over the corpus 126 of 133 files round-trip pixel-identical (124); over the 220 from other applications 176 (175), 218 with the same page count (216). | | 2026-11-04 | trivial | ODT element audit ([odt-element-audit.md](odt-element-audit.md)): every element of the ODF 1.3 schema against two corpora (365 files) and a document of our own for each a text document can hold that no file did. Found: index marks over text, plugins and floating frames weren't kept, a form's data source was taken for a control, and password, number, time and grid form controls were dropped; all kept now. | | 2026-11-04 | trivial | ODT, found in StarOffice's long specifications: a reference mark (§6.6.2 `text:reference-mark`, which `text:reference-ref` names) was written as a bookmark only, so references to it found nothing once its paragraph was written from the model; it is written as both (a reference mark over its text when it ends in the paragraph, else at a point). Lists without an `xml:id` (older files') are kept as read too, given one. | +| 2026-11-07 | trivial | `.doc`: bracket and brace shapes (MSOSPT 85–88, 185, 186) are unfilled unless `fFilled` is set, the default of Word's shapetypes for them, so the box they are drawn as no longer hides the text behind it (#5943) | | 2026-11-04 | trivial | ODT, found checking 220 files from Microsoft Office, OpenOffice.org, StarOffice, Calligra, Lotus Symphony and others: a section hidden with `text:display="none"` showed its text once written without the section (ending in a list, or in a table's cell); what a hidden section holds is now hidden text, so it stays hidden whatever is written, and WordCraft hides it too. A top-level list whose items nobody edited is written as read whole (a label followed by a space, picture bullets, an unnumbered header: what a paragraph's own markup can't say), a list going on with it naming it; a section may begin or end with a list, which then breaks there. A damaged package's media type isn't shown in its error. Of the 220, 166 round-trip pixel-identical and 194 with the same text. | | 2026-11-03 | trivial | ODT, found checking files from other applications: a kept package part without a media type (LibreOffice's empty `Configurations2/accelerator/current.xml`, another application's file the manifest didn't list) was written without a manifest entry, and LibreOffice refused to open the saved file; every file written has its entry now (ODF 1.3 Part 2 §4.3), its media type empty as read. | | 2026-11-03 | trivial | ODT: more paragraphs written back as read while unchanged: those holding bookmarks (their names kept, so a bookmark may start in markup as read and end in a paragraph written from the model; each name written once), footnotes and endnotes (each note's text fingerprinted when read; while the note numbering is as read, as the citation is its number), formulas, charts and other embedded objects (with their files and preview pictures, only while the paragraph showing them is written as read), and those page-anchored frames LibreOffice puts in the text before a paragraph join (their markup kept before the paragraph's). Styles of different families with one name (LibreOffice's paragraph and graphic "Marginalia") are kept apart, as list styles were. The text keeps `text:use-soft-page-breaks` while markup kept as read holds soft page breaks. Still written from the model: form controls (named afresh in the forms), and a chart's paragraph (WordCraft draws the chart after reading it). | From 4cd6ffa5648879ccc45b57877afa7e04803b0bf1 Mon Sep 17 00:00:00 2001 From: David Prieto Date: Sun, 8 Nov 2026 05:48:03 +0100 Subject: [PATCH 06/12] doc: preserve following formatting when deleting an empty paragraph (#5929) * doc: preserve following formatting when deleting an empty paragraph (Leaf #152) * doc: regress empty paragraph merge formatting (Leaf #152) * engine: cover empty heading deletion, history and DOCX (Leaf #152) * typing: pin empty heading deletion semantics (Leaf #152) * docs: record empty paragraph formatting coverage (Leaf #152) * docs: record the empty paragraph deletion fix (Leaf #152) * test: retain structured controls across an empty paragraph join * docs: explain empty paragraph engine regressions * docs: explain the empty heading keyboard regression --------- Co-authored-by: Martin Ball --- ROADMAP.md | 1 + crates/doc/src/edit.rs | 11 +- crates/doc/tests/empty_paragraph_merge.rs | 158 +++++++++++++++++++ crates/engine/src/tests/typing.rs | 13 ++ crates/engine/tests/empty_paragraph_merge.rs | 80 ++++++++++ docs/gaps.md | 1 + docs/typing-parity.md | 10 ++ 7 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 crates/doc/tests/empty_paragraph_merge.rs create mode 100644 crates/engine/tests/empty_paragraph_merge.rs diff --git a/ROADMAP.md b/ROADMAP.md index 64000e722f..61ab68bcbc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -357,6 +357,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | Date | What landed | | 2026-11-04 | The CLI opens a non-Unicode input filename even after options such as `--page 2` or `--format flatopc`; text option values still require Unicode; `run --file` and `run --script` retain native paths. | | 2026-11-06 | Accept All and Reject All no longer restyle the paragraph after an empty one carrying a section break (#5667): a Compare that added a section, then Reject All, left `Body` in the removed paragraph's Normal style and indents. The empty paragraph now goes whole and its section joins the following one, as deleting the break does. | +| 2026-11-07 | Deleting a preceding empty paragraph keeps the following paragraph's style, outline and mark formatting rather than promoting body text to a heading (Leaf #152). Non-empty merges and section absorption retain their existing rules; synthetic model, command, DOCX and Undo/Redo coverage. | | 2026-11-04 | Pictures keep the brightness, contrast and transparency their file gives them (#1711, #1766): a "Washout" picture watermark (VML `gain`/`blacklevel`) and a washed-out letterhead picture behind the text (`a:lum`) are drawn faded on screen and in PDF instead of at full colour, with the pixel curve measured on Word's own PDF export; `a:alphaModFix` transparency too. Saved back as DrawingML (and as VML `gain`/`blacklevel` in Word 2003 XML, without the transparency). Picture Format › Corrections, Transparency and Color › Washout replace the file's values (other Color modes leave them alone), and Reset Picture discards them all, as in Word; the PDF embeds the corrected pixels once per picture | |---|---| | 2026-10-17 | Copies keep their formatting in programs that read RTF rather than HTML: Copy and Cut put RTF beside the text and HTML on the system clipboard on Windows, macOS and Wayland (an X11 copy stays text and HTML, since arboard owns the selection there; a copy of only pictures or shapes carries no HTML or RTF, so pasting it back keeps every detail), and Paste reads another program's RTF on macOS and X11 too, not only on Windows and Wayland | diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 0a869ee70a..fc8fa2bb26 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -772,6 +772,10 @@ impl Document { if a.path.parent() == b.path.parent() { // Same container: trim the ends, drop the blocks between, join. let (ia, ib) = (a.path.last(), b.path.last()); + // Removing an already-empty paragraph must not impose its style or mark formatting + // on the following content. Test before trimming: deleting actual heading text is + // still an ordinary merge that keeps the first paragraph's properties. + let empty_start = self.para(a.story, &a.path).is_some_and(Paragraph::is_empty); // Kept markers of the paragraphs in between move to the join. let mut moved: Vec<(InlineObject, CharProps)> = Vec::new(); // `kept` is sorted: one walk of each paragraph's objects and runs (#3614). @@ -819,15 +823,18 @@ impl Document { if ib < bl.len() && ia < ib { bl.drain(ia + 1..=ib); } - tail.props = ParaProps::default(); // The deleted marks include `a`'s, so a section break there goes too: the joined // paragraph ends where `b`'s mark did, and its text takes the following section's // setup, as in Word (#2127). let sect = tail.section.take(); let pa = self.para_mut(a.story, &a.path)?; - let keep_props = pa.props.clone(); + let keep_props = if empty_start { tail.props.clone() } else { pa.props.clone() }; + let keep_mark = empty_start.then(|| tail.mark.clone()); pa.append(tail); pa.props = keep_props; + if let Some(mark) = keep_mark { + pa.mark = mark; + } let before = std::mem::replace(&mut pa.section, sect); if a.story == crate::StoryRef::Body && a.path.0.len() == 1 { // Nearest first, as "link to previous" resolved them before the delete. diff --git a/crates/doc/tests/empty_paragraph_merge.rs b/crates/doc/tests/empty_paragraph_merge.rs new file mode 100644 index 0000000000..7368ad1be1 --- /dev/null +++ b/crates/doc/tests/empty_paragraph_merge.rs @@ -0,0 +1,158 @@ +use std::sync::Arc; +use wordcraft_doc::props::{Align, CharProps, ParaProps}; +use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block}; + +/// Build an empty heading followed by explicitly formatted, mixed-run body text. +fn fixture(first: &str, style: Option<&str>) -> Document { + let mut d = Document::from_text(&format!("{first}\nBody paragraph.")); + let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.props.style = Some("Heading1".into()); + p.props.outline_level = Some(0); + p.mark = CharProps { size: Some(40.0), bold: Some(true), ..Default::default() }; + let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap(); + p.props = ParaProps { + style: style.map(str::to_string), + align: Some(Align::Right), + indent_left: Some(18.0), + space_after: Some(7.0), + keep_next: Some(false), + ..Default::default() + }; + p.mark = CharProps { size: Some(11.0), bold: Some(false), lang: Some("en-GB".into()), ..Default::default() }; + p.format(0, 4, &|c| c.italic = Some(true)).unwrap(); + d +} + +/// Compare paragraph, mark and inline-run formatting independently of text. +fn assert_format(p: &Paragraph, expected: &Paragraph) { + assert_eq!(p.props, expected.props, "the following paragraph keeps all its properties"); + assert_eq!(p.mark, expected.mark, "the empty heading's mark must not replace the following mark"); + assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap()); +} + +/// Deleting an empty paragraph preserves the following style, including implicit Normal. +#[test] +fn deleting_an_empty_paragraph_keeps_the_following_format() { + for first_style in ["Heading1", "Heading3", "Normal"] { + for style in [None, Some("Normal"), Some("Heading2")] { + let mut d = fixture("", style); + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.style = Some(first_style.into()); + let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone(); + assert_eq!(d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap(), Pos::body(0, 0)); + let p = d.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, expected.text); + assert_format(p, &expected); + } + } +} + +/// A selection spanning several paragraphs keeps the surviving tail format in both directions. +#[test] +fn an_empty_start_preserves_the_last_paragraph_over_a_multi_paragraph_selection() { + for backwards in [false, true] { + let mut d = fixture("", None); + d.body.insert(1, para_block(Paragraph::with_text("Removed middle paragraph", Default::default()).styled("Heading2"))); + let mut expected = d.para_at(&Pos::body(2, 0)).unwrap().clone(); + expected.delete(0, 5).unwrap(); + let (a, b) = (Pos::body(0, 0), Pos::body(2, 5)); + d.delete_range(if backwards { &b } else { &a }, if backwards { &a } else { &b }).unwrap(); + assert_eq!(d.body.len(), 1); + let p = d.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, "paragraph."); + assert_format(p, &expected); + } +} + +/// A control whose closing marker survives must move across the join without replacing tail formatting. +#[test] +fn an_empty_start_keeps_a_control_from_a_deleted_middle_paragraph() { + use wordcraft_doc::control::ContentControl; + use wordcraft_doc::{InlineObject, Revision, RevisionKind}; + + let mut d = fixture("", Some("Normal")); + d.body.insert(1, para_block(Paragraph::with_text("Removed", Default::default()))); + d.body.push(para_block(Paragraph::with_text("After", Default::default()))); + d.revisions.push(Revision { kind: RevisionKind::Insert, ..Default::default() }); + d.revisions.push(Revision { kind: RevisionKind::Delete, ..Default::default() }); + let p = d.para_mut(StoryRef::Body, &Path::top(2)).unwrap(); + p.mark.ins = Some(0); + p.mark.del = Some(1); + let expected = p.clone(); + let marker = CharProps { bold: Some(true), ins: Some(0), ..Default::default() }; + d.insert_object(&Pos::body(1, 0), InlineObject::ControlStart { control: Box::new(ContentControl::default()) }, &marker).unwrap(); + d.insert_object(&Pos::body(3, 5), InlineObject::ControlEnd, &Default::default()).unwrap(); + assert_eq!(d.control_ranges(StoryRef::Body).len(), 1); + + d.delete_range(&Pos::body(0, 0), &Pos::body(2, 0)).unwrap(); + let p = d.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props, expected.props); + assert_eq!(p.mark, expected.mark, "tail revision IDs and formatting survive the moved marker"); + assert_eq!(p.props_of_char(0), &marker); + assert!(matches!(p.objects.as_slice(), [InlineObject::ControlStart { .. }])); + assert_eq!(p.text, format!("\u{fffc}{}", expected.text)); + let ranges = d.control_ranges(StoryRef::Body); + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].start, Pos::body(0, 0)); + assert_eq!(ranges[0].end, Pos::body(1, 5)); + assert!(!d.has_unbalanced_controls()); +} + +/// The empty-paragraph rule applies in header stories and table-cell containers. +#[test] +fn empty_paragraph_merges_work_in_a_header_and_a_table_cell() { + for cell in [false, true] { + let mut d = fixture("", Some("Normal")); + let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone(); + let (story, a, b) = if cell { + let mut table = Table::new(1, 1, 300.0); + table.rows[0].cells[0].blocks = d.body.clone(); + d.body = vec![Arc::new(Block::Table(table)), para_block(Paragraph::new())]; + (StoryRef::Body, Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 0, 1])) + } else { + let id = d.add_part(wordcraft_doc::PartKind::Header, d.body.clone()); + (StoryRef::Part(id), Path::top(0), Path::top(1)) + }; + d.delete_range(&Pos { story, path: a.clone(), off: 0 }, &Pos { story, path: b, off: 0 }).unwrap(); + assert_format(d.para(story, &a).unwrap(), &expected); + } +} + +/// Absorbing an empty section keeps the following format and inherited header. +#[test] +fn deleting_an_empty_section_paragraph_keeps_format_and_linked_headers() { + let mut d = fixture("", None); + let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone(); + let header = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(Paragraph::with_text("Header", Default::default()))]); + let mut section = d.last_section.clone(); + section.headers.default = Some(header); + d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(section)); + d.last_section.margin_left = 96.0; + d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap(); + assert_format(d.para_at(&Pos::body(0, 0)).unwrap(), &expected); + assert!(d.para_at(&Pos::body(0, 0)).unwrap().section.is_none()); + assert_eq!(d.last_section.headers.default, Some(header)); + assert_eq!(d.last_section.margin_left, 96.0); +} + +/// Deleting actual text continues to use the first paragraph merge rule. +#[test] +fn joining_nonempty_paragraphs_keeps_the_existing_first_paragraph_rule() { + for off in [0, 7] { + let mut d = fixture("Heading", None); + let expected = d.para_at(&Pos::body(0, 0)).unwrap().props.clone(); + d.delete_range(&Pos::body(0, off), &Pos::body(1, 0)).unwrap(); + assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected); + } +} + +/// Removing a soft line break must not change paragraph formatting. +#[test] +fn a_soft_break_is_not_an_empty_paragraph_merge() { + let mut d = fixture("", None); + let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.insert_text(0, "\nHeading", &Default::default()).unwrap(); + let expected = p.props.clone(); + d.delete_range(&Pos::body(0, 0), &Pos::body(0, 1)).unwrap(); + assert_eq!(d.body.len(), 2); + assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected); +} diff --git a/crates/engine/src/tests/typing.rs b/crates/engine/src/tests/typing.rs index 01ab762111..e2594492dc 100644 --- a/crates/engine/src/tests/typing.rs +++ b/crates/engine/src/tests/typing.rs @@ -9,6 +9,19 @@ use wordcraft_doc::{Pos, StoryRef}; use crate::Session; +/// Leaf #152: deleting the empty heading above body text must not promote that text to a heading. +/// Deleting the empty heading mark keeps the next paragraph as body text. +#[test] +fn deleting_a_preceding_empty_heading_keeps_the_body_paragraph_style() { + let mut s = Session::new(wordcraft_doc::Document::from_text("\nBody paragraph.")); + run(&mut s, "para.style", json!({"style": "Heading1"})); + run(&mut s, "text.delete", json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, "Body paragraph."); + assert_eq!(p.props.style, None); + assert_eq!(wordcraft_layout::outline::levels(&s.doc), [wordcraft_layout::outline::BODY]); +} + /// Create an empty session for command-driven typing fixtures. fn s() -> Session { Session::new(wordcraft_doc::Document::new()) diff --git a/crates/engine/tests/empty_paragraph_merge.rs b/crates/engine/tests/empty_paragraph_merge.rs new file mode 100644 index 0000000000..40f81088c5 --- /dev/null +++ b/crates/engine/tests/empty_paragraph_merge.rs @@ -0,0 +1,80 @@ +use serde_json::{Value, json}; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Document, Path, Pos, StoryRef}; +use wordcraft_engine::{Selection, Session}; +use wordcraft_layout::outline; + +/// Dispatch an engine command and report its ID if it fails. +fn run(s: &mut Session, id: &str, params: Value) { + s.run(id, ¶ms).unwrap_or_else(|e| panic!("{id}: {e}")); +} + +/// Create an empty heading, mixed-run body text and a genuine TOC heading. +fn fixture() -> Session { + let mut s = Session::new(Document::from_text("\nBody paragraph.\nReal heading\n")); + run(&mut s, "para.style", json!({"style": "Heading1"})); + s.doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap().mark = + CharProps { font: Some("Source Sans 3".into()), size: Some(40.0), bold: Some(true), ..Default::default() }; + let p = s.doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap(); + p.mark = CharProps { size: Some(11.0), bold: Some(false), ..Default::default() }; + p.format(5, 14, &|c| c.italic = Some(true)).unwrap(); + s.doc.para_mut(StoryRef::Body, &Path::top(2)).unwrap().props.style = Some("Heading2".into()); + s +} + +/// Keyboard and selection deletes preserve body formatting through Undo and Redo. +#[test] +fn empty_heading_delete_backspace_and_selection_preserve_body_format_and_history() { + for mode in ["delete", "backspace", "selection", "reverse selection"] { + for pending in [false, true] { + let mut s = fixture(); + let expected = s.doc.para_at(&Pos::body(1, 0)).unwrap().clone(); + s.sel = match mode { + "backspace" => Selection::caret(Pos::body(1, 0)), + "selection" => Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 0) }, + "reverse selection" => Selection { anchor: Pos::body(1, 0), focus: Pos::body(0, 0) }, + _ => Selection::caret(Pos::body(0, 0)), + }; + if pending { + s.pending = Some(CharProps { size: Some(48.0), bold: Some(true), ..Default::default() }); + } + let before = serde_json::to_value(&s.doc.body).unwrap(); + run(&mut s, if mode == "backspace" { "text.backspace" } else { "text.delete" }, json!({})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, expected.text, "{mode}, pending={pending}"); + assert_eq!(p.props, expected.props); + assert_eq!(p.mark, expected.mark); + assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap()); + let actual = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0)); + let old = s.doc.styles.resolve_char(expected.props.style.as_deref(), expected.props_of_char(0)); + assert_eq!((actual.font, actual.size, actual.bold, actual.italic), (old.font, old.size, old.bold, old.italic)); + assert!(s.pending.is_none()); + assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]); + let after = serde_json::to_value(&s.doc.body).unwrap(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before); + run(&mut s, "edit.redo", json!({})); + assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after); + } + } +} + +/// Saving to DOCX and generating a TOC must keep the surviving body text out of the outline. +#[test] +fn deleted_empty_heading_does_not_promote_body_text_into_toc_or_docx() { + let mut s = fixture(); + run(&mut s, "text.delete", json!({})); + assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]); + let bytes = wordcraft_docx::write(&s.doc).unwrap(); + let mut s = Session::new(wordcraft_docx::read(&bytes).unwrap()); + assert_eq!(outline::levels(&s.doc)[..2], [outline::BODY, 2]); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.props.style.as_deref(), None); + assert_eq!(p.mark.size, Some(11.0)); + assert_eq!(p.props_of_char(6).italic, Some(true)); + s.sel = Selection::caret(Pos::body(2, 0)); + run(&mut s, "references.toc", json!({"levels": 3})); + let text = s.doc.plain_text(StoryRef::Body); + assert_eq!(text.matches("Body paragraph.").count(), 1, "body text is not a TOC entry: {text}"); + assert_eq!(text.matches("Real heading").count(), 2, "the genuine heading stays in the TOC: {text}"); +} diff --git a/docs/gaps.md b/docs/gaps.md index e4ed791d1b..55f8757d42 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -204,6 +204,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-02 | trivial | Picture hyperlinks show on hover, follow on Ctrl/⌘+click and round-trip as Word stores them (#5267); no ranked gap changed; readiness estimates unchanged | | 2026-11-02 | trivial | #26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains | | 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. | +| 2026-11-07 | trivial | Empty paragraph deletion preserves following paragraph/mark formatting (Leaf #152); synthetic regressions only, no ranked gap closed or estimate changed. | | 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) | | 2026-11-06 | trivial | Windows print submissions use a separate helper per job, preserving provider serialization inside the helper. An acknowledged cancellation stops that helper, so a stalled cancelled driver cannot block a later job; normal shutdown reaps helpers and removes temporary PDFs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Startup requires an explicit start grant; after sending begins, jobs complete (#5710) | | 2026-11-06 | trivial | Gap 6: File › Print: before sending starts, Cancel printing (`ui.printCancel`) stops a waiting job and frees the Print page while keeping its settings, including after the last document closes; a successfully cancelled job is never sent. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. No ranked gap changed; readiness estimates unchanged | diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 266d9cf9a4..e1b2096742 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -584,11 +584,21 @@ command is one Undo step and still obeys editing protection and the 1–1638 pt limits. Original-fixture engine and Linux CLI/headless UI save/reopen checks cover this correction; native Windows interaction and Microsoft Word remain untested. +## Empty paragraph removal + +Deleting a paragraph mark whose preceding paragraph was already empty preserves the +following paragraph's paragraph properties and mark formatting. Existing inline runs +remain intact. Delete, Backspace and selected marks share the model operation; joining +two originally non-empty paragraphs retains the first paragraph's properties. Synthetic +regressions cover body, header and cell containers, sections, outline/TOC membership, +DOCX and Undo/Redo (Leaf #152). No new Word or native UI comparison was run. + ## Revision history | Date | Change | Summary | |---|---|---| | 2026-11-04 | trivial | Ctrl+K on a selection: a changed Text to display replaces the selected text before it is linked | +| 2026-11-07 | trivial | Removing an already-empty paragraph preserves the following paragraph properties, mark and inline formatting. Delete, Backspace, selected marks, outline/TOC, DOCX and Undo/Redo have synthetic coverage (Leaf #152); no new Word or native acceptance run. | | 2026-10-30 | trivial | Equations: `\int` + Space waits for its limits; `_x^y` then Space builds them, Space alone builds the operator without, typing goes into its operand, and Enter or an arrow key builds it without going into the operand (#4917). Review: any character that can't continue `\name` corrects it (`\int_0^1`, `\alpha+`), and `∫∫` takes the operand into the inner operator; the Word rows are now observed in Word for Windows on #4938, Enter, arrows and `∫∫` are not | | 2026-11-06 | trivial | Track Changes: resolving away an empty paragraph with a section break removes it whole; the next paragraph keeps its style (#5667) | | 2026-11-03 | trivial | Alt+numpad codes type their character in text boxes too (Find what, dialog fields), not the digits or nothing (#4403) | From f0fb31c0c97cb31c402c8ba40ab48b26b0a828fa Mon Sep 17 00:00:00 2001 From: Jhong-Ken Chen Date: Sun, 8 Nov 2026 12:48:20 +0800 Subject: [PATCH 07/12] =?UTF-8?q?XML:=20normalise=20line=20ends=20in=20tex?= =?UTF-8?q?t=20and=20CDATA=20(XML=201.0=20=C2=A72.11)=20(#5897)=20(#5898)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text nodes were decoded without line-end normalisation, so a file saved with Windows line ends kept the CR of each CR LF: a line break inside w:t read as two spaces. Text and CDATA now go through quick-xml's xml_content, which reads CR LF and a lone CR as one line feed; a CR written as stays a CR. Attribute values were already normalised. Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/docx/src/xml.rs | 33 ++++++++++++++++++++++++++++++-- crates/docx/tests/it/word_xml.rs | 9 +++++++++ docs/file-format-parity.md | 5 +++-- 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 61ab68bcbc..ce5a54e177 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -149,6 +149,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log - 2026-11-08: Layout: a turned inline picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`, as other tools write) keeps its line to its frame and overhangs it, cut at the line's top and bottom, as Word does: the frame as it is in Word 2007 layout, with width and height exchanged from 45° to 135° and 225° to 315° in Word 2013+ layout. Such a file saves the same way. Pictures with the room recorded (Word's own files) still take their rotated bounds. Readiness unchanged. +- 2026-11-08: XML text and CDATA read with their line ends normalised (XML 1.0 §2.11): CR LF and a lone CR are one line feed, ` ` stays a CR, so a line break inside `w:t` in a file saved with Windows line ends is one space, not two (#5897); readiness estimates unchanged. - 2026-11-06: Arabic interface: the Home tab's Styles gallery reads from the right, as the rest of the mirrored ribbon does (#5566). Normal is the rightmost tile, the next styles follow leftwards and the scroll arrows and More sit at the gallery's left end; left to right is unchanged. Readiness unchanged. - 2026-11-05: ODT heading text of another size keeps it (gap 19): a smaller word in a heading came back at the heading's size in LibreOffice after a save. - 2026-11-05: ODT table rows kept from breaking across pages stay so (gap 19): they came back from a save free to break. @@ -865,6 +866,7 @@ MATH glyphs that reference the same construction now share its immutable variant | Date | Change | Summary | |---|---|---| +| 2026-11-08 | trivial | Progress log: XML line ends in text normalised (#5897) | | 2026-11-06 | trivial | Progress log: the Arabic interface's Styles gallery reads from the right (#5566) | | 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). | | 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | diff --git a/crates/docx/src/xml.rs b/crates/docx/src/xml.rs index f14991673a..ca12fa5d63 100644 --- a/crates/docx/src/xml.rs +++ b/crates/docx/src/xml.rs @@ -462,15 +462,22 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res attach(&mut stack, &mut root, el); } } + // Line ends are normalised as XML 1.0 says (§2.11): CR LF and a lone CR are read as + // LF, so a file saved with Windows line ends has no CR in its text. A CR written as + // ` ` is a character reference and stays. Event::Text(t) => { if let Some(top) = stack.last_mut() { - let s = t.decode().map_err(|e| DocxError::Xml(e.to_string()))?; + let s = t.xml_content(XmlVersion::Explicit1_0).map_err(|e| DocxError::Xml(e.to_string()))?; push_text(top, &s); } } Event::CData(t) => { if let Some(top) = stack.last_mut() { - let s = String::from_utf8_lossy(&t).into_owned(); + // CDATA that isn't UTF-8 is decoded lossily, with its line ends normalised the same way. + let s = t + .xml_content(XmlVersion::Explicit1_0) + .map(|s| s.into_owned()) + .unwrap_or_else(|_| String::from_utf8_lossy(&t).replace("\r\n", "\n").replace('\r', "\n")); push_text(top, &s); } } @@ -724,6 +731,28 @@ mod tests { assert_eq!(t.attr("xml:space"), Some("preserve")); } + /// CDATA that isn't UTF-8 is decoded lossily, and its line ends are still normalised. + #[test] + fn line_ends_in_cdata_that_isnt_utf8_are_normalised() { + let x = + b""; + assert_eq!(parse(x).unwrap().child("w:t").unwrap().text(), "a\u{FFFD}\nb\nc"); + } + + /// XML 1.0 §2.11: CR LF and a lone CR in text and CDATA are read as LF; ` ` stays a CR. + #[test] + fn line_ends_in_text_are_normalised_but_a_cr_reference_stays() { + let x = b"a\r\nb\rc d"; + let e = parse(x).unwrap(); + assert_eq!(e.child("w:t").unwrap().text(), "a\nb\nc\rde\nf"); + // The writer escapes a CR, so a CR in the text survives a save. + let x = format!( + "{}", + esc("a\r\nb\rc") + ); + assert_eq!(parse(x.as_bytes()).unwrap().child("w:t").unwrap().text(), "a\r\nb\rc"); + } + #[test] fn choice_requirements_resolve_namespace_aliases_and_rebindings() { let x = br#""#; diff --git a/crates/docx/tests/it/word_xml.rs b/crates/docx/tests/it/word_xml.rs index dd614ae59f..99b05e865c 100644 --- a/crates/docx/tests/it/word_xml.rs +++ b/crates/docx/tests/it/word_xml.rs @@ -270,3 +270,12 @@ fn flat_opc_limits_count_what_is_not_kept() { // A part nothing reads, one element over what the XML tree reader takes in a part (8 000 000). assert!(limited(&format!(r#"{}"#, "".repeat(8_000_000)))); } + +/// A Word 2003 XML Document saved with Windows line ends: XML reads CR LF as one line feed (XML +/// 1.0 §2.11), so a line break inside `w:t` is one space, not two. +#[test] +fn word_2003_xml_with_windows_line_ends_reads_one_space_per_line_break() { + let xml = "\r\n\r\na\r\nb\r\n"; + let d = read_xml(xml.as_bytes()).unwrap(); + assert_eq!(paras(&d)[0].plain_text(), "a b"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cd74be849e..b8296f0511 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -4,7 +4,7 @@ > **2026-11-05:** DOCX paragraph `w:adjustRightInd` keeps its default and explicit values through style resolution and schema-ordered repeated saves. -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-05 · **Change:** trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (Replace and Remove Table of Contents treat Word's title above a section break as Word does, #4041); previously trivial (a password-protected .docx opened from the file manager asks for its password also when the startup document was replaced, #5299); previously minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously trivial (a VML horizontal rule (`o:hr`) is drawn across its line at Word's measured width and kept on save, with rotation and flips, #3956); previously trivial (soft edges on a see-through solid fill fade from the fill's own opacity, #1600); previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; 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: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-08 · **Change:** trivial (XML line ends in text normalised, #5897); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT pictures offered in several formats); previously trivial (ODT heading text of another size); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (Replace and Remove Table of Contents treat Word's title above a section break as Word does, #4041); previously trivial (a password-protected .docx opened from the file manager asks for its password also when the startup document was replaced, #5299); previously minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously trivial (a VML horizontal rule (`o:hr`) is drawn across its line at Word's measured width and kept on save, with rotation and flips, #3956); previously trivial (soft edges on a see-through solid fill fade from the fill's own opacity, #1600); previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; 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: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats > **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes. > **Legacy form update:** 2026-10-22 · trivial: field names and selected drop-down labels decode numeric XML character references once. @@ -84,7 +84,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~65% | 5–9 | `crates/formats/src/tests.rs`, `html.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154); the empty comments `` and `` end at once instead of swallowing the rest of the page (#5040). `style` element rules with simple selectors (`p`, `.x`, `p.x`; class names may start with a digit, as Word accepts) in cascade order; paragraph `margin`, `text-indent`, `line-height`; cell `width` and borders. Header cells (``) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). An opened page looks as Word opens one: Times New Roman 12 pt, single lines, browser-sized black headings, automatic 14 pt spacing where a `p` or heading states no margin (none before the first), a span's margin replacing its paragraph's. HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do (#1381). No `@page`, descendant selectors or `div` margins. No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `
` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `basereading` in the base text's formatting and read back from ``/``/`` (`` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). | | `.mht/.mhtml` Single File Web Page | <1% | R/W | 🟡 | ❌ | ~60% | 2–4 | `crates/formats/src/mht.rs` | Opens: the MIME message's page (its `start` part, else the first HTML part) in its part's charset, quoted-printable or base64, pictures found by `Content-Location` (also relative to the page) or `cid:`; then read as an HTML page, on the region's paper. Not saved | | `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/it/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element | -| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/it/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/it/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, transparency, rotation and floating position (pictures become inline; brightness and contrast are kept as VML `gain`/`blacklevel`), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) | +| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/it/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Line ends in text are normalised as XML 1.0 §2.11 says, so CR LF inside `w:t` is one line feed (#5897, `word_2003_xml_with_windows_line_ends_reads_one_space_per_line_break`). Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/it/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, transparency, rotation and floating position (pictures become inline; brightness and contrast are kept as VML `gain`/`blacklevel`), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) | | `.wps` Works 6–9, WordPerfect `.wpd` (Windows) | <1% | R | ❌ | — | 0% | 10–20 | — | Windows-only converters; low value | | `.xlsx`/`.xlsm`/`.xls`/`.ods` as a mail-merge recipient list | — | R (data source) | ✅ | — | ~90% | 1–3 | `crates/formats/src/sheet.rs`, `cmd/mailings.rs` | Read-only, one sheet's cell values (#334): shared/inline/rich strings, numbers, booleans, cached formula results, dates and times as ISO 8601 (`yyyy-mm-dd`) from the cell's number format, sparse cells; ODS value types, repeated rows/columns, covered cells. Select Table picks the sheet. Capped (64 MiB per part, 100,000 rows, 1,000 columns, 2 M cells, 32,767 characters a cell, 64 MiB of cell text a sheet so a shared string repeated in every cell can't exhaust memory, #1287). Legacy `.xls` (Excel 97–2003 BIFF8, #778): SST/LABELSST, LABEL, NUMBER, RK/MULRK, BOOLERR and cached FORMULA results with the same date handling; Excel 5.0/95 and password-protected `.xls` are refused with a message; a CSV/TSV named `.xls` reads as text; an HTML page or XML spreadsheet named `.xls` gets a message saying which it is (#1287). Text lists (.csv/.tsv/.txt) infer comma, semicolon or tab from the complete first record, ignoring quoted punctuation, doubled quotes and quoted line breaks. They decode UTF-8/UTF-16 by BOM or shape, else the detected legacy encoding (GBK/GB18030, Big5, Shift_JIS, EUC-KR, Windows code pages; `encoding` param overrides) (#778). Not shown: number formats other than dates (currency, percent, thousands separators); no Access or Outlook sources | | Encrypted/password-protected `.docx` | 1% | R/W | ✅ | ✅ | ~60% | 1–2 | `crates/docx/src/crypt.rs` (9 unit tests), `tests/malformed.rs`, `crates/engine/src/tests.rs::password_protected_documents_round_trip`, `aes_encrypted_packages_open_under_any_word_extension` | ECMA-376 / [MS-OFFCRYPTO] agile encryption (AES-256-CBC, SHA-512): a protected `.docx` opens with its password and saves encrypted (#55, #281). Standard encryption (Word 2007: AES-128/192/256-ECB, SHA-1, 50,000 spins) opens too, and either kind opens when named `.doc`/`.dot` (gap 9); saving stays agile. Synthetic files only; never checked against Word-made protected files | @@ -334,6 +334,7 @@ For field results, the engine parser treats a backslash in quoted arguments as e | Date | Change | Summary | |---|---|---| +| 2026-11-08 | trivial | XML text and CDATA read with their line ends normalised (XML 1.0 §2.11): CR LF and a lone CR are one line feed, ` ` stays a CR, so a line break inside `w:t` in a file saved with Windows line ends is one space, not two (#5897) | | 2026-11-05 | trivial | ODT: text of another size in a heading, title or code paragraph keeps it. Every run's size went with the style, on screen and in LibreOffice after a save Text at the heading's size goes with the style, stated or not, and a percentage is of the heading's size. Text in a heading style that states no size keeps a size it states. | | 2026-11-05 | trivial | ODT: a table row kept from breaking across pages (`fo:keep-together` on the row) is read and written; it came back from a save free to break A row whose style doesn't say follows the default style. A row free to break says so where the file's default row style, which stays, keeps rows together. | | 2026-11-05 | trivial | ODT: a watermark added, changed or removed, and Different Odd & Even Pages turned on or off, in a file read from ODT are saved. The file's own page definitions were written back whatever happened to these, so the change was gone from the saved file (`odt::tests_more::headers`). Such a change writes the page definitions from the model, as other changes to what a page is made of do, so what WordCraft doesn't model of the page (a header's background) doesn't survive that save. Over 79 LibreOffice-authored files with a watermark added: all 79 have it, with the same page count in LibreOffice 26.8. | From b8a7e2acdb3ccdb9b4a9d4f17410b1d10cd29c05 Mon Sep 17 00:00:00 2001 From: neilcawse Date: Sat, 7 Nov 2026 23:50:41 -0500 Subject: [PATCH 08/12] docx: normalize TOCHeading gallery content controls with trailing FieldEnd paragraph (#5952) * docx: normalize TOCHeading gallery content controls with trailing FieldEnd paragraph * test(docx): assert TOC entries survive round-trip in section-break TOC control test --- crates/docx/src/read/toc.rs | 7 +-- crates/docx/tests/it/content_controls.rs | 54 ++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/crates/docx/src/read/toc.rs b/crates/docx/src/read/toc.rs index 1c246732a2..3516044616 100644 --- a/crates/docx/src/read/toc.rs +++ b/crates/docx/src/read/toc.rs @@ -83,13 +83,14 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) { } // An empty paragraph opening the control takes the field. It may end a section, as above // entries in columns (#3889). - if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) { + let empty_heading = engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()); + if empty_heading { field_to_heading(&mut engine, s + 1); } else { toc_field_to_heading(&mut engine); } if crate::write::written_toc_span(&engine) == Some((s, last)) { - *body = toc; + *body = if empty_heading { toc } else { engine }; } } @@ -97,7 +98,7 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) { /// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles. pub(super) fn toc_field_to_heading(body: &mut Blocks) { for i in 1..body.len() { - if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) { + if body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) { field_to_heading(body, i); } } diff --git a/crates/docx/tests/it/content_controls.rs b/crates/docx/tests/it/content_controls.rs index 63b79c4c4f..ef420e42d8 100644 --- a/crates/docx/tests/it/content_controls.rs +++ b/crates/docx/tests/it/content_controls.rs @@ -342,3 +342,57 @@ fn toc_gallery_around_a_toc_whose_heading_is_in_a_block_control_round_trips() { assert!(xml.rfind("").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}"); } } + +#[test] +fn word_toc_gallery_with_field_end_after_entries_round_trips_without_update() { + let style = |s: &str, inner: &str| format!(r#"{inner}"#); + let begin = r#" TOC \o "1-3" \h \z \u "#; + let content = format!( + "{}{}{}", + style("TOCHeading", &t("Contents")), + style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))), + style("TOC2", &t("Section\t2")) + ); + let pr = r#""#; + let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One")))); + for pass in 0..2 { + let d = read(&bytes).unwrap(); + bytes = write(&d).unwrap(); + let xml = document_xml(&bytes); + assert_eq!(xml.matches("").count(), 1, "pass {pass}: {xml}"); + assert!(xml.contains(r#""#), "pass {pass}: {xml}"); + assert!(xml.find("").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}"); + } +} + +#[test] +fn range_toc_without_control_stays_unwrapped_and_section_break_toc_control_hands_over() { + let style = |s: &str, inner: &str| format!(r#"{inner}"#); + let begin = r#" TOC \o "1-3" \h \z \u "#; + // A range TOC that was never in a content control (with FieldEnd in its own paragraph) must + // not gain a Table of Contents w:sdt on save. + let bare = format!( + "{}{}{}{}", + style("TOCHeading", &t("Contents")), + style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))), + style("TOC2", &t("Section\t2")), + p(&t("Chapter One")), + ); + let d_bare = read(&package(&bare)).unwrap(); + let xml_bare = document_xml(&write(&d_bare).unwrap()); + assert_eq!(xml_bare.matches("").count(), 0, "{xml_bare}"); + + // #3889: a Table of Contents control opening on an empty paragraph that ends a section above + // the entries hands its control over on read so Update Table reuses the section-break paragraph. + let sect_toc = format!( + r#"{}{}"#, + style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))), + style("TOC2", &t("Section\t2")), + ); + let pr = r#""#; + let d_sect = read(&package(&format!("{}{}", sdt(pr, §_toc), p(&t("Chapter One"))))).unwrap(); + assert!(controls(&d_sect).is_empty()); + assert!(d_sect.body[0].as_para().unwrap().section.is_some()); + let xml_sect = document_xml(&write(&d_sect).unwrap()); + assert!(xml_sect.contains("Chapter One") && xml_sect.contains("Section"), "{xml_sect}"); +} From de4ca293097a70e04c79eb39185dbb591d33a2f8 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:53:01 -0500 Subject: [PATCH 09/12] wordcraft-web: window.wordcraft, the page command API from craft-web-api (#5860) The web shell queues the page's calls as control requests (the channel the desktop control server uses) and answers them on the next frame. The API is installed in every mode; opening, saving, the file name and the unsaved flag stay with the host messages. --- Cargo.lock | 14 +++++++- apps/wordcraft-web/Cargo.toml | 4 ++- apps/wordcraft-web/src/api.rs | 61 ++++++++++++++++++++++++++++++++++ apps/wordcraft-web/src/main.rs | 2 ++ apps/wordcraft-web/src/web.rs | 3 +- packaging/web/README.md | 12 +++++++ 6 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 apps/wordcraft-web/src/api.rs diff --git a/Cargo.lock b/Cargo.lock index 0e5395268c..91c94ce265 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1158,10 +1158,21 @@ dependencies = [ "zip 9.1.0", ] +[[package]] +name = "craft-web-api" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505" +dependencies = [ + "js-sys", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "craft-web-host" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6" +source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505" dependencies = [ "wasm-bindgen", "web-sys", @@ -7098,6 +7109,7 @@ dependencies = [ name = "wordcraft-web" version = "0.15.1" dependencies = [ + "craft-web-api", "craft-web-host", "eframe", "egui", diff --git a/apps/wordcraft-web/Cargo.toml b/apps/wordcraft-web/Cargo.toml index 971daf0cbd..8c42c988b1 100644 --- a/apps/wordcraft-web/Cargo.toml +++ b/apps/wordcraft-web/Cargo.toml @@ -17,7 +17,9 @@ path = "src/main.rs" wordcraft-engine = { workspace = true } wordcraft-ui-egui = { workspace = true } # The host messages (storytold/craft-libs): one contract and one bridge for the web builds. -craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" } +# The page command API (storytold/craft-libs#413): window.wordcraft over the control channel. +craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } +craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" } egui = { workspace = true } log = { workspace = true } serde_json = { workspace = true } diff --git a/apps/wordcraft-web/src/api.rs b/apps/wordcraft-web/src/api.rs new file mode 100644 index 0000000000..e0dbfed200 --- /dev/null +++ b/apps/wordcraft-web/src/api.rs @@ -0,0 +1,61 @@ +//! `window.wordcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the +//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`). +//! +//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`, +//! `screenshot`, as promises with a timeout). This module is WordCraft's part: a [`Request`] that +//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the +//! reply. WordCraft's control request has no abandon state, so a call the page gave up on still runs +//! when the app reaches it, as it does for a desktop client that timed out. + +use std::rc::Rc; +use std::sync::mpsc::{Receiver, TryRecvError, channel}; + +use craft_web_api::methods::Methods; +use craft_web_api::page::{PageApi, Request}; +use serde_json::{Value, json}; +use wasm_bindgen_futures::JsFuture; +use wordcraft_ui_egui::ControlRequest; + +/// The app id in `window.`. +const APP: &str = "wordcraft"; + +/// How often a waiting call looks for the app's reply, in milliseconds (about a frame). +const POLL_MS: i32 = 16; + +/// Put `window.wordcraft` on the page and return the queue the app drains each frame (hand it to +/// `WordApp::with_control`). An existing `window.wordcraft` keeps the functions it already has. +pub fn install(ctx: egui::Context) -> Receiver { + let (queue, requests) = channel::(); + let request: Request = Rc::new(move |method, params| { + let (control, reply) = ControlRequest::new(method, params); + if queue.send(control).is_err() { + return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) }); + } + ctx.request_repaint(); + Box::pin(wait(reply)) + }); + if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() { + log::warn!("couldn't install window.{APP}: {e:?}"); + } + requests +} + +/// The app's reply, polled about once a frame. +async fn wait(reply: Receiver) -> Value { + loop { + match reply.try_recv() { + Ok(reply) => return reply, + Err(TryRecvError::Empty) => sleep(POLL_MS).await, + Err(TryRecvError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}), + } + } +} + +async fn sleep(ms: i32) { + let promise = js_sys::Promise::new(&mut |resolve, _| { + if let Some(window) = web_sys::window() { + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms); + } + }); + let _ = JsFuture::from(promise).await; +} diff --git a/apps/wordcraft-web/src/main.rs b/apps/wordcraft-web/src/main.rs index eb24ece139..285ce2e435 100644 --- a/apps/wordcraft-web/src/main.rs +++ b/apps/wordcraft-web/src/main.rs @@ -20,6 +20,8 @@ mod prefs; #[cfg(any(target_arch = "wasm32", test))] mod save_fallback; +#[cfg(target_arch = "wasm32")] +mod api; #[cfg(target_arch = "wasm32")] mod host; #[cfg(target_arch = "wasm32")] diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index a6bb8a8990..fb808decf8 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -76,7 +76,8 @@ pub fn start() { if let Some(bridge) = &app_bridge { bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone()); } - let mut app = WordApp::new(session, services); + let control = crate::api::install(cc.egui_ctx.clone()); + let mut app = WordApp::new(session, services).with_control(control); // Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349). app.services.pasted = Some(pasted); app.autosave = false; diff --git a/packaging/web/README.md b/packaging/web/README.md index 07b3ba4ff9..5a56fdb184 100644 --- a/packaging/web/README.md +++ b/packaging/web/README.md @@ -159,6 +159,18 @@ The messages and both transports are the shared contract of [`craft-web-host`](h - **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the host open limit (512 MiB) and a larger file is refused, and a name is cut to 255 characters. - **The event and message names are the interface.** `wordcraft:ready`, `wordcraft:open`, `wordcraft:save`, `wordcraft:dirty` and `wordcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change. +### Driving it from the page (`window.wordcraft`) + +Every WordCraft web build puts `window.wordcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app. `wordcraft.request(method, params)` calls any method of the control channel the desktop app serves (`docs/control-protocol.md`), and `execute(command, params)`, `commands()`, `docInspect(params)`, `uiInspect(params)` and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects. WordCraft's control requests cannot be withdrawn, so a call that times out in the page still runs when the app reaches it, as it does for a desktop client. + +```js +const doc = await wordcraft.docInspect(); // a summary of the open document +await wordcraft.execute("text.insert", { text: "Hello" }); +const { data } = await wordcraft.screenshot(); // the canvas as PNG (base64) +``` + +The API is there in every mode, with or without a host. Opening a file, handing a saved or exported file to the page, the file name and the unsaved flag stay with the host messages above. + ## Renderer selection and fallback flags WordCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to From c4d5f70c1d4903ae759d41406d00a52ad9198728 Mon Sep 17 00:00:00 2001 From: batistalucasv Date: Sun, 8 Nov 2026 01:55:24 -0300 Subject: [PATCH 10/12] DOCX: resolve LibreOffice font fallback lists to first installed family (#5843) (#5851) * #5843: resolve LibreOffice semicolon-separated font fallback lists to first installed family * #5843: address review comments on font fallback list resolution * #5843: verify fallback list text on cache hit and accept Times New Roman in tests * #5843: bound the fallback lists read from a DOCX and reuse their classification A document could name thousands of fallback families in `w:ascii`/`w:hAnsi`. Nothing bounded that value, and the name-based rules (roman classification, Thai family, named substitutes) then scanned every candidate for each shaped piece, so opening the document took seconds. `w:ascii` and `w:hAnsi` now keep the leading families within MAX_FONT_NAME bytes, cut at a `;` so no name is split, as `w:cs`/`w:eastAsia` already bound their names. The family a real list would pick is kept: a LibreOffice list names a few families, well under the bound. The Thai-family and named-substitute answers are worked out once per list and kept in the fallback-list cache, keyed by the list text, so shaping no longer rescans candidates for every piece. Resolution still reads every candidate. A 500-paragraph document whose font names 20,000 candidates laid out in 10.8 s; bounded on read it takes 17 ms. Tests: - a hostile fallback list keeps its leading families, cut on a `;` - a real LibreOffice list is read whole - a later Thai or named fallback still classifies, and lists are cached --- crates/doc/src/symbol_fonts.rs | 9 +- crates/docx/src/read/parts.rs | 2 + crates/docx/src/read/props.rs | 32 ++- crates/docx/tests/it/fixtures.rs | 36 +++ crates/engine/src/tests/fonts.rs | 40 ++++ crates/fonts/src/word.rs | 398 +++++++++++++++++++++++++++++-- crates/layout/src/para/chars.rs | 10 +- 7 files changed, 500 insertions(+), 27 deletions(-) diff --git a/crates/doc/src/symbol_fonts.rs b/crates/doc/src/symbol_fonts.rs index 775de27736..1d5175540b 100644 --- a/crates/doc/src/symbol_fonts.rs +++ b/crates/doc/src/symbol_fonts.rs @@ -19,8 +19,8 @@ //! //! Tables are indexed by code − 0x20; 0 = no Unicode equivalent. -/// The symbol fonts this module knows, by lowercase family name. -fn table(family: &str) -> Option<&'static [u32; 224]> { +/// The symbol table for a single family name, by lowercase family name. +fn single_table(family: &str) -> Option<&'static [u32; 224]> { let f = family.trim(); if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") { Some(&SYMBOL) @@ -37,6 +37,11 @@ fn table(family: &str) -> Option<&'static [u32; 224]> { } } +/// The symbol fonts this module knows, by lowercase family name. +fn table(family: &str) -> Option<&'static [u32; 224]> { + if family.contains(';') { family.split(';').map(str::trim).filter(|s| !s.is_empty()).find_map(single_table) } else { single_table(family) } +} + /// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3, /// Webdings), whose characters [`symbol_char`] maps. pub fn is_symbol_font(family: &str) -> bool { diff --git a/crates/docx/src/read/parts.rs b/crates/docx/src/read/parts.rs index 2e1f98e32f..ce4184dc7d 100644 --- a/crates/docx/src/read/parts.rs +++ b/crates/docx/src/read/parts.rs @@ -55,6 +55,8 @@ impl Reader<'_> { .children("w:font") .filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman")) .filter_map(|f| f.attr("w:name")) + .flat_map(|n| n.split(';')) + .map(str::trim) .filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME) .take(MAX_TABLE_FONTS) .map(str::to_string) diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index a3d8558bb1..182ee179f2 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -13,6 +13,7 @@ use wordcraft_doc::section::{ PageVAlign, SectionProps, SectionStart, }; +use super::MAX_FONT_NAME; use super::drawing_color::adjust_lum; use crate::units::{int, line_multiple, measure, on_off, tw, u32_of}; @@ -145,6 +146,29 @@ pub(crate) fn resolve_level_only(sheet: &wordcraft_doc::styles::StyleSheet, p: & } } +/// A `w:rFonts` value as the document keeps it: `None` when it names no family, else its leading +/// families up to [`MAX_FONT_NAME`] bytes, cut at a `;` so no name is split. A LibreOffice file +/// names its Latin font as a semicolon-separated fallback list, and a real list names a few +/// families; a longer value is corrupt or hostile, and every piece of text drawn in it would pay +/// for its whole length as the list is classified and resolved. Keeping the leading families keeps +/// the family such a list picks, and the rest of the value is dropped like `w:cs`/`w:eastAsia` +/// drop an over-long name. +fn bounded_font_list(v: &str) -> Option { + if v.len() <= MAX_FONT_NAME { + return (!v.is_empty()).then(|| v.to_string()); + } + let mut end = 0usize; + for part in v.split_inclusive(';') { + let next = end + part.len(); + if next > MAX_FONT_NAME { + break; + } + end = next; + } + let kept = v.get(..end)?.trim_end_matches(';'); + (!kept.is_empty()).then(|| kept.to_string()) +} + impl PropCtx { pub fn style_id(&self, id: &str) -> String { self.alias.get(id).cloned().unwrap_or_else(|| id.to_string()) @@ -226,6 +250,8 @@ impl PropCtx { shd_fill(e) } + /// The character properties `e` (a `w:rPr`) sets: direct formatting, plus the fonts, size, + /// weight and posture of the complex-script and East Asian text beside them. pub fn rpr(&self, e: &El) -> CharProps { let mut c = CharProps::default(); for k in e.els() { @@ -234,7 +260,11 @@ impl PropCtx { "w:rFonts" => { // A theme font supersedes the fixed font beside it (`w:asciiTheme` over // `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`). - let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string); + // A LibreOffice file names the Latin font as a semicolon-separated fallback + // list. Names are bounded like `w:cs`/`w:eastAsia` below: a longer one is + // corrupt or hostile, and every piece of text drawn in it would pay for its + // whole length as the list is classified (see `bounded_font_list`). + let named = |a: &str| k.attr(a).and_then(bounded_font_list); let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t)); // `w:eastAsia` and `w:cs` name fonts for other scripts and stay in `font_ea` / // `font_cs`: `w:hint` only picks between them for ambiguous characters, never for diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index 9f82b95308..9d2e902505 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -3947,6 +3947,33 @@ fn bidi_theme_slots_follow_the_theme_font_language() { } } +/// A `w:rFonts` fallback list longer than the reader keeps is cut at the last complete family +/// within the bound, so the family a real list would pick survives while a hostile list cannot +/// cost its whole length in every piece of text drawn in it. +#[test] +fn a_hostile_fallback_list_keeps_its_leading_families() { + let candidates: Vec = (0..500).map(|i| format!("MissingCandidateFont{i}")).collect(); + let list = format!("{};Source Serif 4", candidates.join(";")); + let body = format!(r#"x"#); + let d = read_body(&body); + let c = &d.body[0].as_para().unwrap().runs[0].props; + let kept = c.font.as_deref().expect("the leading families are kept"); + assert!(kept.len() <= 256, "{} bytes kept", kept.len()); + assert!(kept.starts_with("MissingCandidateFont0"), "{kept}"); + // Every kept entry is a whole family name: the cut lands on a `;`, never inside a name. + assert!(kept.split(';').all(|f| f.starts_with("MissingCandidateFont") || f.contains("Source Serif")), "{kept}"); + assert!(!kept.ends_with(';'), "no dangling separator: {kept}"); +} + +/// A real LibreOffice fallback list is read whole: a few families, well under any bound. +#[test] +fn a_real_fallback_list_is_read_whole() { + let body = r#"x"#; + let d = read_body(body); + let c = &d.body[0].as_para().unwrap().runs[0].props; + assert_eq!(c.font.as_deref(), Some("Liberation Sans;Arial")); +} + /// The font table's family classes: Word draws a missing roman font in a serif face. #[test] fn roman_fonts_are_read_from_the_font_table() { @@ -3956,6 +3983,15 @@ fn roman_fonts_are_read_from_the_font_table() { assert_eq!(d.settings.roman_fonts, ["Brand Serif"]); } +/// Semicolon-separated roman font fallback lists in `fontTable.xml` register each candidate family. +#[test] +fn roman_fonts_split_fallback_lists() { + let fonts = r#""#; + let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")]; + let d = wordcraft_docx::read(&docx("", &rels, &[("word/fontTable.xml", fonts)])).unwrap(); + assert_eq!(d.settings.roman_fonts, ["Liberation Serif", "Times New Roman"]); +} + /// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up. #[test] fn html_auto_spacing_off_is_read_and_kept() { diff --git a/crates/engine/src/tests/fonts.rs b/crates/engine/src/tests/fonts.rs index 103f2029c9..960c56329f 100644 --- a/crates/engine/src/tests/fonts.rs +++ b/crates/engine/src/tests/fonts.rs @@ -469,3 +469,43 @@ fn an_east_asian_font_is_shown_as_the_face_its_text_is_drawn_in() { assert_eq!(m[0].shown_as, [shown], "{m:?}"); assert!(crate::cmd::font::east_asian_shown_as(&doc, "JetBrains Mono", "JetBrains Mono").is_none(), "installed"); } + +/// A semicolon-separated LibreOffice font fallback list is not reported as missing when any +/// candidate is installed, and substitutes by candidate family when none is. +#[test] +fn libreoffice_font_fallback_lists_are_not_reported_as_missing_when_installed() { + let db = wordcraft_fonts::FontDb::global(); + let doc = doc_of(vec![para_in("Text", "Liberation Sans;Arial")]); + if db.has_family("Liberation Sans") || db.has_family("Arial") { + let m = missing_in(doc.clone()); + assert!(m.is_empty(), "expected not to be reported as missing: {m:?}"); + } + let bundled_fallback = doc_of(vec![para_in("Text", "NonExistentSans123;Source Sans 3")]); + assert!(missing_in(bundled_fallback).is_empty(), "bundled fallback candidate is installed"); + + // Roundtrip through DOCX export and import: + let docx_bytes = crate::io::save_bytes("out.docx", &doc).unwrap(); + let loaded = wordcraft_docx::read(&docx_bytes).unwrap(); + assert_eq!(loaded.body[0].as_para().unwrap().runs[0].props.font.as_deref(), Some("Liberation Sans;Arial")); + if db.has_family("Liberation Sans") || db.has_family("Arial") { + let m = missing_in(loaded); + assert!(m.is_empty(), "expected not to report installed fallback font as missing: {m:?}"); + } + + // When none of the fonts is installed, missing_fonts flags it with an appropriate substitute: + if !db.has_family("NonExistentSans123") && !db.has_family("Times New Roman") { + let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;Times New Roman")])); + assert_eq!(m.len(), 1); + assert_eq!(m[0].font, "NonExistentSans123;Times New Roman"); + let drawn = m[0].shown_as.first().expect("drawn face"); + assert!(drawn.contains("Serif") || drawn == "Times" || drawn == "Tinos", "drawn: {drawn}"); + } + let m = missing_in(doc_of(vec![para_in("Text", "NonExistentSans123;NonExistentSerif123")])); + assert_eq!(m.len(), 1); + assert_eq!(m[0].font, "NonExistentSans123;NonExistentSerif123"); + let drawn = m[0].shown_as.first().expect("drawn face"); + assert!( + drawn.contains("Serif") || drawn == "Times" || drawn == "Times New Roman" || drawn == "Tinos" || drawn == "Caladea" || drawn == "Georgia", + "drawn: {drawn}" + ); +} diff --git a/crates/fonts/src/word.rs b/crates/fonts/src/word.rs index 5c73939c22..4860faf5a0 100644 --- a/crates/fonts/src/word.rs +++ b/crates/fonts/src/word.rs @@ -32,12 +32,27 @@ fn substitutes(family: &str) -> &'static [&'static str] { /// as Liberation Serif for Times New Roman, or the same design under another name), rather than /// the generic lists any unknown font gets? pub fn has_named_substitutes(family: &str) -> bool { + if family.contains(';') { + return list_class(family).named; + } substitutes_by_name(charset_base_family(family)).1 } +const SANS: &[&str] = &["Carlito", "Helvetica Neue", "Arial", "Liberation Sans", "Noto Sans", "DejaVu Sans", "Source Sans 3"]; + /// [`substitutes`], and whether they were chosen for this family by name. fn substitutes_by_name(family: &str) -> (&'static [&'static str], bool) { - const SANS: &[&str] = &["Carlito", "Helvetica Neue", "Arial", "Liberation Sans", "Noto Sans", "DejaVu Sans", "Source Sans 3"]; + if family.contains(';') { + for cand in family.split(';').map(str::trim).filter(|s| !s.is_empty()) { + let (subs, named) = substitutes_by_name(cand); + if named || is_thai_family(cand) || subs != SANS { + return (subs, named); + } + } + if let Some(first) = family.split(';').map(str::trim).find(|s| !s.is_empty()) { + return substitutes_by_name(first); + } + } const SERIF: &[&str] = &["Caladea", "Times New Roman", "Liberation Serif", "Georgia", "Noto Serif", "DejaVu Serif", "Source Serif 4"]; const MONO: &[&str] = &["Courier New", "Liberation Mono", "Menlo", "Consolas", "DejaVu Sans Mono", "JetBrains Mono"]; const ARIAL: &[&str] = &["Liberation Sans", "Helvetica", "Helvetica Neue", "Arimo", "Source Sans 3"]; @@ -157,11 +172,11 @@ fn substitutes_by_name(family: &str) -> (&'static [&'static str], bool) { /// itself: a Symbol bullet makes its line taller than the text alone. The values are those Word's /// PDFs give the fonts (`/Ascent`, `/Descent`). pub fn symbol_font_metrics(family: &str) -> Option<(f64, f64)> { - match family.trim().to_ascii_lowercase().as_str() { + family.split(';').map(str::trim).filter(|f| !f.is_empty()).find_map(|f| match f.to_ascii_lowercase().as_str() { "symbol" | "symbol (pcl6)" => Some((1.005, 0.216)), "wingdings" => Some((0.899, 0.205)), _ => None, - } + }) } /// The (ascent, descent) in em Word gives a line with a run in a symbol font, when that isn't the @@ -224,7 +239,10 @@ fn metric_twins(family: &str) -> impl Iterator + '_ { /// wraps and paginates as in `family`. pub fn metric_compatible(family: &str, substitute: &str) -> bool { let s = substitute.trim(); - metric_twins(charset_base_family(family.trim())).any(|t| t.eq_ignore_ascii_case(s)) + family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| { + let base = charset_base_family(f); + base.eq_ignore_ascii_case(s) || metric_twins(base).any(|t| t.eq_ignore_ascii_case(s)) + }) } /// Naskh-style Persian/Arabic document fonts (the "B …" family names come from the common @@ -356,10 +374,63 @@ fn is_thai_serif(f: &str) -> bool { /// Is `family` a Thai document font ([`is_thai`])? A missing one is drawn in a Thai face for the /// whole run, never in a Latin substitute (#1549). pub fn is_thai_family(family: &str) -> bool { - let f = family.to_ascii_lowercase(); + if family.contains(';') { + return list_class(family).thai; + } + let f = family.trim().to_ascii_lowercase(); is_thai(&f) || is_thai_serif(&f) } +/// What the name-based predicates answer about one semicolon-separated fallback list. Shaping asks +/// them for every piece of every run set in the list, so the answer is worked out once per list +/// rather than once per piece. +#[derive(Clone, Copy, Default)] +struct ListClass { + /// [`is_thai_family`]. + thai: bool, + /// [`has_named_substitutes`]. + named: bool, +} + +/// [`ListClass`] of the semicolon-separated `family`, memoised by its text. +fn list_class(family: &str) -> ListClass { + let key = family_list_key(family); + let generation = FontDb::global().generation(); + { + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation != generation { + *c = FallbackListMemo { generation, ..Default::default() }; + } else if let Some((cached, class)) = c.classes.get(&key) + && cached == family + { + return *class; + } + } + let class = ListClass { thai: classify_thai(family), named: classify_named(family) }; + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation == generation { + if c.classes.len() >= FAMILY_CACHE_MAX { + c.classes.clear(); + } + c.classes.insert(key, (family.to_string(), class)); + } + class +} + +/// Whether any family of the list is a Thai document font: what [`is_thai_family`] answers. +fn classify_thai(family: &str) -> bool { + family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| { + let f = f.to_ascii_lowercase(); + is_thai(&f) || is_thai_serif(&f) + }) +} + +/// Whether any family of the list has substitutes of its own: what [`has_named_substitutes`] +/// answers. +fn classify_named(family: &str) -> bool { + family.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| substitutes_by_name(charset_base_family(f)).1) +} + /// Khmer document fonts: the Khmer OS family, Khmer Mool1 and the like, Windows' and the Mac's /// own and Noto's. Matched by name only. fn is_khmer(f: &str) -> bool { @@ -416,8 +487,168 @@ fn resolve_cache() -> &'static Mutex { } const FAMILY_CACHE_MAX: usize = 4096; -/// Family names longer than this (bytes) are resolved but never remembered. +/// Family names longer than this (bytes) are resolved but never remembered as string keys. const FAMILY_KEY_MAX: usize = 256; +/// Maximum number of direct `FontDb::has_family` catalog scans per uncached fallback list +/// for candidate names not present in [`FontDb::families`]. +const MAX_DIRECT_CANDIDATE_PROBES: usize = 32; + +/// Memoised fallback-list resolutions and installed-family lookups for the current [`FontDb::generation`]. +#[derive(Default)] +struct FallbackListMemo { + generation: u64, + families: std::collections::HashSet, + probed: HashMap, + lists: HashMap<(u64, usize), (String, (String, bool))>, + long_resolved: HashMap<(u64, usize, bool, bool), (String, Resolved)>, + classes: HashMap<(u64, usize), (String, ListClass)>, +} + +/// Shared cache for fallback-list resolutions and candidate availability checks. +fn fallback_list_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(FallbackListMemo::default())) +} + +/// Hash and byte length of `family`, used as a compact cache key for fallback lists. +fn family_list_key(family: &str) -> (u64, usize) { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + family.hash(&mut h); + (h.finish(), family.len()) +} + +/// Whether `cand` is an installed family in `db`, using the memoised family set and bounded +/// direct catalog probes so long fallback lists do not rescan the font catalog for every candidate. +fn candidate_has_family(db: &FontDb, cand: &str, direct_probes: &mut usize) -> bool { + let cand = cand.trim(); + if cand.is_empty() || cand.len() > FAMILY_KEY_MAX { + return false; + } + let generation = db.generation(); + let lower = cand.to_ascii_lowercase(); + { + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation != generation { + *c = FallbackListMemo { generation, ..Default::default() }; + } + if c.families.is_empty() { + c.families = db.families().into_iter().map(|f| f.to_ascii_lowercase()).collect(); + } + if c.families.contains(&lower) { + return true; + } + if let Some(&hit) = c.probed.get(&lower) { + return hit; + } + } + if *direct_probes == 0 { + return false; + } + *direct_probes -= 1; + let hit = db.has_family(cand); + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation == generation { + if c.probed.len() >= FAMILY_CACHE_MAX { + c.probed.clear(); + } + c.probed.insert(lower, hit); + } + hit +} + +/// Record a resolved `(family, substituted)` pair for a semicolon-separated fallback list. +fn remember_list_resolution(generation: u64, key: (u64, usize), family: &str, result: (String, bool)) -> (String, bool) { + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation == generation { + if c.lists.len() >= FAMILY_CACHE_MAX { + c.lists.clear(); + } + c.lists.insert(key, (family.to_string(), result.clone())); + } + result +} + +/// Resolve a single family or semicolon-separated fallback list to `(family_name, substituted)`. +fn resolve_family(db: &FontDb, family: &str) -> (String, bool) { + if !family.contains(';') { + let base = if family.trim().eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(family) }; + return if db.has_family(family) { + (family.to_string(), false) + } else if base != family && db.has_family(base) { + (base.to_string(), false) + } else { + (substitute(db, base).unwrap_or(crate::FALLBACK_FAMILY).to_string(), true) + }; + } + + let generation = db.generation(); + let list_key = family_list_key(family); + { + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation != generation { + *c = FallbackListMemo { generation, ..Default::default() }; + } else if let Some((cached_family, r)) = c.lists.get(&list_key) + && cached_family == family + { + return r.clone(); + } + } + + let candidates: Vec<&str> = family.split(';').map(str::trim).filter(|s| !s.is_empty() && s.len() <= FAMILY_KEY_MAX).collect(); + if candidates.is_empty() { + return remember_list_resolution(generation, list_key, family, (crate::FALLBACK_FAMILY.to_string(), true)); + } + + let mut direct_probes = MAX_DIRECT_CANDIDATE_PROBES; + + // 1. First installed family in the list: + for &cand in &candidates { + let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) }; + if candidate_has_family(db, cand, &mut direct_probes) { + return remember_list_resolution(generation, list_key, family, (cand.to_string(), false)); + } + if base != cand && candidate_has_family(db, base, &mut direct_probes) { + return remember_list_resolution(generation, list_key, family, (base.to_string(), false)); + } + } + + // 2. None installed: fall back to an installed substitute for an entry in the list rather than generic Carlito. + // Try Thai substitutes before metric twins of later candidates: + for &cand in &candidates { + let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) }; + if is_thai_family(base) + && let Some(sub) = substitute(db, base) + { + return remember_list_resolution(generation, list_key, family, (sub.to_string(), true)); + } + let mut twin_probes = MAX_DIRECT_CANDIDATE_PROBES; + if let Some(twin) = metric_twins(base).find(|t| candidate_has_family(db, t, &mut twin_probes)) { + return remember_list_resolution(generation, list_key, family, (twin.to_string(), true)); + } + } + + // Try candidates with named or category substitutes (Thai, Hebrew, Khmer, Persian, serif, mono): + for &cand in &candidates { + let base = if cand.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(cand) }; + let (subs, named) = substitutes_by_name(base); + if (named || is_thai_family(base) || subs != SANS) + && let Some(sub) = substitute(db, base) + { + return remember_list_resolution(generation, list_key, family, (sub.to_string(), true)); + } + } + + // Fall back to the first candidate's substitute: + if let Some(&first) = candidates.first() { + let base = if first.eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(first) }; + if let Some(sub) = substitute(db, base) { + return remember_list_resolution(generation, list_key, family, (sub.to_string(), true)); + } + } + + remember_list_resolution(generation, list_key, family, (crate::FALLBACK_FAMILY.to_string(), true)) +} /// Resolve `family` with bold/italic to a face (memoised until user fonts are added or removed, /// [`FontDb::generation`]). @@ -427,6 +658,10 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved { let generation = db.generation(); let cacheable = family.len() <= FAMILY_KEY_MAX; let key = (if cacheable { family.to_string() } else { String::new() }, bold, italic); + let long_key = (!cacheable && family.contains(';')).then(|| { + let (h, len) = family_list_key(family); + (h, len, bold, italic) + }); { let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); if c.0 != generation { @@ -436,24 +671,35 @@ pub fn resolve(family: &str, bold: bool, italic: bool) -> Resolved { return *r; } } - // Printer-driver Symbol aliases use the same encoding as Symbol. - let base = if family.trim().eq_ignore_ascii_case("Symbol (PCL6)") { "Symbol" } else { charset_base_family(family) }; - let (fam, substituted) = if db.has_family(family) { - (family.to_string(), false) - } else if base != family && db.has_family(base) { - // Windows maps the charset names to the font itself: not a substitution. - (base.to_string(), false) - } else { - (substitute(db, base).unwrap_or(crate::FALLBACK_FAMILY).to_string(), true) - }; + if let Some(lk) = long_key { + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation != generation { + *c = FallbackListMemo { generation, ..Default::default() }; + } else if let Some((cached_family, r)) = c.long_resolved.get(&lk) + && cached_family == family + { + return *r; + } + } + let (fam, substituted) = resolve_family(db, family); let face = db.face(&fam, style_name(bold, italic)); let r = Resolved { synth_bold: bold && face.weight < 600.0, synth_italic: italic && !face.italic, substituted, face: FaceRef::of(&face) }; - let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if cacheable && c.0 == generation { - if c.1.len() >= FAMILY_CACHE_MAX { - c.1.clear(); + if cacheable { + let mut c = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.0 == generation { + if c.1.len() >= FAMILY_CACHE_MAX { + c.1.clear(); + } + c.1.insert(key, r); + } + } else if let Some(lk) = long_key { + let mut c = fallback_list_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if c.generation == generation { + if c.long_resolved.len() >= FAMILY_CACHE_MAX { + c.long_resolved.clear(); + } + c.long_resolved.insert(lk, (family.to_string(), r)); } - c.1.insert(key, r); } r } @@ -787,4 +1033,114 @@ mod tests { assert!(!has_named_substitutes(f), "{f}"); } } + + /// Semicolon-separated fallback lists resolve to the first installed family, or to a + /// candidate's substitute when no listed family is installed. + #[test] + fn libreoffice_fallback_list_resolves_first_installed_family() { + let db = FontDb::global(); + if db.has_family("Liberation Sans") || db.has_family("Arial") { + let r = resolve("Liberation Sans;Arial", false, false); + assert!(!r.substituted, "should not be substituted when a candidate is installed: got {:?}", r.face.family); + if db.has_family("Liberation Sans") { + assert_eq!(r.face.family, "Liberation Sans"); + } else { + assert_eq!(r.face.family, "Arial"); + } + } + let bundled = resolve("NonExistentSansFace123;Source Serif 4", false, false); + assert!(!bundled.substituted); + assert_eq!(bundled.face.family, "Source Serif 4"); + + // When none of the families is installed, fall back to a substitute for an entry in the list rather than generic Carlito: + if !db.has_family("NonExistentSansFace123") && !db.has_family("Times New Roman") { + let r = resolve("NonExistentSansFace123;Times New Roman", false, false); + assert!(r.substituted); + assert_ne!(r.face.family, "Carlito", "should not fall back to generic Carlito when an entry is Times New Roman"); + assert!(r.face.family.contains("Serif") || r.face.family == "Times" || r.face.family == "Tinos", "got {:?}", r.face.family); + } + let r = resolve("NonExistentSansFace123;NonExistentSerifFace123", false, false); + assert!(r.substituted); + assert_ne!(r.face.family, "Carlito", "should not fall back to generic Carlito when an entry is a serif family"); + assert!( + r.face.family.contains("Serif") + || r.face.family == "Times" + || r.face.family == "Times New Roman" + || r.face.family == "Tinos" + || r.face.family == "Caladea" + || r.face.family == "Georgia", + "got {:?}", + r.face.family + ); + + // Metric compatibility of fallback list: + assert!(metric_compatible("Open Sans;Arial", "Liberation Sans")); + assert!(metric_compatible("Liberation Sans;Arial", "Helvetica")); + assert!(!metric_compatible("Open Sans;Arial", "Carlito")); + } + + /// In a mixed Thai-and-Latin fallback list where neither listed family is installed, an + /// earlier Thai substitute takes precedence over a later candidate's metric twin. + #[test] + fn thai_fallback_list_prefers_thai_substitute_before_later_metric_twin() { + let db = FontDb::global(); + let Some(thai_sub) = substitute(db, "NonExistentThaiUPC") else { + eprintln!("skipped: no Thai substitute installed"); + return; + }; + if !db.has_family("Cordia New") && !db.has_family("Arial") && metric_twins("Arial").any(|t| db.has_family(t)) { + let r = resolve("Cordia New;Arial", false, false); + assert!(r.substituted); + assert!(substitutes("Cordia New").contains(&r.face.family.as_str()), "got {:?}", r.face.family); + } + if let Some(latin) = + METRIC_TWINS.iter().flat_map(|g| g.iter().copied()).find(|&f| !db.has_family(f) && metric_twins(f).any(|t| db.has_family(t))) + { + let list = format!("NonExistentThaiUPC;{latin}"); + let r = resolve(&list, false, false); + assert!(r.substituted); + assert_eq!(r.face.family, thai_sub); + } + } + + /// Fallback lists longer than `FAMILY_KEY_MAX` still select a later installed family and + /// cache their resolution without growing `resolve_cache` string keys. + #[test] + fn long_fallback_list_selects_later_installed_family_and_caches() { + let mut candidates: Vec = (0..120).map(|i| format!("MissingCandidateFont{i}")).collect(); + candidates.push("Source Serif 4".to_string()); + let list = candidates.join(";"); + assert!(list.len() > FAMILY_KEY_MAX); + + let r1 = resolve(&list, false, false); + let r2 = resolve(&list, false, false); + assert!(!r1.substituted); + assert_eq!(r1.face.family, "Source Serif 4"); + assert_eq!(r1.face.id(), r2.face.id()); + assert!(!resolve_cache().lock().unwrap_or_else(|e| e.into_inner()).1.keys().any(|k| k.0.len() > FAMILY_KEY_MAX)); + } + + /// The name-based predicates look at every candidate, and their answer is cached per list: a + /// later Thai or named fallback still counts, and a list is classified once rather than for + /// every piece of text shaped in it. + #[test] + fn list_classification_reads_every_candidate_and_is_cached() { + // The Thai family is last, past any shorter bound, and still classifies the list. + let mut candidates: Vec = (0..200).map(|i| format!("MissingCandidateFont{i}")).collect(); + candidates.push("TH Sarabun New".to_string()); + let thai_list = candidates.join(";"); + assert!(is_thai_family(&thai_list), "a later Thai family still counts"); + + // A named substitute at the end classifies too (Times New Roman has Liberation Serif). + candidates.pop(); + candidates.push("Times New Roman".to_string()); + let named_list = candidates.join(";"); + assert!(has_named_substitutes(&named_list), "a later named fallback still counts"); + + // Both are memoised by list text: a second ask answers without rescanning, and a list + // whose text differs only after the key's hash is not mistaken for the cached one. + assert!(is_thai_family(&thai_list) && !is_thai_family(&named_list)); + let cached = fallback_list_cache().lock().unwrap_or_else(|e| e.into_inner()).classes.len(); + assert!(cached >= 2, "{cached} lists classified"); + } } diff --git a/crates/layout/src/para/chars.rs b/crates/layout/src/para/chars.rs index 28de11515e..b68d578287 100644 --- a/crates/layout/src/para/chars.rs +++ b/crates/layout/src/para/chars.rs @@ -130,13 +130,17 @@ pub(super) const MISSING_ROMAN_FONT: &str = "Cambria"; /// and where it isn't installed Liberation Serif has its metrics; Word never meets it missing. pub(super) fn lookup_family<'f>(doc: &Document, font: &'f str) -> &'f str { let db = wordcraft_fonts::FontDb::global(); - let roman = doc.settings.roman_fonts.iter().any(|f| f.eq_ignore_ascii_case(font)); + let roman = !doc.settings.roman_fonts.is_empty() + && font.split(';').map(str::trim).filter(|f| !f.is_empty()).any(|f| doc.settings.roman_fonts.iter().any(|r| r.eq_ignore_ascii_case(f))); if roman && !wordcraft_fonts::word::is_thai_family(font) && !wordcraft_doc::symbol_fonts::is_symbol_font(font) && !wordcraft_fonts::word::has_named_substitutes(font) - && !db.has_family(font) - && !db.has_family(wordcraft_fonts::word::charset_base_family(font)) + && if font.contains(';') { + wordcraft_fonts::word::resolve(font, false, false).substituted + } else { + !db.has_family(font) && !db.has_family(wordcraft_fonts::word::charset_base_family(font)) + } { MISSING_ROMAN_FONT } else { From cd4f755dcf970fd5ea4b33567fabcac4da53cad9 Mon Sep 17 00:00:00 2001 From: tpawley2001 Date: Sat, 7 Nov 2026 20:59:51 -0800 Subject: [PATCH 11/12] DOCX: nested block-level content controls stay block-level when a bookmark follows them, so Word opens the file (#5811) ## What changed The DOCX writer's content-control planner (`crates/docx/src/write/sdt.rs`) decides whether a control that covers a whole paragraph is written block-level (`w:sdt` around `w:p`) or run-level (inside the paragraph). It only treated the control as covering the whole paragraph when nothing but control-end markers followed its end. Bookmark, comment and permission range markers after the end now count as "nothing" too, so the control stays block-level and the markers stay at the end of its paragraph. ## Why Word writes a body-level `_GoBack` bookmark (`` directly in `w:body`) in nearly every file it saves. When the last body-level content control was followed by it, the reader put the bookmark at the end of the control's paragraph, and the writer then turned the control (and any controls nested in it) into run-level `w:sdt`s inside that paragraph. For a repeating section (`w15:repeatingSection` / `w15:repeatingSectionItem`), which must wrap whole paragraphs or rows, Microsoft 365 Word then refuses to open the saved file ("the file appears to be corrupted"). Plain nested block controls changed what they cover the same way. Found by the real-file corpus: Word reported our saved copy of a form with a repeating section as corrupted. ## How it was tested - New tests in `crates/docx/tests/it/nested_block_controls.rs` (synthetic packages): a repeating section around one paragraph, plain nested block controls, nested block controls in a table cell, a run-level control that must stay in its paragraph, and repeating sections / block controls followed by a body-level bookmark (with and without data bindings) all round-trip with the controls at the same level and the text kept. - `cargo xtask ci` passes. Co-authored-by: tpawley2001 <268951182+tpawley2001@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- crates/docx/src/write/sdt.rs | 19 +- crates/docx/tests/it/main.rs | 1 + crates/docx/tests/it/nested_block_controls.rs | 180 ++++++++++++++++++ docs/file-format-parity.md | 1 + 4 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 crates/docx/tests/it/nested_block_controls.rs diff --git a/crates/docx/src/write/sdt.rs b/crates/docx/src/write/sdt.rs index 0f8f5a4a92..1b0f4d401b 100644 --- a/crates/docx/src/write/sdt.rs +++ b/crates/docx/src/write/sdt.rs @@ -86,11 +86,28 @@ pub fn plan(bl: &Blocks) -> Plan<'_> { && (0..off).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlStart { .. }))) }) }; + // Bookmark, comment and permission markers after the end may stay there: a body-level + // bookmark after a block control (Word's `_GoBack`) is read onto the end of its paragraph, + // and the control still covers the whole paragraph. let at_end = |b: usize, off: usize| { + use wordcraft_doc::InlineObject as O; para(b).is_some_and(|p| { let from = off + OBJ.len_utf8(); p.text.get(from..).is_some_and(|t| t.chars().all(|c| c == OBJ)) - && (from..p.len()).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlEnd))) + && (from..p.len()).step_by(OBJ.len_utf8()).all(|o| { + matches!( + p.object_at(o), + Some( + O::ControlEnd + | O::BookmarkStart { .. } + | O::BookmarkEnd { .. } + | O::CommentStart { .. } + | O::CommentEnd { .. } + | O::PermStart { .. } + | O::PermEnd { .. } + ) + ) + }) }) }; let mut mode = vec![Mode::Inline; ranges.len()]; diff --git a/crates/docx/tests/it/main.rs b/crates/docx/tests/it/main.rs index 8a1addee7d..68caa1f5be 100644 --- a/crates/docx/tests/it/main.rs +++ b/crates/docx/tests/it/main.rs @@ -21,6 +21,7 @@ mod heading_levels; mod legacy_forms; mod ligatures; mod malformed; +mod nested_block_controls; mod note_comments; mod passthrough; mod picture_links; diff --git a/crates/docx/tests/it/nested_block_controls.rs b/crates/docx/tests/it/nested_block_controls.rs new file mode 100644 index 0000000000..30a0c272b2 --- /dev/null +++ b/crates/docx/tests/it/nested_block_controls.rs @@ -0,0 +1,180 @@ +//! A body-level content control nested directly in another body-level one stays body-level when +//! saved, even when it holds a single paragraph (ECMA-376 §17.5.2.29-30; [MS-DOCX] `w15` +//! repeating sections, which must wrap whole paragraphs or rows). All markup here is synthetic. + +use std::io::Write; + +use wordcraft_doc::{ContentControl, Document, StoryRef}; +use wordcraft_docx::{read, write}; + +const NS: &str = + r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml""#; +const ROOT_RELS: &str = r#""#; + +fn package(body: &str) -> Vec { + let doc = format!(r#"{body}"#); + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in [("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())] { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +fn document_xml(docx: &[u8]) -> String { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap(); + let mut s = String::new(); + std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap(); + s +} + +fn t(s: &str) -> String { + format!(r#"{s}"#) +} +fn p(inner: &str) -> String { + format!("{inner}") +} +fn sdt(pr: &str, content: &str) -> String { + format!("{pr}{content}") +} + +fn controls(d: &Document) -> Vec { + let mut out = Vec::new(); + wordcraft_doc::control::walk_controls(&d.body, &mut |c| out.push(c.clone())); + out +} + +/// How many `w:p` elements are open at byte `at` of `xml`. +fn open_paragraphs(xml: &str, at: usize) -> usize { + let head = &xml[..at]; + let opened = head.matches("").count() + head.matches("").count() +} + +/// Paragraph depth at the start of the `w:sdt` whose `w:sdtPr` holds `marker`. +fn depth_of_control(xml: &str, marker: &str) -> usize { + let at = xml.find(marker).unwrap_or_else(|| panic!("{marker} not written:\n{xml}")); + let start = xml[..at].rfind("").expect("control start"); + open_paragraphs(xml, start) +} + +fn round_trip(body: &str) -> (Document, Document, String) { + let d = read(&package(body)).unwrap(); + let bytes = write(&d).unwrap(); + let back = read(&bytes).unwrap(); + (d, back, document_xml(&bytes)) +} + +fn repeating_section() -> String { + let field = sdt(r#""#, &t("note 1")); + let item = sdt(r#""#, &p(&format!("{field}{}", t(" ")))); + let section = sdt(r#""#, &item); + format!("{}{section}{}", p(&t("Notes")), p(&t("End"))) +} + +#[test] +fn a_repeating_section_around_one_paragraph_stays_around_the_paragraph() { + let (_, _, xml) = round_trip(&repeating_section()); + assert_eq!(depth_of_control(&xml, r#""#), 0, "section written inside a paragraph:\n{xml}"); + assert_eq!(depth_of_control(&xml, r#""#), 0, "item written inside a paragraph:\n{xml}"); + // The text control stays inside the paragraph, and the item holds that paragraph. + assert_eq!(depth_of_control(&xml, r#""#), 1); + assert!(xml.contains(""#, &p(&t("Only paragraph"))); + let outer = sdt(r#""#, &inner); + let (d, back, xml) = round_trip(&format!("{outer}{}", p(&t("After")))); + assert_eq!(depth_of_control(&xml, r#""#), 0, "{xml}"); + assert_eq!(depth_of_control(&xml, r#""#), 0, "{xml}"); + assert_eq!(controls(&back), controls(&d)); +} + +#[test] +fn nested_block_controls_in_a_table_cell_stay_block_level() { + let inner = sdt(r#""#, &p(&t("In cell"))); + let outer = sdt(r#""#, &inner); + let table = format!(r#"{outer}"#); + let (_, _, xml) = round_trip(&format!("{table}{}", p(&t("After")))); + assert_eq!(depth_of_control(&xml, r#""#), 0, "{xml}"); + assert_eq!(depth_of_control(&xml, r#""#), 0, "{xml}"); +} + +#[test] +fn a_run_level_control_stays_inside_its_paragraph() { + let field = sdt(r#""#, &t("value")); + let (_, _, xml) = round_trip(&p(&format!("{}{field}", t("Name: ")))); + assert_eq!(depth_of_control(&xml, r#""#), 1, "{xml}"); +} + +/// A bound repeating section as Word writes one at the end of a form, optionally followed by +/// the body-level `_GoBack` bookmark Word leaves after the last control. +fn bound_section_body(bound: bool, bookmark: bool) -> String { + let section_binding = if bound { + r#""# + } else { + "" + }; + let trailing = if bookmark { r#""# } else { "" }; + let bind = |x: &str| format!(r#""#); + let field = format!( + r#"{}{}"#, + if bound { bind("/a[1]/n[1]") } else { String::new() }, + t("note 1") + ); + let item = format!( + r#"{}"#, + p(&format!("{field}{}", t(" "))) + ); + let section = format!( + r#"{section_binding}{item}"# + ); + format!(r#"{}{section}{trailing}"#, p(&t("Notes"))) +} + +fn assert_section_block_level(body: &str) { + let (d, back, xml) = round_trip(body); + assert_eq!(depth_of_control(&xml, r#""#), 0, "section written inside a paragraph:\n{xml}"); + assert_eq!(depth_of_control(&xml, r#""#), 0, "item written inside a paragraph:\n{xml}"); + assert_eq!(depth_of_control(&xml, r#""#), 1, "{xml}"); + assert_eq!(controls(&back), controls(&d)); + assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body)); +} + +#[test] +fn a_bound_repeating_section_followed_by_a_body_bookmark_stays_block_level() { + assert_section_block_level(&bound_section_body(true, true)); +} + +#[test] +fn a_bound_repeating_section_at_the_end_of_the_body_stays_block_level() { + assert_section_block_level(&bound_section_body(true, false)); +} + +#[test] +fn a_repeating_section_followed_by_a_body_bookmark_stays_block_level() { + assert_section_block_level(&bound_section_body(false, true)); +} + +#[test] +fn a_block_control_followed_by_a_body_bookmark_stays_around_its_paragraph() { + let inner = sdt(r#""#, &p(&t("Last paragraph"))); + let outer = sdt(r#""#, &inner); + let body = format!(r#"{}{outer}"#, p(&t("First"))); + let (d, back, xml) = round_trip(&body); + assert_eq!(depth_of_control(&xml, r#""#), 0, "{xml}"); + assert_eq!(depth_of_control(&xml, r#""#), 0, "{xml}"); + assert_eq!(controls(&back), controls(&d)); + // The bookmark is kept. + assert!(xml.contains(r#"w:name="_GoBack""#), "{xml}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b8296f0511..f0022bfe8b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -682,3 +682,4 @@ For field results, the engine parser treats a backslash in quoted arguments as e | 2026-11-03 | trivial | Opening a password-protected file named on the command line shows the Password box also when a chosen region replaced the first blank document (the window followed the replacement only on the first frame, which dropped the box, #5299) | | 2026-10-27 | trivial | 2026-10-27 · #4533 review: numeric REF updates lazily index body bookmarks once per update, retaining the first body bookmark for duplicate names. Unsupported switches and ordinary REF/PAGEREF behavior are unchanged; a synthetic 64-reference regression covers repeated updates and duplicate names. Estimates unchanged. | | 2026-10-27 | trivial | 2026-10-27 · #4533: numeric REF fields use paragraph list numbers for no/full/relative context, reusing bounded numbering. Shared context already embedded in a level label is removed; merged-away cells do not count. Unsupported separator/text/position switches and ambiguous contexts or delimiter-free joins retain cached results. Synthetic OOXML regressions; no native Word or real-document validation, estimates unchanged. | +| 2026-11-05 | trivial | DOCX: a block-level content control (including `w15:repeatingSection` / `w15:repeatingSectionItem` nested around one paragraph) followed by body-level bookmark, comment or permission markup (Word's `_GoBack` bookmark) is written back around its paragraph instead of inside it, so Word opens the saved file; the markers are kept at the end of that paragraph (P0069). | From 7634553900e6f3cd7f42d587fa3ed57d82f12832 Mon Sep 17 00:00:00 2001 From: Kevin Joseph <47004376+kevinj24fr@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:02:01 +0100 Subject: [PATCH 12/12] Layout: superscript and subscript text at 0.65 of its size rounded to the half point (#5802) Word for Mac sets raised and lowered text at 0.65 of the run's size rounded to the half point (measured from digit advances in its PDF export at base sizes 8 to 36 pt: 11 pt -> 7, 12 -> 8, 24 -> 15.5). WordCraft used 0.65 exactly, so lines with superscripts were up to 0.5 pt wider and some broke a word early. Co-authored-by: Martin Ball --- ROADMAP.md | 2 ++ crates/doc/src/resolve.rs | 33 +++++++++++++++++++++++++++++++-- crates/layout/src/tests.rs | 28 ++++++++++++++++++++++++++++ docs/layout-parity.md | 3 ++- 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ce5a54e177..5f557c6f9c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -166,6 +166,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-01: Shapes: a line's arrowheads are as long and as wide as Word draws them: 2, 3 or 5 times the line's width for small, medium and large (a line under 2 points as a 2-point one). Their kind, width and length are read from DOCX (DrawingML and VML) and saved back; before, every arrowhead was one size and saved as a medium triangle. - 2026-11-07: DOCX: a VML text box keeps its text direction: `mso-layout-flow-alt:bottom-to-top` reads as Rotate all text 270° whatever `layout-flow` says, and otherwise `layout-flow:vertical` (or `vertical-ideographic`) as 90°, so the turned text box in a compatibility-mode document's header is drawn turned, as in Word. (#5193) - 2026-11-06: Change Picture keeps the picture's width and takes the new picture's aspect ratio, dropping the old crop, instead of stretching the new picture into the old frame (a revised figure came out about 2% taller in a real manuscript) (#5761). Readiness estimates unchanged. +- 2026-11-06: Superscript and subscript text is set at 0.65 of its size rounded to the half point, as Word does (8 pt → 5, 11 → 7, 12 → 8, 24 → 15.5; measured in Word for Mac from digit advances with `cargo xtask word-compare`), not 0.65 exactly: lines with citation numbers and footnote references break where Word's do (the Arial superscript and author-line probes now match Word line for line). Readiness estimates unchanged. - 2026-11-06: Windows print submissions run in separate helper processes (#5710). A helper acknowledges cancellation before it stops, so a driver stalled in a cancelled job cannot hold another job’s provider lock. A private temporary PDF and bounded settings messages cross the process boundary; normal shutdown reaps helpers and removes their inputs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Jobs already sending still complete. Readiness estimates unchanged. - 2026-11-06: File › Print: before sending starts, Cancel printing stops a waiting job, frees the Print page and keeps the settings; a successfully cancelled job is never sent. Cancellation remains available after the last document closes. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. Readiness estimates unchanged. - 2026-11-06: File › Print on Windows no longer waits on printer drivers to list printers (#5710). The list used to wait until each printer's driver said whether it prints on both sides, and a networked printer asleep in power save could keep it empty with Refresh unavailable; now discovery asks no driver (the Windows spooler still supplies the list), and printing still checks the job's options with the driver. The reporter's sleeping printer has not been retested yet. Readiness estimates unchanged. @@ -877,6 +878,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-02 | trivial | Progress log: a linked picture shows and follows its link; DOCX `a:hlinkClick` (#5267) | | 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open | | 2026-11-06 | trivial | Progress log: Change Picture keeps the width and takes the new picture's aspect ratio (#5761) | +| 2026-11-06 | trivial | Progress log: superscript and subscript text at 0.65 of its size rounded to the half point (Word for Mac) | | 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) | | 2026-11-06 | trivial | Windows print submissions isolated per helper process; cancelled driver work cannot block a retry (#5710) | | 2026-11-06 | trivial | Progress log: print cancellation before sending, including without a document, and the Windows repeated-conversion limitation (#5710) | diff --git a/crates/doc/src/resolve.rs b/crates/doc/src/resolve.rs index 8c35c6862f..40449e82cb 100644 --- a/crates/doc/src/resolve.rs +++ b/crates/doc/src/resolve.rs @@ -137,9 +137,12 @@ impl ResolvedChar { pub fn uses_complex(&self, c: char) -> bool { self.rtl || self.cs || crate::bidi::is_complex_script(c) } - /// The size glyphs are drawn at (super/subscript shrink to ~2/3). + /// The size glyphs are drawn and measured at. Word sets superscript and subscript text at + /// 0.65 of the run's size rounded to the half point, a font size the format can store + /// (measured in Word for Mac from digit advances: 8 pt → 5, 9 → 6, 11 → 7, 12 → 8, + /// 16 → 10.5, 24 → 15.5, 36 → 23.5). pub fn draw_size(&self) -> f32 { - if self.vert_align == VertAlign::Baseline { self.size } else { self.size * 0.65 } + if self.vert_align == VertAlign::Baseline { self.size } else { (self.size * 0.65 * 2.0).round() / 2.0 } } /// Baseline shift, points (positive = up). pub fn baseline_shift(&self) -> f32 { @@ -453,6 +456,32 @@ fn diff_patch(with: &T, wi mod tests { use super::*; + /// Superscript and subscript sizes as Word for Mac sets them (measured from digit advances in + /// its PDF export): 0.65 of the size, rounded to the half point. + #[test] + fn raised_and_lowered_text_is_sized_to_the_half_point() { + for (size, want) in [ + (8.0, 5.0), + (9.0, 6.0), + (10.0, 6.5), + (11.0, 7.0), + (12.0, 8.0), + (13.0, 8.5), + (14.0, 9.0), + (16.0, 10.5), + (20.0, 13.0), + (24.0, 15.5), + (36.0, 23.5), + ] { + for vert_align in [VertAlign::Superscript, VertAlign::Subscript] { + let rc = ResolvedChar { size, vert_align, ..StyleSheet::builtin().resolve_char(None, &CharProps::default()) }; + assert_eq!(rc.draw_size(), want, "{size} pt {vert_align:?}"); + } + } + let rc = StyleSheet::builtin().resolve_char(None, &CharProps { size: Some(11.0), ..Default::default() }); + assert_eq!(rc.draw_size(), 11.0, "baseline text keeps its size"); + } + /// #2036: only a character itself (direct formatting) has a symbol font of its own; a /// style never gives one to its text. #[test] diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index cc5df29f05..891e417982 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -8980,6 +8980,34 @@ fn superscript_and_subscript_lines_are_as_tall_as_full_size_text() { assert!((lowered - plain - 6.0).abs() < 0.01, "lowered {lowered} vs plain {plain}"); } +#[test] +fn superscript_digits_advance_at_the_half_point_size() { + use wordcraft_doc::props::VertAlign; + // Word sets an 11pt superscript at 7pt (0.65 × 11 = 7.15, rounded to the half point): its + // digits advance by 7pt digit widths (Word for Mac, measured from its PDF export). + let width = |size: f32, vert_align: VertAlign| { + let mut p = wordcraft_doc::Paragraph::new(); + let props = + wordcraft_doc::CharProps { font: Some(WRAP_TEST_FONT.into()), size: Some(size), vert_align: Some(vert_align), ..Default::default() }; + p.runs.push(wordcraft_doc::para::Run { len: 10, props: props.clone() }); + p.text.push_str("1234567890"); + p.mark = props; + let mut d = Document::new(); + d.body = vec![wordcraft_doc::para_block(p)]; + let l = lay(&d); + // From the first digit's start to the last's: nine digit advances. + let xs = l.pages[0] + .items + .iter() + .find_map(|it| if let Placed::Lines { para, .. } = it { para.lines.first().map(|x| x.xs.clone()) } else { None }) + .unwrap(); + xs.last().unwrap() - xs.first().unwrap() + }; + let seven = width(7.0, VertAlign::Baseline); + assert!((width(11.0, VertAlign::Superscript) - seven).abs() < 0.01, "{} vs {seven}", width(11.0, VertAlign::Superscript)); + assert!((width(11.0, VertAlign::Subscript) - seven).abs() < 0.01); +} + #[test] fn ruby_text_sits_over_its_base_and_the_line_grows_to_fit() { use wordcraft_doc::para::RubyAlign; diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 942c87e8af..d395d6c126 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -45,7 +45,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Symbol fonts (Symbol including its PCL6 alias, Wingdings, Wingdings 2/3, Webdings) without the proprietary font: each character, stored as U+F020–U+F0FF or a plain 8-bit code, is drawn as its Unicode equivalent from a face that has it; PDF text and copies read as those symbols | ✅ | `doc::symbol_fonts` tables (Adobe Symbol table and Unicode L2/12-368), `layout::tests::wingdings_text_draws_and_reads_as_its_unicode_symbols` (#776). Shapes come from Noto Sans Symbols / Symbols 2, DejaVu Sans or Segoe UI Symbol, so they differ from Word's; Wingdings 0xFF (not in Unicode) still has no symbol. A line keeps the symbol font's own height (Symbol, Wingdings: the ascent and descent Word's PDFs give them), so a Symbol bullet makes its line as tall as in Word, `layout::tests::a_symbol_bullet_sizes_its_line_by_the_symbol_font`. Apple's one-em Symbol takes the same height, as in Word for Mac (`fonts::word::tests::a_one_em_symbol_face_takes_windows_symbols_line`) | 0 | | List bullets stored as a private-use character (U+E000–U+F8FF) in a font that isn't installed and isn't a mapped symbol-font code: drawn as the level's default bullet (• ○ ▪), not as whatever an installed icon font has at that code | ✅ | `layout::tests::private_use_bullet_no_font_draws_shows_the_levels_default_bullet` (#1683: U+F081 showed Font Awesome's Twitter logo) | — | | Indents in characters (`w:firstLineChars`, `w:leftChars`…), measured in the paragraph mark's font size | ✅ | #534, `layout::tests::character_indents_use_the_paragraph_mark_size`; spacing in lines (`w:beforeLines`/`w:afterLines`) uses the stored points (needs the document grid) | — | -| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); a superscript or subscript sizes its line at the full font size and its raise or drop doesn't grow the line, while a `w:position` raise or lowering does (Word for Mac); other line-spacing combinations remain (#108, #135 open) | 3–5 | +| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Contextual spacing measured in body paragraphs and headers (below), with a four-paragraph table-cell regression; list-label ascent overflow is unscaled by multiple spacing and uses descent for automatic and at-least spacing (#601); a superscript or subscript sizes its line at the full font size and its raise or drop doesn't grow the line, while a `w:position` raise or lowering does, and its text is set at 0.65 of its size rounded to the half point (Word for Mac); other line-spacing combinations remain (#108, #135 open) | 3–5 | | Kerning only where Kerning for fonts asks for it (`w:kern`, at or above its size) | ✅ | Measured in Word for Windows: no `w:kern` or `w:kern="0"` is unkerned; WordCraft kerned every run, so its lines held more text than Word's. `layout::tests::kerning_follows_kerning_for_fonts_like_word` | — | | OpenType ligatures as the document asks (`w14:ligatures`: standard `liga`, contextual `clig`, historical `hlig`, discretional `dlig`, any combination, in runs, styles and defaults) | 🟡 | `layout::tests::ligatures_follow_w14_ligatures`, `ligature_flags_map_to_opentype_features`. Without the property WordCraft keeps drawing standard and contextual ligatures, while Word draws none; the Font dialog's Ligatures choice is still missing | 1 | | Automatic (HTML-style) paragraph spacing (`w:beforeAutospacing`, `w:afterAutospacing`) | ✅ | Measured in Word for Windows: 14 pt at any size, none at the top of the document or a cell or after a cell's last paragraph, the larger of two neighbouring spaces. `layout::tests::automatic_paragraph_spacing_like_word`, `…_drops_at_cell_edges` | — | @@ -309,6 +309,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-11-03 | trivial | Body PAGE fields in tables use the active section number format; reflow views measure headers and footers with it before body placement | | 2026-11-06 | trivial | An in-line picture lowered further than its own height (`w:position`) was held with its top on the baseline and its line only as deep as the picture; Word keeps the top there but lowers the line as far as the position says. A 6 pt picture lowered 30 pt now makes a 30 pt line alone and the text's ascent plus 30 pt beside text, as in Word. This closes the gap noted on 2026-11-05: all 156 synthetic lines measured then now match Word within 0.35 pt, heights and picture positions. Exact spacing keeps the line's baseline as before (`tests::lowered_pictures_and_check_boxes_take_words_line_heights`, `tests::an_inline_picture_moves_with_its_runs_position`). | | 2026-10-31 | trivial | List labels: a level with numFmt none is an ordinary level whose own number reads as nothing: it shows its literal text (`RESPONSE:`), counts and restarts the levels below it, stays empty in legal numbering, and an empty label still tabs the text to the indent, as in Word (#4985) | +| 2026-11-06 | trivial | Superscript and subscript text: Word for Mac sets it at 0.65 of the run's size rounded to the half point (from digit advances in its PDF export, base sizes 8–36 pt: 8 → 5, 9 → 6, 10 → 6.5, 11 → 7, 12 → 8, 13 → 8.5, 14 → 9, 16 → 10.5, 20 → 13, 24 → 15.5, 36 → 23.5). WordCraft used 0.65 exactly (11 pt → 7.15), so lines with superscripts came out up to 0.5 pt wider and some broke a word early; the `cargo xtask word-compare` superscript and author-line probes in Arial go from 95–97% of lines with Word's breaks to 100%. `doc::resolve::tests::raised_and_lowered_text_is_sized_to_the_half_point`, `layout::tests::superscript_digits_advance_at_the_half_point_size` | | 2026-11-05 | trivial | PR #4545: eligible cell floats retain in-bounds offsets and touch the outer cell edge only on overflow, ignoring wrap distances for object placement. Front-of-text, turned-cell and outside-cell opt-outs remain. Synthetic geometry controls; native front/behind-text parity remains unqualified. | | 2026-11-05 | trivial | With multiple spacing a line of only in-line pictures is as tall as they reach, raised or lowered, and never less than its font's height (a list label's font counting too): a picture shorter than the font keeps the font's ascent and descent, as noted for ODT documents below; a taller one ends at the line's bottom with no descent under it. The spacing adds its share of the font's height, not of a lowered picture's. A legacy check box sizes its line as text of its size. Measured in Word with Arial at 10 and 20 pt, single, 1.15, 1.25 and 1.5 spacing, 156 synthetic lines (`tests::lowered_pictures_and_check_boxes_take_words_line_heights`). Known gap: a picture lowered further than its own height is limited to that height where Word's line keeps growing. | | 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. |