From 94362712735d0445c719e972e4a22f3ea49195d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 22:58:55 +0000 Subject: [PATCH 001/182] Gap 19: ODT paragraph spacing, indents and line spacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read `fo:margin-*`, `fo:text-indent`, `fo:line-height` (percent, length, normal), `style:line-height-at-least` and `style:line-spacing` (leading, as a proportional approximation) from automatic and common paragraph styles (ODF 1.3 §20.204-209, §20.225, §20.317-318), and write them back from each paragraph's direct formatting. The default paragraph style and Standard give the document defaults, on ODF's initial values: an ODT paragraph without spacing has none, not Word's 8 pt after and 1.15 lines. Export writes the document's Normal spacing as the default paragraph style, so the copy reopens the same. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC --- ROADMAP.md | 3 +- crates/formats/src/model/from_doc.rs | 9 +- crates/formats/src/odt.rs | 3 + crates/formats/src/odt/body.rs | 6 +- crates/formats/src/odt/import.rs | 27 +++- crates/formats/src/odt/package.rs | 19 ++- crates/formats/src/odt/styles.rs | 144 +++++++++++++++--- crates/formats/src/odt/tests.rs | 4 +- crates/formats/src/odt/tests_more.rs | 3 + crates/formats/src/odt/tests_more/spacing.rs | 146 +++++++++++++++++++ crates/formats/src/odt/writer.rs | 26 ++++ docs/file-format-parity.md | 5 +- 12 files changed, 353 insertions(+), 42 deletions(-) create mode 100644 crates/formats/src/odt/tests_more.rs create mode 100644 crates/formats/src/odt/tests_more/spacing.rs diff --git a/ROADMAP.md b/ROADMAP.md index 90c51e17dc..15114df637 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #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 (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (progress log: ODT paragraph spacing); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #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 (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -140,6 +140,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-31: ODT: paragraph spacing, indents and line spacing are read and written, and an ODT's own default spacing (none, single lines) replaces Word's 8 pt after and 1.15 lines, so LibreOffice documents keep their vertical rhythm on open and save (gap 19). Readiness unchanged. - 2026-10-31: DOCX: a paragraph naming a style the file doesn't define is formatted as Normal, as in Word, not with the document defaults alone, so a Pandoc footer naming an undefined `Footer` style is as tall as Word's and no longer pushes the last paragraph to the next page (#5077). Readiness unchanged. - 2026-10-31: A table of contents writes its page numbers as `PAGEREF` fields to the headings' bookmarks, as Word does, so updating the fields in Word moves the numbers with the headings; they were plain text (#5097). - 2026-10-31: Keytips reach the Backstage: Alt, F badges its pages with Word's letters, and Alt, F, P (or V) opens Print (#4960). diff --git a/crates/formats/src/model/from_doc.rs b/crates/formats/src/model/from_doc.rs index 120cf9d379..1ea0053093 100644 --- a/crates/formats/src/model/from_doc.rs +++ b/crates/formats/src/model/from_doc.rs @@ -123,6 +123,11 @@ fn media_ext(key: &str, data: &[u8]) -> String { } } +/// The spacing and indents a paragraph sets itself (its style's are not included). +fn direct_spacing(p: &wordcraft_doc::props::ParaProps) -> Spacing { + Spacing { before: p.space_before, after: p.space_after, line: p.line_spacing, left: p.indent_left, right: p.indent_right, first: p.indent_first } +} + /// A document paragraph as flow paragraphs: a page break character inside the text ends the /// paragraph, and the text after it starts a new one that carries `page_break`. pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec { @@ -142,7 +147,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec { page_break: p.props.page_break_before.unwrap_or(false), keep_next: p.props.keep_next.unwrap_or(false), inlines: Vec::new(), - spacing: Spacing::default(), + spacing: direct_spacing(&p.props), borders, section: None, mark_ins: None, @@ -173,7 +178,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec { page_break: true, keep_next: p.props.keep_next.unwrap_or(false), inlines: Vec::new(), - spacing: Spacing::default(), + spacing: direct_spacing(&p.props), borders, section: None, mark_ins: None, diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index aeb1ad0597..f70b226efd 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -56,3 +56,6 @@ mod page_parts; #[cfg(test)] mod tests; + +#[cfg(test)] +mod tests_more; diff --git a/crates/formats/src/odt/body.rs b/crates/formats/src/odt/body.rs index 628c7257a4..b8326fd7bf 100644 --- a/crates/formats/src/odt/body.rs +++ b/crates/formats/src/odt/body.rs @@ -302,8 +302,8 @@ impl Body<'_> { fn start_para(&mut self, style: Option<&str>, heading: Option) { self.end_para(); - let (mut kind, align, mut pb, rule, keep_next, base) = - style.map(|s| self.styles.para(s)).unwrap_or((Kind::Normal, None, false, false, false, Fmt::default())); + let look = style.map(|s| self.styles.para(s)).unwrap_or_default(); + let (mut kind, align, mut pb, rule, keep_next, base) = (look.kind, look.align, look.page_break, look.rule, look.keep_next, look.fmt); // The section break starts the new page; no page break besides. if let Some(next) = style.and_then(|s| self.page_section(s)) && self.break_section(next) @@ -313,7 +313,7 @@ impl Body<'_> { if let Some(h) = heading { kind = Kind::Heading(h.clamp(1, 6)); } - let mut p = Para { kind, align, page_break: pb, keep_next, ..Default::default() }; + let mut p = Para { kind, align, page_break: pb, keep_next, spacing: look.spacing, ..Default::default() }; if rule && kind == Kind::Normal { p.kind = Kind::Rule; } diff --git a/crates/formats/src/odt/import.rs b/crates/formats/src/odt/import.rs index 61d4460045..424c3da66e 100644 --- a/crates/formats/src/odt/import.rs +++ b/crates/formats/src/odt/import.rs @@ -110,11 +110,25 @@ pub fn parse(bytes: &[u8]) -> Result { /// [`parse`], with the last section's properties (the others are on the paragraphs that end them). fn parse_full(bytes: &[u8]) -> Result<(Flow, SectionProps), String> { - parse_tracked(bytes).map(|(f, last, ..)| (f, last)) + parse_tracked(bytes).map(|p| (p.flow, p.last)) } -/// [`parse_full`], plus whether change tracking is on (`None`: the document has no tracked changes). -fn parse_tracked(bytes: &[u8]) -> Result<(Flow, SectionProps, Option, Option, page_parts::Parts), String> { +/// What [`parse_tracked`] reads besides the flow. +struct Parsed { + flow: Flow, + /// The last section's properties. + last: SectionProps, + /// Whether change tracking is on (`None`: the document has no tracked changes). + track: Option, + /// The document's default font. + font: Option, + /// The document's default paragraph spacing. + spacing: model::Spacing, + parts: page_parts::Parts, +} + +/// [`parse_full`], plus what the document holds outside the flow. +fn parse_tracked(bytes: &[u8]) -> Result { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("ODT: not a valid package ({e})"))?; if let Some(mt) = read_entry(&mut z, "mimetype") { let mt = String::from_utf8_lossy(&mt); @@ -154,17 +168,20 @@ fn parse_tracked(bytes: &[u8]) -> Result<(Flow, SectionProps, Option, Opti blocks.push(FBlock::Para(Para::default())); } let track = body.track; - Ok((Flow { blocks, meta }, last, track, styles.doc_font(), parts)) + Ok(Parsed { flow: Flow { blocks, meta }, last, track, font: styles.doc_font(), spacing: styles.doc_spacing(), parts }) } /// Parse an `.odt` package into a document. pub fn import(bytes: &[u8]) -> Result { model::begin_import(); - let (flow, last, track, font, parts) = parse_tracked(bytes)?; + let Parsed { flow, last, track, font, spacing, parts } = parse_tracked(bytes)?; let mut doc = model::to_doc(&flow); if font.is_some() { doc.styles.default_chr.font = font; } + let d = &mut doc.styles.default_para; + (d.space_before, d.space_after, d.line_spacing) = (spacing.before, spacing.after, spacing.line); + (d.indent_left, d.indent_right, d.indent_first) = (spacing.left, spacing.right, spacing.first); doc.last_section = last; page_parts::add(&mut doc, parts); if let Some(t) = track { diff --git a/crates/formats/src/odt/package.rs b/crates/formats/src/odt/package.rs index 74c6704a45..9e0bdbb2c6 100644 --- a/crates/formats/src/odt/package.rs +++ b/crates/formats/src/odt/package.rs @@ -1,4 +1,4 @@ -use super::writer::{NS, Writer, pt, x}; +use super::writer::{NS, Writer, pt, spacing_props, x}; use super::{Columns, Cursor, Document, FBlock, Meta, SectionProps, SectionStart, Write, model}; /// `style:columns` (ODF 1.3 §17.12) of equal columns with their gap and, when on, the line between. @@ -9,10 +9,12 @@ fn columns_xml(c: &Columns) -> String { /// One master page per entry of `pages` (name, setup), each with its own page layout `pm1`, `pm2`… /// (§16.5, §16.9); the first is the default one, "Standard". -fn styles_xml(pages: &[(String, wordcraft_doc::SectionProps)], body_font: &str) -> String { +/// `defaults` is the spacing of the document's Normal paragraphs, written as the default style's. +fn styles_xml(pages: &[(String, wordcraft_doc::SectionProps)], body_font: &str, defaults: &model::Spacing) -> String { let mut s = format!("\n"); s.push_str(&format!( - "", + "", + spacing_props(defaults), x(body_font) )); s.push_str(""); @@ -155,7 +157,16 @@ pub fn export(doc: &Document) -> Result, String> { w.automatic_styles() ); let body_font = doc.styles.default_chr.font.clone().unwrap_or_else(|| wordcraft_doc::styles::BODY_FONT.to_string()); - let styles = styles_xml(&pages, &body_font); + let normal = doc.styles.resolve_para(&Default::default()); + let defaults = model::Spacing { + before: Some(normal.space_before), + after: Some(normal.space_after), + line: Some(normal.line_spacing), + left: Some(normal.indent_left), + right: Some(normal.indent_right), + first: Some(normal.indent_first), + }; + let styles = styles_xml(&pages, &body_font, &defaults); let meta = meta_xml(&flow.meta); let mut manifest = String::from( "\n\n\n\n\n\n", diff --git a/crates/formats/src/odt/styles.rs b/crates/formats/src/odt/styles.rs index 38fe63150b..045d3792e4 100644 --- a/crates/formats/src/odt/styles.rs +++ b/crates/formats/src/odt/styles.rs @@ -1,5 +1,7 @@ use super::xml::{Attrs, Ev, events, get, length, local}; use super::{Align, Arc, Columns, Fmt, HashMap, Kind, Rgb, VAlign, model}; +use crate::model::Spacing; +use wordcraft_doc::props::LineSpacing; // --------------------------------------------------------------------------------------------- // Import @@ -66,6 +68,73 @@ struct StyleInfo { page_number: Option>, /// `style:columns` of a section style. columns: Option, + /// Margins, first-line indent and line spacing of `style:paragraph-properties`. + spacing: Spacing, +} + +/// Paragraph spacing, indents and line height of a `style:paragraph-properties` element (ODF 1.3 +/// §20.204–209, §20.225, §20.317–318) over `sp`. Percentages of the parent's margins aren't +/// followed (left as they were); spacing is clamped to 0..=1584 pt, indents to ±1584 pt. +fn paragraph_spacing(a: &Attrs, sp: &mut Spacing) { + let len = |k: &str| get(a, k).and_then(length); + let space = |k: &str| len(k).map(|v| v.clamp(0.0, MAX_LEN)); + let indent = |k: &str| len(k).map(|v| v.clamp(-MAX_LEN, MAX_LEN)); + if let Some(v) = get(a, "fo:margin").and_then(length) { + sp.before = Some(v.clamp(0.0, MAX_LEN)); + sp.after = sp.before; + sp.left = Some(v.clamp(-MAX_LEN, MAX_LEN)); + sp.right = sp.left; + } + sp.before = space("fo:margin-top").or(sp.before); + sp.after = space("fo:margin-bottom").or(sp.after); + sp.left = indent("fo:margin-left").or(sp.left); + sp.right = indent("fo:margin-right").or(sp.right); + sp.first = indent("fo:text-indent").or(sp.first); + if let Some(v) = get(a, "fo:line-height").map(str::trim) { + if v.eq_ignore_ascii_case("normal") { + sp.line = Some(LineSpacing::Multiple(1.0)); + } else if let Some(pc) = v.strip_suffix('%').and_then(|n| n.trim().parse::().ok()).filter(|n| n.is_finite() && *n > 0.0) { + // Word's range: a sixteenth of a line up to 132 lines. + sp.line = Some(LineSpacing::Multiple((pc / 100.0).clamp(0.06, 132.0))); + } else if let Some(h) = length(v).filter(|h| *h > 0.0) { + sp.line = Some(LineSpacing::Exactly(h.clamp(0.7, MAX_LEN))); + } + } + if let Some(h) = len("style:line-height-at-least").filter(|h| *h >= 0.0) { + sp.line = Some(LineSpacing::AtLeast(h.clamp(0.7, MAX_LEN))); + } + // Leading (extra space between lines) has no WordCraft mode: as a multiple of a 12 pt line. + if let Some(l) = len("style:line-spacing").filter(|l| *l >= 0.0) { + sp.line = Some(LineSpacing::Multiple(((LEADING_LINE + l.min(MAX_LEN)) / LEADING_LINE).clamp(0.06, 132.0))); + } +} + +/// Largest spacing or indent read, in points (22 inches, Word's page limit). +const MAX_LEN: f32 = 1584.0; + +/// Height of the single line `style:line-spacing` leading is added to (12 pt text). +const LEADING_LINE: f32 = 14.0; + +/// `over` laid on `base`: what `over` sets wins. +fn overlay_spacing(base: &mut Spacing, over: &Spacing) { + base.before = over.before.or(base.before); + base.after = over.after.or(base.after); + base.line = over.line.or(base.line); + base.left = over.left.or(base.left); + base.right = over.right.or(base.right); + base.first = over.first.or(base.first); +} + +/// How a paragraph style looks ([`Styles::para`]). +#[derive(Clone, Debug, Default)] +pub(super) struct ParaLook { + pub(super) kind: Kind, + pub(super) align: Option, + pub(super) page_break: bool, + pub(super) rule: bool, + pub(super) keep_next: bool, + pub(super) fmt: Fmt, + pub(super) spacing: Spacing, } /// Columns of a `style:columns` element (ODF 1.3 §17.12): count and gap, clamped as the RTF and @@ -138,12 +207,40 @@ fn text_properties(a: &Attrs, style: &mut StyleInfo) { } } +/// A paragraph style's `style:paragraph-properties` (ODF 1.3 §17.6) into `s`. +fn paragraph_properties(a: &Attrs, s: &mut StyleInfo) { + s.align = match get(a, "fo:text-align").map(|v| v.trim().to_ascii_lowercase()).as_deref() { + Some("center") => Some(Align::Center), + Some("end") | Some("right") => Some(Align::Right), + Some("justify") => Some(Align::Justify), + Some("start") | Some("left") => Some(Align::Left), + _ => s.align, + }; + if get(a, "fo:break-before").is_some_and(|b| b.trim().eq_ignore_ascii_case("page")) { + s.page_break = true; + } + match get(a, "fo:keep-with-next") { + Some("always") => s.keep_next = Some(true), + Some("auto") => s.keep_next = Some(false), + _ => {} + } + if let Some(v) = get(a, "style:page-number") { + s.page_number = Some(v.trim().parse::().ok().map(|n| n.min(32_767))); + } + if get(a, "fo:border-bottom").is_some_and(|b| !b.trim().eq_ignore_ascii_case("none")) { + s.border_bottom = true; + } + paragraph_spacing(a, &mut s.spacing); +} + #[derive(Default)] pub(super) struct Styles { map: HashMap, pub(super) lists: HashMap>, /// Font of `style:default-style` for paragraphs (the document default, ODF 1.3 §16.4). default_font: Option, + /// Spacing of `style:default-style` for paragraphs. + default_spacing: Spacing, } impl Styles { @@ -172,6 +269,7 @@ impl Styles { "text-properties" if in_default && cur.is_none() => { self.default_font = default_font(&a); } + "paragraph-properties" if in_default && cur.is_none() => paragraph_spacing(&a, &mut self.default_spacing), "text-properties" => { if let Some((_, s)) = &mut cur { text_properties(&a, s); @@ -208,27 +306,7 @@ impl Styles { } "paragraph-properties" => { if let Some((_, s)) = &mut cur { - s.align = match get(&a, "fo:text-align").map(|v| v.trim().to_ascii_lowercase()).as_deref() { - Some("center") => Some(Align::Center), - Some("end") | Some("right") => Some(Align::Right), - Some("justify") => Some(Align::Justify), - Some("start") | Some("left") => Some(Align::Left), - _ => s.align, - }; - if get(&a, "fo:break-before").is_some_and(|b| b.trim().eq_ignore_ascii_case("page")) { - s.page_break = true; - } - match get(&a, "fo:keep-with-next") { - Some("always") => s.keep_next = Some(true), - Some("auto") => s.keep_next = Some(false), - _ => {} - } - if let Some(v) = get(&a, "style:page-number") { - s.page_number = Some(v.trim().parse::().ok().map(|n| n.min(32_767))); - } - if get(&a, "fo:border-bottom").is_some_and(|b| !b.trim().eq_ignore_ascii_case("none")) { - s.border_bottom = true; - } + paragraph_properties(&a, s); } } "columns" if q.starts_with("style:") => { @@ -281,6 +359,24 @@ impl Styles { self.map.get("Standard").filter(|s| !s.auto).and_then(|s| s.fmt.font.as_deref().map(str::to_string)).or_else(|| self.default_font.clone()) } + /// The document's default paragraph spacing: the default style's, with the Standard + /// paragraph style's over it, on ODF's initial values (no margins, single lines). + pub(super) fn doc_spacing(&self) -> Spacing { + let mut sp = Spacing { + before: Some(0.0), + after: Some(0.0), + line: Some(LineSpacing::Multiple(1.0)), + left: Some(0.0), + right: Some(0.0), + first: Some(0.0), + }; + overlay_spacing(&mut sp, &self.default_spacing); + if let Some(s) = self.map.get("Standard").filter(|s| !s.auto) { + overlay_spacing(&mut sp, &s.spacing); + } + sp + } + fn chain<'a>(&'a self, name: &'a str) -> Vec<(&'a str, &'a StyleInfo)> { let mut out = Vec::new(); let mut cur = Some(name); @@ -340,7 +436,7 @@ impl Styles { /// the style that gives the paragraph its kind and that style's ancestors: their formatting /// is the kind's own look (a heading's size and colour), not direct formatting. `Standard`, /// the default paragraph style, holds document defaults and is left out too. - pub(super) fn para(&self, name: &str) -> (Kind, Option, bool, bool, bool, Fmt) { + pub(super) fn para(&self, name: &str) -> ParaLook { let chain = self.chain(name); let mut kind = Kind::Normal; let mut kind_at = chain.len(); @@ -372,11 +468,13 @@ impl Styles { let pb = chain.iter().take_while(|(_, s)| s.auto).any(|(_, s)| s.page_break); let rule = chain.iter().any(|(_, s)| s.border_bottom); let mut f = Fmt::default(); + let mut spacing = Spacing::default(); for (_, s) in chain.iter().take(kind_at).rev().filter(|(n, s)| s.auto || *n != "Standard") { s.fmt.apply(&mut f); + overlay_spacing(&mut spacing, &s.spacing); } // Like the text formatting, the kind style's own `fo:keep-with-next` (a heading's) is the kind's look, not direct formatting. let keep_next = chain.iter().take(kind_at).find_map(|(_, s)| s.keep_next).unwrap_or(false); - (kind, align, pb, rule, keep_next, f) + ParaLook { kind, align, page_break: pb, rule, keep_next, fmt: f, spacing } } } diff --git a/crates/formats/src/odt/tests.rs b/crates/formats/src/odt/tests.rs index a73c806353..729334beab 100644 --- a/crates/formats/src/odt/tests.rs +++ b/crates/formats/src/odt/tests.rs @@ -183,10 +183,10 @@ fn object_without_mathml_falls_back_to_its_replacement_image() { assert!(imgs[0].data.as_slice() == png.as_slice()); } -const NS_T: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#; +pub(super) const NS_T: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#; /// A package with `content.xml` and, when given, `styles.xml`. -fn package(content: &str, styles: Option<&str>) -> Vec { +pub(super) fn package(content: &str, styles: Option<&str>) -> Vec { let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); for (name, data) in diff --git a/crates/formats/src/odt/tests_more.rs b/crates/formats/src/odt/tests_more.rs new file mode 100644 index 0000000000..1906c58e61 --- /dev/null +++ b/crates/formats/src/odt/tests_more.rs @@ -0,0 +1,3 @@ +//! Tests for the ODT features added after the first reader and writer: one module per feature. + +mod spacing; diff --git a/crates/formats/src/odt/tests_more/spacing.rs b/crates/formats/src/odt/tests_more/spacing.rs new file mode 100644 index 0000000000..aae3ff201a --- /dev/null +++ b/crates/formats/src/odt/tests_more/spacing.rs @@ -0,0 +1,146 @@ +//! Paragraph spacing, indents and line spacing (ODF 1.3 §20.204 `fo:line-height`, §20.206–209 +//! `fo:margin-*`, §20.225 `fo:text-indent`, §20.317 `style:line-height-at-least`, §20.318 +//! `style:line-spacing`). + +use crate::odt::tests::{NS_T, package}; +use crate::odt::{export, import}; +use wordcraft_doc::props::{LineSpacing, ParaProps}; +use wordcraft_doc::{Document, Paragraph, para_block}; + +fn props(d: &Document) -> Vec { + d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.clone()).collect() +} + +fn content(auto: &str, body: &str) -> String { + format!( + r#"{auto}{body}"# + ) +} + +fn p_style(name: &str, parent: &str, props: &str) -> String { + format!( + r#""# + ) +} + +fn close(a: Option, b: f32) -> bool { + a.is_some_and(|a| (a - b).abs() < 0.01) +} + +#[test] +fn margins_indents_and_line_spacing_are_read() { + let auto = [ + p_style("P1", "Standard", r#"fo:margin-top="24pt" fo:margin-bottom="0.5cm""#), + p_style("P2", "Standard", r#"fo:margin-left="2cm" fo:margin-right="1cm" fo:text-indent="-1cm""#), + p_style("P3", "Standard", r#"fo:line-height="200%""#), + p_style("P4", "Standard", r#"fo:line-height="20pt""#), + p_style("P5", "Standard", r#"style:line-height-at-least="17pt""#), + p_style("P6", "Standard", r#"fo:line-height="normal""#), + ] + .concat(); + let body = (1..=6).map(|i| format!(r#"p{i}"#)).collect::(); + let d = import(&package(&content(&auto, &body), None)).unwrap(); + let p = props(&d); + assert!(close(p[0].space_before, 24.0) && close(p[0].space_after, 14.17), "{:?}", p[0]); + assert!(close(p[1].indent_left, 56.69) && close(p[1].indent_right, 28.35) && close(p[1].indent_first, -28.35), "{:?}", p[1]); + assert_eq!(p[2].line_spacing, Some(LineSpacing::Multiple(2.0))); + assert_eq!(p[3].line_spacing, Some(LineSpacing::Exactly(20.0))); + assert_eq!(p[4].line_spacing, Some(LineSpacing::AtLeast(17.0))); + assert_eq!(p[5].line_spacing, Some(LineSpacing::Multiple(1.0))); +} + +#[test] +fn spacing_is_inherited_through_common_styles() { + // LibreOffice's "Text body" style carries spacing that its paragraphs show. + let styles = format!( + r#"{}"#, + p_style("Text_20_body", "Standard", r#"fo:margin-top="0cm" fo:margin-bottom="0.247cm" fo:line-height="115%""#) + ); + let auto = p_style("P1", "Text_20_body", r#"fo:margin-left="1cm""#); + let body = r#"ab"#; + let d = import(&package(&content(&auto, body), Some(&styles))).unwrap(); + for p in props(&d) { + let r = d.styles.resolve_para(&p); + assert!((r.space_after - 7.0).abs() < 0.01 && r.space_before == 0.0, "{r:?}"); + assert_eq!(r.line_spacing, LineSpacing::Multiple(1.15)); + } + assert!(close(props(&d)[1].indent_left, 28.35)); +} + +#[test] +fn the_default_paragraph_style_gives_the_document_defaults() { + // An ODT paragraph with no spacing has none (ODF's initial values), not Word's 8 pt after + // and 1.15 lines. + let body = "plain"; + let d = import(&package(&content("", body), None)).unwrap(); + let r = d.styles.resolve_para(&props(&d)[0]); + assert_eq!((r.space_before, r.space_after, r.line_spacing), (0.0, 0.0, LineSpacing::Multiple(1.0))); + // The default style and Standard set them: Standard wins. + let styles = format!( + r#""# + ); + let d = import(&package(&content("", body), Some(&styles))).unwrap(); + let p = props(&d); + assert_eq!(p[0].space_after, None, "the defaults are not direct formatting"); + let r = d.styles.resolve_para(&p[0]); + assert_eq!((r.space_after, r.line_spacing), (3.0, LineSpacing::Multiple(1.5))); +} + +#[test] +fn hostile_spacing_values_are_clamped() { + let auto = [ + p_style("P1", "Standard", r#"fo:margin-top="-5pt" fo:margin-bottom="1e30pt" fo:margin-left="-1e30pt" fo:text-indent="NaNpt""#), + p_style("P2", "Standard", r#"fo:line-height="100000%""#), + p_style("P3", "Standard", r#"fo:line-height="-3pt""#), + p_style("P4", "Standard", r#"fo:line-height="0%" style:line-height-at-least="1e9pt""#), + p_style("P5", "Standard", r#"fo:margin-top="10%" fo:line-height="x""#), + ] + .concat(); + let body = (1..=5).map(|i| format!(r#"p{i}"#)).collect::(); + let d = import(&package(&content(&auto, &body), None)).unwrap(); + let p = props(&d); + assert_eq!(p[0].space_before, Some(0.0)); + assert_eq!(p[0].space_after, Some(1584.0)); + assert_eq!(p[0].indent_left, Some(-1584.0)); + assert_eq!(p[0].indent_first, None); + assert_eq!(p[1].line_spacing, Some(LineSpacing::Multiple(132.0))); + assert_eq!(p[2].line_spacing, None); + assert!(matches!(p[3].line_spacing, Some(LineSpacing::AtLeast(v)) if v == 1584.0), "{:?}", p[3].line_spacing); + assert_eq!((p[4].space_before, p[4].line_spacing), (None, None)); +} + +fn para(text: &str, props: ParaProps) -> std::sync::Arc { + let mut p = Paragraph::new(); + p.text = text.into(); + p.runs = vec![wordcraft_doc::para::Run { len: text.len(), props: Default::default() }]; + p.props = props; + para_block(p) +} + +#[test] +fn direct_spacing_round_trips() { + let mut d = Document::new(); + let want = vec![ + ParaProps { space_before: Some(18.0), space_after: Some(6.0), ..Default::default() }, + ParaProps { indent_left: Some(36.0), indent_right: Some(18.0), indent_first: Some(-18.0), ..Default::default() }, + ParaProps { line_spacing: Some(LineSpacing::Multiple(1.5)), ..Default::default() }, + ParaProps { line_spacing: Some(LineSpacing::Exactly(20.0)), ..Default::default() }, + ParaProps { line_spacing: Some(LineSpacing::AtLeast(15.0)), ..Default::default() }, + ParaProps::default(), + ]; + d.body = want.iter().enumerate().map(|(i, p)| para(&format!("p{i}"), p.clone())).collect(); + let back = import(&export(&d).unwrap()).unwrap(); + assert_eq!(props(&back), want); +} + +#[test] +fn the_document_defaults_round_trip() { + // A new document's Normal (8 pt after, 1.15 lines) is written as the default paragraph style, + // so the copy reopens with the same spacing. + let mut d = Document::new(); + d.body = vec![para("x", ParaProps::default())]; + let back = import(&export(&d).unwrap()).unwrap(); + let r = back.styles.resolve_para(&props(&back)[0]); + let o = d.styles.resolve_para(&ParaProps::default()); + assert_eq!((r.space_before, r.space_after, r.line_spacing), (o.space_before, o.space_after, o.line_spacing)); +} diff --git a/crates/formats/src/odt/writer.rs b/crates/formats/src/odt/writer.rs index 2b9392002e..73a29c8084 100644 --- a/crates/formats/src/odt/writer.rs +++ b/crates/formats/src/odt/writer.rs @@ -1,4 +1,5 @@ use super::{Align, FBlock, FTable, Fmt, HashMap, Inline, Kind, Para, RevMark, Rgb, VAlign, mime_of, model}; +use wordcraft_doc::props::LineSpacing; // --------------------------------------------------------------------------------------------- // Export @@ -115,6 +116,30 @@ fn text_props(f: &Fmt) -> String { p } +/// `style:paragraph-properties` attributes for the spacing, indents and line spacing `sp` sets +/// (ODF 1.3 §20.204–209, §20.225, §20.317). +pub(super) fn spacing_props(sp: &model::Spacing) -> String { + let mut p = String::new(); + for (k, v) in [ + ("fo:margin-top", sp.before), + ("fo:margin-bottom", sp.after), + ("fo:margin-left", sp.left), + ("fo:margin-right", sp.right), + ("fo:text-indent", sp.first), + ] { + if let Some(v) = v.filter(|v| v.is_finite()) { + p.push_str(&format!(" {k}=\"{}\"", pt(v))); + } + } + match sp.line { + Some(LineSpacing::Multiple(m)) if m.is_finite() => p.push_str(&format!(" fo:line-height=\"{}%\"", (m * 100.0).round().max(1.0))), + Some(LineSpacing::Exactly(h)) if h.is_finite() => p.push_str(&format!(" fo:line-height=\"{}\"", pt(h))), + Some(LineSpacing::AtLeast(h)) if h.is_finite() => p.push_str(&format!(" style:line-height-at-least=\"{}\"", pt(h))), + _ => {} + } + p +} + fn parent_style(k: Kind) -> &'static str { match k { Kind::Heading(1) => "Heading_20_1", @@ -171,6 +196,7 @@ impl Writer { if p.keep_next { props.push_str(" fo:keep-with-next=\"always\""); } + props.push_str(&spacing_props(&p.spacing)); if p.kind == Kind::Rule { props.push_str(" fo:border-bottom=\"0.75pt solid #a0a0a0\" fo:padding-bottom=\"1pt\""); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c46d294de5..8f612b627a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** 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-10-31 · **Change:** trivial (ODT paragraph spacing, indents and line spacing read and written); 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 Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -57,7 +57,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | 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; Windows and Enhanced Metafiles, `\wmetafile` and `\emfblip`, read and written, #4259), 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, Wayland) 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). Thin: comments, notes, RTL | -| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/it/odt_sections.rs` | Styles, lists (#87), tables, pictures, page breaks (#86), headers and footers of each master page (every, left and first page) read on open, not yet written (#863), keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099; export still drops equations, as before), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL | +| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/it/odt_sections.rs` | Styles, lists (#87), tables, pictures, page breaks (#86), paragraph spacing, indents and line spacing (proportional, fixed, at least; leading read as a proportional approximation) both ways, with the default and Standard styles' spacing as the document defaults (ODT's are none, not Word's 8 pt after and 1.15 lines), headers and footers of each master page (every, left and first page) read on open, not yet written (#863), keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099; export still drops equations, as before), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL | | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (``, ``) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As | | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.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). 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). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). 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 | ❌ | ❌ | 0% | 3–5 | — | | @@ -279,6 +279,7 @@ Custom document properties retain their bookmark link targets through DOCX saves | Date | Change | Summary | |---|---|---| +| 2026-10-31 | trivial | ODT: paragraph spacing, indents and line spacing (`fo:margin-*`, `fo:text-indent`, `fo:line-height`, `style:line-height-at-least`; `style:line-spacing` leading as a proportional approximation) read from automatic and common styles and written back; the default and Standard paragraph styles give the document defaults, so an ODT paragraph without spacing has none instead of Word's 8 pt after and 1.15 lines. `formats::odt::tests_more::spacing` | | 2026-10-31 | trivial | DOCX: a paragraph naming a style the file doesn't define is formatted as Normal, as in Word (#5077) | | 2026-10-31 | trivial | DOCX: a hyperlinked table of contents writes each page number as a `PAGEREF _Toc… \h` field to the heading's bookmark, as Word does, so updating the fields in Word moves the numbers with the headings; they were plain text (checked in Word for Mac 16.93: with the headings moved a page down, the three fields update from 2, 3, 4 to 3, 4, 5). `engine::tests::toc_page_numbers_are_pageref_fields` | | 2026-10-31 | trivial | HTML import ends the empty comments `` and `` at once, as the HTML spec does, instead of swallowing the rest of the page (#5040) | From 3402e9b64472ea33f4268a21f9a7ecefd9eb5500 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:13:26 +0000 Subject: [PATCH 002/182] Gap 19: ODT headers and footers written; text fields read and written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export writes each section's headers and footers into its master page (style:header, -left for even pages, -first for a different first page; an empty element where Word shows nothing), with their automatic styles in styles.xml under names of their own. Word's page-edge header and footer distances become ODF's page margin plus header/footer band height, and import converts back, so headers sit where LibreOffice draws them. Text fields (ODF 1.3 §7: page number and count, date, time, document information, bookmark references) open as WordCraft fields with their shown text as the result, and are written back as ODF fields. Fixed dates and fields without a Word equivalent stay text; Word fields ODF lacks are written as their result. The flow model carries fields only when asked (FlowOpts), so the other formats are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC --- ROADMAP.md | 3 +- crates/formats/src/html/export.rs | 9 ++ crates/formats/src/latex/export.rs | 4 + crates/formats/src/markdown/export.rs | 1 + crates/formats/src/model.rs | 6 +- crates/formats/src/model/from_doc.rs | 33 +++- crates/formats/src/model/to_doc.rs | 10 ++ crates/formats/src/model/types.rs | 24 ++- crates/formats/src/odt.rs | 1 + crates/formats/src/odt/body.rs | 71 ++++++++- crates/formats/src/odt/fields.rs | 80 ++++++++++ crates/formats/src/odt/import.rs | 79 ++++++++-- crates/formats/src/odt/limits.rs | 3 + crates/formats/src/odt/package.rs | 99 +++++++++++- crates/formats/src/odt/tests_more.rs | 2 + crates/formats/src/odt/tests_more/fields.rs | 153 ++++++++++++++++++ crates/formats/src/odt/tests_more/headers.rs | 154 +++++++++++++++++++ crates/formats/src/odt/writer.rs | 58 +++++-- crates/formats/src/rtf/export.rs | 1 + docs/file-format-parity.md | 5 +- docs/gaps.md | 4 +- 21 files changed, 745 insertions(+), 55 deletions(-) create mode 100644 crates/formats/src/odt/fields.rs create mode 100644 crates/formats/src/odt/tests_more/fields.rs create mode 100644 crates/formats/src/odt/tests_more/headers.rs diff --git a/ROADMAP.md b/ROADMAP.md index 15114df637..7718cf19bc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (progress log: ODT paragraph spacing); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #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 (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #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 (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -140,6 +140,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-31: ODT: headers and footers are saved (they were dropped on every save), each page's header sits where LibreOffice puts it on open, and page numbers, page counts, dates and other text fields open as fields that update and are saved as ODF fields (gap 19). Readiness unchanged. - 2026-10-31: ODT: paragraph spacing, indents and line spacing are read and written, and an ODT's own default spacing (none, single lines) replaces Word's 8 pt after and 1.15 lines, so LibreOffice documents keep their vertical rhythm on open and save (gap 19). Readiness unchanged. - 2026-10-31: DOCX: a paragraph naming a style the file doesn't define is formatted as Normal, as in Word, not with the document defaults alone, so a Pandoc footer naming an undefined `Footer` style is as tall as Word's and no longer pushes the last paragraph to the next page (#5077). Readiness unchanged. - 2026-10-31: A table of contents writes its page numbers as `PAGEREF` fields to the headings' bookmarks, as Word does, so updating the fields in Word moves the numbers with the headings; they were plain text (#5097). diff --git a/crates/formats/src/html/export.rs b/crates/formats/src/html/export.rs index 0862ca04d0..84aae0a8fd 100644 --- a/crates/formats/src/html/export.rs +++ b/crates/formats/src/html/export.rs @@ -137,6 +137,15 @@ pub fn inlines_html(inlines: &[Inline]) -> String { )); } Inline::Anchor(a) => o.push_str(&format!("", esc(a))), + Inline::Field { result, fmt, .. } => { + let (open, close) = fmt_tags(fmt); + let at_start = o.is_empty(); + o.push_str(&open); + o.push_str(&text_html(result, at_start)); + for c in close.iter().rev() { + o.push_str(c); + } + } // No picture to show: the browser shows the alt text in its place. Inline::Figure(alt) => o.push_str(&format!("\"{}\"", esc(alt))), // MathML, which browsers, Word and LibreOffice show as an equation (#816). diff --git a/crates/formats/src/latex/export.rs b/crates/formats/src/latex/export.rs index 00e9f4577f..77aa3a054c 100644 --- a/crates/formats/src/latex/export.rs +++ b/crates/formats/src/latex/export.rs @@ -241,6 +241,10 @@ impl Writer { } Inline::Anchor(name) => o.push_str(&format!("\\hypertarget{{{}}}{{}}", escape_url(name))), Inline::Figure(_) => {} + Inline::Field { result, fmt, .. } => { + let s = self.span(result, fmt); + push_apart(&mut o, &s); + } // LaTeX keeps no ruby (no package): its base text, in the run's formatting. Inline::Ruby { base, fmt, .. } => { let s = self.span(base, fmt); diff --git a/crates/formats/src/markdown/export.rs b/crates/formats/src/markdown/export.rs index c1dbe52678..7dc8856081 100644 --- a/crates/formats/src/markdown/export.rs +++ b/crates/formats/src/markdown/export.rs @@ -134,6 +134,7 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String { } Inline::Figure(alt) => pieces.push((String::new(), Fmt::default(), Some(format!("![{}]()", escape(alt))))), Inline::Equation { .. } => {} + Inline::Field { result, fmt, .. } => pieces.push((result.clone(), fmt.clone(), None)), // Read as its base text by `ruby_as_text` above. Inline::Ruby { .. } => {} } diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index 75e7d0d5e2..39e00cccc9 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -20,12 +20,12 @@ mod to_doc; mod types; pub use color::parse_color; -pub use from_doc::{ListCounter, flow_paras, from_doc, from_doc_tracked, is_mono, para_kind}; +pub use from_doc::{ListCounter, flow_paras, from_doc, from_doc_tracked, from_doc_with, is_mono, para_kind, part_flow}; pub use media::{base64_decode, base64_encode, data_uri, decode_bounded, image_px, make_img, mime_of, percent_decode, sized_img, sniff_image}; pub use to_doc::{char_props, part_blocks, to_doc, to_doc_in}; pub use types::{ - CODE_STYLE, Cell, FBlock, FTable, Flow, Fmt, FmtChange, Img, Inline, Kind, ListInfo, MAX_DEPTH, MAX_FONT_NAME, MAX_LINK, MONO_FONT, Meta, Para, - RUBY_BASE_SIZE, RevMark, Spacing, clean_text, equations_as_text, kept_font, kept_link, ruby_as_text, ruby_object, + CODE_STYLE, Cell, FBlock, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind, ListInfo, MAX_DEPTH, MAX_FONT_NAME, MAX_LINK, MONO_FONT, + Meta, Para, RUBY_BASE_SIZE, RevMark, Spacing, clean_text, equations_as_text, kept_font, kept_link, ruby_as_text, ruby_object, }; pub(crate) use types::{Dropped, begin_import, dropped}; diff --git a/crates/formats/src/model/from_doc.rs b/crates/formats/src/model/from_doc.rs index 1ea0053093..b4abdac04b 100644 --- a/crates/formats/src/model/from_doc.rs +++ b/crates/formats/src/model/from_doc.rs @@ -1,4 +1,4 @@ -use super::types::{Cell, FBlock, FTable, Flow, Fmt, FmtChange, Img, Inline, Kind, ListInfo, MAX_DEPTH, Meta, Para, RevMark, Spacing}; +use super::types::{Cell, FBlock, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind, ListInfo, MAX_DEPTH, Meta, Para, RevMark, Spacing}; use super::{ Arc, Block, Blocks, CharProps, Document, InlineObject, NumFormat, OBJ, PAGE_BREAK, ParaProps, Paragraph, StyleKind, Table, TextColor, Underline, VMerge, VertAlign, @@ -131,12 +131,13 @@ fn direct_spacing(p: &wordcraft_doc::props::ParaProps) -> Spacing { /// A document paragraph as flow paragraphs: a page break character inside the text ends the /// paragraph, and the text after it starts a new one that carries `page_break`. pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec { - flow_paras_in(doc, p, false) + flow_paras_in(doc, p, FlowOpts::default()) } /// [`flow_paras`]; `tracked` keeps tracked changes: deleted text stays, and text carries its /// insertion and deletion marks. -fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec { +fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec { + let tracked = opts.tracked; let (kind, list) = para_kind(doc, p); let mut done: Vec = Vec::new(); let borders = if kind == Kind::Rule { None } else { p.props.borders.filter(|b| b.any_visible()) }; @@ -250,6 +251,12 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec { // attributes (alignment, size, raise) stay in the document model. out.inlines.push(Inline::Ruby { base: base.clone(), ruby: ruby.clone(), fmt: f.clone() }); } + Some(InlineObject::Field { instr, result, .. }) if opts.fields => { + if !buf.is_empty() { + out.push_text(&std::mem::take(&mut buf), &f); + } + out.inlines.push(Inline::Field { instr: instr.clone(), result: result.clone(), fmt: f.clone() }); + } Some(InlineObject::BookmarkStart { name }) if name != "_GoBack" => { if !buf.is_empty() { out.push_text(&std::mem::take(&mut buf), &f); @@ -281,7 +288,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec { done } -fn flow_blocks(doc: &Document, bl: &Blocks, depth: usize, tracked: bool) -> Vec { +fn flow_blocks(doc: &Document, bl: &Blocks, depth: usize, tracked: FlowOpts) -> Vec { let mut out = Vec::new(); for b in bl { match &**b { @@ -299,7 +306,7 @@ fn flow_blocks(doc: &Document, bl: &Blocks, depth: usize, tracked: bool) -> Vec< out } -fn flow_table(doc: &Document, t: &Table, depth: usize, tracked: bool) -> FTable { +fn flow_table(doc: &Document, t: &Table, depth: usize, tracked: FlowOpts) -> FTable { let mut rows = Vec::new(); for (ri, r) in t.rows.iter().enumerate() { let header = r.props.header || (ri == 0 && t.props.look.header_row && t.rows.len() > 1); @@ -338,16 +345,26 @@ fn flow_table(doc: &Document, t: &Table, depth: usize, tracked: bool) -> FTable /// The document body as a flow. pub fn from_doc(doc: &Document) -> Flow { - flow_of(doc, false) + flow_of(doc, FlowOpts::default()) } /// [`from_doc`] keeping tracked changes: deleted text stays, and text carries its insertion and /// deletion marks ([`Fmt::ins`], [`Fmt::del`]), for formats that can write revisions (RTF). pub fn from_doc_tracked(doc: &Document) -> Flow { - flow_of(doc, true) + flow_of(doc, FlowOpts { tracked: true, ..Default::default() }) } -fn flow_of(doc: &Document, tracked: bool) -> Flow { +/// [`from_doc`] keeping what `opts` asks for. +pub fn from_doc_with(doc: &Document, opts: FlowOpts) -> Flow { + flow_of(doc, opts) +} + +/// `blocks` of `doc` (a header, footer or note) as flow blocks, keeping what `opts` asks for. +pub fn part_flow(doc: &Document, blocks: &Blocks, opts: FlowOpts) -> Vec { + flow_blocks(doc, blocks, 0, opts) +} + +fn flow_of(doc: &Document, tracked: FlowOpts) -> Flow { let c = &doc.core; Flow { blocks: flow_blocks(doc, &doc.body, 0, tracked), diff --git a/crates/formats/src/model/to_doc.rs b/crates/formats/src/model/to_doc.rs index 2f4fa13613..c9fec5b902 100644 --- a/crates/formats/src/model/to_doc.rs +++ b/crates/formats/src/model/to_doc.rs @@ -218,6 +218,16 @@ impl<'a> Builder<'a> { append(&mut out, "", &CharProps::default(), Some(InlineObject::BookmarkEnd { name: name.clone() })); } Inline::Figure(_) => {} + Inline::Field { instr, result, fmt } => { + let cp = self.budget(char_props(fmt)); + let (instr, result) = (clean_text(instr), clean_text(result)); + if instr.trim().is_empty() { + append(&mut out, &result, &cp, None); + } else { + let obj = InlineObject::Field { instr, result, locked: false, dirty: false, code: false, form: String::new() }; + append(&mut out, "", &cp, Some(obj)); + } + } Inline::Equation { linear, display } => { let linear = clean_text(linear); if !linear.is_empty() { diff --git a/crates/formats/src/model/types.rs b/crates/formats/src/model/types.rs index 1b52b2e68f..056fe0b942 100644 --- a/crates/formats/src/model/types.rs +++ b/crates/formats/src/model/types.rs @@ -164,6 +164,24 @@ pub enum Inline { ruby: String, fmt: Fmt, }, + /// A field (page number, date, document property, cross-reference): its Word field code + /// (`PAGE`, `REF name \h`…) and the result it last showed, in its run's formatting. Only + /// flows asked for fields ([`FlowOpts::fields`]) hold them; formats without fields write the + /// result as text. + Field { + instr: String, + result: String, + fmt: Fmt, + }, +} + +/// What [`super::from_doc_with`] keeps besides text and formatting. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FlowOpts { + /// Tracked changes: deleted text stays, and text carries its insertion and deletion marks. + pub tracked: bool, + /// Fields as [`Inline::Field`] (otherwise their result is text). + pub fields: bool, } /// Base text size (points) a phonetic guide falls back to when the source states none: the @@ -269,7 +287,9 @@ impl Para { let mut s = String::new(); for i in &self.inlines { match i { - Inline::Text(t, _) | Inline::Equation { linear: t, .. } | Inline::Ruby { base: t, .. } => s.push_str(t), + Inline::Text(t, _) | Inline::Equation { linear: t, .. } | Inline::Ruby { base: t, .. } | Inline::Field { result: t, .. } => { + s.push_str(t) + } Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) => {} } } @@ -285,7 +305,7 @@ impl Para { Some(font) if wordcraft_doc::symbol_fonts::is_symbol_font(font) => s.push_str(&wordcraft_doc::symbol_fonts::map_text(font, t)), _ => s.push_str(t), }, - Inline::Equation { linear: t, .. } | Inline::Ruby { base: t, .. } => s.push_str(t), + Inline::Equation { linear: t, .. } | Inline::Ruby { base: t, .. } | Inline::Field { result: t, .. } => s.push_str(t), Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) => {} } } diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index f70b226efd..0c511bbec7 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -30,6 +30,7 @@ use wordcraft_doc::{Align, Document, RevisionKind, Rgb, SectionProps}; use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img, mime_of}; mod body; +mod fields; mod import; mod limits; mod package; diff --git a/crates/formats/src/odt/body.rs b/crates/formats/src/odt/body.rs index b8326fd7bf..4d68945e06 100644 --- a/crates/formats/src/odt/body.rs +++ b/crates/formats/src/odt/body.rs @@ -1,5 +1,5 @@ use super::import::Pages; -use super::limits::{MAX_INFO_CHARS, MAX_OPEN_INSERTIONS, MAX_REGIONS, MAX_SECTIONS, MAX_XML_DEPTH}; +use super::limits::{MAX_FIELD_RESULT, MAX_INFO_CHARS, MAX_OPEN_INSERTIONS, MAX_REGIONS, MAX_SECTIONS, MAX_XML_DEPTH}; use super::styles::Styles; use super::xml::{Attrs, Ev, events, get, length, local}; use super::{Cell, FBlock, FTable, Fmt, HashMap, Inline, Kind, ListInfo, Para, RevMark, RevisionKind, SectionProps, SectionStart, make_img, model}; @@ -157,6 +157,21 @@ pub(super) struct Body<'a> { del: Option, /// Insertions open in the body: (region id, revision). active_ins: Vec<(String, RevMark)>, + /// The text field being read. + field: Option, +} + +/// A text field being read ([`super::fields`]). +struct OpenField { + /// Its Word field code. + instr: String, + /// Its shown text so far. + result: String, + /// Its element's name and depth. + name: String, + depth: usize, + /// The formatting it is in. + fmt: Fmt, } impl<'a> Body<'a> { @@ -195,6 +210,7 @@ impl<'a> Body<'a> { info_field: None, del: None, active_ins: Vec::new(), + field: None, } } @@ -440,6 +456,22 @@ impl Body<'_> { } return; } + if let Some(OpenField { result, .. }) = &mut self.field { + // A field's shown text, its XML whitespace collapsed; bounded like any one text run. + for c in t.chars() { + if result.len() >= MAX_FIELD_RESULT { + break; + } + if matches!(c, ' ' | '\t' | '\n' | '\r') { + if !result.ends_with(' ') { + result.push(' '); + } + } else { + result.push(c); + } + } + return; + } let Some(p) = &self.para else { return; }; @@ -805,6 +837,17 @@ impl Body<'_> { } fn start_inline(&mut self, q: &str, a: &Attrs) -> bool { + // Inside a field everything is its shown text. + if self.field.is_some() { + return true; + } + if q.starts_with("text:") + && self.frame.is_none() + && let Some(instr) = super::fields::instr_of(local(q), a) + { + self.field = Some(OpenField { instr, result: String::new(), name: q.to_string(), depth: self.depth, fmt: self.cur_fmt() }); + return true; + } match local(q) { "span" => { let base = self.cur_fmt(); @@ -842,9 +885,35 @@ impl Body<'_> { true } + /// Put the field read so far into the paragraph. + fn end_field(&mut self) { + let Some(OpenField { instr, result, mut fmt, .. }) = self.field.take() else { return }; + fmt.ins = self.open_insertion(); + if self.para.is_none() { + self.start_para(None, None); + } + if let Some(p) = &mut self.para { + p.inlines.push(Inline::Field { instr, result: result.trim().to_string(), fmt }); + } + self.xml_space_end = false; + } + fn end(&mut self, q: &str) { self.depth = self.depth.saturating_sub(1); + // Everything inside a field was its text: only the field's own end does anything. An + // outer element ending first (malformed) ends the field, then itself. + if let Some(f) = &self.field { + if self.depth >= f.depth { + return; + } + let own = f.name == q; + self.end_field(); + if own { + return; + } + } if q == "office:text" { + self.end_field(); if self.del.is_some() { // Unclosed deleted content (hostile): drop it. self.close_deletion(); diff --git a/crates/formats/src/odt/fields.rs b/crates/formats/src/odt/fields.rs new file mode 100644 index 0000000000..3c0b972840 --- /dev/null +++ b/crates/formats/src/odt/fields.rs @@ -0,0 +1,80 @@ +//! Text fields (ODF 1.3 §7.3–7.7) and the Word field codes WordCraft keeps them as. +//! +//! Read: a field element becomes a field with that code and the element's text as its result. +//! A fixed date or time (`text:fixed="true"`) is plain text, as is a field with no Word +//! equivalent (sequences, chapters, variables…), so nothing it shows is lost. Written: a field +//! whose code has an ODF element is that element around its result; any other field (or one +//! with a code ODF can't hold) is its result as text. + +use super::writer::x; +use super::xml::{Attrs, get}; + +/// Longest bookmark name a reference field keeps; a longer one leaves the field as text. +const MAX_REF_NAME: usize = 1024; + +/// ODF element (local name) ↔ Word field code, for the fields that take no argument. +const SIMPLE: &[(&str, &str)] = &[ + ("page-count", "NUMPAGES"), + ("date", "DATE"), + ("time", "TIME"), + ("title", "TITLE"), + ("subject", "SUBJECT"), + ("initial-creator", "AUTHOR"), + ("creator", "LASTSAVEDBY"), + ("author-name", "USERNAME"), + ("description", "COMMENTS"), + ("keywords", "KEYWORDS"), + ("file-name", "FILENAME"), + ("word-count", "NUMWORDS"), + ("character-count", "NUMCHARS"), + ("creation-date", "CREATEDATE"), + ("modification-date", "SAVEDATE"), + ("print-date", "PRINTDATE"), +]; + +/// The Word field code for the `text:` field element `local` with attributes `a`, or `None` +/// when it is no field WordCraft keeps (then its text is read as text). +pub(super) fn instr_of(local: &str, a: &Attrs) -> Option { + let fixed = get(a, "text:fixed").is_some_and(|v| v.trim() == "true"); + match local { + // Only the current page's number: "previous" and "next" have no Word equivalent. + "page-number" => (get(a, "text:select-page").is_none_or(|v| v.trim() == "current")).then(|| "PAGE".to_string()), + "bookmark-ref" => { + let name = + get(a, "text:ref-name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= MAX_REF_NAME && !n.contains(char::is_whitespace))?; + Some(match get(a, "text:reference-format").map(str::trim) { + Some("page") => format!("PAGEREF {name} \\h"), + Some("number" | "number-all-superior" | "number-no-superior") => format!("REF {name} \\r \\h"), + _ => format!("REF {name} \\h"), + }) + } + _ if fixed => None, + _ => SIMPLE.iter().find(|(e, _)| *e == local).map(|(_, code)| code.to_string()), + } +} + +/// The ODF field element for Word field code `instr` around `result` (already escaped), or +/// `None` when ODF has none for it. +pub(super) fn element(instr: &str, result: &str) -> Option { + let mut words = instr.split_whitespace(); + let code = words.next()?.to_ascii_uppercase(); + match code.as_str() { + "PAGE" => Some(format!("{result}")), + "REF" | "PAGEREF" => { + let name = words.next().filter(|n| !n.starts_with('\\') && n.len() <= MAX_REF_NAME)?; + let rest: Vec<&str> = words.collect(); + let format = if code == "PAGEREF" { + "page" + } else if rest.iter().any(|s| s.eq_ignore_ascii_case("\\r") || s.eq_ignore_ascii_case("\\n") || s.eq_ignore_ascii_case("\\w")) { + "number" + } else { + "text" + }; + Some(format!("{result}", x(name))) + } + _ => { + let (el, _) = SIMPLE.iter().find(|(_, c)| *c == code)?; + Some(format!("{result}")) + } + } +} diff --git a/crates/formats/src/odt/import.rs b/crates/formats/src/odt/import.rs index 424c3da66e..a4aa795b61 100644 --- a/crates/formats/src/odt/import.rs +++ b/crates/formats/src/odt/import.rs @@ -199,49 +199,102 @@ pub(super) struct Pages { pub(super) first_name: Option, } +/// A page layout's header or footer area (`style:header-style`/`style:footer-style`, ODF 1.3 +/// §16.6–16.7): its minimum height and its spacing from the text, in points. +#[derive(Clone, Copy, Default)] +struct Band { + height: f32, + spacing: f32, +} + +/// A page layout read from `styles.xml`: its properties, columns, and header and footer areas. +#[derive(Default)] +struct Layout { + attrs: Attrs, + columns: Option, + header: Band, + footer: Band, +} + fn read_pages(xml: &[u8]) -> Pages { - let mut layouts: HashMap)> = HashMap::new(); + let mut layouts: HashMap = HashMap::new(); let mut cur: Option = None; - let mut masters: Vec<(String, String)> = Vec::new(); + // Inside a layout's footer style (else its header style). + let mut in_footer = false; + // Master pages: name, layout, whether a header and a footer show. + let mut masters: Vec<(String, String, bool, bool)> = Vec::new(); + let mut in_master = false; events(xml, |ev| match ev { Ev::Start(q, a) => match local(&q) { "page-layout" if q.starts_with("style:") => cur = get(&a, "style:name").map(String::from), + "header-style" => in_footer = false, + "footer-style" => in_footer = true, + "header-footer-properties" => { + if let Some(l) = cur.as_ref().and_then(|n| layouts.get_mut(n)) { + let len = |k: &str| get(&a, k).and_then(length).filter(|v| *v >= 0.0).map(|v| v.min(1584.0)); + let band = if in_footer { &mut l.footer } else { &mut l.header }; + band.height = len("fo:min-height").or_else(|| len("svg:height")).unwrap_or(0.0); + band.spacing = len(if in_footer { "fo:margin-top" } else { "fo:margin-bottom" }).unwrap_or(0.0); + } + } "page-layout-properties" => { if let Some(n) = &cur && layouts.len() < MAX_SECTIONS { - layouts.insert(n.clone(), (a, None)); + layouts.insert(n.clone(), Layout { attrs: a, ..Default::default() }); } } "columns" if q.starts_with("style:") => { if let Some(l) = cur.as_ref().and_then(|n| layouts.get_mut(n)) { - l.1 = Some(columns_of(&a)); + l.columns = Some(columns_of(&a)); } } "column-sep" => { - if let Some(c) = cur.as_ref().and_then(|n| layouts.get_mut(n)).and_then(|l| l.1.as_mut()) { + if let Some(c) = cur.as_ref().and_then(|n| layouts.get_mut(n)).and_then(|l| l.columns.as_mut()) { c.separator = column_sep(&a); } } "master-page" if masters.len() < MAX_SECTIONS => { + in_master = false; if let (Some(n), Some(l)) = (get(&a, "style:name"), get(&a, "style:page-layout-name")) { - masters.push((n.to_string(), l.to_string())); + masters.push((n.to_string(), l.to_string(), false, false)); + in_master = true; + } + } + "header" | "footer" if in_master && q.starts_with("style:") && get(&a, "style:display") != Some("false") => { + if let Some(m) = masters.last_mut() { + if local(&q) == "header" { + m.2 = true; + } else { + m.3 = true; + } } } _ => {} }, - Ev::End(q) => { - if local(&q) == "page-layout" { - cur = None; - } - } + Ev::End(q) => match local(&q) { + "page-layout" => cur = None, + "master-page" => in_master = false, + _ => {} + }, Ev::Text(_) => {} }); let mut pages = Pages::default(); - for (name, layout) in masters { - let Some((a, cols)) = layouts.get(&layout) else { continue }; + for (name, layout, header, footer) in masters { + let Some(Layout { attrs: a, columns: cols, header: hb, footer: fb }) = layouts.get(&layout) else { continue }; let mut sec = SectionProps::default(); apply_layout(a, &mut sec); + // ODF starts a header at the page margin and the text after the header's height and + // spacing; Word measures the header's distance and the text's margin from the edge. + let limit = sec.page_w.max(sec.page_h); + if header { + sec.header = sec.margin_top; + sec.margin_top = (sec.margin_top + hb.height + hb.spacing).min(limit); + } + if footer { + sec.footer = sec.margin_bottom; + sec.margin_bottom = (sec.margin_bottom + fb.height + fb.spacing).min(limit); + } // A one-column layout keeps the default columns (and their gap). if let Some(c) = cols.as_ref().filter(|c| c.count > 1) { sec.columns = c.clone(); diff --git a/crates/formats/src/odt/limits.rs b/crates/formats/src/odt/limits.rs index 48d765588c..8f3607814f 100644 --- a/crates/formats/src/odt/limits.rs +++ b/crates/formats/src/odt/limits.rs @@ -7,6 +7,9 @@ pub(super) const MAX_XML_DEPTH: usize = 512; /// Most sections an import makes (page-style changes and column sections); later ones are ignored. pub(super) const MAX_SECTIONS: usize = 10_000; +/// Longest shown text a field keeps, in bytes. +pub(super) const MAX_FIELD_RESULT: usize = 64 << 10; + /// Most `text:changed-region`s read (hostile input). pub(super) const MAX_REGIONS: usize = 10_000; diff --git a/crates/formats/src/odt/package.rs b/crates/formats/src/odt/package.rs index 9e0bdbb2c6..1d57fe6518 100644 --- a/crates/formats/src/odt/package.rs +++ b/crates/formats/src/odt/package.rs @@ -9,8 +9,62 @@ fn columns_xml(c: &Columns) -> String { /// One master page per entry of `pages` (name, setup), each with its own page layout `pm1`, `pm2`… /// (§16.5, §16.9); the first is the default one, "Standard". -/// `defaults` is the spacing of the document's Normal paragraphs, written as the default style's. -fn styles_xml(pages: &[(String, wordcraft_doc::SectionProps)], body_font: &str, defaults: &model::Spacing) -> String { +/// A master page's headers and footers (ODF 1.3 §16.10), written. +#[derive(Default)] +struct PageParts { + /// The `style:header…`/`style:footer…` elements. + xml: String, + header: bool, + footer: bool, +} + +/// The headers and footers section `sec` shows, written by `w` (its styles go in `styles.xml`): +/// the default one for every page, the even one as left pages' when the document has different +/// odd and even pages, the first one with a different first page. A slot Word leaves empty is an +/// empty element, so the page shows nothing there rather than the default one. +fn page_parts(doc: &Document, sec: &SectionProps, w: &mut Writer) -> PageParts { + let mut out = PageParts::default(); + let even = doc.settings.even_odd_headers; + for (set, name) in [(&sec.headers, "header"), (&sec.footers, "footer")] { + let wanted = set.default.is_some() || (even && set.even.is_some()) || (sec.title_page && set.first.is_some()); + if !wanted { + continue; + } + for (id, el, on) in + [(set.default, name.to_string(), true), (set.even, format!("{name}-left"), even), (set.first, format!("{name}-first"), sec.title_page)] + { + if !on { + continue; + } + let mut body = String::new(); + if let Some(part) = id.and_then(|i| doc.parts.get(&i)) { + let blocks = model::part_flow(doc, &part.blocks, model::FlowOpts { tracked: false, fields: true }); + w.blocks(&blocks, &mut body, 0); + } + if body.is_empty() { + body.push_str(""); + } + out.xml.push_str(&format!("{body}")); + } + if name == "header" { + out.header = true; + } else { + out.footer = true; + } + } + out +} + +/// `defaults` is the spacing of the document's Normal paragraphs, written as the default style's; +/// `parts` the headers and footers of each page in `pages` and `part_styles` their automatic +/// styles. +fn styles_xml( + pages: &[(String, wordcraft_doc::SectionProps)], + parts: &[PageParts], + part_styles: &str, + body_font: &str, + defaults: &model::Spacing, +) -> String { let mut s = format!("\n"); s.push_str(&format!( "", @@ -31,16 +85,41 @@ fn styles_xml(pages: &[(String, wordcraft_doc::SectionProps)], body_font: &str, s.push_str(""); s.push_str(&format!("", model::MONO_FONT)); s.push_str(""); + s.push_str(part_styles); + let none = PageParts::default(); for (i, (_, sec)) in pages.iter().enumerate() { + let pp = parts.get(i).unwrap_or(&none); // A page of several columns carries them in its layout. let cols = if sec.columns.count > 1 { columns_xml(&sec.columns) } else { String::new() }; + // Word measures the header from the page edge and the text from the page edge; ODF + // starts the header at the page margin and the text after the header's height. So with + // a header the page margin is the header's distance, and the header is tall enough to + // reach the text (it grows with its content, as Word's pushes the text down). + let (top, header_h) = if pp.header { (sec.header, (sec.margin_top - sec.header).max(0.0)) } else { (sec.margin_top, 0.0) }; + let (bottom, footer_h) = if pp.footer { (sec.footer, (sec.margin_bottom - sec.footer).max(0.0)) } else { (sec.margin_bottom, 0.0) }; + let header = if pp.header { + format!( + "", + pt(header_h) + ) + } else { + String::new() + }; + let footer = if pp.footer { + format!( + "", + pt(footer_h) + ) + } else { + String::new() + }; s.push_str(&format!( - "{cols}", + "{cols}{header}{footer}", i + 1, pt(sec.page_w), pt(sec.page_h), - pt(sec.margin_top), - pt(sec.margin_bottom), + pt(top), + pt(bottom), pt(sec.margin_left), pt(sec.margin_right), if sec.page_w > sec.page_h { "landscape" } else { "portrait" } @@ -48,7 +127,8 @@ fn styles_xml(pages: &[(String, wordcraft_doc::SectionProps)], body_font: &str, } s.push_str(""); for (i, (name, _)) in pages.iter().enumerate() { - s.push_str(&format!("", x(name), i + 1)); + let xml = parts.get(i).map(|p| p.xml.as_str()).unwrap_or(""); + s.push_str(&format!("{xml}", x(name), i + 1)); } s.push_str("\n"); s @@ -101,7 +181,7 @@ fn flow_sections(blocks: &[FBlock], last: &SectionProps) -> Vec<(SectionProps, s /// Write an `.odt` package. pub fn export(doc: &Document) -> Result, String> { - let flow = model::from_doc_tracked(doc); + let flow = model::from_doc_with(doc, model::FlowOpts { tracked: true, fields: true }); let mut w = Writer::default(); let mut body = String::new(); let sections = flow_sections(&flow.blocks, &doc.last_section); @@ -166,7 +246,10 @@ pub fn export(doc: &Document) -> Result, String> { right: Some(normal.indent_right), first: Some(normal.indent_first), }; - let styles = styles_xml(&pages, &body_font, &defaults); + let mut hw = Writer::with_prefix("M"); + let parts: Vec = pages.iter().map(|(_, sec)| page_parts(doc, sec, &mut hw)).collect(); + let styles = styles_xml(&pages, &parts, &hw.style_defs(), &body_font, &defaults); + w.pictures.append(&mut hw.pictures); let meta = meta_xml(&flow.meta); let mut manifest = String::from( "\n\n\n\n\n\n", diff --git a/crates/formats/src/odt/tests_more.rs b/crates/formats/src/odt/tests_more.rs index 1906c58e61..9becd96901 100644 --- a/crates/formats/src/odt/tests_more.rs +++ b/crates/formats/src/odt/tests_more.rs @@ -1,3 +1,5 @@ //! Tests for the ODT features added after the first reader and writer: one module per feature. +mod fields; +mod headers; mod spacing; diff --git a/crates/formats/src/odt/tests_more/fields.rs b/crates/formats/src/odt/tests_more/fields.rs new file mode 100644 index 0000000000..09e5537130 --- /dev/null +++ b/crates/formats/src/odt/tests_more/fields.rs @@ -0,0 +1,153 @@ +//! Text fields (ODF 1.3 §7): page number and count, dates and times, document information and +//! bookmark references open as WordCraft fields and are written back as ODF fields. + +use crate::odt::tests::{NS_T, package}; +use crate::odt::{export, import}; +use wordcraft_doc::para::InlineObject; +use wordcraft_doc::{Document, Paragraph, para_block}; + +/// Every field in the body: (code, cached result). +pub(super) fn fields(d: &Document) -> Vec<(String, String)> { + let mut out = Vec::new(); + for b in d.body.iter() { + if let Some(p) = b.as_para() { + for o in &p.objects { + if let InlineObject::Field { instr, result, .. } = o { + out.push((instr.clone(), result.clone())); + } + } + } + } + out +} + +fn body(xml: &str) -> Vec { + package( + &format!(r#"{xml}"#), + None, + ) +} + +#[test] +fn fields_open_as_fields_with_their_shown_text() { + let d = import(&body(concat!( + r#"Page 3 of 9."#, + r#"31/10/2026 12:00"#, + r#"My title|Subj|Ada|f.odt"#, + r#"See 2 and Intro"#, + ))) + .unwrap(); + let got = fields(&d); + let want = [ + ("PAGE", "3"), + ("NUMPAGES", "9"), + ("DATE", "31/10/2026"), + ("TIME", "12:00"), + ("TITLE", "My title"), + ("SUBJECT", "Subj"), + ("AUTHOR", "Ada"), + ("FILENAME", "f.odt"), + ("PAGEREF intro \\h", "2"), + ("REF intro \\h", "Intro"), + ]; + assert_eq!(got.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect::>(), want); + // The text around the fields stays where it was. + let p0 = d.body[0].as_para().unwrap(); + assert_eq!(p0.text.replace(wordcraft_doc::para::OBJ, "#"), "Page # of #."); +} + +#[test] +fn a_fixed_date_and_unknown_fields_are_plain_text() { + let d = import(&body(r#"1 May, 4, 5"#)).unwrap(); + assert!(fields(&d).is_empty()); + assert_eq!(d.body[0].as_para().unwrap().text, "1 May, 4, 5"); +} + +fn field_para(parts: &[(&str, Option<&str>)]) -> std::sync::Arc { + // Text pieces, and fields given as (code, Some(result)). + let mut p = Paragraph::new(); + for (s, field) in parts { + match field { + Some(result) => { + p.text.push(wordcraft_doc::para::OBJ); + p.objects.push(InlineObject::Field { + instr: s.to_string(), + result: result.to_string(), + locked: false, + dirty: false, + code: false, + form: String::new(), + }); + } + None => p.text.push_str(s), + } + } + p.runs = vec![wordcraft_doc::para::Run { len: p.text.len(), props: Default::default() }]; + para_block(p) +} + +#[test] +fn fields_round_trip() { + let mut d = Document::new(); + d.body = vec![field_para(&[ + ("Page ", None), + ("PAGE", Some("1")), + (" of ", None), + ("NUMPAGES", Some("2")), + (" ", None), + ("DATE \\@ \"d MMMM yyyy\"", Some("31 October 2026")), + (" ", None), + ("TITLE", Some("T")), + (" ", None), + ("PAGEREF intro \\h", Some("4")), + ])]; + let xml = export(&d).unwrap(); + let back = import(&xml).unwrap(); + let got: Vec = fields(&back).into_iter().map(|(c, _)| c).collect(); + assert_eq!(got, ["PAGE", "NUMPAGES", "DATE", "TITLE", "PAGEREF intro \\h"]); + assert_eq!(back.body[0].as_para().unwrap().text.matches(wordcraft_doc::para::OBJ).count(), 5); +} + +#[test] +fn a_field_odf_has_no_element_for_keeps_its_result_as_text() { + let mut d = Document::new(); + d.body = vec![field_para(&[("x ", None), ("SECTIONPAGES", Some("7")), (" y", None)])]; + let back = import(&export(&d).unwrap()).unwrap(); + assert!(fields(&back).is_empty()); + assert_eq!(back.body[0].as_para().unwrap().text, "x 7 y"); +} + +#[test] +fn hostile_fields_do_not_panic() { + // Unclosed, nested and empty fields, and a long reference name. + let long = "n".repeat(100_000); + for xml in [ + "".to_string(), + "1".to_string(), + "".to_string(), + format!(r#"1"#), + ] { + let d = import(&body(&xml)).unwrap(); + let _ = export(&d).unwrap(); + } +} + +#[test] +fn an_element_inside_a_field_leaves_the_formatting_after_it_alone() { + // A span inside a field was skipped at its start but still ended, popping the enclosing + // span's formatting: the text after the field lost its bold. + let content = format!( + r#"x1y"# + ); + let d = import(&package(&content, None)).unwrap(); + let p = d.body[0].as_para().unwrap(); + assert_eq!(fields(&d), [("PAGE".to_string(), "1".to_string())]); + assert!(p.runs.iter().all(|r| r.props.bold == Some(true)), "{:?}", p.runs); +} + +#[test] +fn a_paragraph_ending_inside_a_field_still_ends() { + let d = import(&body("a1b")).unwrap(); + let texts: Vec = d.body.iter().filter_map(|b| b.as_para()).map(|p| p.text.replace(wordcraft_doc::para::OBJ, "#")).collect(); + assert_eq!(texts, ["a#", "b"]); +} diff --git a/crates/formats/src/odt/tests_more/headers.rs b/crates/formats/src/odt/tests_more/headers.rs new file mode 100644 index 0000000000..6071b07fb7 --- /dev/null +++ b/crates/formats/src/odt/tests_more/headers.rs @@ -0,0 +1,154 @@ +//! Headers and footers written to the master pages (ODF 1.3 §16.10), read back by the reader +//! that already opens them. + +use crate::odt::{export, import}; +use wordcraft_doc::para::InlineObject; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Document, Paragraph, Part, PartKind, para_block}; + +fn text_para(s: &str, props: CharProps) -> std::sync::Arc { + let mut p = Paragraph::new(); + p.text = s.into(); + p.runs = vec![wordcraft_doc::para::Run { len: s.len(), props }]; + para_block(p) +} + +fn page_field_para(before: &str) -> std::sync::Arc { + let mut p = Paragraph::new(); + p.text = format!("{before}{}", wordcraft_doc::para::OBJ); + p.objects.push(InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }); + p.runs = vec![wordcraft_doc::para::Run { len: p.text.len(), props: Default::default() }]; + para_block(p) +} + +fn part(kind: PartKind, blocks: Vec>) -> Part { + Part { kind, blocks, body: Default::default() } +} + +fn part_text(d: &Document, id: Option) -> String { + id.and_then(|i| d.parts.get(&i)) + .map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::>().join("|")) + .unwrap_or_default() +} + +#[test] +fn headers_and_footers_are_written_and_read_back() { + let mut d = Document::new(); + d.body = vec![text_para("Body", Default::default())]; + d.parts.insert(1, part(PartKind::Header, vec![text_para("Bold header", CharProps { bold: Some(true), ..Default::default() })])); + d.parts.insert(2, part(PartKind::Footer, vec![page_field_para("Page ")])); + d.parts.insert(3, part(PartKind::Header, vec![text_para("First page header", Default::default())])); + d.last_section.headers.default = Some(1); + d.last_section.footers.default = Some(2); + d.last_section.headers.first = Some(3); + d.last_section.title_page = true; + let back = import(&export(&d).unwrap()).unwrap(); + let s = &back.last_section; + assert_eq!(part_text(&back, s.headers.default), "Bold header"); + assert_eq!(part_text(&back, s.headers.first), "First page header"); + assert!(s.title_page); + assert_eq!(part_text(&back, s.footers.default).replace(wordcraft_doc::para::OBJ, "#"), "Page #"); + // The header's text keeps its formatting; the footer its page field. + let h = back.parts.get(&s.headers.default.unwrap()).unwrap().blocks[0].as_para().unwrap(); + assert_eq!(h.runs[0].props.bold, Some(true)); + let f = back.parts.get(&s.footers.default.unwrap()).unwrap().blocks[0].as_para().unwrap(); + assert!(matches!(&f.objects[..], [InlineObject::Field { instr, .. }] if instr == "PAGE")); +} + +#[test] +fn even_page_headers_are_left_page_headers() { + let mut d = Document::new(); + d.body = vec![text_para("Body", Default::default())]; + d.parts.insert(1, part(PartKind::Header, vec![text_para("Odd", Default::default())])); + d.parts.insert(2, part(PartKind::Header, vec![text_para("Even", Default::default())])); + d.last_section.headers.default = Some(1); + d.last_section.headers.even = Some(2); + d.settings.even_odd_headers = true; + let back = import(&export(&d).unwrap()).unwrap(); + assert!(back.settings.even_odd_headers); + assert_eq!(part_text(&back, back.last_section.headers.default), "Odd"); + assert_eq!(part_text(&back, back.last_section.headers.even), "Even"); +} + +#[test] +fn each_section_keeps_its_own_header() { + let mut d = Document::new(); + let mut first = Paragraph::new(); + first.text = "One".into(); + first.runs = vec![wordcraft_doc::para::Run { len: 3, props: Default::default() }]; + let mut sec = d.last_section.clone(); + sec.headers.default = Some(1); + first.section = Some(Box::new(sec)); + d.body = vec![para_block(first), text_para("Two", Default::default())]; + d.parts.insert(1, part(PartKind::Header, vec![text_para("H1", Default::default())])); + d.parts.insert(2, part(PartKind::Header, vec![text_para("H2", Default::default())])); + d.last_section.headers.default = Some(2); + let back = import(&export(&d).unwrap()).unwrap(); + let firsts: Vec<_> = back.body.iter().filter_map(|b| b.as_para()).filter_map(|p| p.section.as_ref()).collect(); + assert_eq!(firsts.len(), 1); + assert_eq!(part_text(&back, firsts[0].headers.default), "H1"); + assert_eq!(part_text(&back, back.last_section.headers.default), "H2"); +} + +#[test] +fn header_styles_do_not_clash_with_body_styles() { + // The header's automatic styles live in styles.xml, the body's in content.xml: their names + // must differ so neither reader takes one for the other. + let mut d = Document::new(); + d.body = vec![text_para("Italic body", CharProps { italic: Some(true), ..Default::default() })]; + d.parts.insert(1, part(PartKind::Header, vec![text_para("Bold header", CharProps { bold: Some(true), ..Default::default() })])); + d.last_section.headers.default = Some(1); + let back = import(&export(&d).unwrap()).unwrap(); + let body = back.body[0].as_para().unwrap(); + assert_eq!((body.runs[0].props.italic, body.runs[0].props.bold), (Some(true), None)); + let h = back.parts.get(&back.last_section.headers.default.unwrap()).unwrap().blocks[0].as_para().unwrap(); + assert_eq!((h.runs[0].props.bold, h.runs[0].props.italic), (Some(true), None)); +} + +fn styles_with_header(header: &str) -> String { + format!( + r#"{header}"#, + crate::odt::tests::NS_T + ) +} + +fn cm(v: f32) -> f32 { + v * 72.0 / 2.54 +} + +#[test] +fn a_header_starts_at_the_page_margin_and_the_text_after_it() { + // ODF puts the header at the page margin and the text below the header's height and spacing; + // Word measures both from the page edge. + let content = format!( + r#"x"#, + crate::odt::tests::NS_T + ); + let with = crate::odt::tests::package( + &content, + Some(&styles_with_header("HF")), + ); + let s = import(&with).unwrap().last_section; + let near = |a: f32, b: f32| (a - b).abs() < 0.05; + assert!(near(s.header, cm(2.0)) && near(s.margin_top, cm(2.75)), "{} {}", s.header, s.margin_top); + assert!(near(s.footer, cm(1.5)) && near(s.margin_bottom, cm(3.0)), "{} {}", s.footer, s.margin_bottom); + // No header (or a hidden one): the page margins are the text's. + let without = + crate::odt::tests::package(&content, Some(&styles_with_header(r#"H"#))); + let s = import(&without).unwrap().last_section; + assert!(near(s.margin_top, cm(2.0)) && near(s.margin_bottom, cm(1.5)), "{} {}", s.margin_top, s.margin_bottom); +} + +#[test] +fn header_and_footer_distances_round_trip() { + let mut d = Document::new(); + d.body = vec![text_para("Body", Default::default())]; + d.parts.insert(1, part(PartKind::Header, vec![text_para("H", Default::default())])); + d.parts.insert(2, part(PartKind::Footer, vec![text_para("F", Default::default())])); + d.last_section.headers.default = Some(1); + d.last_section.footers.default = Some(2); + (d.last_section.header, d.last_section.margin_top, d.last_section.footer, d.last_section.margin_bottom) = (30.0, 80.0, 20.0, 70.0); + let s = import(&export(&d).unwrap()).unwrap().last_section; + let near = |a: f32, b: f32| (a - b).abs() < 0.05; + assert!(near(s.header, 30.0) && near(s.margin_top, 80.0) && near(s.footer, 20.0) && near(s.margin_bottom, 70.0), "{s:?}"); +} diff --git a/crates/formats/src/odt/writer.rs b/crates/formats/src/odt/writer.rs index 73a29c8084..aad25abd68 100644 --- a/crates/formats/src/odt/writer.rs +++ b/crates/formats/src/odt/writer.rs @@ -45,6 +45,9 @@ type SecStart = (String, Option); #[derive(Default)] pub(super) struct Writer { + /// Prefix of every name it makes (styles, lists, tables, pictures): headers and footers are + /// written by a second writer whose styles go in `styles.xml`, apart from the body's. + pub(super) prefix: &'static str, /// Automatic text styles: properties XML → name. text_styles: Vec<(String, String)>, /// Automatic paragraph styles: (parent, properties XML, master page) → name. @@ -156,6 +159,11 @@ fn parent_style(k: Kind) -> &'static str { } impl Writer { + /// A writer whose names all start with `prefix`. + pub(super) fn with_prefix(prefix: &'static str) -> Self { + Writer { prefix, ..Default::default() } + } + fn text_style(&mut self, f: &Fmt) -> Option { let props = text_props(f); if props.is_empty() { @@ -164,7 +172,7 @@ impl Writer { if let Some((_, n)) = self.text_styles.iter().find(|(p, _)| *p == props) { return Some(n.clone()); } - let n = format!("T{}", self.text_styles.len() + 1); + let n = format!("{}T{}", self.prefix, self.text_styles.len() + 1); self.text_styles.push((props, n.clone())); Some(n) } @@ -208,14 +216,14 @@ impl Writer { if let Some((_, n)) = self.para_styles.iter().find(|(k, _)| *k == key) { return n.clone(); } - let n = format!("P{}", self.para_styles.len() + 1); + let n = format!("{}P{}", self.prefix, self.para_styles.len() + 1); self.para_styles.push((key, n.clone())); n } fn picture(&mut self, img: &model::Img) -> String { let ext = if img.ext == "jpeg" { "jpg".to_string() } else { img.ext.clone() }; - let name = format!("Pictures/image{}.{}", self.pictures.len() + 1, ext); + let name = format!("Pictures/{}image{}.{}", self.prefix, self.pictures.len() + 1, ext); self.pictures.push((name.clone(), img.data.clone(), mime_of(&img.ext).to_string())); name } @@ -402,7 +410,8 @@ impl Writer { let href = self.picture(img); let n = self.pictures.len(); out.push_str(&format!( - "", + "", + self.prefix, pt(img.w), pt(img.h) )); @@ -413,6 +422,20 @@ impl Writer { prev_space = false; } Inline::Anchor(a) => out.push_str(&format!("", x(a))), + Inline::Field { instr, result, fmt } => match super::fields::element(instr, &x(result)) { + Some(el) => { + match self.text_style(&Fmt { link: None, ..fmt.clone() }) { + Some(n) => out.push_str(&format!("{el}")), + None => out.push_str(&el), + } + prev_space = false; + } + // No ODF field for it: what it shows, as text. + None => { + self.plain_inlines(&[Inline::Text(result.clone(), fmt.clone())], out); + prev_space = result.ends_with(' '); + } + }, // Read as its base text by `ruby_as_text` above. Inline::Ruby { .. } => {} Inline::Figure(_) | Inline::Equation { .. } => {} @@ -440,7 +463,7 @@ impl Writer { } }; let tracked = paras.len() > 1 - || p.inlines.iter().any(|i| matches!(i, Inline::Text(_, f) | Inline::Ruby { fmt: f, .. } if f.ins.is_some() || f.del.is_some())); + || p.inlines.iter().any(|i| matches!(i, Inline::Text(_, f) | Inline::Ruby { fmt: f, .. } | Inline::Field { fmt: f, .. } if f.ins.is_some() || f.del.is_some())); if tracked { let mut items = Vec::new(); for (k, q) in paras.iter().enumerate() { @@ -503,7 +526,7 @@ impl Writer { i += 1; } self.list_styles.push(kinds); - let lname = format!("L{}", self.list_styles.len()); + let lname = format!("{}L{}", self.prefix, self.list_styles.len()); // Open lists: per level, whether a list-item is open. let mut stack: Vec = Vec::new(); for (qi, q) in blocks.get(start..i).unwrap_or(&[]).iter().enumerate() { @@ -523,7 +546,7 @@ impl Writer { *top = true; } if stack.is_empty() { - let id = format!("lst{}", self.list_styles.len()); + let id = format!("{}lst{}", self.prefix, self.list_styles.len()); // A list cut by a section boundary goes on numbering: continue-list names the // preceding list by its xml:id, whatever its style name (ODF 1.3 §19.743). let prev = self.last_list.clone(); @@ -577,7 +600,7 @@ impl Writer { self.cell_styles.len() - 1 } }; - format!("Cell{}", k + 1) + format!("{}Cell{}", self.prefix, k + 1) } fn table(&mut self, t: &FTable, out: &mut String, depth: usize) { @@ -590,7 +613,7 @@ impl Writer { }; self.tables.push(widths); self.table_starts.push(self.master.take()); - let tn = self.tables.len(); + let tn = format!("{}{}", self.prefix, self.tables.len()); out.push_str(&format!("")); for c in 0..cols { out.push_str(&format!("", c + 1)); @@ -642,7 +665,12 @@ impl Writer { } pub(super) fn automatic_styles(&self) -> String { - let mut s = String::from(""); + format!("{}", self.style_defs()) + } + + /// The automatic styles made so far, without their `office:automatic-styles` element. + pub(super) fn style_defs(&self) -> String { + let mut s = String::new(); for (props, n) in &self.text_styles { s.push_str(&format!("")); } @@ -653,7 +681,7 @@ impl Writer { )); } for (i, kinds) in self.list_styles.iter().enumerate() { - s.push_str(&format!("", i + 1)); + s.push_str(&format!("", self.prefix, i + 1)); for (l, ordered) in kinds.iter().enumerate() { let lv = l + 1; let props = format!( @@ -674,7 +702,7 @@ impl Writer { s.push_str(""); } for (i, widths) in self.tables.iter().enumerate() { - let tn = i + 1; + let tn = format!("{}{}", self.prefix, i + 1); let total: f32 = widths.iter().sum(); let (master, number) = match self.table_starts.get(i).and_then(Option::as_ref) { Some((m, n)) => { @@ -703,14 +731,14 @@ impl Writer { None => "", }; s.push_str(&format!( - "", + "", + self.prefix, i + 1 )); } for st in &self.section_styles { s.push_str(st); } - s.push_str(""); s } } @@ -726,7 +754,7 @@ impl Item<'_> { /// Its tracked insertion and deletion. fn marks(&self) -> (Option<&RevMark>, Option<&RevMark>) { match self { - Item::Inl(Inline::Text(_, f) | Inline::Ruby { fmt: f, .. }) => (f.ins.as_ref(), f.del.as_ref()), + Item::Inl(Inline::Text(_, f) | Inline::Ruby { fmt: f, .. } | Inline::Field { fmt: f, .. }) => (f.ins.as_ref(), f.del.as_ref()), Item::Inl(_) => (None, None), Item::Mark(p) => (p.mark_ins.as_deref(), p.mark_del.as_deref()), } diff --git a/crates/formats/src/rtf/export.rs b/crates/formats/src/rtf/export.rs index 0b98c81eb7..2209857a55 100644 --- a/crates/formats/src/rtf/export.rs +++ b/crates/formats/src/rtf/export.rs @@ -399,6 +399,7 @@ impl Writer { } // RTF keeps no ruby yet: its base text, in the run's formatting. Inline::Ruby { base, fmt, .. } => self.run(base, fmt, out), + Inline::Field { result, fmt, .. } => self.run(result, fmt, out), Inline::Figure(_) | Inline::Equation { .. } => {} } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8f612b627a..90c927a5f3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** trivial (ODT paragraph spacing, indents and line spacing read and written); 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-10-31 · **Change:** trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); 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 Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -57,7 +57,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | 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; Windows and Enhanced Metafiles, `\wmetafile` and `\emfblip`, read and written, #4259), 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, Wayland) 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). Thin: comments, notes, RTL | -| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/it/odt_sections.rs` | Styles, lists (#87), tables, pictures, page breaks (#86), paragraph spacing, indents and line spacing (proportional, fixed, at least; leading read as a proportional approximation) both ways, with the default and Standard styles' spacing as the document defaults (ODT's are none, not Word's 8 pt after and 1.15 lines), headers and footers of each master page (every, left and first page) read on open, not yet written (#863), keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099; export still drops equations, as before), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL | +| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/it/odt_sections.rs` | Styles, lists (#87), tables, pictures, page breaks (#86), paragraph spacing, indents and line spacing (proportional, fixed, at least; leading read as a proportional approximation) both ways, with the default and Standard styles' spacing as the document defaults (ODT's are none, not Word's 8 pt after and 1.15 lines), headers and footers of each master page (every, left and first page) read on open and written on save, with the header and footer distances converted between Word's page-edge measures and ODF's header bands (#863), text fields (page number and count, date, time, title, subject, author, file name, word and character counts, bookmark references as `REF`/`PAGEREF`) read as fields and written back as ODF fields; fixed dates and fields without a Word equivalent (sequences, chapters, variables) read as their text, and Word fields ODF lacks written as their result, date formats not kept, keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099; export still drops equations, as before), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments or RTL | | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (``, ``) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As | | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.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). 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). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). 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 | ❌ | ❌ | 0% | 3–5 | — | | @@ -279,6 +279,7 @@ Custom document properties retain their bookmark link targets through DOCX saves | Date | Change | Summary | |---|---|---| +| 2026-10-31 | trivial | ODT: headers and footers are written to each section's master page (every, left and first page), with Word's header and footer distances converted to ODF's header and footer bands and back on open; text fields (`text:page-number`, `text:page-count`, `text:date`, `text:time`, document information, `text:bookmark-ref`) open as WordCraft fields and are written back as ODF fields. `formats::odt::tests_more::{headers, fields}` | | 2026-10-31 | trivial | ODT: paragraph spacing, indents and line spacing (`fo:margin-*`, `fo:text-indent`, `fo:line-height`, `style:line-height-at-least`; `style:line-spacing` leading as a proportional approximation) read from automatic and common styles and written back; the default and Standard paragraph styles give the document defaults, so an ODT paragraph without spacing has none instead of Word's 8 pt after and 1.15 lines. `formats::odt::tests_more::spacing` | | 2026-10-31 | trivial | DOCX: a paragraph naming a style the file doesn't define is formatted as Normal, as in Word (#5077) | | 2026-10-31 | trivial | DOCX: a hyperlinked table of contents writes each page number as a `PAGEREF _Toc… \h` field to the heading's bookmark, as Word does, so updating the fields in Word moves the numbers with the headings; they were plain text (checked in Word for Mac 16.93: with the headings moved a page down, the three fields update from 2, 3, 4 to 3, 4, 5). `engine::tests::toc_page_numbers_are_pageref_fields` | diff --git a/docs/gaps.md b/docs/gaps.md index 32a4369fba..c545cc8da9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #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 (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (gap 19 narrowed: ODT headers and footers written, text fields and paragraph spacing read and written); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #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 (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; 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: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 @@ -81,7 +81,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189; Word's own `Sources.xml` master list imports into Manage Sources, #2640) | | #790 stores sources in `customXml` and reads them back; `crates/zotero`; `references.sources` `import` reads a Word bibliography file (`b:Sources`) | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) | | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | | 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | -| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; RTF section/table and ODT formatting revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s; ODT table column widths are read (they were dropped on every open). ODT export writes sections back (a master page per new-page section, a columned `text:section` per continuous one); RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259) | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | +| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; RTF section/table and ODT formatting revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s; ODT table column widths are read (they were dropped on every open). ODT export writes sections back (a master page per new-page section, a columned `text:section` per continuous one) and their headers and footers; ODT paragraph spacing and text fields read and written; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259) | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | | 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | DOCX comment saves avoid repeated paragraph-ID and durable-ID reservation searches; large threaded-comment fixtures retain metadata through save/reopen. Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds; opening a document with pictures no longer copies them (92 MB with 20 pictures: about 90 MB less); opening a document with many relationships now costs the size of its part and not that size times the number of relationships (1024 relationships and a 64 MiB part: 224 s → 0.2 s, #3411); a word with no break opportunity in it no longer lays out quadratically (1 MiB: 29.4 s → 0.35 s, #3412); saving a document with many content controls now costs the time the controls take (32,000 controls: 3.6 s → 0.1 s, #13) | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | | 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778), finds records and can show only the merged ones (#1576) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | From 4959ac5f63384e4c5d447aa845ff3e79adf10724 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:17:19 +0000 Subject: [PATCH 003/182] Gap 19: an ODT header's spacing is inside its height, as LibreOffice draws it A LibreOffice header 2 cm high with 1 cm spacing puts the text 2 cm below the page margin: `fo:min-height` takes in the `fo:margin-bottom` spacing. Import added the spacing on top, so the text started a spacing too low (measured: 113.9 pt in LibreOffice, 142.3 pt after a WordCraft save). The spacing now counts only past the height. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC --- crates/formats/src/odt/import.rs | 10 ++++++---- crates/formats/src/odt/tests_more/headers.rs | 9 +++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/formats/src/odt/import.rs b/crates/formats/src/odt/import.rs index a4aa795b61..574e26eac9 100644 --- a/crates/formats/src/odt/import.rs +++ b/crates/formats/src/odt/import.rs @@ -284,16 +284,18 @@ fn read_pages(xml: &[u8]) -> Pages { let Some(Layout { attrs: a, columns: cols, header: hb, footer: fb }) = layouts.get(&layout) else { continue }; let mut sec = SectionProps::default(); apply_layout(a, &mut sec); - // ODF starts a header at the page margin and the text after the header's height and - // spacing; Word measures the header's distance and the text's margin from the edge. + // ODF starts a header at the page margin and the text after the header's band; Word + // measures the header's distance and the text's margin from the edge. The band's height + // takes in its spacing (as LibreOffice draws it: a 2 cm header with 1 cm spacing puts + // the text 2 cm below the margin), so the spacing counts only past the height. let limit = sec.page_w.max(sec.page_h); if header { sec.header = sec.margin_top; - sec.margin_top = (sec.margin_top + hb.height + hb.spacing).min(limit); + sec.margin_top = (sec.margin_top + hb.height.max(hb.spacing)).min(limit); } if footer { sec.footer = sec.margin_bottom; - sec.margin_bottom = (sec.margin_bottom + fb.height + fb.spacing).min(limit); + sec.margin_bottom = (sec.margin_bottom + fb.height.max(fb.spacing)).min(limit); } // A one-column layout keeps the default columns (and their gap). if let Some(c) = cols.as_ref().filter(|c| c.count > 1) { diff --git a/crates/formats/src/odt/tests_more/headers.rs b/crates/formats/src/odt/tests_more/headers.rs index 6071b07fb7..da87cae2a6 100644 --- a/crates/formats/src/odt/tests_more/headers.rs +++ b/crates/formats/src/odt/tests_more/headers.rs @@ -118,8 +118,9 @@ fn cm(v: f32) -> f32 { #[test] fn a_header_starts_at_the_page_margin_and_the_text_after_it() { - // ODF puts the header at the page margin and the text below the header's height and spacing; - // Word measures both from the page edge. + // ODF puts the header at the page margin and the text below the header's band, whose height + // takes in its spacing (LibreOffice: a 2 cm header with 1 cm spacing puts the text 2 cm + // below the margin); Word measures both from the page edge. let content = format!( r#"x"#, crate::odt::tests::NS_T @@ -130,8 +131,8 @@ fn a_header_starts_at_the_page_margin_and_the_text_after_it() { ); let s = import(&with).unwrap().last_section; let near = |a: f32, b: f32| (a - b).abs() < 0.05; - assert!(near(s.header, cm(2.0)) && near(s.margin_top, cm(2.75)), "{} {}", s.header, s.margin_top); - assert!(near(s.footer, cm(1.5)) && near(s.margin_bottom, cm(3.0)), "{} {}", s.footer, s.margin_bottom); + assert!(near(s.header, cm(2.0)) && near(s.margin_top, cm(2.5)), "{} {}", s.header, s.margin_top); + assert!(near(s.footer, cm(1.5)) && near(s.margin_bottom, cm(2.5)), "{} {}", s.footer, s.margin_bottom); // No header (or a hidden one): the page margins are the text's. let without = crate::odt::tests::package(&content, Some(&styles_with_header(r#"H"#))); From 1dbcf073a4c3c622e97ed2043e1b8d78b3f58375 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:31:02 +0000 Subject: [PATCH 004/182] Gap 19: ODT footnotes and endnotes read and written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `text:note` (ODF 1.3 §6.3) opens as a note story built as Word's: its paragraphs in Footnote Text or Endnote Text, the first starting with the note's own mark, the reference in the text in the reference style, and a `text:label` custom mark kept. The paragraph a note sits in (with its spans, list and table cell) is put aside while the note's content is read, and goes on after it. Notes inside notes, in deleted text or in frames are left out. Before, notes were dropped on open. Export writes each note with a citation numbered as LibreOffice shows it (footnotes 1, 2, 3; endnotes i, ii, iii) or its custom mark, its paragraphs in Footnote or Endnote styles defined in styles.xml. An insertion open around a note ends before it and goes on after it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC --- ROADMAP.md | 3 +- crates/formats/src/html/export.rs | 1 + crates/formats/src/latex/export.rs | 2 +- crates/formats/src/markdown/export.rs | 1 + crates/formats/src/model/from_doc.rs | 13 ++ crates/formats/src/model/to_doc.rs | 43 +++++++ crates/formats/src/model/types.rs | 14 ++- crates/formats/src/odt/body.rs | 134 +++++++++++++++++++-- crates/formats/src/odt/package.rs | 10 +- crates/formats/src/odt/tests.rs | 6 + crates/formats/src/odt/tests_more.rs | 1 + crates/formats/src/odt/tests_more/notes.rs | 122 +++++++++++++++++++ crates/formats/src/odt/writer.rs | 50 +++++++- crates/formats/src/rtf/export.rs | 2 +- docs/file-format-parity.md | 5 +- docs/gaps.md | 4 +- 16 files changed, 387 insertions(+), 24 deletions(-) create mode 100644 crates/formats/src/odt/tests_more/notes.rs diff --git a/ROADMAP.md b/ROADMAP.md index 7718cf19bc..c49b6524af 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #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 (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #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 (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -140,6 +140,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-31: ODT: footnotes and endnotes open and save (they were dropped on open), as Word-style note stories with their custom marks (gap 19). Readiness unchanged. - 2026-10-31: ODT: headers and footers are saved (they were dropped on every save), each page's header sits where LibreOffice puts it on open, and page numbers, page counts, dates and other text fields open as fields that update and are saved as ODF fields (gap 19). Readiness unchanged. - 2026-10-31: ODT: paragraph spacing, indents and line spacing are read and written, and an ODT's own default spacing (none, single lines) replaces Word's 8 pt after and 1.15 lines, so LibreOffice documents keep their vertical rhythm on open and save (gap 19). Readiness unchanged. - 2026-10-31: DOCX: a paragraph naming a style the file doesn't define is formatted as Normal, as in Word, not with the document defaults alone, so a Pandoc footer naming an undefined `Footer` style is as tall as Word's and no longer pushes the last paragraph to the next page (#5077). Readiness unchanged. diff --git a/crates/formats/src/html/export.rs b/crates/formats/src/html/export.rs index 84aae0a8fd..167042506b 100644 --- a/crates/formats/src/html/export.rs +++ b/crates/formats/src/html/export.rs @@ -137,6 +137,7 @@ pub fn inlines_html(inlines: &[Inline]) -> String { )); } Inline::Anchor(a) => o.push_str(&format!("", esc(a))), + Inline::Note { .. } => {} Inline::Field { result, fmt, .. } => { let (open, close) = fmt_tags(fmt); let at_start = o.is_empty(); diff --git a/crates/formats/src/latex/export.rs b/crates/formats/src/latex/export.rs index 77aa3a054c..80654f3321 100644 --- a/crates/formats/src/latex/export.rs +++ b/crates/formats/src/latex/export.rs @@ -240,7 +240,7 @@ impl Writer { o.push_str(&format!("\\textit{{[{}]}}", escape_text(&alt))); } Inline::Anchor(name) => o.push_str(&format!("\\hypertarget{{{}}}{{}}", escape_url(name))), - Inline::Figure(_) => {} + Inline::Figure(_) | Inline::Note { .. } => {} Inline::Field { result, fmt, .. } => { let s = self.span(result, fmt); push_apart(&mut o, &s); diff --git a/crates/formats/src/markdown/export.rs b/crates/formats/src/markdown/export.rs index 7dc8856081..70b6e56837 100644 --- a/crates/formats/src/markdown/export.rs +++ b/crates/formats/src/markdown/export.rs @@ -135,6 +135,7 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String { Inline::Figure(alt) => pieces.push((String::new(), Fmt::default(), Some(format!("![{}]()", escape(alt))))), Inline::Equation { .. } => {} Inline::Field { result, fmt, .. } => pieces.push((result.clone(), fmt.clone(), None)), + Inline::Note { .. } => {} // Read as its base text by `ruby_as_text` above. Inline::Ruby { .. } => {} } diff --git a/crates/formats/src/model/from_doc.rs b/crates/formats/src/model/from_doc.rs index b4abdac04b..344e32af33 100644 --- a/crates/formats/src/model/from_doc.rs +++ b/crates/formats/src/model/from_doc.rs @@ -251,6 +251,19 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec { // attributes (alignment, size, raise) stay in the document model. out.inlines.push(Inline::Ruby { base: base.clone(), ruby: ruby.clone(), fmt: f.clone() }); } + Some(InlineObject::NoteRef { kind, id, custom }) if opts.notes => { + if !buf.is_empty() { + out.push_text(&std::mem::take(&mut buf), &f); + } + // The note's content without its own mark (a note holds no notes). + let story = doc.parts.get(id).map(|part| &part.blocks); + let mut blocks = story.map(|b| flow_blocks(doc, b, 0, FlowOpts { notes: false, ..opts })).unwrap_or_default(); + if let Some(FBlock::Para(first)) = blocks.first_mut() { + first.trim_with(&[' ', '\t'], &[]); + } + let endnote = *kind == wordcraft_doc::para::NoteKind::Endnote; + out.inlines.push(Inline::Note { endnote, custom: custom.clone(), blocks }); + } Some(InlineObject::Field { instr, result, .. }) if opts.fields => { if !buf.is_empty() { out.push_text(&std::mem::take(&mut buf), &f); diff --git a/crates/formats/src/model/to_doc.rs b/crates/formats/src/model/to_doc.rs index c9fec5b902..257b1588aa 100644 --- a/crates/formats/src/model/to_doc.rs +++ b/crates/formats/src/model/to_doc.rs @@ -162,6 +162,43 @@ impl<'a> Builder<'a> { cp } + /// A note story holding `blocks`, as Word's: its paragraphs in the note text style, the first + /// starting with the note's mark and a space. The reference to put in the text, or `None` + /// when the document has no room for another story. + fn note(&mut self, endnote: bool, custom: &str, blocks: &[FBlock]) -> Option { + use wordcraft_doc::para::NoteKind; + let (kind, part, text, mark) = if endnote { + (NoteKind::Endnote, wordcraft_doc::PartKind::Endnote, "EndnoteText", "EndnoteReference") + } else { + (NoteKind::Footnote, wordcraft_doc::PartKind::Footnote, "FootnoteText", "FootnoteReference") + }; + let custom: String = clean_text(custom).chars().filter(|c| !c.is_control()).take(10).collect(); + let id = self.doc.try_add_part(part, Vec::new()).ok()?; + let mut story = self.blocks(blocks, 1); + if story.is_empty() { + story.push(para_block(Paragraph::new())); + } + for (i, b) in story.iter_mut().enumerate() { + if let Block::Para(p) = Arc::make_mut(b) { + if p.props.style.is_none() { + p.props.style = Some(text.into()); + } + if i == 0 { + let starts_blank = p.text.starts_with([' ', '\t']); + let reference = InlineObject::NoteRef { kind, id, custom: custom.clone() }; + if p.insert_object(0, reference, &CharProps { style: Some(mark.into()), ..Default::default() }).is_ok() && !starts_blank { + let at = p.text.len().min(wordcraft_doc::para::OBJ.len_utf8()); + let _ = p.insert_text(at, " ", &CharProps::default()); + } + } + } + } + if let Some(part) = self.doc.parts.get_mut(&id) { + part.blocks = story; + } + Some(InlineObject::NoteRef { kind, id, custom }) + } + fn para(&mut self, p: &Para, list_num: Option) -> Paragraph { let mut out = Paragraph::new(); out.props = para_props(p, list_num); @@ -218,6 +255,12 @@ impl<'a> Builder<'a> { append(&mut out, "", &CharProps::default(), Some(InlineObject::BookmarkEnd { name: name.clone() })); } Inline::Figure(_) => {} + Inline::Note { endnote, custom, blocks } => { + if let Some(obj) = self.note(*endnote, custom, blocks) { + let style = if *endnote { "EndnoteReference" } else { "FootnoteReference" }; + append(&mut out, "", &CharProps { style: Some(style.into()), ..Default::default() }, Some(obj)); + } + } Inline::Field { instr, result, fmt } => { let cp = self.budget(char_props(fmt)); let (instr, result) = (clean_text(instr), clean_text(result)); diff --git a/crates/formats/src/model/types.rs b/crates/formats/src/model/types.rs index 056fe0b942..9ab2428f64 100644 --- a/crates/formats/src/model/types.rs +++ b/crates/formats/src/model/types.rs @@ -173,6 +173,14 @@ pub enum Inline { result: String, fmt: Fmt, }, + /// A footnote or endnote: its custom mark (empty: numbered) and its content, without the + /// note's own mark. Only flows asked for notes ([`FlowOpts::notes`]) hold them; formats + /// without notes leave them out. + Note { + endnote: bool, + custom: String, + blocks: Vec, + }, } /// What [`super::from_doc_with`] keeps besides text and formatting. @@ -182,6 +190,8 @@ pub struct FlowOpts { pub tracked: bool, /// Fields as [`Inline::Field`] (otherwise their result is text). pub fields: bool, + /// Footnotes and endnotes as [`Inline::Note`] (otherwise left out). + pub notes: bool, } /// Base text size (points) a phonetic guide falls back to when the source states none: the @@ -290,7 +300,7 @@ impl Para { Inline::Text(t, _) | Inline::Equation { linear: t, .. } | Inline::Ruby { base: t, .. } | Inline::Field { result: t, .. } => { s.push_str(t) } - Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) => {} + Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) | Inline::Note { .. } => {} } } s @@ -306,7 +316,7 @@ impl Para { _ => s.push_str(t), }, Inline::Equation { linear: t, .. } | Inline::Ruby { base: t, .. } | Inline::Field { result: t, .. } => s.push_str(t), - Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) => {} + Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) | Inline::Note { .. } => {} } } s diff --git a/crates/formats/src/odt/body.rs b/crates/formats/src/odt/body.rs index 4d68945e06..9e34a1d33c 100644 --- a/crates/formats/src/odt/body.rs +++ b/crates/formats/src/odt/body.rs @@ -159,6 +159,29 @@ pub(super) struct Body<'a> { active_ins: Vec<(String, RevMark)>, /// The text field being read. field: Option, + /// The note being read. + note: Option, +} + +/// A footnote or endnote being read (ODF 1.3 §6.3): what it is, and the reader state of the +/// paragraph it sits in, put aside while its content is read into a container of its own. +struct OpenNote { + /// The `text:note` element's depth. + depth: usize, + endnote: bool, + /// The citation's `text:label` (a custom mark). + custom: String, + para: Option, + fmt: Vec, + xml_space_end: bool, + para_cont: bool, + lists: Vec<(Option, bool)>, + item_used: Vec, + /// Stack heights when the note began: what the note opens and leaves open goes with it. + containers: usize, + tables: usize, + cells: usize, + lists_saved: usize, } /// A text field being read ([`super::fields`]). @@ -211,6 +234,7 @@ impl<'a> Body<'a> { del: None, active_ins: Vec::new(), field: None, + note: None, } } @@ -523,7 +547,7 @@ impl Body<'_> { if self.tracked_context(q, a) { return; } - if self.start_inline(q, a) { + if self.start_inline(q, a) || self.start_note_part(q, a) { return; } match local(q) { @@ -885,6 +909,98 @@ impl Body<'_> { true } + /// A note or its citation starting: true when handled. A note inside a note (allowed by the + /// schema, never written by Word or LibreOffice), or one in deleted text or a frame, is left + /// out with its content. + fn start_note_part(&mut self, q: &str, a: &Attrs) -> bool { + if !q.starts_with("text:") { + return false; + } + match local(q) { + "note" if self.note.is_none() && self.del.is_none() && !self.in_tc && self.frame.is_none() => self.start_note(a), + // The citation's text is LibreOffice's number: only a custom mark is kept. + "note-citation" => { + if let Some(n) = &mut self.note { + n.custom = get(a, "text:label").map(|l| l.chars().filter(|c| !c.is_control()).take(10).collect()).unwrap_or_default(); + } + self.skip = 1; + } + _ => return false, + } + true + } + + /// Begin reading a note: put the paragraph it is in aside and read its content into a + /// container of its own, outside any list. + fn start_note(&mut self, a: &Attrs) { + let note = OpenNote { + depth: self.depth, + endnote: get(a, "text:note-class").is_some_and(|c| c.trim() == "endnote"), + custom: String::new(), + para: self.para.take(), + fmt: std::mem::take(&mut self.fmt), + xml_space_end: std::mem::take(&mut self.xml_space_end), + para_cont: std::mem::take(&mut self.para_cont), + lists: std::mem::take(&mut self.lists), + item_used: std::mem::take(&mut self.item_used), + containers: self.containers.len(), + tables: self.tables.len(), + cells: self.cells.len(), + lists_saved: self.lists_saved.len(), + }; + self.containers.push(Vec::new()); + self.note = Some(note); + } + + /// End the note being read: its content becomes an [`Inline::Note`] in the paragraph put + /// aside, which goes on. Tables and cells the note left open (malformed) are dropped. + fn end_note(&mut self) { + self.end_para(); + let Some(n) = self.note.take() else { return }; + self.tables.truncate(n.tables); + self.cells.truncate(n.cells); + self.lists_saved.truncate(n.lists_saved); + self.containers.truncate(n.containers + 1); + let blocks = if self.containers.len() > n.containers { self.containers.pop().unwrap_or_default() } else { Vec::new() }; + self.para = n.para; + self.fmt = n.fmt; + self.xml_space_end = n.xml_space_end; + self.para_cont = n.para_cont; + self.lists = n.lists; + self.item_used = n.item_used; + if self.para.is_none() { + self.start_para(None, None); + } + if let Some(p) = &mut self.para { + p.inlines.push(Inline::Note { endnote: n.endnote, custom: n.custom, blocks }); + } + } + + /// An element ending at the current depth, for an open note or field: true when that was all + /// there was to it. + fn end_note_or_field(&mut self, q: &str) -> bool { + if self.note.as_ref().is_some_and(|n| self.depth < n.depth) { + // The note's own end, or (malformed) an outer element ending first: the note ends, + // then that element. + self.end_field(); + self.end_note(); + if local(q) == "note" { + return true; + } + } + // Everything inside a field was its text: only the field's own end does anything. An + // outer element ending first (malformed) ends the field, then itself. + if let Some(f) = &self.field { + if self.depth >= f.depth { + return true; + } + let own = f.name == q; + self.end_field(); + return own; + } + false + } + /// Put the field read so far into the paragraph. fn end_field(&mut self) { let Some(OpenField { instr, result, mut fmt, .. }) = self.field.take() else { return }; @@ -900,20 +1016,14 @@ impl Body<'_> { fn end(&mut self, q: &str) { self.depth = self.depth.saturating_sub(1); - // Everything inside a field was its text: only the field's own end does anything. An - // outer element ending first (malformed) ends the field, then itself. - if let Some(f) = &self.field { - if self.depth >= f.depth { - return; - } - let own = f.name == q; - self.end_field(); - if own { - return; - } + if self.end_note_or_field(q) { + return; } if q == "office:text" { self.end_field(); + if self.note.is_some() { + self.end_note(); + } if self.del.is_some() { // Unclosed deleted content (hostile): drop it. self.close_deletion(); diff --git a/crates/formats/src/odt/package.rs b/crates/formats/src/odt/package.rs index 1d57fe6518..e06848a4e5 100644 --- a/crates/formats/src/odt/package.rs +++ b/crates/formats/src/odt/package.rs @@ -38,7 +38,7 @@ fn page_parts(doc: &Document, sec: &SectionProps, w: &mut Writer) -> PageParts { } let mut body = String::new(); if let Some(part) = id.and_then(|i| doc.parts.get(&i)) { - let blocks = model::part_flow(doc, &part.blocks, model::FlowOpts { tracked: false, fields: true }); + let blocks = model::part_flow(doc, &part.blocks, model::FlowOpts { fields: true, ..Default::default() }); w.blocks(&blocks, &mut body, 0); } if body.is_empty() { @@ -84,6 +84,12 @@ fn styles_xml( s.push_str(""); s.push_str(""); s.push_str(&format!("", model::MONO_FONT)); + // The note text styles, as WordCraft's Footnote Text and Endnote Text: 10 pt, no spacing. + for (name, class) in [("Footnote", "extra"), ("Endnote", "extra")] { + s.push_str(&format!( + "" + )); + } s.push_str(""); s.push_str(part_styles); let none = PageParts::default(); @@ -181,7 +187,7 @@ fn flow_sections(blocks: &[FBlock], last: &SectionProps) -> Vec<(SectionProps, s /// Write an `.odt` package. pub fn export(doc: &Document) -> Result, String> { - let flow = model::from_doc_with(doc, model::FlowOpts { tracked: true, fields: true }); + let flow = model::from_doc_with(doc, model::FlowOpts { tracked: true, fields: true, notes: true }); let mut w = Writer::default(); let mut body = String::new(); let sections = flow_sections(&flow.blocks, &doc.last_section); diff --git a/crates/formats/src/odt/tests.rs b/crates/formats/src/odt/tests.rs index 729334beab..601a1fd846 100644 --- a/crates/formats/src/odt/tests.rs +++ b/crates/formats/src/odt/tests.rs @@ -1135,3 +1135,9 @@ fn meta_creation_date_timezones_and_fallbacks() { assert_eq!(m2.created, "2024-03-05T13:45:00Z"); assert_eq!(m2.author, "Solo Creator"); } + +/// The `content.xml` of a package. +pub(super) fn content_of(bytes: &[u8]) -> String { + let mut z = archive(bytes); + String::from_utf8(read_entry(&mut z, "content.xml").unwrap()).unwrap() +} diff --git a/crates/formats/src/odt/tests_more.rs b/crates/formats/src/odt/tests_more.rs index 9becd96901..1272873822 100644 --- a/crates/formats/src/odt/tests_more.rs +++ b/crates/formats/src/odt/tests_more.rs @@ -2,4 +2,5 @@ mod fields; mod headers; +mod notes; mod spacing; diff --git a/crates/formats/src/odt/tests_more/notes.rs b/crates/formats/src/odt/tests_more/notes.rs new file mode 100644 index 0000000000..60302584f1 --- /dev/null +++ b/crates/formats/src/odt/tests_more/notes.rs @@ -0,0 +1,122 @@ +//! Footnotes and endnotes (ODF 1.3 §6.3 `text:note`): read into note stories, written back. + +use crate::odt::tests::{NS_T, package}; +use crate::odt::{export, import}; +use wordcraft_doc::para::{InlineObject, NoteKind, OBJ}; +use wordcraft_doc::{Document, PartKind}; + +fn body(xml: &str) -> Vec { + package( + &format!(r#"{xml}"#), + None, + ) +} + +/// Every note referenced from the body, in order: (kind, custom mark, the note's text). +fn notes(d: &Document) -> Vec<(NoteKind, String, String)> { + let mut out = Vec::new(); + for b in d.body.iter() { + let Some(p) = b.as_para() else { continue }; + for o in &p.objects { + if let InlineObject::NoteRef { kind, id, custom } = o { + let part = d.parts.get(id).expect("the note's story"); + assert_eq!(part.kind, if *kind == NoteKind::Footnote { PartKind::Footnote } else { PartKind::Endnote }); + let text = part.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.replace(OBJ, "")).collect::>().join("|"); + out.push((*kind, custom.clone(), text.trim().to_string())); + } + } + } + out +} + +const NOTES: &str = concat!( + r#"Text1The footnote body."#, + r#" andiEnd one.End two."#, + r#" a mark*Starred.."#, + "After." +); + +#[test] +fn notes_open_as_note_stories() { + let d = import(&body(NOTES)).unwrap(); + assert_eq!( + notes(&d), + [ + (NoteKind::Footnote, String::new(), "The footnote body.".to_string()), + (NoteKind::Endnote, String::new(), "End one.|End two.".to_string()), + (NoteKind::Footnote, "*".to_string(), "Starred.".to_string()), + ] + ); + // The citation numbers are not text; the paragraph goes on after each note. + let p = d.body[0].as_para().unwrap(); + assert_eq!(p.text.replace(OBJ, "#"), "Text# and# a mark#."); + assert_eq!(d.body[1].as_para().unwrap().text, "After."); + // As Word's: the reference is in the reference style, the note's text in the note style and + // starts with its own mark. + let first = d.parts.values().find(|p| p.kind == PartKind::Footnote).unwrap(); + let np = first.blocks[0].as_para().unwrap(); + assert_eq!(np.props.style.as_deref(), Some("FootnoteText")); + assert!(matches!(np.objects.first(), Some(InlineObject::NoteRef { .. }))); + assert_eq!(p.runs.iter().filter(|r| r.props.style.as_deref() == Some("FootnoteReference")).count(), 2); +} + +#[test] +fn notes_round_trip() { + let d = import(&body(NOTES)).unwrap(); + let back = import(&export(&d).unwrap()).unwrap(); + assert_eq!(notes(&back), notes(&d)); + assert_eq!(back.body[0].as_para().unwrap().text, d.body[0].as_para().unwrap().text); + // Saving again changes nothing more. + assert_eq!(notes(&import(&export(&back).unwrap()).unwrap()), notes(&d)); +} + +#[test] +fn a_note_keeps_formatting_and_the_text_around_it() { + let content = format!( + r#"bold1in note still bold"# + ); + let d = import(&package(&content, None)).unwrap(); + let p = d.body[0].as_para().unwrap(); + assert!(p.props.numbering.is_some(), "the list survives the note"); + let tail = p.runs.last().unwrap(); + assert_eq!(tail.props.bold, Some(true)); + assert_eq!(p.text.replace(OBJ, "#"), "bold# still bold"); + let note = d.parts.values().find(|p| p.kind == PartKind::Footnote).unwrap().blocks[0].as_para().unwrap().clone(); + assert!(note.runs.iter().any(|r| r.props.bold == Some(true))); + assert!(note.props.numbering.is_none(), "the body's list doesn't reach into the note"); +} + +#[test] +fn hostile_notes_do_not_panic() { + let deep = "".repeat(200); + for xml in [ + "a".to_string(), + "ab".to_string(), + "xy".to_string(), + format!("{deep}"), + "outside a paragraph".to_string(), + "x".to_string(), + ] { + let d = import(&body(&xml)).unwrap(); + let _ = export(&d).unwrap(); + } +} + +#[test] +fn a_note_inside_a_table_cell_is_kept() { + let xml = r#"cellicell note"#; + let d = import(&body(xml)).unwrap(); + let back = import(&export(&d).unwrap()).unwrap(); + let ends: Vec<_> = back.parts.values().filter(|p| p.kind == PartKind::Endnote).collect(); + assert_eq!(ends.len(), 1); + assert_eq!(ends[0].blocks[0].as_para().unwrap().text.replace(OBJ, "").trim(), "cell note"); +} + +#[test] +fn citations_number_footnotes_and_endnotes_apart() { + let d = import(&body(NOTES)).unwrap(); + let xml = crate::odt::tests::content_of(&export(&d).unwrap()); + let citations: Vec<&str> = + xml.split("')).map(|(_, rest)| rest.split('<').next().unwrap_or("")).collect(); + assert_eq!(citations, ["1", "i", "*"]); +} diff --git a/crates/formats/src/odt/writer.rs b/crates/formats/src/odt/writer.rs index aad25abd68..89bb20a3a0 100644 --- a/crates/formats/src/odt/writer.rs +++ b/crates/formats/src/odt/writer.rs @@ -45,6 +45,12 @@ type SecStart = (String, Option); #[derive(Default)] pub(super) struct Writer { + /// Notes written so far (their ids), and footnotes and endnotes among them (their numbers). + notes: usize, + footnotes: u32, + endnotes: u32, + /// Paragraph style that plain paragraphs get: the note style inside a note. + note_parent: Option<&'static str>, /// Prefix of every name it makes (styles, lists, tables, pictures): headers and footers are /// written by a second writer whose styles go in `styles.xml`, apart from the body's. pub(super) prefix: &'static str, @@ -184,7 +190,10 @@ impl Writer { /// The paragraph style of `p`; `start` makes it begin a page of that master page (ODF 1.3 /// §19.471 `style:master-page-name`), restarting page numbering where asked (§20.322). fn para_style_at(&mut self, p: &Para, start: Option<&SecStart>) -> String { - let parent = parent_style(p.kind).to_string(); + let parent = match (p.kind, self.note_parent) { + (Kind::Normal, Some(n)) => n.to_string(), + _ => parent_style(p.kind).to_string(), + }; let mut props = String::new(); match p.align { Some(Align::Center) => props.push_str(" fo:text-align=\"center\""), @@ -422,6 +431,10 @@ impl Writer { prev_space = false; } Inline::Anchor(a) => out.push_str(&format!("", x(a))), + Inline::Note { endnote, custom, blocks } => { + self.note(*endnote, custom, blocks, out); + prev_space = false; + } Inline::Field { instr, result, fmt } => match super::fields::element(instr, &x(result)) { Some(el) => { match self.text_style(&Fmt { link: None, ..fmt.clone() }) { @@ -443,6 +456,41 @@ impl Writer { } } + /// A footnote or endnote (ODF 1.3 §6.3): its citation (the number LibreOffice shows until it + /// renumbers, or the custom mark) and its content. The paragraph being written is put aside + /// meanwhile, and an insertion open around the note ends before it and goes on after it. + fn note(&mut self, endnote: bool, custom: &str, blocks: &[FBlock], out: &mut String) { + let reopen = self.open_ins.as_ref().map(|(m, _)| m.clone()); + self.close_ins(out); + self.notes += 1; + let id = format!("{}ftn{}", self.prefix, self.notes); + let (class, parent) = if endnote { ("endnote", "Endnote") } else { ("footnote", "Footnote") }; + let saved = (std::mem::take(&mut self.cur_style), std::mem::take(&mut self.chain), self.part, self.note_parent.replace(parent)); + let mut body = String::new(); + self.blocks(blocks, &mut body, 1); + self.close_ins(&mut body); + (self.cur_style, self.chain, self.part, self.note_parent) = saved; + if body.is_empty() { + body.push_str(&format!("")); + } + // The number LibreOffice shows by default (footnotes 1, 2, 3; endnotes i, ii, iii) until it + // renumbers them itself. + let number = if endnote { + self.endnotes = self.endnotes.saturating_add(1); + wordcraft_doc::section::roman(self.endnotes) + } else { + self.footnotes = self.footnotes.saturating_add(1); + self.footnotes.to_string() + }; + let (label, citation) = if custom.is_empty() { (String::new(), number) } else { (format!(" text:label=\"{}\"", x(custom)), x(custom)) }; + out.push_str(&format!( + "{citation}{body}" + )); + if let Some(m) = reopen { + self.open_ins(&m, out); + } + } + /// Paragraph `p`, or with `chain` the paragraphs joined at deleted paragraph marks (`p` /// is the first). fn para_in(&mut self, p: &Para, chain: Option<&Vec>, out: &mut String) { diff --git a/crates/formats/src/rtf/export.rs b/crates/formats/src/rtf/export.rs index 2209857a55..9256ffc653 100644 --- a/crates/formats/src/rtf/export.rs +++ b/crates/formats/src/rtf/export.rs @@ -400,7 +400,7 @@ impl Writer { // RTF keeps no ruby yet: its base text, in the run's formatting. Inline::Ruby { base, fmt, .. } => self.run(base, fmt, out), Inline::Field { result, fmt, .. } => self.run(result, fmt, out), - Inline::Figure(_) | Inline::Equation { .. } => {} + Inline::Figure(_) | Inline::Equation { .. } | Inline::Note { .. } => {} } } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 90c927a5f3..68abb860ec 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); 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-10-31 · **Change:** 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 (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 Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -57,7 +57,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | 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; Windows and Enhanced Metafiles, `\wmetafile` and `\emfblip`, read and written, #4259), 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, Wayland) 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). Thin: comments, notes, RTL | -| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/it/odt_sections.rs` | Styles, lists (#87), tables, pictures, page breaks (#86), paragraph spacing, indents and line spacing (proportional, fixed, at least; leading read as a proportional approximation) both ways, with the default and Standard styles' spacing as the document defaults (ODT's are none, not Word's 8 pt after and 1.15 lines), headers and footers of each master page (every, left and first page) read on open and written on save, with the header and footer distances converted between Word's page-edge measures and ODF's header bands (#863), text fields (page number and count, date, time, title, subject, author, file name, word and character counts, bookmark references as `REF`/`PAGEREF`) read as fields and written back as ODF fields; fixed dates and fields without a Word equivalent (sequences, chapters, variables) read as their text, and Word fields ODF lacks written as their result, date formats not kept, keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099; export still drops equations, as before), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments or RTL | +| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/it/odt_sections.rs` | Styles, lists (#87), tables, pictures, page breaks (#86), paragraph spacing, indents and line spacing (proportional, fixed, at least; leading read as a proportional approximation) both ways, with the default and Standard styles' spacing as the document defaults (ODT's are none, not Word's 8 pt after and 1.15 lines), headers and footers of each master page (every, left and first page) read on open and written on save, with the header and footer distances converted between Word's page-edge measures and ODF's header bands (#863), text fields (page number and count, date, time, title, subject, author, file name, word and character counts, bookmark references as `REF`/`PAGEREF`) read as fields and written back as ODF fields; fixed dates and fields without a Word equivalent (sequences, chapters, variables) read as their text, and Word fields ODF lacks written as their result, date formats not kept, keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099; export still drops equations, as before), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). footnotes and endnotes (`text:note`) read into note stories as Word's (Footnote Text style, the note's own mark first, a custom mark kept) and written back with their citations; notes inside notes and in deleted text left out. No comments or RTL | | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (``, ``) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As | | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.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). 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). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). 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 | ❌ | ❌ | 0% | 3–5 | — | | @@ -279,6 +279,7 @@ Custom document properties retain their bookmark link targets through DOCX saves | Date | Change | Summary | |---|---|---| +| 2026-10-31 | trivial | ODT: footnotes and endnotes (`text:note`, ODF 1.3 §6.3) open as note stories built as Word's (Footnote Text or Endnote Text paragraphs starting with the note's mark, the reference in the reference style, a `text:label` custom mark kept) and are written back with `text:note-citation`s numbered as LibreOffice shows them (footnotes 1, 2, 3; endnotes i, ii, iii); before, notes were dropped on open. `formats::odt::tests_more::notes` | | 2026-10-31 | trivial | ODT: headers and footers are written to each section's master page (every, left and first page), with Word's header and footer distances converted to ODF's header and footer bands and back on open; text fields (`text:page-number`, `text:page-count`, `text:date`, `text:time`, document information, `text:bookmark-ref`) open as WordCraft fields and are written back as ODF fields. `formats::odt::tests_more::{headers, fields}` | | 2026-10-31 | trivial | ODT: paragraph spacing, indents and line spacing (`fo:margin-*`, `fo:text-indent`, `fo:line-height`, `style:line-height-at-least`; `style:line-spacing` leading as a proportional approximation) read from automatic and common styles and written back; the default and Standard paragraph styles give the document defaults, so an ODT paragraph without spacing has none instead of Word's 8 pt after and 1.15 lines. `formats::odt::tests_more::spacing` | | 2026-10-31 | trivial | DOCX: a paragraph naming a style the file doesn't define is formatted as Normal, as in Word (#5077) | diff --git a/docs/gaps.md b/docs/gaps.md index c545cc8da9..6cce18241d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (gap 19 narrowed: ODT headers and footers written, text fields and paragraph spacing read and written); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #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 (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (gap 19 narrowed: ODT footnotes and endnotes read and written); previously trivial (gap 19 narrowed: ODT headers and footers written, text fields and paragraph spacing read and written); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #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 (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; 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: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 @@ -81,7 +81,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189; Word's own `Sources.xml` master list imports into Manage Sources, #2640) | | #790 stores sources in `customXml` and reads them back; `crates/zotero`; `references.sources` `import` reads a Word bibliography file (`b:Sources`) | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) | | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | | 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | -| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; RTF section/table and ODT formatting revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s; ODT table column widths are read (they were dropped on every open). ODT export writes sections back (a master page per new-page section, a columned `text:section` per continuous one) and their headers and footers; ODT paragraph spacing and text fields read and written; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259) | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | +| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; RTF section/table and ODT formatting revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s; ODT table column widths are read (they were dropped on every open). ODT export writes sections back (a master page per new-page section, a columned `text:section` per continuous one) and their headers and footers; ODT paragraph spacing, text fields, footnotes and endnotes read and written; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259) | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | | 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | DOCX comment saves avoid repeated paragraph-ID and durable-ID reservation searches; large threaded-comment fixtures retain metadata through save/reopen. Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds; opening a document with pictures no longer copies them (92 MB with 20 pictures: about 90 MB less); opening a document with many relationships now costs the size of its part and not that size times the number of relationships (1024 relationships and a 64 MiB part: 224 s → 0.2 s, #3411); a word with no break opportunity in it no longer lays out quadratically (1 MiB: 29.4 s → 0.35 s, #3412); saving a document with many content controls now costs the time the controls take (32,000 controls: 3.6 s → 0.1 s, #13) | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | | 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778), finds records and can show only the merged ones (#1576) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | From 084d26a3c85fc18602f7d3b3de4d43dd8c0d17b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:48:09 +0000 Subject: [PATCH 005/182] Gap 19: ODT comments read and written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `office:annotation` (ODF 1.3 §14.1) opens as a document comment with its author, date, initials and text; `office:name` and `office:annotation-end` (§14.2) give its range, across paragraphs too. LibreOffice's resolved state (`loext:resolved`) and reply threads (`loext:parent-name`) are kept: a reply is a comment of its parent with no range of its own. An end with no comment is dropped, and a comment whose end never comes covers nothing. LibreOffice's all-zero date is no date. Before, comments were dropped on open. Export writes each comment named, with its end where its range ends, and its replies after it naming it; LibreOffice keeps the threads when it saves the file again. Truncated XML no longer loses the paragraph a note or comment sits in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC --- ROADMAP.md | 3 +- crates/formats/src/html/export.rs | 2 +- crates/formats/src/latex/export.rs | 2 +- crates/formats/src/markdown/export.rs | 2 +- crates/formats/src/model.rs | 5 +- crates/formats/src/model/from_doc.rs | 75 +++++-- crates/formats/src/model/to_doc.rs | 46 +++- crates/formats/src/model/types.rs | 50 ++++- crates/formats/src/odt.rs | 2 +- crates/formats/src/odt/body.rs | 198 ++++++++++++++++-- crates/formats/src/odt/limits.rs | 3 + crates/formats/src/odt/package.rs | 2 +- crates/formats/src/odt/tests_more.rs | 1 + crates/formats/src/odt/tests_more/comments.rs | 170 +++++++++++++++ crates/formats/src/odt/tests_more/notes.rs | 9 + crates/formats/src/odt/writer.rs | 46 ++++ crates/formats/src/rtf/export.rs | 2 +- docs/file-format-parity.md | 5 +- docs/gaps.md | 4 +- 19 files changed, 574 insertions(+), 53 deletions(-) create mode 100644 crates/formats/src/odt/tests_more/comments.rs diff --git a/ROADMAP.md b/ROADMAP.md index c49b6524af..38ebb2080a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #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 (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #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 (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -140,6 +140,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-31: ODT: comments open and save with their ranges, replies and resolved state, as LibreOffice keeps them (gap 19; they were dropped on open). Readiness unchanged. - 2026-10-31: ODT: footnotes and endnotes open and save (they were dropped on open), as Word-style note stories with their custom marks (gap 19). Readiness unchanged. - 2026-10-31: ODT: headers and footers are saved (they were dropped on every save), each page's header sits where LibreOffice puts it on open, and page numbers, page counts, dates and other text fields open as fields that update and are saved as ODF fields (gap 19). Readiness unchanged. - 2026-10-31: ODT: paragraph spacing, indents and line spacing are read and written, and an ODT's own default spacing (none, single lines) replaces Word's 8 pt after and 1.15 lines, so LibreOffice documents keep their vertical rhythm on open and save (gap 19). Readiness unchanged. diff --git a/crates/formats/src/html/export.rs b/crates/formats/src/html/export.rs index 167042506b..a913b5aaa3 100644 --- a/crates/formats/src/html/export.rs +++ b/crates/formats/src/html/export.rs @@ -137,7 +137,7 @@ pub fn inlines_html(inlines: &[Inline]) -> String { )); } Inline::Anchor(a) => o.push_str(&format!("", esc(a))), - Inline::Note { .. } => {} + Inline::Note { .. } | Inline::Comment { .. } | Inline::CommentEnd(_) => {} Inline::Field { result, fmt, .. } => { let (open, close) = fmt_tags(fmt); let at_start = o.is_empty(); diff --git a/crates/formats/src/latex/export.rs b/crates/formats/src/latex/export.rs index 80654f3321..20dd9ab038 100644 --- a/crates/formats/src/latex/export.rs +++ b/crates/formats/src/latex/export.rs @@ -240,7 +240,7 @@ impl Writer { o.push_str(&format!("\\textit{{[{}]}}", escape_text(&alt))); } Inline::Anchor(name) => o.push_str(&format!("\\hypertarget{{{}}}{{}}", escape_url(name))), - Inline::Figure(_) | Inline::Note { .. } => {} + Inline::Figure(_) | Inline::Note { .. } | Inline::Comment { .. } | Inline::CommentEnd(_) => {} Inline::Field { result, fmt, .. } => { let s = self.span(result, fmt); push_apart(&mut o, &s); diff --git a/crates/formats/src/markdown/export.rs b/crates/formats/src/markdown/export.rs index 70b6e56837..21951598b3 100644 --- a/crates/formats/src/markdown/export.rs +++ b/crates/formats/src/markdown/export.rs @@ -135,7 +135,7 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String { Inline::Figure(alt) => pieces.push((String::new(), Fmt::default(), Some(format!("![{}]()", escape(alt))))), Inline::Equation { .. } => {} Inline::Field { result, fmt, .. } => pieces.push((result.clone(), fmt.clone(), None)), - Inline::Note { .. } => {} + Inline::Note { .. } | Inline::Comment { .. } | Inline::CommentEnd(_) => {} // Read as its base text by `ruby_as_text` above. Inline::Ruby { .. } => {} } diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index 39e00cccc9..6af8b9a4a1 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -24,8 +24,9 @@ pub use from_doc::{ListCounter, flow_paras, from_doc, from_doc_tracked, from_doc pub use media::{base64_decode, base64_encode, data_uri, decode_bounded, image_px, make_img, mime_of, percent_decode, sized_img, sniff_image}; pub use to_doc::{char_props, part_blocks, to_doc, to_doc_in}; pub use types::{ - CODE_STYLE, Cell, FBlock, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind, ListInfo, MAX_DEPTH, MAX_FONT_NAME, MAX_LINK, MONO_FONT, - Meta, Para, RUBY_BASE_SIZE, RevMark, Spacing, clean_text, equations_as_text, kept_font, kept_link, ruby_as_text, ruby_object, + CODE_STYLE, Cell, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind, ListInfo, MAX_DEPTH, MAX_FONT_NAME, MAX_LINK, + MONO_FONT, Meta, Para, RUBY_BASE_SIZE, RevMark, Spacing, clean_text, equations_as_text, flatten_tables, kept_font, kept_link, ruby_as_text, + ruby_object, }; pub(crate) use types::{Dropped, begin_import, dropped}; diff --git a/crates/formats/src/model/from_doc.rs b/crates/formats/src/model/from_doc.rs index 344e32af33..ef90a5bd4e 100644 --- a/crates/formats/src/model/from_doc.rs +++ b/crates/formats/src/model/from_doc.rs @@ -1,4 +1,6 @@ -use super::types::{Cell, FBlock, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind, ListInfo, MAX_DEPTH, Meta, Para, RevMark, Spacing}; +use super::types::{ + Cell, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind, ListInfo, MAX_DEPTH, Meta, Para, RevMark, Spacing, +}; use super::{ Arc, Block, Blocks, CharProps, Document, InlineObject, NumFormat, OBJ, PAGE_BREAK, ParaProps, Paragraph, StyleKind, Table, TextColor, Underline, VMerge, VertAlign, @@ -123,6 +125,51 @@ fn media_ext(key: &str, data: &[u8]) -> String { } } +/// The flow inlines for a note reference, comment mark or field `o` of `doc`, when `opts` asks +/// for them; `None` for other objects. `f` is the formatting of the run `o` is in. +fn rich_inlines(doc: &Document, o: &InlineObject, f: &Fmt, opts: FlowOpts) -> Option> { + Some(match o { + InlineObject::NoteRef { kind, id, custom } if opts.notes => { + // The note's content without its own mark (a note holds no notes). + let story = doc.parts.get(id).map(|part| &part.blocks); + let mut blocks = story.map(|b| flow_blocks(doc, b, 0, FlowOpts { notes: false, ..opts })).unwrap_or_default(); + if let Some(FBlock::Para(first)) = blocks.first_mut() { + first.trim_with(&[' ', '\t'], &[]); + } + let endnote = *kind == wordcraft_doc::para::NoteKind::Endnote; + vec![Inline::Note { endnote, custom: custom.clone(), blocks }] + } + // The comment, then its replies, which have no place of their own. + InlineObject::CommentStart { id } if opts.comments => { + let Some(c) = doc.comments.get(id) else { return Some(Vec::new()) }; + let mut out = vec![Inline::Comment { id: *id, comment: Box::new(flow_comment(doc, c, None, opts)) }]; + for (rid, r) in doc.comments.iter().filter(|(_, r)| r.parent == Some(*id)) { + out.push(Inline::Comment { id: *rid, comment: Box::new(flow_comment(doc, r, Some(*id), opts)) }); + } + out + } + InlineObject::CommentEnd { id } if opts.comments => { + if doc.comments.contains_key(id) { + vec![Inline::CommentEnd(*id)] + } else { + Vec::new() + } + } + InlineObject::Field { instr, result, .. } if opts.fields => { + vec![Inline::Field { instr: instr.clone(), result: result.clone(), fmt: f.clone() }] + } + _ => return None, + }) +} + +/// Comment `c` (a reply to `parent`) of `doc` as a flow comment: its text holds no comments or +/// notes of its own. +fn flow_comment(doc: &Document, c: &wordcraft_doc::Comment, parent: Option, opts: FlowOpts) -> FComment { + let story = doc.parts.get(&c.part).map(|p| &p.blocks); + let blocks = story.map(|b| flow_blocks(doc, b, 0, FlowOpts { notes: false, comments: false, ..opts })).unwrap_or_default(); + FComment { author: c.author.clone(), initials: c.initials.clone(), date: c.date.clone(), resolved: c.resolved, parent, blocks } +} + /// The spacing and indents a paragraph sets itself (its style's are not included). fn direct_spacing(p: &wordcraft_doc::props::ParaProps) -> Spacing { Spacing { before: p.space_before, after: p.space_after, line: p.line_spacing, left: p.indent_left, right: p.indent_right, first: p.indent_first } @@ -206,6 +253,13 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec { if hidden || obj.and_then(InlineObject::frame).is_some_and(|(_, _, f)| f.hidden) { continue; } + if let Some(items) = obj.and_then(|o| rich_inlines(doc, o, &f, opts)) { + if !buf.is_empty() { + out.push_text(&std::mem::take(&mut buf), &f); + } + out.inlines.extend(items); + continue; + } match obj { Some(o @ InlineObject::Image { media, w, h, alt, .. }) => { if !buf.is_empty() { @@ -251,25 +305,6 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec { // attributes (alignment, size, raise) stay in the document model. out.inlines.push(Inline::Ruby { base: base.clone(), ruby: ruby.clone(), fmt: f.clone() }); } - Some(InlineObject::NoteRef { kind, id, custom }) if opts.notes => { - if !buf.is_empty() { - out.push_text(&std::mem::take(&mut buf), &f); - } - // The note's content without its own mark (a note holds no notes). - let story = doc.parts.get(id).map(|part| &part.blocks); - let mut blocks = story.map(|b| flow_blocks(doc, b, 0, FlowOpts { notes: false, ..opts })).unwrap_or_default(); - if let Some(FBlock::Para(first)) = blocks.first_mut() { - first.trim_with(&[' ', '\t'], &[]); - } - let endnote = *kind == wordcraft_doc::para::NoteKind::Endnote; - out.inlines.push(Inline::Note { endnote, custom: custom.clone(), blocks }); - } - Some(InlineObject::Field { instr, result, .. }) if opts.fields => { - if !buf.is_empty() { - out.push_text(&std::mem::take(&mut buf), &f); - } - out.inlines.push(Inline::Field { instr: instr.clone(), result: result.clone(), fmt: f.clone() }); - } Some(InlineObject::BookmarkStart { name }) if name != "_GoBack" => { if !buf.is_empty() { out.push_text(&std::mem::take(&mut buf), &f); diff --git a/crates/formats/src/model/to_doc.rs b/crates/formats/src/model/to_doc.rs index 257b1588aa..7ccf634376 100644 --- a/crates/formats/src/model/to_doc.rs +++ b/crates/formats/src/model/to_doc.rs @@ -112,6 +112,8 @@ struct Builder<'a> { revs: std::collections::HashMap<(u8, RevMark), u32>, /// Link and font bytes given to runs so far ([`MAX_RUN_STRINGS`]). run_strings: usize, + /// The document's comment for each flow comment id. + comments: std::collections::HashMap, } fn rev_key(kind: RevisionKind) -> u8 { @@ -131,7 +133,7 @@ impl<'a> Builder<'a> { revs.entry((rev_key(r.kind), mark)).or_insert(i as u32); } } - Builder { doc, revs, run_strings: 0 } + Builder { doc, revs, run_strings: 0, comments: Default::default() } } /// The document revision for a tracked change, added the first time it is seen. @@ -162,6 +164,35 @@ impl<'a> Builder<'a> { cp } + /// Comment `c` (flow id `id`) added to the document with its text, as a reply when its parent + /// was added before it; its document id, or `None` when the document has no room for it or + /// the flow already used `id`. + fn comment(&mut self, id: u32, c: &super::types::FComment) -> Option { + if self.comments.contains_key(&id) { + return None; + } + let blocks = self.blocks(&c.blocks, 1); + let part = self.doc.try_add_part(wordcraft_doc::PartKind::Comment, blocks).ok()?; + let ids = &self.doc.comments; + let Some(doc_id) = wordcraft_doc::free_id(ids.keys().next_back().copied(), ids.keys().copied(), 0) else { + self.doc.parts.remove(&part); + return None; + }; + let parent = c.parent.and_then(|p| self.comments.get(&p).copied()); + let comment = wordcraft_doc::Comment { + author: clean_text(&c.author), + initials: clean_text(&c.initials), + date: clean_text(&c.date), + parent, + resolved: c.resolved, + part, + ..Default::default() + }; + self.doc.comments.insert(doc_id, comment); + self.comments.insert(id, doc_id); + Some(doc_id) + } + /// A note story holding `blocks`, as Word's: its paragraphs in the note text style, the first /// starting with the note's mark and a space. The reference to put in the text, or `None` /// when the document has no room for another story. @@ -255,6 +286,19 @@ impl<'a> Builder<'a> { append(&mut out, "", &CharProps::default(), Some(InlineObject::BookmarkEnd { name: name.clone() })); } Inline::Figure(_) => {} + Inline::Comment { id, comment } => { + // A reply is the comment's, not the text's: it has no range. + if let Some(doc_id) = self.comment(*id, comment) + && comment.parent.is_none() + { + append(&mut out, "", &CharProps::default(), Some(InlineObject::CommentStart { id: doc_id })); + } + } + Inline::CommentEnd(id) => { + if let Some(doc_id) = self.comments.get(id) { + append(&mut out, "", &CharProps::default(), Some(InlineObject::CommentEnd { id: *doc_id })); + } + } Inline::Note { endnote, custom, blocks } => { if let Some(obj) = self.note(*endnote, custom, blocks) { let style = if *endnote { "EndnoteReference" } else { "FootnoteReference" }; diff --git a/crates/formats/src/model/types.rs b/crates/formats/src/model/types.rs index 9ab2428f64..1cc99dc722 100644 --- a/crates/formats/src/model/types.rs +++ b/crates/formats/src/model/types.rs @@ -181,6 +181,50 @@ pub enum Inline { custom: String, blocks: Vec, }, + /// Where comment `id` (the flow's own number for it) starts, and the comment. A reply + /// ([`FComment::parent`]) has no range: it only follows its comment. Only flows asked for + /// comments ([`FlowOpts::comments`]) hold them. + Comment { + id: u32, + comment: Box, + }, + /// Where comment `id`'s range ends. + CommentEnd(u32), +} + +/// `blocks` with each table replaced by the blocks of its cells, row by row (nested tables too), +/// for places that hold paragraphs only. +pub fn flatten_tables(blocks: &[FBlock]) -> Vec { + let mut out = Vec::new(); + let mut stack: Vec> = vec![blocks.iter()]; + while let Some(top) = stack.last_mut() { + match top.next() { + Some(FBlock::Para(p)) => out.push(FBlock::Para(p.clone())), + Some(FBlock::Table(t)) => { + // Cells in reverse so they come off the stack in order. + for c in t.rows.iter().rev().flat_map(|r| r.iter().rev()) { + stack.push(c.blocks.iter()); + } + } + None => { + stack.pop(); + } + } + } + out +} + +/// A comment in a flow ([`Inline::Comment`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FComment { + pub author: String, + pub initials: String, + /// ISO 8601; empty = unknown. + pub date: String, + pub resolved: bool, + /// The comment this one answers (its flow id). + pub parent: Option, + pub blocks: Vec, } /// What [`super::from_doc_with`] keeps besides text and formatting. @@ -192,6 +236,8 @@ pub struct FlowOpts { pub fields: bool, /// Footnotes and endnotes as [`Inline::Note`] (otherwise left out). pub notes: bool, + /// Comments as [`Inline::Comment`] and [`Inline::CommentEnd`] (otherwise left out). + pub comments: bool, } /// Base text size (points) a phonetic guide falls back to when the source states none: the @@ -300,7 +346,7 @@ impl Para { Inline::Text(t, _) | Inline::Equation { linear: t, .. } | Inline::Ruby { base: t, .. } | Inline::Field { result: t, .. } => { s.push_str(t) } - Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) | Inline::Note { .. } => {} + Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) | Inline::Note { .. } | Inline::Comment { .. } | Inline::CommentEnd(_) => {} } } s @@ -316,7 +362,7 @@ impl Para { _ => s.push_str(t), }, Inline::Equation { linear: t, .. } | Inline::Ruby { base: t, .. } | Inline::Field { result: t, .. } => s.push_str(t), - Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) | Inline::Note { .. } => {} + Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) | Inline::Note { .. } | Inline::Comment { .. } | Inline::CommentEnd(_) => {} } } s diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index 0c511bbec7..0348f6a98a 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -27,7 +27,7 @@ use wordcraft_doc::props::VAlign; use wordcraft_doc::section::{Columns, SectionStart}; use wordcraft_doc::{Align, Document, RevisionKind, Rgb, SectionProps}; -use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img, mime_of}; +use crate::model::{self, Cell, FBlock, FComment, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img, mime_of}; mod body; mod fields; diff --git a/crates/formats/src/odt/body.rs b/crates/formats/src/odt/body.rs index 9e34a1d33c..f86277b4bd 100644 --- a/crates/formats/src/odt/body.rs +++ b/crates/formats/src/odt/body.rs @@ -1,8 +1,10 @@ use super::import::Pages; -use super::limits::{MAX_FIELD_RESULT, MAX_INFO_CHARS, MAX_OPEN_INSERTIONS, MAX_REGIONS, MAX_SECTIONS, MAX_XML_DEPTH}; +use super::limits::{MAX_COMMENTS, MAX_FIELD_RESULT, MAX_INFO_CHARS, MAX_OPEN_INSERTIONS, MAX_REGIONS, MAX_SECTIONS, MAX_XML_DEPTH}; use super::styles::Styles; use super::xml::{Attrs, Ev, events, get, length, local}; -use super::{Cell, FBlock, FTable, Fmt, HashMap, Inline, Kind, ListInfo, Para, RevMark, RevisionKind, SectionProps, SectionStart, make_img, model}; +use super::{ + Cell, FBlock, FComment, FTable, Fmt, HashMap, Inline, Kind, ListInfo, Para, RevMark, RevisionKind, SectionProps, SectionStart, make_img, model, +}; /// An embedded ODF formula sub-document's `content.xml`, read for its `math:math` element /// (ODF 1.3 §10.4.3.2 wraps it in `office:document-content`/`office:body`/`office:math`, but @@ -60,6 +62,52 @@ fn read_formula(xml: &[u8]) -> Option<(String, bool)> { crate::mathml::read(&math?) } +/// Make every comment range in `blocks` close: an end before its comment (or of no comment) is +/// dropped, and a comment whose end never comes covers nothing. Replies have no range. +pub(super) fn balance_comments(blocks: &mut [FBlock]) { + fn each_para(blocks: &mut [FBlock], depth: usize, f: &mut dyn FnMut(&mut Para)) { + for b in blocks { + match b { + FBlock::Para(p) => f(p), + FBlock::Table(t) if depth < model::MAX_DEPTH => { + for c in t.rows.iter_mut().flatten() { + each_para(&mut c.blocks, depth + 1, f); + } + } + FBlock::Table(_) => {} + } + } + } + let mut open = std::collections::HashSet::new(); + each_para(blocks, 0, &mut |p| { + p.inlines.retain(|i| match i { + Inline::Comment { id, comment } if comment.parent.is_none() => { + open.insert(*id); + true + } + Inline::CommentEnd(id) => open.remove(id), + _ => true, + }) + }); + if open.is_empty() { + return; + } + each_para(blocks, 0, &mut |p| { + let mut k = 0; + while k < p.inlines.len() { + if let Some(Inline::Comment { id, comment }) = p.inlines.get(k) + && comment.parent.is_none() + && open.remove(id) + { + let id = *id; + p.inlines.insert(k + 1, Inline::CommentEnd(id)); + k += 1; + } + k += 1; + } + }); +} + struct TableB { rows: Vec>, row: Vec, @@ -159,18 +207,42 @@ pub(super) struct Body<'a> { active_ins: Vec<(String, RevMark)>, /// The text field being read. field: Option, - /// The note being read. + /// The note or comment being read. note: Option, + /// Comments by `office:name`: their flow ids; and the next flow id. + comment_names: HashMap, + next_comment: u32, } -/// A footnote or endnote being read (ODF 1.3 §6.3): what it is, and the reader state of the -/// paragraph it sits in, put aside while its content is read into a container of its own. +/// A comment being read (ODF 1.3 §14.1): what `office:annotation` and its `dc:creator`, +/// `dc:date` and `meta:creator-initials` say. +#[derive(Default)] +struct CommentInfo { + /// Its flow id. + id: u32, + /// Its `office:name`, which its `office:annotation-end` names. + named: bool, + /// The flow id of the comment it answers (`loext:parent-name`). + parent: Option, + resolved: bool, + author: String, + date: String, + initials: String, + /// The child whose text is being read: 0 author, 1 date, 2 initials. + field: Option, +} + +/// A footnote, endnote or comment being read (ODF 1.3 §6.3, §14.1): what it is, and the +/// reader state of the paragraph it sits in, put aside while its content is read into a +/// container of its own. struct OpenNote { - /// The `text:note` element's depth. + /// The `text:note` or `office:annotation` element's depth. depth: usize, endnote: bool, /// The citation's `text:label` (a custom mark). custom: String, + /// Set for a comment. + comment: Option, para: Option, fmt: Vec, xml_space_end: bool, @@ -235,6 +307,8 @@ impl<'a> Body<'a> { active_ins: Vec::new(), field: None, note: None, + comment_names: HashMap::new(), + next_comment: 0, } } @@ -260,6 +334,11 @@ impl<'a> Body<'a> { /// The blocks read, with anything still open closed. pub(super) fn take_blocks(&mut self) -> Vec { + // What truncated XML left open: a field, a note or comment (its paragraph goes on). + self.end_field(); + if self.note.is_some() { + self.end_note(); + } self.end_para(); while self.containers.len() > 1 { let c = self.containers.pop().unwrap_or_default(); @@ -271,6 +350,7 @@ impl<'a> Body<'a> { blocks.push(FBlock::Table(t.finish())); } } + balance_comments(&mut blocks); blocks } } @@ -480,6 +560,18 @@ impl Body<'_> { } return; } + if let Some(c) = self.note.as_mut().and_then(|n| n.comment.as_mut()) + && let Some(k) = c.field + { + let s = match k { + 0 => &mut c.author, + 1 => &mut c.date, + _ => &mut c.initials, + }; + let room = MAX_INFO_CHARS.saturating_sub(s.chars().count()); + s.extend(t.chars().take(room)); + return; + } if let Some(OpenField { result, .. }) = &mut self.field { // A field's shown text, its XML whitespace collapsed; bounded like any one text run. for c in t.chars() { @@ -913,13 +1005,28 @@ impl Body<'_> { /// schema, never written by Word or LibreOffice), or one in deleted text or a frame, is left /// out with its content. fn start_note_part(&mut self, q: &str, a: &Attrs) -> bool { - if !q.starts_with("text:") { - return false; + let free = self.note.is_none() && self.del.is_none() && !self.in_tc && self.frame.is_none(); + if let Some(c) = self.note.as_mut().and_then(|n| n.comment.as_mut()) { + c.field = match q { + "dc:creator" => Some(0), + "dc:date" => Some(1), + "meta:creator-initials" => Some(2), + _ => None, + }; + if c.field.is_some() { + return true; + } } - match local(q) { - "note" if self.note.is_none() && self.del.is_none() && !self.in_tc && self.frame.is_none() => self.start_note(a), + match q { + "office:annotation" if free && self.comment_names.len() < MAX_COMMENTS => self.start_comment(a), + "office:annotation-end" => { + if let Some(id) = get(a, "office:name").and_then(|n| self.comment_names.get(n)).copied() { + self.push_inline(Inline::CommentEnd(id)); + } + } + "text:note" if free => self.start_note(a), // The citation's text is LibreOffice's number: only a custom mark is kept. - "note-citation" => { + "text:note-citation" => { if let Some(n) = &mut self.note { n.custom = get(a, "text:label").map(|l| l.chars().filter(|c| !c.is_control()).take(10).collect()).unwrap_or_default(); } @@ -930,6 +1037,34 @@ impl Body<'_> { true } + /// Begin reading a comment, as a note: its text in a container of its own. + fn start_comment(&mut self, a: &Attrs) { + let id = self.next_comment; + self.next_comment = self.next_comment.saturating_add(1); + let name = get(a, "office:name").filter(|n| !n.is_empty() && n.len() <= MAX_INFO_CHARS); + if let Some(n) = name { + // The first comment of a name keeps it. + self.comment_names.entry(n.to_string()).or_insert(id); + } + let parent = get(a, "loext:parent-name").and_then(|n| self.comment_names.get(n)).copied(); + let resolved = get(a, "loext:resolved").is_some_and(|r| r.trim() == "true"); + let info = CommentInfo { id, named: name.is_some(), parent, resolved, ..Default::default() }; + self.start_note(a); + if let Some(n) = &mut self.note { + n.comment = Some(info); + } + } + + /// `inline` at the end of the paragraph being read (a new one if none is). + fn push_inline(&mut self, inline: Inline) { + if self.para.is_none() { + self.start_para(None, None); + } + if let Some(p) = &mut self.para { + p.inlines.push(inline); + } + } + /// Begin reading a note: put the paragraph it is in aside and read its content into a /// container of its own, outside any list. fn start_note(&mut self, a: &Attrs) { @@ -937,6 +1072,7 @@ impl Body<'_> { depth: self.depth, endnote: get(a, "text:note-class").is_some_and(|c| c.trim() == "endnote"), custom: String::new(), + comment: None, para: self.para.take(), fmt: std::mem::take(&mut self.fmt), xml_space_end: std::mem::take(&mut self.xml_space_end), @@ -968,23 +1104,51 @@ impl Body<'_> { self.para_cont = n.para_cont; self.lists = n.lists; self.item_used = n.item_used; - if self.para.is_none() { - self.start_para(None, None); - } - if let Some(p) = &mut self.para { - p.inlines.push(Inline::Note { endnote: n.endnote, custom: n.custom, blocks }); + match n.comment { + Some(c) => { + // LibreOffice writes an empty date as all zeroes; a date without a zone is + // read as UTC, as the document's own dates are. + let date = c.date.trim(); + let date = if date.is_empty() || date.starts_with("0000") { + String::new() + } else if date.ends_with(['Z', 'z']) || date.rsplit_once('T').is_some_and(|(_, t)| t.contains(['+', '-'])) { + date.to_string() + } else { + format!("{date}Z") + }; + let comment = FComment { + author: c.author.trim().to_string(), + initials: c.initials.trim().to_string(), + date, + resolved: c.resolved, + parent: c.parent, + blocks, + }; + self.push_inline(Inline::Comment { id: c.id, comment: Box::new(comment) }); + // A comment without a name has no end to wait for: it covers nothing. + if !c.named && c.parent.is_none() { + self.push_inline(Inline::CommentEnd(c.id)); + } + } + None => self.push_inline(Inline::Note { endnote: n.endnote, custom: n.custom, blocks }), } } /// An element ending at the current depth, for an open note or field: true when that was all /// there was to it. fn end_note_or_field(&mut self, q: &str) -> bool { + if let Some(c) = self.note.as_mut().and_then(|n| n.comment.as_mut()) + && c.field.is_some() + { + c.field = None; + return true; + } if self.note.as_ref().is_some_and(|n| self.depth < n.depth) { // The note's own end, or (malformed) an outer element ending first: the note ends, // then that element. self.end_field(); self.end_note(); - if local(q) == "note" { + if q == "text:note" || q == "office:annotation" { return true; } } diff --git a/crates/formats/src/odt/limits.rs b/crates/formats/src/odt/limits.rs index 8f3607814f..b67109ed57 100644 --- a/crates/formats/src/odt/limits.rs +++ b/crates/formats/src/odt/limits.rs @@ -10,6 +10,9 @@ pub(super) const MAX_SECTIONS: usize = 10_000; /// Longest shown text a field keeps, in bytes. pub(super) const MAX_FIELD_RESULT: usize = 64 << 10; +/// Most comments read; later ones are left out. +pub(super) const MAX_COMMENTS: usize = 100_000; + /// Most `text:changed-region`s read (hostile input). pub(super) const MAX_REGIONS: usize = 10_000; diff --git a/crates/formats/src/odt/package.rs b/crates/formats/src/odt/package.rs index e06848a4e5..34ad812b30 100644 --- a/crates/formats/src/odt/package.rs +++ b/crates/formats/src/odt/package.rs @@ -187,7 +187,7 @@ fn flow_sections(blocks: &[FBlock], last: &SectionProps) -> Vec<(SectionProps, s /// Write an `.odt` package. pub fn export(doc: &Document) -> Result, String> { - let flow = model::from_doc_with(doc, model::FlowOpts { tracked: true, fields: true, notes: true }); + let flow = model::from_doc_with(doc, model::FlowOpts { tracked: true, fields: true, notes: true, comments: true }); let mut w = Writer::default(); let mut body = String::new(); let sections = flow_sections(&flow.blocks, &doc.last_section); diff --git a/crates/formats/src/odt/tests_more.rs b/crates/formats/src/odt/tests_more.rs index 1272873822..b1b167e022 100644 --- a/crates/formats/src/odt/tests_more.rs +++ b/crates/formats/src/odt/tests_more.rs @@ -1,5 +1,6 @@ //! Tests for the ODT features added after the first reader and writer: one module per feature. +mod comments; mod fields; mod headers; mod notes; diff --git a/crates/formats/src/odt/tests_more/comments.rs b/crates/formats/src/odt/tests_more/comments.rs new file mode 100644 index 0000000000..0890f99d23 --- /dev/null +++ b/crates/formats/src/odt/tests_more/comments.rs @@ -0,0 +1,170 @@ +//! Comments (ODF 1.3 §14.1 `office:annotation`, §14.2 `office:annotation-end`): read into the +//! document's comments with their ranges, replies and resolved state, and written back. + +use crate::odt::tests::{NS_T, package}; +use crate::odt::{export, import}; +use wordcraft_doc::para::{InlineObject, OBJ}; +use wordcraft_doc::{Document, StoryRef}; + +const NS_C: &str = r#"xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" xmlns:loext="urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0""#; + +fn body(xml: &str) -> Vec { + package( + &format!(r#"{xml}"#), + None, + ) +} + +/// Each comment: (author, initials, date, resolved, parent's text, its text, the text it covers). +type Summary = (String, String, String, bool, Option, String, String); + +fn text_of(d: &Document, part: u32) -> String { + d.parts + .get(&part) + .map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.replace(OBJ, "")).collect::>().join("|")) + .unwrap_or_default() +} + +/// The body text between each comment's start and end. +fn covered(d: &Document, id: u32) -> String { + let mut out = String::new(); + let mut inside = false; + for b in d.body.iter() { + let Some(p) = b.as_para() else { continue }; + let offs = p.object_offsets(); + let mut last = 0; + for (k, o) in p.objects.iter().enumerate() { + let at = offs.get(k).copied().unwrap_or(0); + if inside { + out.push_str(&p.text[last..at].replace(OBJ, "")); + } + match o { + InlineObject::CommentStart { id: i } if *i == id => inside = true, + InlineObject::CommentEnd { id: i } if *i == id => inside = false, + _ => {} + } + last = at + OBJ.len_utf8(); + } + if inside { + out.push_str(&p.text[last.min(p.text.len())..].replace(OBJ, "")); + out.push('|'); + } + } + out +} + +fn comments(d: &Document) -> Vec { + d.comments + .iter() + .map(|(id, c)| { + let parent = c.parent.and_then(|p| d.comments.get(&p)).map(|p| text_of(d, p.part)); + (c.author.clone(), c.initials.clone(), c.date.clone(), c.resolved, parent, text_of(d, c.part), covered(d, *id)) + }) + .collect() +} + +const COMMENTS: &str = concat!( + r#"Before Ada Lovelace2026-10-31T10:00:00ALFirst comment.Second line.commented words and"#, + r#"Grace0000-00-00T00:00:00A point. after."#, +); + +#[test] +fn comments_open_with_their_ranges() { + let d = import(&body(COMMENTS)).unwrap(); + let got = comments(&d); + assert_eq!(got.len(), 2, "{got:?}"); + assert_eq!( + got[0], + ( + "Ada Lovelace".into(), + "AL".into(), + "2026-10-31T10:00:00Z".into(), + false, + None, + "First comment.|Second line.".into(), + "commented words".into() + ) + ); + // A comment without a range covers nothing; LibreOffice's empty date is no date. + assert_eq!(got[1], ("Grace".into(), String::new(), String::new(), true, None, "A point.".into(), String::new())); + // The comment's text isn't body text. + assert_eq!(d.plain_text(StoryRef::Body).trim(), "Before commented words and after."); +} + +#[test] +fn a_comment_across_paragraphs_keeps_its_range() { + let d = import(&body(r#"a Aconetwo b"#)).unwrap(); + let (id, _) = d.comments.iter().next().unwrap(); + assert_eq!(covered(&d, *id), "one|two"); +} + +#[test] +fn replies_follow_their_parent() { + let xml = concat!( + r#"AdaQuestion?text"#, + r#"AlanAnswer."#, + ); + let d = import(&body(xml)).unwrap(); + let got = comments(&d); + assert_eq!(got.len(), 2); + assert_eq!(got[1].4.as_deref(), Some("Question?")); + assert_eq!(got[1].5, "Answer."); + // A reply has no place in the text of its own. + let reply_id = *d.comments.iter().find(|(_, c)| c.parent.is_some()).unwrap().0; + let anchored = d + .body + .iter() + .filter_map(|b| b.as_para()) + .flat_map(|p| p.objects.iter()) + .any(|o| matches!(o, InlineObject::CommentStart { id } if *id == reply_id)); + assert!(!anchored); +} + +#[test] +fn comments_round_trip() { + let xml = concat!( + r#"Before Ada Lovelace2026-10-31T10:00:00ALFirst comment.commentedwords"#, + r#"AlanReply. and"#, + r#"GraceA point. after."#, + ); + let d = import(&body(xml)).unwrap(); + let back = import(&export(&d).unwrap()).unwrap(); + assert_eq!(comments(&back), comments(&d)); + assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body)); +} + +#[test] +fn unmatched_and_hostile_comments_do_not_panic() { + for xml in [ + // An end without a start, a start whose end never comes, a nested annotation. + r#"ab"#.to_string(), + r#"acb"#.to_string(), + r#"xy"# + .to_string(), + r#""#.to_string(), + format!(r#"{}long"#, "n".repeat(100_000)), + r#"orphan"#.to_string(), + ] { + let d = import(&body(&xml)).unwrap(); + let again = import(&export(&d).unwrap()).unwrap(); + // Every comment kept is whole: its story exists and its range closes. + for (id, c) in &again.comments { + assert!(again.parts.contains_key(&c.part), "{xml}"); + let starts = again + .body + .iter() + .filter_map(|b| b.as_para()) + .flat_map(|p| p.objects.iter()) + .filter(|o| matches!(o, InlineObject::CommentStart { id: i } if i == id)) + .count(); + let ends = again + .body + .iter() + .filter_map(|b| b.as_para()) + .flat_map(|p| p.objects.iter()) + .filter(|o| matches!(o, InlineObject::CommentEnd { id: i } if i == id)) + .count(); + assert_eq!(starts, ends, "{xml}"); + } + } +} diff --git a/crates/formats/src/odt/tests_more/notes.rs b/crates/formats/src/odt/tests_more/notes.rs index 60302584f1..5435684e89 100644 --- a/crates/formats/src/odt/tests_more/notes.rs +++ b/crates/formats/src/odt/tests_more/notes.rs @@ -120,3 +120,12 @@ fn citations_number_footnotes_and_endnotes_apart() { xml.split("')).map(|(_, rest)| rest.split('<').next().unwrap_or("")).collect(); assert_eq!(citations, ["1", "i", "*"]); } + +#[test] +fn a_note_cut_off_by_the_end_of_the_file_keeps_its_paragraph() { + // Truncated XML: no end for the note, its paragraph or the body. + let content = format!(r#"beforein note"#); + let d = import(&package(&content, None)).unwrap(); + assert_eq!(d.body[0].as_para().unwrap().text.replace(OBJ, "#"), "before#"); + assert_eq!(notes(&d), [(NoteKind::Footnote, String::new(), "in note".to_string())]); +} diff --git a/crates/formats/src/odt/writer.rs b/crates/formats/src/odt/writer.rs index 89bb20a3a0..710042868b 100644 --- a/crates/formats/src/odt/writer.rs +++ b/crates/formats/src/odt/writer.rs @@ -32,6 +32,7 @@ pub(super) const NS: &str = concat!( "xmlns:dc=\"http://purl.org/dc/elements/1.1/\" ", "xmlns:meta=\"urn:oasis:names:tc:opendocument:xmlns:meta:1.0\" ", "xmlns:svg=\"urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0\" ", + "xmlns:loext=\"urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0\" ", "office:version=\"1.3\"" ); @@ -149,6 +150,11 @@ pub(super) fn spacing_props(sp: &model::Spacing) -> String { p } +/// The `office:name` of comment `id` written by a writer with names prefixed `prefix`. +fn annotation_name(prefix: &str, id: u32) -> String { + format!("__Annotation__{prefix}{id}") +} + fn parent_style(k: Kind) -> &'static str { match k { Kind::Heading(1) => "Heading_20_1", @@ -435,6 +441,11 @@ impl Writer { self.note(*endnote, custom, blocks, out); prev_space = false; } + Inline::Comment { id, comment } => { + self.comment(*id, comment, out); + prev_space = false; + } + Inline::CommentEnd(id) => out.push_str(&format!("", annotation_name(self.prefix, *id))), Inline::Field { instr, result, fmt } => match super::fields::element(instr, &x(result)) { Some(el) => { match self.text_style(&Fmt { link: None, ..fmt.clone() }) { @@ -456,6 +467,41 @@ impl Writer { } } + /// A comment (ODF 1.3 §14.1): named, so its `office:annotation-end` closes its range, or for + /// a reply naming the comment it answers (LibreOffice's `loext:parent-name`); its author, + /// date, initials, resolved state (`loext:resolved`) and text. + fn comment(&mut self, id: u32, c: &model::FComment, out: &mut String) { + let reopen = self.open_ins.as_ref().map(|(m, _)| m.clone()); + self.close_ins(out); + let mut attrs = match c.parent { + Some(p) => format!(" loext:parent-name=\"{}\"", annotation_name(self.prefix, p)), + None => format!(" office:name=\"{}\"", annotation_name(self.prefix, id)), + }; + if c.resolved { + attrs.push_str(" loext:resolved=\"true\""); + } + let mut head = String::new(); + if !c.author.is_empty() { + head.push_str(&format!("{}", x(&c.author))); + } + if !c.date.is_empty() { + head.push_str(&format!("{}", x(&c.date))); + } + if !c.initials.is_empty() { + head.push_str(&format!("{}", x(&c.initials))); + } + let saved = (std::mem::take(&mut self.cur_style), std::mem::take(&mut self.chain), self.part, self.note_parent.take()); + let mut body = String::new(); + // An annotation holds paragraphs and lists only: a table's text goes in as paragraphs. + self.blocks(&model::flatten_tables(&c.blocks), &mut body, 1); + self.close_ins(&mut body); + (self.cur_style, self.chain, self.part, self.note_parent) = saved; + out.push_str(&format!("{head}{body}")); + if let Some(m) = reopen { + self.open_ins(&m, out); + } + } + /// A footnote or endnote (ODF 1.3 §6.3): its citation (the number LibreOffice shows until it /// renumbers, or the custom mark) and its content. The paragraph being written is put aside /// meanwhile, and an insertion open around the note ends before it and goes on after it. diff --git a/crates/formats/src/rtf/export.rs b/crates/formats/src/rtf/export.rs index 9256ffc653..966ff1f217 100644 --- a/crates/formats/src/rtf/export.rs +++ b/crates/formats/src/rtf/export.rs @@ -400,7 +400,7 @@ impl Writer { // RTF keeps no ruby yet: its base text, in the run's formatting. Inline::Ruby { base, fmt, .. } => self.run(base, fmt, out), Inline::Field { result, fmt, .. } => self.run(result, fmt, out), - Inline::Figure(_) | Inline::Equation { .. } | Inline::Note { .. } => {} + Inline::Figure(_) | Inline::Equation { .. } | Inline::Note { .. } | Inline::Comment { .. } | Inline::CommentEnd(_) => {} } } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 68abb860ec..2965141706 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** 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 (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-10-31 · **Change:** 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 (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 Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -57,7 +57,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | 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; Windows and Enhanced Metafiles, `\wmetafile` and `\emfblip`, read and written, #4259), 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, Wayland) 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). Thin: comments, notes, RTL | -| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/it/odt_sections.rs` | Styles, lists (#87), tables, pictures, page breaks (#86), paragraph spacing, indents and line spacing (proportional, fixed, at least; leading read as a proportional approximation) both ways, with the default and Standard styles' spacing as the document defaults (ODT's are none, not Word's 8 pt after and 1.15 lines), headers and footers of each master page (every, left and first page) read on open and written on save, with the header and footer distances converted between Word's page-edge measures and ODF's header bands (#863), text fields (page number and count, date, time, title, subject, author, file name, word and character counts, bookmark references as `REF`/`PAGEREF`) read as fields and written back as ODF fields; fixed dates and fields without a Word equivalent (sequences, chapters, variables) read as their text, and Word fields ODF lacks written as their result, date formats not kept, keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099; export still drops equations, as before), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). footnotes and endnotes (`text:note`) read into note stories as Word's (Footnote Text style, the note's own mark first, a custom mark kept) and written back with their citations; notes inside notes and in deleted text left out. No comments or RTL | +| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/it/odt_sections.rs` | Styles, lists (#87), tables, pictures, page breaks (#86), paragraph spacing, indents and line spacing (proportional, fixed, at least; leading read as a proportional approximation) both ways, with the default and Standard styles' spacing as the document defaults (ODT's are none, not Word's 8 pt after and 1.15 lines), headers and footers of each master page (every, left and first page) read on open and written on save, with the header and footer distances converted between Word's page-edge measures and ODF's header bands (#863), text fields (page number and count, date, time, title, subject, author, file name, word and character counts, bookmark references as `REF`/`PAGEREF`) read as fields and written back as ODF fields; fixed dates and fields without a Word equivalent (sequences, chapters, variables) read as their text, and Word fields ODF lacks written as their result, date formats not kept, keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099; export still drops equations, as before), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). footnotes and endnotes (`text:note`) read into note stories as Word's (Footnote Text style, the note's own mark first, a custom mark kept) and written back with their citations; notes inside notes and in deleted text left out; comments (`office:annotation` with `office:annotation-end` ranges, author, date, initials, LibreOffice's resolved state and reply threads, `loext:resolved`/`loext:parent-name`) read and written, a comment whose end never comes covering nothing; comments in notes and in deleted text left out. No RTL | | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (``, ``) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As | | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.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). 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). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). 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 | ❌ | ❌ | 0% | 3–5 | — | | @@ -279,6 +279,7 @@ Custom document properties retain their bookmark link targets through DOCX saves | Date | Change | Summary | |---|---|---| +| 2026-10-31 | trivial | ODT: comments (`office:annotation`, ODF 1.3 §14.1–14.2) open with their ranges (across paragraphs too), author, date, initials, resolved state and replies (`loext:resolved`, `loext:parent-name`, kept by LibreOffice when it saves our files), and are written back; an end with no comment is dropped and a comment whose end never comes covers nothing; before, comments were dropped on open. `formats::odt::tests_more::comments` | | 2026-10-31 | trivial | ODT: footnotes and endnotes (`text:note`, ODF 1.3 §6.3) open as note stories built as Word's (Footnote Text or Endnote Text paragraphs starting with the note's mark, the reference in the reference style, a `text:label` custom mark kept) and are written back with `text:note-citation`s numbered as LibreOffice shows them (footnotes 1, 2, 3; endnotes i, ii, iii); before, notes were dropped on open. `formats::odt::tests_more::notes` | | 2026-10-31 | trivial | ODT: headers and footers are written to each section's master page (every, left and first page), with Word's header and footer distances converted to ODF's header and footer bands and back on open; text fields (`text:page-number`, `text:page-count`, `text:date`, `text:time`, document information, `text:bookmark-ref`) open as WordCraft fields and are written back as ODF fields. `formats::odt::tests_more::{headers, fields}` | | 2026-10-31 | trivial | ODT: paragraph spacing, indents and line spacing (`fo:margin-*`, `fo:text-indent`, `fo:line-height`, `style:line-height-at-least`; `style:line-spacing` leading as a proportional approximation) read from automatic and common styles and written back; the default and Standard paragraph styles give the document defaults, so an ODT paragraph without spacing has none instead of Word's 8 pt after and 1.15 lines. `formats::odt::tests_more::spacing` | diff --git a/docs/gaps.md b/docs/gaps.md index 6cce18241d..5dc3f7b9e9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (gap 19 narrowed: ODT footnotes and endnotes read and written); previously trivial (gap 19 narrowed: ODT headers and footers written, text fields and paragraph spacing read and written); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #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 (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (gap 19 narrowed: ODT comments read and written); previously trivial (gap 19 narrowed: ODT footnotes and endnotes read and written); previously trivial (gap 19 narrowed: ODT headers and footers written, text fields and paragraph spacing read and written); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #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 (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; 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: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; 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: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 @@ -81,7 +81,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 15 | **References depth**: no EndNote/Mendeley desktop integration (bibliography sources are saved in DOCX, #790; Zotero landed, #189; Word's own `Sources.xml` master list imports into Manage Sources, #2640) | | #790 stores sources in `customXml` and reads them back; `crates/zotero`; `references.sources` `import` reads a Word bibliography file (`b:Sources`) | Students and researchers | 3–6 | [target-app-parity.md](target-app-parity.md) | | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; six still-open older RTL bug reports to re-verify after #207 (#211, #199, #66, #63, #48, #19; #215 is closed). RTL tables and sections landed (#362); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | | 17 | **East Asian typography**: hint-reliant DFKai-SB contours repaired (local Windows regression; no readiness estimate change); Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | `para.asianTypography` is live (Paragraph dialog Asian Typography tab, #323), automatic Asian/Latin and Asian/number spacing is laid out (#2358), and no East Asian item is left in the catalog's missing list; the remaining depth is not measured | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | -| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; RTF section/table and ODT formatting revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s; ODT table column widths are read (they were dropped on every open). ODT export writes sections back (a master page per new-page section, a columned `text:section` per continuous one) and their headers and footers; ODT paragraph spacing, text fields, footnotes and endnotes read and written; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259) | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | +| 19 | **RTF and ODT depth**: notes, comments, revisions (RTF and ODT insertions and deletions done; RTF paragraph-mark and character/paragraph formatting revisions done; RTF section/table and ODT formatting revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT sections landed on import (#1379): page-style changes and columned `text:section`s; ODT table column widths are read (they were dropped on every open). ODT export writes sections back (a master page per new-page section, a columned `text:section` per continuous one) and their headers and footers; ODT paragraph spacing, text fields, footnotes, endnotes and comments read and written; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259) | | ~2,100 (RTF) and ~1,600 (ODT) lines in `crates/formats`; RTF code pages (#896) and page setup (#902), ODT page setup (#829) landed; open PRs: RTF notes and comments (#1056), ODT notes and comments (#1057), ODT headers and footers (#873) | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are (#3172); Windows Magnifier follows the caret, onto a new line too (#1119); no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | | 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | DOCX comment saves avoid repeated paragraph-ID and durable-ID reservation searches; large threaded-comment fixtures retain metadata through save/reopen. Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds; opening a document with pictures no longer copies them (92 MB with 20 pictures: about 90 MB less); opening a document with many relationships now costs the size of its part and not that size times the number of relationships (1024 relationships and a 64 MiB part: 224 s → 0.2 s, #3411); a word with no break opportunity in it no longer lays out quadratically (1 MiB: 29.4 s → 0.35 s, #3412); saving a document with many content controls now costs the time the controls take (32,000 controls: 3.6 s → 0.1 s, #13) | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | | 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334, legacy .xls #778) only | | Select Recipients reads .csv/.tsv/.txt in any common encoding (UTF-8/16, GBK, Big5, Shift_JIS…), .xlsx/.xlsm, .xls and .ods; Edit Recipient List filters and sorts (#778), finds records and can show only the merged ones (#1576) | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | From bc50c6f7f933e7a56305af94e34852407aaa2dfc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 23:53:03 +0000 Subject: [PATCH 006/182] Gap 19: ODT export keeps replies to replies Export wrote only the direct replies of a comment, so a reply to a reply was lost on save. Every reply in a comment's thread is written now, each answering the comment that starts the thread, as LibreOffice's threads are flat. A reply chain that loops (hostile) is written once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC --- crates/formats/src/model/from_doc.rs | 15 ++++++++++++--- crates/formats/src/odt/tests_more/comments.rs | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/formats/src/model/from_doc.rs b/crates/formats/src/model/from_doc.rs index ef90a5bd4e..d5da5a3801 100644 --- a/crates/formats/src/model/from_doc.rs +++ b/crates/formats/src/model/from_doc.rs @@ -139,12 +139,21 @@ fn rich_inlines(doc: &Document, o: &InlineObject, f: &Fmt, opts: FlowOpts) -> Op let endnote = *kind == wordcraft_doc::para::NoteKind::Endnote; vec![Inline::Note { endnote, custom: custom.clone(), blocks }] } - // The comment, then its replies, which have no place of their own. + // The comment, then the replies in its thread (replies to replies too, each answering the + // comment that starts the thread), which have no place of their own. InlineObject::CommentStart { id } if opts.comments => { let Some(c) = doc.comments.get(id) else { return Some(Vec::new()) }; let mut out = vec![Inline::Comment { id: *id, comment: Box::new(flow_comment(doc, c, None, opts)) }]; - for (rid, r) in doc.comments.iter().filter(|(_, r)| r.parent == Some(*id)) { - out.push(Inline::Comment { id: *rid, comment: Box::new(flow_comment(doc, r, Some(*id), opts)) }); + let mut thread = std::collections::BTreeSet::from([*id]); + let mut k = 0; + while let Some(Inline::Comment { id: at, .. }) = out.get(k) { + let at = *at; + k += 1; + for (rid, r) in doc.comments.iter().filter(|(_, r)| r.parent == Some(at)) { + if thread.insert(*rid) { + out.push(Inline::Comment { id: *rid, comment: Box::new(flow_comment(doc, r, Some(*id), opts)) }); + } + } } out } diff --git a/crates/formats/src/odt/tests_more/comments.rs b/crates/formats/src/odt/tests_more/comments.rs index 0890f99d23..0e3a43a7d8 100644 --- a/crates/formats/src/odt/tests_more/comments.rs +++ b/crates/formats/src/odt/tests_more/comments.rs @@ -168,3 +168,20 @@ fn unmatched_and_hostile_comments_do_not_panic() { } } } + +#[test] +fn a_reply_to_a_reply_is_kept() { + let xml = concat!( + r#"ARootx"#, + r#"BReply"#, + r#"CReply to reply"#, + ); + let d = import(&body(xml)).unwrap(); + assert_eq!(d.comments.len(), 3); + // LibreOffice's threads are flat: every reply answers the comment that starts them. + let back = import(&export(&d).unwrap()).unwrap(); + let got = comments(&back); + assert_eq!(got.len(), 3, "{got:?}"); + assert_eq!(got[2].4.as_deref(), Some("Root")); + assert_eq!(got[2].5, "Reply to reply"); +} From e4bac37236235baff1f74c672a1ace33ae075878 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 00:08:10 +0000 Subject: [PATCH 007/182] Gap 19: ODT lists keep their number formats, bullets and identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each level of a list style (ODF 1.3 §16.30-16.33) is read with its number format (1, i, I, a, A, none), prefix and suffix, levels shown, start value and bullet character, and written back. Each `text:list` is a list of its own unless it continues the one before it (`text:continue-numbering`) or the one it names (`text:continue-list`); an item's `text:start-value` restarts from there. A list inside a note or table cell leaves the list around it alone. Before, consecutive lists merged into one: three LibreOffice lists numbered 1., i. and A. opened as one decimal list 1-7. The flow model's ListInfo carries a list number and a compact label (prefix and suffix up to 12 bytes, so "Chapter %1" fits) only for flows that ask for them (FlowOpts::lists); the other formats are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC --- ROADMAP.md | 3 +- crates/formats/src/html/builder.rs | 2 +- crates/formats/src/latex/parser.rs | 2 +- crates/formats/src/latex/tests.rs | 4 +- crates/formats/src/markdown/blocks.rs | 2 +- crates/formats/src/markdown/tests.rs | 14 +- crates/formats/src/model.rs | 10 +- crates/formats/src/model/from_doc.rs | 22 +++- crates/formats/src/model/to_doc.rs | 48 ++++++- crates/formats/src/model/types.rs | 100 ++++++++++++++ crates/formats/src/odt/body.rs | 110 ++++++++++++---- crates/formats/src/odt/package.rs | 4 +- crates/formats/src/odt/styles.rs | 51 +++++++- crates/formats/src/odt/tests_more.rs | 1 + crates/formats/src/odt/tests_more/lists.rs | 143 +++++++++++++++++++++ crates/formats/src/odt/writer.rs | 76 +++++++++-- crates/formats/src/rtf/styles.rs | 2 +- crates/formats/src/rtf/tests.rs | 8 +- crates/formats/src/tests.rs | 5 +- docs/file-format-parity.md | 5 +- docs/gaps.md | 4 +- 21 files changed, 539 insertions(+), 77 deletions(-) create mode 100644 crates/formats/src/odt/tests_more/lists.rs diff --git a/ROADMAP.md b/ROADMAP.md index 38ebb2080a..776aacb432 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #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 (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #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 (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -140,6 +140,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-31: ODT: lists keep their numbering (i., A., (1), bullets) and stay separate lists as in LibreOffice; consecutive lists used to merge into one decimal list (gap 19). Readiness unchanged. - 2026-10-31: ODT: comments open and save with their ranges, replies and resolved state, as LibreOffice keeps them (gap 19; they were dropped on open). Readiness unchanged. - 2026-10-31: ODT: footnotes and endnotes open and save (they were dropped on open), as Word-style note stories with their custom marks (gap 19). Readiness unchanged. - 2026-10-31: ODT: headers and footers are saved (they were dropped on every save), each page's header sits where LibreOffice puts it on open, and page numbers, page counts, dates and other text fields open as fields that update and are saved as ODF fields (gap 19). Readiness unchanged. diff --git a/crates/formats/src/html/builder.rs b/crates/formats/src/html/builder.rs index 5de71edd67..90242f010d 100644 --- a/crates/formats/src/html/builder.rs +++ b/crates/formats/src/html/builder.rs @@ -272,7 +272,7 @@ impl<'r> Builder<'r> { } } if let (Some((o, _)), Some(li)) = (list_level, li_at) { - p.list = Some(ListInfo { ordered: o, level: lists.saturating_sub(1).min(8) as u8, start: 1 }); + p.list = Some(ListInfo { ordered: o, level: lists.saturating_sub(1).min(8) as u8, start: 1, ..Default::default() }); self.para_cont = self.stack.get(li).is_some_and(|e| e.used); if let Some(e) = self.stack.get_mut(li) { e.used = true; diff --git a/crates/formats/src/latex/parser.rs b/crates/formats/src/latex/parser.rs index 2be4d3b8ab..ff78f06397 100644 --- a/crates/formats/src/latex/parser.rs +++ b/crates/formats/src/latex/parser.rs @@ -729,7 +729,7 @@ impl<'a> Parser<'a> { let level = self.lists.len().saturating_sub(1).min(8) as u8; self.para = Para::default(); if let Some(&ordered) = self.lists.last() { - self.para.list = Some(ListInfo { ordered, level, start: 1 }); + self.para.list = Some(ListInfo { ordered, level, start: 1, ..Default::default() }); } let label = self.opt_arg(); if let Some(l) = label { diff --git a/crates/formats/src/latex/tests.rs b/crates/formats/src/latex/tests.rs index 60638d6f84..cc258a83a1 100644 --- a/crates/formats/src/latex/tests.rs +++ b/crates/formats/src/latex/tests.rs @@ -151,8 +151,8 @@ fn assert_article_blocks(ps: &[Para]) { assert_eq!(find("On Sample Documents").kind, Kind::Title); assert_eq!(find("Introduction").kind, Kind::Heading(1)); assert_eq!(find("Lists").kind, Kind::Heading(2)); - assert_eq!(find("First").list, Some(ListInfo { ordered: false, level: 0, start: 1 })); - assert_eq!(find("Nested one").list, Some(ListInfo { ordered: true, level: 1, start: 1 })); + assert_eq!(find("First").list, Some(ListInfo { ordered: false, level: 0, start: 1, ..Default::default() })); + assert_eq!(find("Nested one").list, Some(ListInfo { ordered: true, level: 1, start: 1, ..Default::default() })); assert!(ps.iter().any(|p| p.text().trim() == "b) Nested two" && p.list.is_some()), "{:?}", texts(ARTICLE)); assert_eq!(find("Quoted text.").kind, Kind::Quote); assert_eq!(find("Centered.").align, Some(Align::Center)); diff --git a/crates/formats/src/markdown/blocks.rs b/crates/formats/src/markdown/blocks.rs index 3a1bb2a269..cf6216b640 100644 --- a/crates/formats/src/markdown/blocks.rs +++ b/crates/formats/src/markdown/blocks.rs @@ -620,7 +620,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut while item.len() > 1 && item.last().is_some_and(String::is_empty) { item.pop(); } - let li = ListInfo { ordered: first.ordered, level: level.min(8), start: first.start }; + let li = ListInfo { ordered: first.ordered, level: level.min(8), start: first.start, ..Default::default() }; let inner = if depth >= MAX_NEST { let mut p = Para::default(); p.push_text(&item.join(" "), &Fmt::default()); diff --git a/crates/formats/src/markdown/tests.rs b/crates/formats/src/markdown/tests.rs index 32767eeee4..97bd3c6443 100644 --- a/crates/formats/src/markdown/tests.rs +++ b/crates/formats/src/markdown/tests.rs @@ -418,8 +418,8 @@ fn code_span_line_endings_stay_literal_on_export() { ]; let shapes = [ (Kind::Normal, None), - (Kind::Normal, Some(ListInfo { ordered: false, level: 0, start: 1 })), - (Kind::Code, Some(ListInfo { ordered: true, level: 0, start: 1 })), + (Kind::Normal, Some(ListInfo { ordered: false, level: 0, start: 1, ..Default::default() })), + (Kind::Code, Some(ListInfo { ordered: true, level: 0, start: 1, ..Default::default() })), ]; for (source, expected) in cases { for (kind, list) in shapes { @@ -576,10 +576,10 @@ fn lines_after_line_break_stay_in_paragraph() { fn lines_after_line_break_stay_in_list_item_and_quote() { let cases = ["a\n- x", "a\n# h", "a\n1. x", "a\n--", "a\n---", "a\n==", "a\n> q"]; let shapes: [(Kind, Option); 5] = [ - (Kind::Normal, Some(ListInfo { ordered: false, level: 0, start: 1 })), - (Kind::Normal, Some(ListInfo { ordered: true, level: 0, start: 1 })), - (Kind::Normal, Some(ListInfo { ordered: false, level: 1, start: 1 })), - (Kind::Normal, Some(ListInfo { ordered: true, level: 1, start: 1 })), + (Kind::Normal, Some(ListInfo { ordered: false, level: 0, start: 1, ..Default::default() })), + (Kind::Normal, Some(ListInfo { ordered: true, level: 0, start: 1, ..Default::default() })), + (Kind::Normal, Some(ListInfo { ordered: false, level: 1, start: 1, ..Default::default() })), + (Kind::Normal, Some(ListInfo { ordered: true, level: 1, start: 1, ..Default::default() })), (Kind::Quote, None), ]; for s in cases { @@ -587,7 +587,7 @@ fn lines_after_line_break_stay_in_list_item_and_quote() { let mut blocks = Vec::new(); if let Some(li) = list.filter(|l| l.level > 0) { let mut parent = Para::new(Kind::Normal); - parent.list = Some(ListInfo { ordered: li.ordered, level: 0, start: 1 }); + parent.list = Some(ListInfo { ordered: li.ordered, level: 0, start: 1, ..Default::default() }); parent.push_text("top", &Fmt::default()); blocks.push(FBlock::Para(parent)); } diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index 6af8b9a4a1..c55fb2e5fe 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -24,9 +24,9 @@ pub use from_doc::{ListCounter, flow_paras, from_doc, from_doc_tracked, from_doc pub use media::{base64_decode, base64_encode, data_uri, decode_bounded, image_px, make_img, mime_of, percent_decode, sized_img, sniff_image}; pub use to_doc::{char_props, part_blocks, to_doc, to_doc_in}; pub use types::{ - CODE_STYLE, Cell, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind, ListInfo, MAX_DEPTH, MAX_FONT_NAME, MAX_LINK, - MONO_FONT, Meta, Para, RUBY_BASE_SIZE, RevMark, Spacing, clean_text, equations_as_text, flatten_tables, kept_font, kept_link, ruby_as_text, - ruby_object, + Affix, CODE_STYLE, Cell, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind, Label, ListInfo, MAX_DEPTH, MAX_FONT_NAME, + MAX_LINK, MONO_FONT, Meta, Para, RUBY_BASE_SIZE, RevMark, Spacing, clean_text, equations_as_text, flatten_tables, kept_font, kept_link, + ruby_as_text, ruby_object, }; pub(crate) use types::{Dropped, begin_import, dropped}; @@ -90,12 +90,12 @@ mod tests { #[test] fn list_labels() { let mut c = ListCounter::default(); - let o = |level| ListInfo { ordered: true, level, start: 1 }; + let o = |level| ListInfo { ordered: true, level, start: 1, ..Default::default() }; assert_eq!(c.label(o(0)), "1."); assert_eq!(c.label(o(1)), "a."); assert_eq!(c.label(o(1)), "b."); assert_eq!(c.label(o(0)), "2."); - assert_eq!(c.label(ListInfo { ordered: false, level: 0, start: 1 }), "•"); + assert_eq!(c.label(ListInfo { ordered: false, level: 0, start: 1, ..Default::default() }), "•"); } #[test] diff --git a/crates/formats/src/model/from_doc.rs b/crates/formats/src/model/from_doc.rs index d5da5a3801..50c7259fc7 100644 --- a/crates/formats/src/model/from_doc.rs +++ b/crates/formats/src/model/from_doc.rs @@ -109,7 +109,13 @@ fn props_kind(doc: &Document, props: &ParaProps, blank: bool) -> (Kind, Option Vec { /// insertion and deletion marks. fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec { let tracked = opts.tracked; - let (kind, list) = para_kind(doc, p); + let (kind, mut list) = para_kind(doc, p); + if !opts.lists + && let Some(li) = &mut list + { + (li.list, li.label) = (0, None); + } let mut done: Vec = Vec::new(); let borders = if kind == Kind::Rule { None } else { p.props.borders.filter(|b| b.any_visible()) }; let mut out = Para { @@ -334,7 +345,12 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec { // The formatting change belongs to the paragraph the mark ends. out.fmt_change = p.props.fmt_change.as_deref().map(|c| { let blank = out.inlines.is_empty(); - let (kind, list) = props_kind(doc, &c.old, blank); + let (kind, mut list) = props_kind(doc, &c.old, blank); + if !opts.lists + && let Some(li) = &mut list + { + (li.list, li.label) = (0, None); + } let old = Para { kind, list, align: c.old.align, page_break: c.old.page_break_before.unwrap_or(false), ..Default::default() }; Box::new(FmtChange { mark: rev_mark(doc, c.rev), old }) }); diff --git a/crates/formats/src/model/to_doc.rs b/crates/formats/src/model/to_doc.rs index 7ccf634376..80e5d28ee2 100644 --- a/crates/formats/src/model/to_doc.rs +++ b/crates/formats/src/model/to_doc.rs @@ -114,6 +114,8 @@ struct Builder<'a> { run_strings: usize, /// The document's comment for each flow comment id. comments: std::collections::HashMap, + /// The document's list instance for each flow list number ([`ListInfo::list`]). + lists: std::collections::HashMap, } fn rev_key(kind: RevisionKind) -> u8 { @@ -133,7 +135,7 @@ impl<'a> Builder<'a> { revs.entry((rev_key(r.kind), mark)).or_insert(i as u32); } } - Builder { doc, revs, run_strings: 0, comments: Default::default() } + Builder { doc, revs, run_strings: 0, comments: Default::default(), lists: Default::default() } } /// The document revision for a tracked change, added the first time it is seen. @@ -358,12 +360,18 @@ impl<'a> Builder<'a> { } } let mut starts: [Option; 9] = [None; 9]; + let mut labels: [Option; 9] = [None; 9]; for li in group { if let Some(s) = starts.get_mut(li.level.min(8) as usize) && s.is_none() { *s = Some(li.start); } + if let Some(l) = labels.get_mut(li.level.min(8) as usize) + && l.is_none() + { + *l = li.label; + } } // Levels nobody used follow the first level's kind. let first = group.first().map(|l| l.ordered).unwrap_or(false); @@ -375,6 +383,21 @@ impl<'a> Builder<'a> { if ordered && let Some(s) = starts.get(i).copied().flatten() { level.start = s; } + // The source's own label: its format and text, or its bullet. + match labels.get(i).copied().flatten() { + Some(l) if ordered && l.format != wordcraft_doc::section::NumFormat::Bullet => { + level.format = l.format; + level.text = l.text(i as u8); + } + Some(l) if !ordered => { + if let Some(c) = l.bullet().filter(|c| !c.is_control() && !level.text.starts_with(*c)) { + let custom = levels_for(ListKind::BulletChar(c)); + level.text = c.to_string(); + level.chr.font = custom.first().and_then(|l| l.chr.font.clone()); + } + } + _ => {} + } level }) .collect(); @@ -392,14 +415,20 @@ impl<'a> Builder<'a> { while let Some(b) = blocks.get(i) { match b { FBlock::Para(p) if p.list.is_some() => { - // A run of consecutive list paragraphs is one list, until a top-level item of - // the other kind (bulleted or numbered) starts a new one. + // A run of consecutive list paragraphs is one list, until an item of another + // list or (when the source doesn't tell lists apart) a top-level item of the + // other kind (bulleted or numbered) starts a new one. A list the source + // numbers goes on where it shows up again. let mut j = i; let mut group: Vec = Vec::new(); let mut top: Option = None; + let id = p.list.map_or(0, |l| l.list); while let Some(FBlock::Para(q)) = blocks.get(j) { let Some(li) = q.list else { break }; - if li.level == 0 { + if li.list != id { + break; + } + if li.level == 0 && id == 0 { if top.is_some_and(|o| o != li.ordered) { break; } @@ -408,7 +437,16 @@ impl<'a> Builder<'a> { group.push(li); j += 1; } - let num = self.new_list(&group); + let num = match self.lists.get(&id) { + Some(num) if id != 0 => *num, + _ => { + let num = self.new_list(&group); + if id != 0 { + self.lists.insert(id, num); + } + num + } + }; for q in blocks.get(i..j).unwrap_or(&[]) { if let FBlock::Para(q) = q { let para = self.para(q, Some(num)); diff --git a/crates/formats/src/model/types.rs b/crates/formats/src/model/types.rs index 1cc99dc722..460699db62 100644 --- a/crates/formats/src/model/types.rs +++ b/crates/formats/src/model/types.rs @@ -238,6 +238,9 @@ pub struct FlowOpts { pub notes: bool, /// Comments as [`Inline::Comment`] and [`Inline::CommentEnd`] (otherwise left out). pub comments: bool, + /// Lists with their identity and labels ([`ListInfo::list`], [`ListInfo::label`]; otherwise + /// consecutive items form one list in the default look of its kind). + pub lists: bool, } /// Base text size (points) a phonetic guide falls back to when the source states none: the @@ -281,6 +284,103 @@ pub struct ListInfo { pub level: u8, /// Number of the first item of an ordered list (1 unless the source says otherwise). pub start: u32, + /// The list the paragraph belongs to, when the source tells its lists apart (ODT): items + /// of one list share its number, wherever they are, and a different number starts another + /// list. 0: not stated (consecutive items form one list). + pub list: u32, + /// How the paragraph's level shows its number or bullet, when the source says. + pub label: Option