From 650a97b623a32ebd604c04cfa555f47accaa06f8 Mon Sep 17 00:00:00 2001 From: Aleksander Helgaker Date: Wed, 4 Nov 2026 23:08:09 +0100 Subject: [PATCH] ODT: remove the display settings; one choice for each open case (#5609) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- apps/wordcraft-cli/src/main.rs | 33 +-- apps/wordcraft-cli/src/tests.rs | 18 -- crates/doc/src/lib.rs | 3 +- crates/doc/src/odf_profile.rs | 255 ------------------ crates/doc/src/settings.rs | 18 +- crates/engine/src/cmd/file.rs | 4 +- crates/engine/src/cmd/options.rs | 51 +--- crates/engine/src/io.rs | 57 ++-- crates/engine/src/io_ext.rs | 9 +- crates/engine/src/prefs.rs | 12 - crates/engine/src/tests.rs | 54 +--- crates/formats/src/lib.rs | 15 +- crates/formats/src/model/from_doc.rs | 3 +- crates/formats/src/odt.rs | 4 +- crates/formats/src/odt/body.rs | 11 +- crates/formats/src/odt/body/drawing.rs | 12 +- crates/formats/src/odt/flat.rs | 13 +- crates/formats/src/odt/frames.rs | 51 ++-- crates/formats/src/odt/import.rs | 106 ++++---- crates/formats/src/odt/package.rs | 2 +- crates/formats/src/odt/strokes.rs | 3 - crates/formats/src/odt/styles.rs | 23 +- crates/formats/src/odt/tests.rs | 14 +- crates/formats/src/odt/tests_more.rs | 2 +- crates/formats/src/odt/tests_more/floats.rs | 53 ++-- crates/formats/src/odt/tests_more/headers.rs | 34 +-- .../formats/src/odt/tests_more/page_setup.rs | 26 +- crates/formats/src/odt/tests_more/shapes.rs | 2 +- crates/formats/src/odt/tests_more/tabs.rs | 48 ++-- .../formats/src/odt/tests_more/text_boxes.rs | 6 +- .../tests_more/{profile.rs => unstated.rs} | 124 ++++----- crates/layout/src/para/lines.rs | 15 -- crates/layout/src/tests.rs | 30 +-- crates/ui-egui/src/options.rs | 95 ------- crates/ui-egui/src/options/tests.rs | 43 --- docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- docs/layout-parity.md | 3 +- docs/odf-interpretation.md | 101 +++---- 40 files changed, 324 insertions(+), 1043 deletions(-) delete mode 100644 crates/doc/src/odf_profile.rs rename crates/formats/src/odt/tests_more/{profile.rs => unstated.rs} (58%) diff --git a/ROADMAP.md b/ROADMAP.md index 5b5ae25159..87c0e70128 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-11-04 · **Change:** trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously 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: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); 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-11-04 · **Change:** trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously 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: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); 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 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and @@ -139,6 +139,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-11-04: OpenDocument files have no display settings any more (gap 19): File › Options › Advanced › OpenDocument files, `file.options` `odf`, `WORDCRAFT_ODF`, the LibreOffice style and custom values are gone. WordCraft makes one choice for each case the standard leaves open, the one the default made, and still follows what a file states ([docs/odf-interpretation.md](docs/odf-interpretation.md)). - 2026-10-31: Dialogs are modal, as in Word (#3859): while one is open, the ribbon, File, the page and the panes of its document window take no clicks, and typing and shortcuts (Ctrl+O…) do nothing until it closes; WordCraft's own Open/Save As window is modal too. A press beside a modal dialog shakes it a little, so it is clear why nothing behind it reacts (Word beeps); with animation effects off, its border flashes instead. Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field stay modeless, as in Word for Mac 16.114.1 (checked on a newer build than the 16.113.4 the parity target names). - 2026-11-04: OpenDocument files are shown in one of two styles (gap 19): Microsoft Word style, the default, or LibreOffice style (File › Options › Advanced › OpenDocument files; `file.options` `{"odf": {"preset": "word"}}` or `"libreoffice"`; `WORDCRAFT_ODF=word` or `WORDCRAFT_ODF=libreoffice`). Custom stays for one's own value per case; `wordcraft`, the earlier id of Word's style, is still taken. - 2026-11-04: ODT element audit (gap 19, [docs/odt-element-audit.md](docs/odt-element-audit.md)): every element of the ODF 1.3 schema checked against 365 files and a document of our own for each one no file held that a text document can hold (the `presentation:`, `anim:` and `db:` elements only presentations and databases hold stay unseen); all open and save, and a save without edits keeps them (password, number, time and grid form controls included). Over the 220 files from other applications, 177 round-trip pixel-identical, 201 with the same text and 218 with the same page count (the latest measurement, after the groups and form-control work). @@ -769,6 +770,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-11-04 | trivial | Progress log: OpenDocument display settings removed (gap 19) | | 2026-10-31 | trivial | Progress log: dialogs are modal except the five Word keeps modeless; a press beside one shakes it (#3859) | | 2026-11-04 | trivial | Progress log: picture brightness, contrast and transparency (#1711, #1766) | | 2026-11-04 | trivial | Progress log: ODT element audit (gap 19) | diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 480484dac1..661bb1861d 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -68,10 +68,6 @@ Paper: documents created here, and HTML, Markdown and text files (which give no the system region's paper (A4, or Letter in the US and other Letter regions), read from LC_ALL / LC_PAPER / LANG or the platform. Set WORDCRAFT_REGION=en-US (or another tag) for the same output on every machine. - -OpenDocument (.odt, .ott, .fodt) files fill in what their standard leaves to the application in -Microsoft Word's style (WORDCRAFT_ODF=word, the default) or LibreOffice's -(WORDCRAFT_ODF=libreoffice); see docs/odf-interpretation.md. "; /// The subcommands, in `USAGE` order; `--help` or `-h` after any of them prints `USAGE` (#968). @@ -90,18 +86,11 @@ fn missing_argument() -> Failure { /// ([`password_from`]). As in the app, a document opened with one is saved with it again, so /// `convert` does not quietly write a copy the password no longer protects. fn open(path: &Path, password: Option<&str>) -> Result { - open_in(path, password, odf_profile()?) -} - -/// [`open`] with the OpenDocument style `odf`, which the session keeps: a later `file.open` or -/// `file.new` in a `run` script reads files the same way. -fn open_in(path: &Path, password: Option<&str>, odf: wordcraft_doc::odf_profile::OdfProfile) -> Result { // A file that gives no page size (HTML, Markdown, plain text) opens on the system region's // paper, as in the app: A4 in Italy, Letter in the US (#1381). let region = system_region(); - let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, ®ion, &odf)?; + let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, ®ion)?; let mut s = session(doc); - s.prefs.odf = odf; s.path = Some(path.into()); if encrypted { s.password = password.map(wordcraft_engine::io::Password::new); @@ -183,24 +172,6 @@ fn session(doc: wordcraft_doc::Document) -> Session { s } -/// How OpenDocument files fill in what their standard leaves open: `WORDCRAFT_ODF` names a preset -/// (`word`, Microsoft Word's style and the default, or `libreoffice`; `wordcraft` is taken too). -fn odf_profile() -> Result { - odf_from(std::env::var("WORDCRAFT_ODF").ok()) -} - -/// [`odf_profile`] from `WORDCRAFT_ODF`'s value, if set. -fn odf_from(var: Option) -> Result { - use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile}; - match var.filter(|v| !v.trim().is_empty()) { - None => Ok(OdfProfile::default()), - Some(v) => match OdfPreset::from_id(&v).filter(|p| *p != OdfPreset::Custom) { - Some(p) => Ok(OdfProfile::of(p)), - None => Err(format!("WORDCRAFT_ODF={v}: one of word, libreoffice")), - }, - } -} - /// The system region, read as the app reads it (locale variables, else the platform's locale). fn system_region() -> wordcraft_engine::Region { wordcraft_engine::Region::from_locale_vars(|name| std::env::var(name).ok(), sys_locale::get_locale) @@ -533,8 +504,6 @@ fn run>(args: &[S]) -> Result<(), Failure> { // run starts with one open document. (None, t) => { let mut s = session(wordcraft_doc::Document::new()); - // The script's own `file.open`s read OpenDocument files in `WORDCRAFT_ODF`'s style. - s.prefs.odf = odf_profile()?; let first = s.document_id(); let params = t.map_or_else(|| json!({}), |t| json!({"template": t})); s.run("file.new", ¶ms).map_err(|e| e.to_string())?; diff --git a/apps/wordcraft-cli/src/tests.rs b/apps/wordcraft-cli/src/tests.rs index 1201fb0caa..a87f778808 100644 --- a/apps/wordcraft-cli/src/tests.rs +++ b/apps/wordcraft-cli/src/tests.rs @@ -543,21 +543,3 @@ fn script_parse_resolves_paths_against_the_base() { assert_eq!(s.save, vec!["o.docx".to_string()]); assert_eq!(s.commands.len(), 1); } - -/// `WORDCRAFT_ODF` names a display style, and a session opened in one keeps it, so a `run` -/// script's later `file.open` reads OpenDocument files the same way. -#[test] -fn the_odf_style_is_kept_by_the_session() { - use wordcraft_doc::odf_profile::OdfProfile; - assert_eq!(odf_from(None), Ok(OdfProfile::default())); - assert_eq!(odf_from(Some("libreoffice".into())), Ok(OdfProfile::libreoffice())); - assert_eq!(odf_from(Some("word".into())), Ok(OdfProfile::word())); - assert!(odf_from(Some("custom".into())).is_err()); - let dir = std::env::temp_dir().join(format!("wc-cli-odf-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("a.txt"); - std::fs::write(&path, "Hello").unwrap(); - let s = open_in(&path, None, OdfProfile::libreoffice()).unwrap(); - assert_eq!(s.prefs.odf, OdfProfile::libreoffice()); - let _ = std::fs::remove_dir_all(&dir); -} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 6682440f38..e195ad7c3a 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -39,7 +39,6 @@ pub mod math_latex; pub mod math_linear; pub mod math_symbols; pub mod numbering; -pub mod odf_profile; pub mod para; pub mod picfx; pub mod props; @@ -87,7 +86,7 @@ pub use error::{DocError, Result}; pub use parts::{MAX_LINKED_BOXES, MAX_TEXT_BOX_INSET, NoteSeparators, Part, PartKind, PermRange, TEXT_BOX_INSET, TextBody, free_id}; pub use position::{Path, Pos, StoryRef}; pub use settings::{ - COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, PagePicture, Settings, Source, + COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source, THEME_COLORS, Watermark, Zoom, ZoomFit, }; pub use text_walk::count_words; diff --git a/crates/doc/src/odf_profile.rs b/crates/doc/src/odf_profile.rs deleted file mode 100644 index 36f3d188da..0000000000 --- a/crates/doc/src/odf_profile.rs +++ /dev/null @@ -1,255 +0,0 @@ -//! How WordCraft fills in what the OpenDocument standard leaves to the application. -//! -//! Where ODF 1.3 defines a value (directly, or by referring to XSL for an `fo:` property), that -//! is the rule and nothing here changes it. Where it doesn't (ODF 1.3 Part 3 §16.2: "in all -//! remaining cases an implementation-dependent value is used"), each application chooses, and -//! documents from one application can look different in another. An [`OdfProfile`] is that -//! choice for every such case: Microsoft Word's style ([`OdfPreset::Word`], the default: as -//! WordCraft lays out Word documents), LibreOffice's style ([`OdfPreset::LibreOffice`]), or the -//! user's own ([`OdfPreset::Custom`]). `docs/odf-interpretation.md` lists every case. - -use serde::{Deserialize, Serialize}; - -use crate::props::Rgb; - -/// Whose choices fill in what the standard leaves open. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum OdfPreset { - /// Microsoft Word's style: the behaviour WordCraft has for its own and Word documents. - /// (`wordcraft`, its earlier id, is still taken.) - #[default] - #[serde(alias = "wordcraft")] - Word, - /// LibreOffice's (and Collabora's, which share its engine). - LibreOffice, - /// The user's own choice for each case ([`OdfProfile`]'s values). - Custom, -} - -impl OdfPreset { - pub const ALL: [OdfPreset; 3] = [OdfPreset::Word, OdfPreset::LibreOffice, OdfPreset::Custom]; - - pub fn id(self) -> &'static str { - match self { - OdfPreset::Word => "word", - OdfPreset::LibreOffice => "libreoffice", - OdfPreset::Custom => "custom", - } - } - - /// The preset named `id` (any case): its id, or `wordcraft` / `msword` for Word's. - pub fn from_id(id: &str) -> Option { - let id = id.trim(); - if ["wordcraft", "msword", "ms-word"].iter().any(|a| a.eq_ignore_ascii_case(id)) { - return Some(OdfPreset::Word); - } - OdfPreset::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id)) - } - - /// The two styles a document can be shown in, as the user chooses between them (Custom - /// aside). - pub const STYLES: [OdfPreset; 2] = [OdfPreset::Word, OdfPreset::LibreOffice]; - - /// Its name as the user sees it (File › Options, English; the UI translates it). - pub fn label(self) -> &'static str { - match self { - OdfPreset::Word => "Microsoft Word style", - OdfPreset::LibreOffice => "LibreOffice style", - OdfPreset::Custom => "Custom", - } - } -} - -/// The most a length in a profile may be, points (22 inches). -pub const MAX_LENGTH: f32 = 1584.0; - -/// The choice for each case the standard leaves open (see the module docs). With a preset other -/// than [`OdfPreset::Custom`], the preset's values apply whatever these hold -/// ([`OdfProfile::effective`]). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct OdfProfile { - pub preset: OdfPreset, - /// A page margin a page layout doesn't state, points. §20.206–20.209 refer to XSL's margins - /// (§7.10), whose initial value is 0; whether that initial value comes with the reference - /// the standard doesn't say (LibreOffice uses 2 cm). - pub page_margin: f32, - /// The height of a header or footer whose style states none (§20.212.2 `fo:min-height` - /// has no default), points. - pub header_min_height: f32, - /// A header's or footer's minimum height takes in its spacing from the body (`fo:margin-*` - /// of `style:header-footer-properties`; the standard doesn't say), rather than the spacing - /// coming after it. - pub header_height_includes_spacing: bool, - /// What a header or footer whose style doesn't say does with its spacing when its content - /// is taller than its height (§20.264 `style:dynamic-spacing` has no default): grows into it - /// (`true`), or keeps it (`false`, both presets: with no height stated, growing into it would - /// drop the spacing the file states). - pub dynamic_spacing: bool, - /// Paragraph tab stops are measured from the paragraph's indent rather than the margin when - /// the document doesn't say (§19.512.3 `style:position` doesn't say from where; §19.861 `text:relative-tab-stop-position` says so only for indexes; LibreOffice's own - /// `TabsRelativeToIndent` setting, in `settings.xml`, says so when present). - pub tabs_from_indent: bool, - /// The gap between columns whose `style:columns` states neither one nor each column - /// (§19.233 `fo:column-gap` has no default), points. - pub column_gap: f32, - /// A drawing shape's fill colour when no style gives one (§20.119 `draw:fill-color` has no - /// default). - pub shape_fill: Rgb, - /// A drawing shape's line colour when no style gives one (§20.408 `svg:stroke-color` has no default). - pub shape_line: Rgb, - /// A drawing shape's line width when no style gives one, and what a width of 0 draws at - /// (§20.410 `svg:stroke-width` gives neither), points. - pub hairline: f32, - /// A page-anchored frame placed against a paragraph (a combination §20.398 and §20.299 don't - /// define) is placed against the page (`true`), else against the page's text area. - pub page_frame_paragraph_is_page: bool, - /// An in-line frame placed against something an in-line frame can't be (a paragraph or a - /// page, §20.398 Table 23) is placed against the baseline (`true`), else stands on it. - pub inline_invalid_relation_is_baseline: bool, - /// A line holding only in-line pictures is as tall as they are (`true`), else as tall as - /// its text would be too (the standard defines no line-height algorithm). - pub picture_lines_fit_pictures: bool, - /// A columned section that doesn't say whether its columns are balanced (§20.428 - /// `text:dont-balance-text-columns` has no default, and its value descriptions contradict - /// its name) ends with balanced columns at the end of the document too (`true`), else only - /// when text follows on the same page, as in Word. - pub balance_section_columns: bool, - /// The space between two paragraphs is the first's space after plus the second's space - /// before (`true`), else the larger of the two, when the document doesn't say (the - /// standard doesn't; LibreOffice's own `AddParaTableSpacing` setting does when present). - pub sum_paragraph_spacing: bool, - /// Widow and orphan control when no style states `fo:widows` or `fo:orphans` (§20.312, - /// §20.244 refer to XSL's, whose initial value is 2 lines; whether that initial value comes - /// with the reference the standard doesn't say, and LibreOffice reads none stated as none). - pub widow_control: bool, -} - -impl Default for OdfProfile { - fn default() -> Self { - OdfProfile::word() - } -} - -impl OdfProfile { - /// Microsoft Word's style: as WordCraft lays out its own and Word documents. - pub fn word() -> OdfProfile { - OdfProfile { - preset: OdfPreset::Word, - page_margin: 0.0, - header_min_height: 0.0, - header_height_includes_spacing: false, - dynamic_spacing: false, - tabs_from_indent: false, - column_gap: 36.0, - shape_fill: Rgb(0x15, 0x60, 0x82), - shape_line: Rgb(0x0E, 0x40, 0x5A), - hairline: 0.25, - page_frame_paragraph_is_page: false, - inline_invalid_relation_is_baseline: false, - picture_lines_fit_pictures: false, - balance_section_columns: false, - sum_paragraph_spacing: false, - widow_control: true, - } - } - - /// LibreOffice's choices, measured in LibreOffice 24 where it doesn't document them. - pub fn libreoffice() -> OdfProfile { - OdfProfile { - preset: OdfPreset::LibreOffice, - page_margin: 2.0 * 72.0 / 2.54, - header_min_height: 0.5 * 72.0 / 2.54, - header_height_includes_spacing: true, - dynamic_spacing: false, - tabs_from_indent: true, - column_gap: 0.0, - shape_fill: Rgb(0x72, 0x9F, 0xCF), - shape_line: Rgb(0x34, 0x65, 0xA4), - hairline: 0.5, - page_frame_paragraph_is_page: true, - inline_invalid_relation_is_baseline: true, - picture_lines_fit_pictures: true, - balance_section_columns: true, - sum_paragraph_spacing: true, - widow_control: false, - } - } - - /// A preset's values (a custom profile starts from Word's style). - pub fn of(preset: OdfPreset) -> OdfProfile { - match preset { - OdfPreset::Word | OdfPreset::Custom => OdfProfile { preset, ..OdfProfile::word() }, - OdfPreset::LibreOffice => OdfProfile::libreoffice(), - } - } - - /// The values that apply: a preset's own, or, for a custom profile, these (lengths made - /// finite and bounded). - pub fn effective(&self) -> OdfProfile { - let length = |v: f32, least: f32| if v.is_finite() { v.clamp(least, MAX_LENGTH) } else { least }; - match self.preset { - OdfPreset::Custom => OdfProfile { - page_margin: length(self.page_margin, 0.0), - header_min_height: length(self.header_min_height, 0.0), - column_gap: length(self.column_gap, 0.0), - hairline: length(self.hairline, 0.05), - ..self.clone() - }, - p => OdfProfile::of(p), - } - } - - /// What the layout needs to know of it for a document opened from ODF. - pub fn layout(&self) -> OdfLayout { - let e = self.effective(); - OdfLayout { picture_lines_fit_pictures: e.picture_lines_fit_pictures, tabs_from_indent: e.tabs_from_indent } - } -} - -/// A document opened from ODF, for the layout: rules of the standard that differ from Word's -/// (a paragraph's padding takes room on a side without a border, XSL's box model) apply, and -/// the profile's choices that are made as the document is laid out. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct OdfLayout { - /// See [`OdfProfile::picture_lines_fit_pictures`]. - pub picture_lines_fit_pictures: bool, - /// Tab stops are measured from the paragraph's left indent, default ones too (LibreOffice's - /// `TabsRelativeToIndent`, else [`OdfProfile::tabs_from_indent`]). The document's explicit - /// stops are held from the margin either way; the default ones are counted from the indent. - pub tabs_from_indent: bool, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn presets_and_custom_values() { - assert_eq!(OdfProfile::default().preset, OdfPreset::Word); - assert_eq!(OdfPreset::from_id(" LibreOffice "), Some(OdfPreset::LibreOffice)); - assert_eq!(OdfPreset::from_id("google"), None); - // Word's style by its id, and by the id it had before. - for id in ["word", "Word", "wordcraft", "MSWord", "ms-word"] { - assert_eq!(OdfPreset::from_id(id), Some(OdfPreset::Word), "{id}"); - } - assert_eq!(serde_json::from_value::(serde_json::json!("wordcraft")).unwrap(), OdfPreset::Word); - assert_eq!(serde_json::from_value::(serde_json::json!("word")).unwrap(), OdfPreset::Word); - assert_eq!(OdfPreset::STYLES.map(OdfPreset::label), ["Microsoft Word style", "LibreOffice style"]); - // Saved and taken by `file.options` as its id. - for p in OdfPreset::ALL { - assert_eq!(serde_json::to_value(p).unwrap(), serde_json::json!(p.id())); - } - // A preset's values apply whatever the profile holds. - let lo = OdfProfile { column_gap: 99.0, ..OdfProfile::libreoffice() }; - assert_eq!(lo.effective(), OdfProfile::libreoffice()); - // A custom profile's own, bounded. - let custom = OdfProfile { preset: OdfPreset::Custom, column_gap: f32::NAN, header_min_height: 1e9, ..OdfProfile::word() }; - let e = custom.effective(); - assert_eq!((e.column_gap, e.header_min_height), (0.0, MAX_LENGTH)); - assert!(OdfProfile::libreoffice().layout().picture_lines_fit_pictures); - assert!(!OdfProfile::word().layout().picture_lines_fit_pictures); - } -} diff --git a/crates/doc/src/settings.rs b/crates/doc/src/settings.rs index 6efe200d2e..3dd38116c7 100644 --- a/crates/doc/src/settings.rs +++ b/crates/doc/src/settings.rs @@ -167,10 +167,22 @@ pub struct Settings { #[serde(skip_serializing_if = "std::ops::Not::not")] pub remove_date_time: bool, /// The document was opened from ODF: the standard's rules that differ from Word's apply as - /// it is laid out, with the choices it leaves open made as the user's profile made them - /// (see [`crate::odf_profile`]). + /// it is laid out. #[serde(skip_serializing_if = "Option::is_none")] - pub odf: Option, + pub odf: Option, +} + +/// A document opened from ODF, for the layout: rules of the standard that differ from Word's (a +/// paragraph's padding takes room on a side without a border, XSL's box model) apply, and what +/// the file says about laying it out. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct OdfLayout { + /// Tab stops are measured from the paragraph's left indent, default ones too (LibreOffice's + /// `TabsRelativeToIndent` in the file; from the margin when it doesn't say). The document's + /// explicit stops are held from the margin either way; the default ones are counted from the + /// indent. + pub tabs_from_indent: bool, } /// A saved zoom: a percentage, or a fit mode (the percentage it last came to). diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 3d38a39173..67284a2474 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -260,12 +260,12 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { if bytes.is_empty() { (normal_document(s), false) } else { - crate::io::open_bytes_in(path, &bytes, password, &s.region(), &s.prefs.odf).map_err(CmdError::Failed)? + crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)? } } else if crate::io::is_empty_file(std::path::Path::new(path)) { (normal_document(s), false) } else { - crate::io::open_path_in(std::path::Path::new(path), password, &s.region(), &s.prefs.odf).map_err(CmdError::Failed)? + crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)? }; // Plain text and Markdown carry no language: they are in the editing language. let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase(); diff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs index a0e590759f..add7d879d2 100644 --- a/crates/engine/src/cmd/options.rs +++ b/crates/engine/src/cmd/options.rs @@ -5,7 +5,6 @@ use serde_json::{Value, json}; use crate::{CmdError, CmdResult, CommandSpec, Prefs, Session, p}; -use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile}; /// Options panes, in the order the page lists them (the `pane` param of `file.options`). pub const PANES: [&str; 8] = ["general", "display", "proofing", "save", "language", "accessibility", "advanced", "agents"]; @@ -23,7 +22,7 @@ pub fn specs() -> Vec { vec![ CommandSpec::new("file.options", "Options", "File", options) .params( - r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string], "odf"?: {"preset"?: "word|libreoffice|custom" (word: Microsoft Word style, the default; libreoffice: LibreOffice style), "pageMargin"?: pt, "headerMinHeight"?: pt, "headerHeightIncludesSpacing"?: bool, "dynamicSpacing"?: bool, "tabsFromIndent"?: bool, "columnGap"?: pt, "shapeFill"?: "RRGGBB", "shapeLine"?: "RRGGBB", "hairline"?: pt, "pageFrameParagraphIsPage"?: bool, "inlineInvalidRelationIsBaseline"?: bool, "pictureLinesFitPictures"?: bool, "balanceSectionColumns"?: bool, "sumParagraphSpacing"?: bool, "widowControl"?: bool} (how OpenDocument files opened from now on fill in what their standard leaves open, docs/odf-interpretation.md; a value without a preset makes it custom)} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#, + r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#, ) .pure(), CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| { @@ -146,7 +145,6 @@ fn options(s: &mut Session, v: &Value) -> CmdResult { Some(_) => return Err(CmdError::Params("`marks` must be an object of true/false".into())), } let check_spelling = bool_param(v, "checkSpelling")?; - let odf = odf_param(v, &s.prefs.odf)?; let add = words_param(v, "addWords")?; let remove = words_param(v, "removeWords")?; @@ -159,7 +157,6 @@ fn options(s: &mut Session, v: &Value) -> CmdResult { || !flag_values.is_empty() || !mark_values.is_empty() || check_spelling.is_some() - || odf.is_some() || !add.is_empty() || !remove.is_empty(); if let Some(name) = author { @@ -191,10 +188,6 @@ fn options(s: &mut Session, v: &Value) -> CmdResult { s.view.proofing = b; s.prefs.check_spelling = b; } - // ODF files opened from now on fill in what their standard leaves open this way. - if let Some(o) = odf { - s.prefs.odf = o; - } for w in &add { wordcraft_proof::add_word(w); } @@ -240,9 +233,6 @@ pub fn current(s: &Session) -> Value { "dictionary": wordcraft_proof::user_dictionary(), "dictionariesFolder": pr.dictionaries_folder, "userTemplatesFolder": pr.user_templates_folder, - // How ODF files fill in what the standard leaves open: the chosen preset, the values - // that apply, and the custom values kept for when the preset is custom. - "odf": {"preset": pr.odf.preset, "effective": pr.odf.effective(), "custom": pr.odf}, "dictionaries": wordcraft_proof::installed_dictionaries() .into_iter() .map(|(lang, dic)| json!({"language": lang.tag(), "file": dic.map(|d| d.to_string_lossy().into_owned())})) @@ -250,45 +240,6 @@ pub fn current(s: &Session) -> Value { }) } -/// The `odf` setting: `{"preset"?: "word|libreoffice|custom" (word: Microsoft Word style, the default; libreoffice: LibreOffice style), …case values}` over `current`. -/// A case value given without a preset makes the profile custom; the values are kept whatever -/// the preset, so choosing custom again brings them back. Lengths are points, at most 22 inches; -/// colours `RRGGBB`. -fn odf_param(v: &Value, current: &OdfProfile) -> Result, CmdError> { - let Some(o) = v.get("odf").filter(|o| !o.is_null()) else { return Ok(None) }; - let obj = o.as_object().ok_or_else(|| CmdError::Params("`odf` must be an object".into()))?; - let mut merged = serde_json::to_value(current).map_err(|e| CmdError::Failed(e.to_string()))?; - let known: Vec = merged.as_object().map(|m| m.keys().cloned().collect()).unwrap_or_default(); - let mut values = false; - for (k, x) in obj { - if !known.contains(k) { - return Err(CmdError::Params(format!("`odf.{k}`: not a setting; one of {}", known.join(", ")))); - } - if let Some(n) = x.as_f64().filter(|_| k != "preset") - && !(n.is_finite() && (0.0..=f64::from(wordcraft_doc::odf_profile::MAX_LENGTH)).contains(&n)) - { - return Err(CmdError::Params(format!("`odf.{k}`: a length from 0 to {} points", wordcraft_doc::odf_profile::MAX_LENGTH))); - } - let x = if k == "preset" { - let p = x.as_str().and_then(OdfPreset::from_id).ok_or_else(|| CmdError::Params("`odf.preset`: word, libreoffice or custom".into()))?; - json!(p) - } else { - values = true; - x.clone() - }; - if let Some(m) = merged.as_object_mut() { - m.insert(k.clone(), x); - } - } - if values - && !obj.contains_key("preset") - && let Some(m) = merged.as_object_mut() - { - m.insert("preset".into(), json!(OdfPreset::Custom)); - } - serde_json::from_value(merged).map(Some).map_err(|e| CmdError::Params(format!("`odf`: {e}"))) -} - /// A folder setting (`dictionariesFolder`, `userTemplatesFolder`): `auto` (or nothing) forgets /// it; otherwise an existing folder, given as an absolute path or one starting with `~/`. With /// `local_only`, a network folder is refused before anything looks at it (#2852): even checking diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index e0933f087a..5ffc345e53 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -1,7 +1,6 @@ //! File format dispatch by extension. Format crates plug in here. use wordcraft_doc::Document; -use wordcraft_doc::odf_profile::OdfProfile; #[cfg(not(target_arch = "wasm32"))] mod safe_save; @@ -142,13 +141,13 @@ pub fn can_encrypt(name: &str) -> bool { /// Open a password-protected ODT package (ODF 1.3 Part 2 §3.4). The errors for a missing or /// wrong password are the `.docx` reader's texts, so [`needs_password`] and [`wrong_password`] /// recognise them. -fn open_encrypted_odt(bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps, odf: &OdfProfile) -> Result { +fn open_encrypted_odt(bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result { let pw = password.ok_or_else(|| format!("ODT: {}", wordcraft_docx::PASSWORD_REQUIRED))?; let plain = wordcraft_formats::odt::decrypt(bytes, pw).map_err(|e| match e { wordcraft_formats::odt::CryptError::WrongPassword => format!("ODT: {}", wordcraft_docx::WRONG_PASSWORD), other => other.to_string(), })?; - match crate::io_ext::open("odt", &plain, page, odf) { + match crate::io_ext::open("odt", &plain, page) { Some(r) => r, None => Err("ODT: no reader".into()), } @@ -188,7 +187,7 @@ fn open_binary_doc(bytes: &[u8], password: Option<&str>) -> Result<(Document, bo /// whether the file was encrypted. Without the password needed the error satisfies /// [`needs_password`]; with a wrong one, [`wrong_password`]. pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Document, bool), String> { - open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default(), &OdfProfile::default()) + open_bytes_in(name, bytes, password, &wordcraft_geom::Region::default()) } /// The page a file that gives no page size (plain text, Markdown, HTML) opens on: `region`'s @@ -199,17 +198,10 @@ pub fn region_page(region: &wordcraft_geom::Region) -> wordcraft_doc::SectionPro } /// [`open_bytes_with`] in `region`: a file that gives no page size opens on the region's paper -/// (see [`region_page`]); a document with its own page size keeps it. An OpenDocument file fills -/// in what its standard leaves open as `odf` says (the user's choice, [`crate::Prefs::odf`]). -pub fn open_bytes_in( - name: &str, - bytes: &[u8], - password: Option<&str>, - region: &wordcraft_geom::Region, - odf: &OdfProfile, -) -> Result<(Document, bool), String> { +/// (see [`region_page`]); a document with its own page size keeps it. +pub fn open_bytes_in(name: &str, bytes: &[u8], password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { let page = region_page(region); - let (doc, encrypted) = read_bytes(name, bytes, password, &page, odf)?; + let (doc, encrypted) = read_bytes(name, bytes, password, &page)?; load_embedded_fonts(&doc); Ok((doc, encrypted)) } @@ -227,13 +219,7 @@ fn load_embedded_fonts(doc: &Document) { } } -fn read_bytes( - name: &str, - bytes: &[u8], - password: Option<&str>, - page: &wordcraft_doc::SectionProps, - odf: &OdfProfile, -) -> Result<(Document, bool), String> { +fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result<(Document, bool), String> { let ext = ext_of(name); if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) { let mut doc = read_docx(bytes, password).map_err(|e| e.to_string())?; @@ -241,7 +227,7 @@ fn read_bytes( return Ok((doc, true)); } if (ext == "odt" || ext == "ott") && wordcraft_formats::odt::is_encrypted(bytes) { - let mut doc = open_encrypted_odt(bytes, password, page, odf)?; + let mut doc = open_encrypted_odt(bytes, password, page)?; opened(&mut doc); return Ok((doc, true)); } @@ -261,7 +247,7 @@ fn read_bytes( d.normalize_runs(); d } - other => match crate::io_ext::open(other, bytes, page, odf) { + other => match crate::io_ext::open(other, bytes, page) { Some(r) => r?, None => return open_by_content(name, other, bytes, password, page), }, @@ -435,11 +421,10 @@ fn open_by_content( } else if is_word_xml(bytes) { (wordcraft_docx::read_xml(bytes).map_err(|e| format!("{name}: {e}"))?, false) } else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && looks_like_web_page(bytes) { - let opened = crate::io_ext::open("html", bytes, page, &OdfProfile::default()).unwrap_or_else(|| Err(format!("{name}: can't open web pages"))); + let opened = crate::io_ext::open("html", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web pages"))); (opened?, false) } else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && wordcraft_formats::mht::is_mht(bytes) { - let opened = - crate::io_ext::open("mht", bytes, page, &OdfProfile::default()).unwrap_or_else(|| Err(format!("{name}: can't open web archives"))); + let opened = crate::io_ext::open("mht", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web archives"))); (opened?, false) } else if wordcraft_formats::txt::is_text(bytes) { (wordcraft_formats::txt::import_on(bytes, page), false) @@ -492,17 +477,12 @@ pub fn open_path(path: &std::path::Path) -> Result { /// [`open_path`] for a file that may be password-protected (see [`open_bytes_with`]). #[cfg(not(target_arch = "wasm32"))] pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<(Document, bool), String> { - open_path_in(path, password, &wordcraft_geom::Region::default(), &OdfProfile::default()) + open_path_in(path, password, &wordcraft_geom::Region::default()) } -/// [`open_path_with`] in `region`, OpenDocument files as `odf` says (see [`open_bytes_in`]). +/// [`open_path_with`] in `region` (see [`open_bytes_in`]). #[cfg(not(target_arch = "wasm32"))] -pub fn open_path_in( - path: &std::path::Path, - password: Option<&str>, - region: &wordcraft_geom::Region, - odf: &OdfProfile, -) -> Result<(Document, bool), String> { +pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { let bytes = read_document(path)?; let name = path.to_string_lossy(); if matches!(ext_of(&name).as_str(), "html" | "htm" | "xhtml") { @@ -514,7 +494,7 @@ pub fn open_path_in( opened(&mut doc); return Ok((doc, false)); } - open_bytes_in(&name, &bytes, password, region, odf) + open_bytes_in(&name, &bytes, password, region) } /// Largest document file opened by path. @@ -851,12 +831,7 @@ pub fn open_path_with(path: &std::path::Path, _password: Option<&str>) -> Result } #[cfg(target_arch = "wasm32")] -pub fn open_path_in( - path: &std::path::Path, - password: Option<&str>, - _region: &wordcraft_geom::Region, - _odf: &OdfProfile, -) -> Result<(Document, bool), String> { +pub fn open_path_in(path: &std::path::Path, password: Option<&str>, _region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { open_path_with(path, password) } diff --git a/crates/engine/src/io_ext.rs b/crates/engine/src/io_ext.rs index a0b3996bb0..c9df08441a 100644 --- a/crates/engine/src/io_ext.rs +++ b/crates/engine/src/io_ext.rs @@ -4,19 +4,14 @@ use wordcraft_doc::Document; /// Open a format other than plain text / JSON; files that give no page size (Markdown, HTML) /// start on `page`. -pub fn open( - ext: &str, - bytes: &[u8], - page: &wordcraft_doc::SectionProps, - odf: &wordcraft_doc::odf_profile::OdfProfile, -) -> Option> { +pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option> { match ext { "docx" | "docm" | "dotx" | "dotm" => Some(crate::io::read_docx(bytes, None).map_err(|e| e.to_string())), "doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())), // Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element. "xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())), // HTML, Markdown, ODT, RTF…: pictures fit the text width (see `io::fit_pictures`). - other => wordcraft_formats::import_with(other, bytes, page, odf).map(|r| { + other => wordcraft_formats::import_on(other, bytes, page).map(|r| { r.map(|mut doc| { crate::io::fit_pictures(&mut doc); draw_charts(&mut doc); diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index cb645ce1d6..a1cece1bd8 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -59,17 +59,6 @@ pub struct Prefs { /// in Word). A damaged saved value reads as the defaults. #[serde(deserialize_with = "crate::cmd::tools::lenient_autocorrect")] pub autocorrect: crate::cmd::tools::AutoCorrectPrefs, - /// How OpenDocument files fill in what their standard leaves to the application: WordCraft's - /// choices, LibreOffice's, or the user's own (File › Options › Advanced, `file.options`'s - /// `odf`; see `docs/odf-interpretation.md`). A damaged saved value reads as WordCraft's. - #[serde(deserialize_with = "lenient_odf")] - pub odf: wordcraft_doc::odf_profile::OdfProfile, -} - -/// A saved ODF profile that doesn't read (an older or damaged one) is WordCraft's. -fn lenient_odf<'de, D: serde::Deserializer<'de>>(d: D) -> Result { - let v = serde_json::Value::deserialize(d)?; - Ok(serde_json::from_value(v).unwrap_or_default()) } impl Default for Prefs { @@ -95,7 +84,6 @@ impl Default for Prefs { detect_language: true, default_language: None, autocorrect: Default::default(), - odf: Default::default(), } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index eaa77c7dd9..0792962d01 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -12023,54 +12023,14 @@ fn emphasis_marks_are_set_and_drawn() { assert!(s.run("format.emphasisMark", &json!({"mark": "sparkles"})).is_err()); } -/// File › Options › Advanced › OpenDocument files: a preset, or one case's value (which makes the -/// profile custom); junk is refused; files opened afterwards follow it. +/// Preferences saved while OpenDocument files had their own settings still read, without them, +/// as does a document saved with what those settings kept for its layout. #[test] -fn odf_interpretation_is_an_option_files_follow() { - use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile}; - let mut s = s(); - assert_eq!(s.prefs.odf, OdfProfile::word(), "Microsoft Word's style is the default"); - let r = run(&mut s, "file.options", json!({"odf": {"preset": "LibreOffice"}})); - assert_eq!(r["odf"]["preset"], json!("libreoffice")); - assert_eq!(s.prefs.odf.effective(), OdfProfile::libreoffice()); - // The two display styles, back and forth; Word's by its earlier id too. - let r = run(&mut s, "file.options", json!({"odf": {"preset": "word"}})); - assert_eq!(r["odf"]["preset"], json!("word")); - assert_eq!(s.prefs.odf.effective(), OdfProfile::word()); - run(&mut s, "file.options", json!({"odf": {"preset": "libreoffice"}})); - let r = run(&mut s, "file.options", json!({"odf": {"preset": "wordcraft"}})); - assert_eq!(r["odf"]["preset"], json!("word")); - run(&mut s, "file.options", json!({"odf": {"preset": "libreoffice"}})); - run(&mut s, "file.options", json!({"odf": {"columnGap": 18.0}})); - assert_eq!((s.prefs.odf.preset, s.prefs.odf.effective().column_gap), (OdfPreset::Custom, 18.0)); - for junk in [ - json!({"odf": {"nope": 1}}), - json!({"odf": {"columnGap": -1}}), - json!({"odf": {"hairline": 1e9}}), - json!({"odf": 3}), - json!({"odf": {"preset": "google"}}), - ] { - assert!(s.run("file.options", &junk).is_err(), "{junk}"); - } - assert_eq!(s.prefs.odf.effective().column_gap, 18.0, "a refused change leaves it"); - - // A tab stop in an indented paragraph of a file that doesn't say how tabs are measured. - let content = r#"a"#; - let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); - for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content)] { - z.start_file(name, o).unwrap(); - std::io::Write::write_all(&mut z, data.as_bytes()).unwrap(); - } - let bytes = z.finish().unwrap().into_inner(); - let tab = |s: &Session| { - let (d, _) = crate::io::open_bytes_in("t.odt", &bytes, None, &s.region(), &s.prefs.odf).unwrap(); - d.body[0].as_para().unwrap().props.tabs.clone().unwrap_or_default().first().map(|t| t.pos) - }; - run(&mut s, "file.options", json!({"odf": {"preset": "wordcraft"}})); - assert_eq!(tab(&s), Some(72.0), "WordCraft: from the margin"); - run(&mut s, "file.options", json!({"odf": {"preset": "libreoffice"}})); - assert_eq!(tab(&s), Some(108.0), "LibreOffice: from the indent"); +fn what_the_opendocument_settings_saved_still_reads() { + let prefs: crate::Prefs = serde_json::from_value(json!({"countNotes": false, "odf": {"preset": "libreoffice", "columnGap": 0.0}})).unwrap(); + assert!(!prefs.count_notes); + let layout: wordcraft_doc::OdfLayout = serde_json::from_value(json!({"pictureLinesFitPictures": true, "tabsFromIndent": true})).unwrap(); + assert!(layout.tabs_from_indent); } #[test] diff --git a/crates/formats/src/lib.rs b/crates/formats/src/lib.rs index ef322ca417..e7e6e45ad0 100644 --- a/crates/formats/src/lib.rs +++ b/crates/formats/src/lib.rs @@ -56,21 +56,10 @@ pub fn import(ext: &str, bytes: &[u8]) -> Option> { /// plain text, Markdown, HTML and MHT start on it, and their tables size to it (#1381). The other /// formats keep the page their file gives. pub fn import_on(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option> { - import_with(ext, bytes, page, &wordcraft_doc::odf_profile::OdfProfile::default()) -} - -/// [`import_on`], OpenDocument files filling in what their standard leaves open as `odf` says -/// (see [`wordcraft_doc::odf_profile`]). -pub fn import_with( - ext: &str, - bytes: &[u8], - page: &wordcraft_doc::SectionProps, - odf: &wordcraft_doc::odf_profile::OdfProfile, -) -> Option> { let ext = ext.trim_start_matches('.').to_ascii_lowercase(); let mut doc = match ext.as_str() { - "odt" | "ott" => return Some(odt::import_with(bytes, page, odf)), - "fodt" => return Some(odt::import_flat_with(bytes, page, odf)), + "odt" | "ott" => return Some(odt::import_on(bytes, page)), + "fodt" => return Some(odt::import_flat_on(bytes, page)), "txt" | "text" => txt::import_on(bytes, page), "md" | "markdown" => markdown::import_on(&txt::decode(bytes), page), "html" | "htm" | "xhtml" => html::import_on(&html::decode(bytes), &|_| None, page), diff --git a/crates/formats/src/model/from_doc.rs b/crates/formats/src/model/from_doc.rs index 1a8351cd81..a3fb7fe45c 100644 --- a/crates/formats/src/model/from_doc.rs +++ b/crates/formats/src/model/from_doc.rs @@ -164,8 +164,7 @@ pub fn is_custom_style(doc: &Document, id: &str) -> bool { } /// Whether `doc`'s tab stops are measured from each paragraph's indent (an ODF document whose -/// file says so, or that was opened with a profile saying so): [`Para::extra`]'s tab stops then -/// are too. +/// file says so): [`Para::extra`]'s tab stops then are too. pub fn tabs_from_indent(doc: &Document) -> bool { doc.settings.odf.is_some_and(|o| o.tabs_from_indent) } diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index 8e9ed101b2..b476892bd6 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -63,11 +63,11 @@ mod xml; use body::Body; pub use crypt::{CryptError, decrypt, encrypt, is_encrypted}; -pub use flat::{export_flat, import_flat, import_flat_with}; +pub use flat::{export_flat, import_flat, import_flat_on}; use import::Pages; #[cfg(test)] use import::{Pictures, apply_page_layout, read_entry, read_meta}; -pub use import::{import, import_on, import_with, parse}; +pub use import::{import, import_on, parse}; #[cfg(test)] use limits::{MAX_EXTRA_SPACES, MAX_XML_DEPTH}; pub use package::{TEMPLATE_MIME, TEXT_MIME, export, export_template}; diff --git a/crates/formats/src/odt/body.rs b/crates/formats/src/odt/body.rs index a11ad10d2d..731c94ba9d 100644 --- a/crates/formats/src/odt/body.rs +++ b/crates/formats/src/odt/body.rs @@ -1324,7 +1324,7 @@ impl Body<'_> { let h = get(a, "svg:height").and_then(length); self.frame = Some((w, h, None, String::new(), None, None, None)); let g = get(a, "draw:style-name").map(|s| self.styles.graphic(s)).unwrap_or_default(); - self.frame_float = Some(super::frames::place(a, w.unwrap_or(0.0), h.unwrap_or(0.0), &g, self.text_size(), &self.styles.defs.profile)); + self.frame_float = Some(super::frames::place(a, w.unwrap_or(0.0), h.unwrap_or(0.0), &g, self.text_size())); self.member_start(a, w.unwrap_or(0.0), h.unwrap_or(0.0), None); // Without a height, a text box in it grows to its text (`fo:min-height` below). self.frame_look = g.look(h.is_none(), &self.styles.defs); @@ -1343,12 +1343,7 @@ impl Body<'_> { // section in a one-column page) is no new section. .filter(|c| c.count != self.sec.columns.count || (c.count > 1 && *c != self.sec.columns)) { - let balance = get(a, "text:style-name").and_then(|s| self.styles.section_balance(s)).or(self - .styles - .defs - .profile - .balance_section_columns - .then_some(true)); + let balance = get(a, "text:style-name").and_then(|s| self.styles.section_balance(s)); let next = SectionProps { start: SectionStart::Continuous, page_num_start: None, @@ -1407,7 +1402,7 @@ impl Body<'_> { control.form_size = Some([w, h]); // Where it stands on the line (§20.387), as an in-line frame does. let g = self.styles.shape_graphic(get(a, "draw:style-name")); - let raise = super::frames::place(a, w, h, &g, self.text_size(), &self.styles.defs.profile).y; + let raise = super::frames::place(a, w, h, &g, self.text_size()).y; control.form_raise = (raise.is_finite() && raise != 0.0).then_some(raise); } let fmt = self.cur_fmt(); diff --git a/crates/formats/src/odt/body/drawing.rs b/crates/formats/src/odt/body/drawing.rs index 9fd7698195..e3701bd9b5 100644 --- a/crates/formats/src/odt/body/drawing.rs +++ b/crates/formats/src/odt/body/drawing.rs @@ -338,13 +338,7 @@ impl Body<'_> { } let members = g.members.into_iter().map(|m| model::Member { x: fin(m.x) - x0, y: fin(m.y) - y0, ..m }).collect(); let graphic = self.styles.shape_graphic(g.style.as_deref()); - let float = crate::odt::frames::float_of( - g.anchor.as_deref(), - Some(&crate::odt::writer::pt(x0)), - Some(&crate::odt::writer::pt(y0)), - &graphic, - &self.styles.defs.profile, - ); + let float = crate::odt::frames::float_of(g.anchor.as_deref(), Some(&crate::odt::writer::pt(x0)), Some(&crate::odt::writer::pt(y0)), &graphic); let float = (float.wrap != wordcraft_doc::para::Wrap::Inline).then(|| Box::new(float)); let fit = |v: f32| if v.is_finite() { v.clamp(1.0, MAX_LEN) } else { 1.0 }; self.push_inline(Inline::Group { w: fit(x1 - x0), h: fit(y1 - y0), float, members }); @@ -362,11 +356,11 @@ impl Body<'_> { let mut float = if kind.is_open() { // A line is placed by its ends. let at = |v: Option| v.map(crate::odt::writer::pt); - let mut f = crate::odt::frames::float_of(get(a, "text:anchor-type"), at(x).as_deref(), at(y).as_deref(), &g, &self.styles.defs.profile); + let mut f = crate::odt::frames::float_of(get(a, "text:anchor-type"), at(x).as_deref(), at(y).as_deref(), &g); f.z = crate::odt::frames::z_of(a); f } else { - crate::odt::frames::place(a, w, h, &g, self.text_size(), &self.styles.defs.profile) + crate::odt::frames::place(a, w, h, &g, self.text_size()) }; (float.flip_h, float.flip_v) = (flip_h, flip_v); self.shape_flip = (false, false); diff --git a/crates/formats/src/odt/flat.rs b/crates/formats/src/odt/flat.rs index f9c8c9c7db..7c818b2c0f 100644 --- a/crates/formats/src/odt/flat.rs +++ b/crates/formats/src/odt/flat.rs @@ -188,16 +188,11 @@ fn take_out(xml: &str, files: &mut Vec<(String, Vec)>) -> String { /// Open a flat OpenDocument text document. pub fn import_flat(bytes: &[u8]) -> Result { - import_flat_with(bytes, &wordcraft_doc::SectionProps::default(), &wordcraft_doc::odf_profile::OdfProfile::default()) + import_flat_on(bytes, &wordcraft_doc::SectionProps::default()) } -/// [`import_flat`] onto `page`, filling in what the standard leaves open as `profile` says (see -/// [`super::import_with`]). -pub fn import_flat_with( - bytes: &[u8], - page: &wordcraft_doc::SectionProps, - profile: &wordcraft_doc::odf_profile::OdfProfile, -) -> Result { +/// [`import_flat`] onto `page` (see [`super::import_on`]). +pub fn import_flat_on(bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Result { let xml = std::str::from_utf8(bytes).map_err(|_| "FODT: not UTF-8 text".to_string())?; let root = element(xml, "office:document"); let [content, styles, meta, settings] = package_parts(root).ok_or("FODT: no office:document")?; @@ -221,7 +216,7 @@ pub fn import_flat_with( z.write_all(data).map_err(|e| format!("FODT: {e}"))?; } let package = z.finish().map_err(|e| format!("FODT: {e}"))?.into_inner(); - super::import_with(&package, page, profile) + super::import_on(&package, page) } /// Save `doc` as flat OpenDocument text: its package's files in one `office:document`, the diff --git a/crates/formats/src/odt/frames.rs b/crates/formats/src/odt/frames.rs index 3c993b54f8..6bb23da1f5 100644 --- a/crates/formats/src/odt/frames.rs +++ b/crates/formats/src/odt/frames.rs @@ -10,7 +10,6 @@ use super::xml::{Attrs, get, length}; use crate::model::{BoxLook, parse_color}; use wordcraft_doc::fill::Fill; use wordcraft_doc::line_dash::LineDash; -use wordcraft_doc::odf_profile::OdfProfile; use wordcraft_doc::para::{Anchor, Float, FloatAlign, ShapeKind, Wrap, WrapText}; use wordcraft_doc::props::BorderStyle; use wordcraft_doc::props::Rgb; @@ -18,6 +17,18 @@ use wordcraft_doc::props::Rgb; /// Largest offset or distance read, points (22 inches). const MAX_LEN: f32 = 1584.0; +/// A drawing shape's fill colour when no style gives one (§20.119 `draw:fill-color` has no +/// default): WordCraft's shape blue. +const SHAPE_FILL: Rgb = Rgb(0x15, 0x60, 0x82); + +/// A drawing shape's line colour when no style gives one (§20.408 `svg:stroke-color` has no +/// default). +const SHAPE_LINE: Rgb = Rgb(0x0E, 0x40, 0x5A); + +/// A drawing shape's line width when no style gives one (§20.410 `svg:stroke-width` has no +/// default), and what a stated width of 0 (a hairline) draws at, points. +const HAIRLINE: f32 = 0.25; + /// The placement a graphic style states; each part `None` where it says nothing (its parent's /// then counts). #[derive(Clone, Debug, Default)] @@ -244,24 +255,23 @@ impl Graphic { } /// The look of drawing shape `kind` in this style: a solid fill and line where the styles - /// say nothing (the standard gives `draw:fill` and `draw:stroke` no default), in the - /// profile's colours and line width (nor do `draw:fill-color`, `svg:stroke-color` and + /// say nothing (the standard gives `draw:fill` and `draw:stroke` no default), in WordCraft's + /// colours and line width (nor do `draw:fill-color`, `svg:stroke-color` and /// `svg:stroke-width` have defaults); a line has no fill. pub(super) fn shape_look(&self, kind: ShapeKind, defs: &Defs) -> BoxLook { - let profile = &defs.profile; let fill = if kind == ShapeKind::Line { None } else { let mut g = self.clone(); - g.fill_color = g.fill_color.or(Some(profile.shape_fill)); + g.fill_color = g.fill_color.or(Some(SHAPE_FILL)); g.fill_with(Some("solid"), defs) }; let line = match self.stroke.as_deref() { Some("none") => None, - _ => Some((self.stroke_color.unwrap_or(profile.shape_line), self.stroke_width.filter(|w| *w > 0.0).unwrap_or(profile.hairline))), + _ => Some((self.stroke_color.unwrap_or(SHAPE_LINE), self.stroke_width.filter(|w| *w > 0.0).unwrap_or(HAIRLINE))), }; // Only a shape that says it grows to its text does: WordCraft's fit shrinks it too. - let (dash, arrows) = self.stroke_extra(line.map_or(profile.hairline, |l| l.1), defs); + let (dash, arrows) = self.stroke_extra(line.map_or(HAIRLINE, |l| l.1), defs); BoxLook { fill, line, @@ -283,8 +293,8 @@ impl Graphic { // (`draw:stroke`, as LibreOffice's letter logo box has), a 0 width a hairline. let stroke = || { matches!(self.stroke.as_deref(), Some("solid" | "dash")).then(|| { - let width = self.stroke_width.filter(|w| *w > 0.0).unwrap_or(defs.profile.hairline); - (self.stroke_color.unwrap_or(defs.profile.shape_line), width) + let width = self.stroke_width.filter(|w| *w > 0.0).unwrap_or(HAIRLINE); + (self.stroke_color.unwrap_or(SHAPE_LINE), width) }) }; BoxLook { @@ -308,7 +318,7 @@ impl Graphic { /// The [`Float`] of a frame anchored as `anchor` (`text:anchor-type`) at `x`, `y` (`svg:x`, /// `svg:y`) with graphic style placement `g`. A frame in line with the text (`as-char`, or no /// anchor stated) doesn't float. -pub(super) fn float_of(anchor: Option<&str>, x: Option<&str>, y: Option<&str>, g: &Graphic, profile: &OdfProfile) -> Float { +pub(super) fn float_of(anchor: Option<&str>, x: Option<&str>, y: Option<&str>, g: &Graphic) -> Float { // What the frame leaves unsaid, its style says. let anchor = anchor.or(g.anchor.as_deref()).map(str::trim); let (x, y) = (x.or(g.x.as_deref()), y.or(g.y.as_deref())); @@ -339,12 +349,12 @@ pub(super) fn float_of(anchor: Option<&str>, x: Option<&str>, y: Option<&str>, g Some("page-start-margin") => Anchor::LeftMargin, Some("page-end-margin") => Anchor::RightMargin, Some("page-content") => Anchor::Margin, - // A page has no paragraph (§20.299: no such combination): its content relations are the - // page's text area, its own the page or the text area as the profile says. + // A page has no paragraph (§20.299: no such combination): relations to one are the + // page's text area. Some("paragraph-content") if page => Anchor::Margin, Some("paragraph-start-margin") if page => Anchor::LeftMargin, Some("paragraph-end-margin") if page => Anchor::RightMargin, - Some("paragraph" | "char") if page && !profile.page_frame_paragraph_is_page => Anchor::Margin, + Some("paragraph" | "char") if page => Anchor::Margin, Some("paragraph-content") => Anchor::Column, Some("char") if !page => Anchor::Character, Some(_) | None if page => Anchor::Page, @@ -355,7 +365,7 @@ pub(super) fn float_of(anchor: Option<&str>, x: Option<&str>, y: Option<&str>, g Some("page") => Anchor::Page, Some("page-content") => Anchor::Margin, Some("paragraph-content") if page => Anchor::Margin, - Some("paragraph" | "char" | "text" | "line" | "baseline") if page && !profile.page_frame_paragraph_is_page => Anchor::Margin, + Some("paragraph" | "char" | "text" | "line" | "baseline") if page => Anchor::Margin, Some("line") | Some("char") | Some("text") | Some("baseline") if !page => Anchor::Line, Some(_) | None if page => Anchor::Page, Some(_) => Anchor::Paragraph, @@ -396,19 +406,19 @@ pub(super) fn float_of(anchor: Option<&str>, x: Option<&str>, y: Option<&str>, g /// Where a frame or shape `w` × `h` (points) sits: anchored as its `text:anchor-type` says, at /// its `svg:x`/`svg:y`, or turned and moved by its `draw:transform`, in graphic style `g`, in /// text `size` points tall. -pub(super) fn place(a: &Attrs, w: f32, h: f32, g: &Graphic, size: f32, profile: &OdfProfile) -> Float { +pub(super) fn place(a: &Attrs, w: f32, h: f32, g: &Graphic, size: f32) -> Float { let turned = get(a, "draw:transform").and_then(|t| transform(t, w, h)); let (x, y) = match turned { Some((x, y, _)) => (Some(pt(x)), Some(pt(y))), None => (get(a, "svg:x").map(String::from), get(a, "svg:y").map(String::from)), }; - let mut f = float_of(get(a, "text:anchor-type"), x.as_deref(), y.as_deref(), g, profile); + let mut f = float_of(get(a, "text:anchor-type"), x.as_deref(), y.as_deref(), g); if let Some((.., rot)) = turned { f.rot = rot; } f.z = z_of(a); if f.wrap == Wrap::Inline { - f.y = inline_raise(g, y.as_deref().and_then(length), h, size, profile); + f.y = inline_raise(g, y.as_deref().and_then(length), h, size); } f } @@ -433,7 +443,7 @@ pub(crate) fn z_index(float: Option<&Float>) -> u32 { /// its descent to its ascent (a serif face's, as LibreOffice's templates use): `middle` centred /// on it (measured in LibreOffice), `top` at its top, `bottom` at its bottom. Placement relative /// to the line stays on the baseline. -fn inline_raise(g: &Graphic, y: Option, h: f32, size: f32, profile: &OdfProfile) -> f32 { +fn inline_raise(g: &Graphic, y: Option, h: f32, size: f32) -> f32 { if !h.is_finite() || g.vrel.as_deref() == Some("line") { return 0.0; } @@ -449,9 +459,8 @@ fn inline_raise(g: &Graphic, y: Option, h: f32, size: f32, profile: &OdfPro return raise.clamp(-MAX_RAISE, MAX_RAISE); } // A relation an in-line frame can't have (§20.398 Table 23: a paragraph's or a page's, from - // a parent style for anchored frames): against the baseline, or standing on it, as the - // profile says. - if !matches!(g.vrel.as_deref(), None | Some("baseline")) && !profile.inline_invalid_relation_is_baseline { + // a parent style for anchored frames): it stands on the baseline. + if !matches!(g.vrel.as_deref(), None | Some("baseline")) { return 0.0; } let raise = match g.vpos.as_deref() { diff --git a/crates/formats/src/odt/import.rs b/crates/formats/src/odt/import.rs index dd05559a41..c863452896 100644 --- a/crates/formats/src/odt/import.rs +++ b/crates/formats/src/odt/import.rs @@ -4,7 +4,6 @@ use super::styles::{Styles, column_sep, columns_of}; use super::xml::{Attrs, Ev, events, get, length, local}; use super::{Columns, Cursor, Document, FBlock, Flow, HashMap, Meta, Para, Read, SectionProps, model, page_parts}; use std::sync::Arc; -use wordcraft_doc::odf_profile::OdfProfile; pub(super) fn read_entry(z: &mut zip::ZipArchive>, name: &str) -> Option> { let f = z.by_name(name).ok()?; @@ -422,7 +421,7 @@ 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, default_paper(), &OdfProfile::default()).map(|p| (p.flow, p.last)) + parse_tracked(bytes, default_paper()).map(|p| (p.flow, p.last)) } /// The paragraphs kept as read that their links tie together (see `model::Group`), in `blocks`: @@ -706,7 +705,7 @@ fn default_paper() -> (f32, f32) { /// [`parse_full`], plus what the document holds outside the flow. A page layout that states no /// size (§20.215 `fo:page-width` is optional) is `paper` (width, height) in size. -fn parse_tracked(bytes: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Result { +fn parse_tracked(bytes: &[u8], paper: (f32, f32)) -> Result { wordcraft_doc::zip_guard::check(bytes, crate::MAX_ZIP_ENTRIES).map_err(|e| format!("ODT: {e}"))?; 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") { @@ -725,34 +724,24 @@ fn parse_tracked(bytes: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Resul None if styles_xml.is_some() => EMPTY_CONTENT.as_bytes().to_vec(), None => return Err("ODT: content.xml is missing".into()), }; - let profile = profile.effective(); - let mut styles = Styles::with_profile(&profile); + let mut styles = Styles::default(); let mut pages = Pages::default(); if let Some(s) = &styles_xml { styles.read(s, false); - pages = read_pages(s, paper, &profile); + pages = read_pages(s, paper); } - // No page layout at all (no styles.xml, or no master page): the default paper, with the - // profile's margins. + // No page layout at all (no styles.xml, or no master page). if pages.first.is_none() { - pages.first = Some(SectionProps { - page_w: paper.0, - page_h: paper.1, - margin_top: profile.page_margin, - margin_bottom: profile.page_margin, - margin_left: profile.page_margin, - margin_right: profile.page_margin, - ..SectionProps::default() - }); + pages.first = Some(unstated_page(paper)); } styles.read(&content, true); let settings = read_entry(&mut z, "settings.xml"); // Where tab stops are measured from: as the document's settings say (LibreOffice's own), else - // the profile's choice (the standard ties it only to indexes, §19.512.3). - styles.tabs_from_indent = settings.as_deref().and_then(|s| setting(s, "TabsRelativeToIndent")).unwrap_or(profile.tabs_from_indent); + // the margin, as Word (the standard ties it only to indexes, §19.512.3). + styles.tabs_from_indent = settings.as_deref().and_then(|s| setting(s, "TabsRelativeToIndent")).unwrap_or(false); // How the space between two paragraphs adds up: as LibreOffice's `AddParaTableSpacing` says - // (false: the larger of the two, as Word), else the profile's choice (the standard doesn't say). - let sum_spacing = settings.as_deref().and_then(|s| setting(s, "AddParaTableSpacing")).unwrap_or(profile.sum_paragraph_spacing); + // (false: the larger of the two, as Word), else the larger (the standard doesn't say). + let sum_spacing = settings.as_deref().and_then(|s| setting(s, "AddParaTableSpacing")).unwrap_or(false); let meta_xml = read_entry(&mut z, "meta.xml"); let meta = meta_xml.as_deref().map(read_meta).unwrap_or_default(); let changes = meta_xml.as_deref().map(read_changes).unwrap_or_default(); @@ -887,12 +876,6 @@ pub fn import(bytes: &[u8]) -> Result { /// [`import`], with `page` the default page (the user's region's): a page layout that states no /// size is `page`'s size, as LibreOffice makes it its default paper's. pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result { - import_with(bytes, page, &OdfProfile::default()) -} - -/// [`import_on`], filling in what the standard leaves open as `profile` says (see -/// [`wordcraft_doc::odf_profile`]). -pub fn import_with(bytes: &[u8], page: &SectionProps, profile: &OdfProfile) -> Result { model::begin_import(); let Parsed { flow, @@ -927,7 +910,7 @@ pub fn import_with(bytes: &[u8], page: &SectionProps, profile: &OdfProfile) -> R kept_defs, kept_files, section_masters, - } = parse_tracked(bytes, (page.page_w, page.page_h), profile)?; + } = parse_tracked(bytes, (page.page_w, page.page_h))?; let mut doc = model::to_doc(&flow); kept.store(&mut doc); // Elements can't hold a NUL: it parts them. @@ -953,8 +936,8 @@ pub fn import_with(bytes: &[u8], page: &SectionProps, profile: &OdfProfile) -> R doc.settings.default_tab = t; } doc.settings.page_color = page_doc.page_color; - // Laid out with the standard's rules, and the profile's choices made as it is laid out. - doc.settings.odf = Some(wordcraft_doc::odf_profile::OdfLayout { tabs_from_indent, ..profile.layout() }); + // Laid out with the standard's rules, its tab stops measured as the file says. + doc.settings.odf = Some(wordcraft_doc::OdfLayout { tabs_from_indent }); doc.settings.sum_paragraph_spacing = sum_spacing; // The page background picture (Design › Page Color › Fill Effects › Picture). if let Some((bytes, fill)) = page_image @@ -972,8 +955,8 @@ pub fn import_with(bytes: &[u8], page: &SectionProps, profile: &OdfProfile) -> R let d = &mut Arc::make_mut(&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); - // Unstated, as the profile says (`widowControl`). - d.widow_control = Some(widows.unwrap_or(profile.widow_control)); + // Unstated: on, XSL's initial value of 2 lines (§20.312 and §20.244 refer to it), and Word's. + d.widow_control = Some(widows.unwrap_or(true)); for (kind, look) in &looks { apply_look(&mut doc, *kind, look); } @@ -1080,23 +1063,24 @@ fn line_numbering(a: &Attrs) -> Option { /// §16.6–16.7): its minimum height and its spacing from the text, in points. #[derive(Clone, Copy, Default)] struct Band { - /// Its height (`fo:min-height`), when stated: the standard gives no default (the profile's - /// [`OdfProfile::header_min_height`]). + /// Its height (`fo:min-height`), when stated: the standard gives no default (see + /// [`Band::room`]). height: Option, spacing: f32, /// Content taller than the band takes its spacing first (§20.264 `style:dynamic-spacing`, - /// as Word does), instead of keeping it below; `None` when unstated (the profile's - /// [`OdfProfile::dynamic_spacing`]). + /// as Word does), instead of keeping it below; `None` when unstated (§20.264 has no default; + /// see [`Band::room`]). dynamic: Option, } impl Band { /// Where the text starts below the band's start (the page margin), and the gap kept below - /// content taller than the band, as `profile` reads what the standard leaves open. - fn room(&self, profile: &OdfProfile) -> (f32, f32) { - let height = self.height.unwrap_or(profile.header_min_height); - let room = if profile.header_height_includes_spacing { height.max(self.spacing) } else { height + self.spacing }; - let gap = if self.dynamic.unwrap_or(profile.dynamic_spacing) { 0.0 } else { self.spacing }; + /// content taller than the band. What the standard leaves open: the spacing comes after the + /// height, an unstated height is none (as tall as its text), and unstated dynamic spacing is + /// off (on, with no height stated, it would always drop the spacing the file states). + fn room(&self) -> (f32, f32) { + let room = self.height.unwrap_or(0.0) + self.spacing; + let gap = if self.dynamic.unwrap_or(false) { 0.0 } else { self.spacing }; (room, gap) } } @@ -1113,7 +1097,22 @@ struct Layout { footer: Band, } -fn read_pages(xml: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Pages { +/// A page stating nothing, `paper` (width, height) in size: no margins (§20.206–20.209 refer to +/// XSL's margins, whose initial value is 0; whether it comes with the reference the standard +/// doesn't say). +fn unstated_page(paper: (f32, f32)) -> SectionProps { + SectionProps { + page_w: paper.0, + page_h: paper.1, + margin_top: 0.0, + margin_bottom: 0.0, + margin_left: 0.0, + margin_right: 0.0, + ..SectionProps::default() + } +} + +fn read_pages(xml: &[u8], paper: (f32, f32)) -> Pages { let mut layouts: HashMap = HashMap::new(); let mut cur: Option = None; // Inside a layout's footer style (else its header style). @@ -1176,7 +1175,7 @@ fn read_pages(xml: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Pages { } "columns" if q.starts_with("style:") => { if let Some(l) = cur.as_ref().and_then(|n| layouts.get_mut(n)) { - l.columns = Some(columns_of(&a, profile.column_gap)); + l.columns = Some(columns_of(&a)); } } "column" if q.starts_with("style:") => { @@ -1225,17 +1224,8 @@ fn read_pages(xml: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Pages { let mut pages = Pages { doc, next: nexts, ..Default::default() }; for (name, layout, header, footer) in masters { let Some(Layout { attrs: a, columns: cols, column_specs, header: hb, footer: fb, image }) = layouts.get(&layout) else { continue }; - // What a layout leaves unsaid: the default paper, and the profile's margins (§20.206–20.209 - // refer to XSL's margins; whether XSL's initial 0 comes with them the standard doesn't say). - let mut sec = SectionProps { - page_w: paper.0, - page_h: paper.1, - margin_top: profile.page_margin, - margin_bottom: profile.page_margin, - margin_left: profile.page_margin, - margin_right: profile.page_margin, - ..SectionProps::default() - }; + // What a layout leaves unsaid: the default paper, and no margins. + let mut sec = unstated_page(paper); apply_layout(a, &mut sec); sec.line_numbers.clone_from(&lines); if pages.first.is_none() { @@ -1245,17 +1235,17 @@ fn read_pages(xml: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Pages { // 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. How the band's // height and spacing add up, and what an unstated height or spacing rule is, the - // standard leaves open: the profile's (see `Band::room`). + // standard leaves open (see `Band::room`). let limit = sec.page_w.max(sec.page_h); if header { - let (room, gap) = hb.room(profile); + let (room, gap) = hb.room(); sec.header = sec.margin_top; sec.margin_top = (sec.margin_top + room).min(limit); // Content taller than the band pushes the text down, the spacing below it kept or not. sec.header_gap = gap; } if footer { - let (room, gap) = fb.room(profile); + let (room, gap) = fb.room(); sec.footer = sec.margin_bottom; sec.margin_bottom = (sec.margin_bottom + room).min(limit); sec.footer_gap = gap; @@ -1300,7 +1290,7 @@ fn first_pages(pages: &mut Pages) { /// Replace `sec` with the page setup of the first master page in `styles.xml`, if any. #[cfg(test)] pub(super) fn apply_page_layout(xml: &[u8], sec: &mut SectionProps) { - if let Some(first) = read_pages(xml, default_paper(), &OdfProfile::default()).first { + if let Some(first) = read_pages(xml, default_paper()).first { *sec = first; } } diff --git a/crates/formats/src/odt/package.rs b/crates/formats/src/odt/package.rs index e4fa5b8545..0e3bff03a0 100644 --- a/crates/formats/src/odt/package.rs +++ b/crates/formats/src/odt/package.rs @@ -350,7 +350,7 @@ fn styles_xml( spacing_props(defaults), x(&body_font), // Widow and orphan control as Normal has it, said either way (the standard leaves it open - // when unsaid: see `OdfProfile::widow_control`). + // when unsaid, and readers differ: docs/odf-interpretation.md). widows = if normal.widow_control { " fo:widows=\"2\" fo:orphans=\"2\"" } else { " fo:widows=\"0\" fo:orphans=\"0\"" }, size = pt(size), // The document's text colour (a colour, not automatic). diff --git a/crates/formats/src/odt/strokes.rs b/crates/formats/src/odt/strokes.rs index 4a98b30ebe..355263fd20 100644 --- a/crates/formats/src/odt/strokes.rs +++ b/crates/formats/src/odt/strokes.rs @@ -259,9 +259,6 @@ const MAX_DEFS: usize = 10_000; pub(super) struct Defs { dashes: std::collections::HashMap, gradients: std::collections::HashMap, - /// How what the standard leaves open is filled in (shapes' default colours and lines among - /// it), with the effective values. - pub(super) profile: wordcraft_doc::odf_profile::OdfProfile, /// The part read is older than ODF 1.3: unitless angles are tenths of a degree (see /// [`legacy_angles`]). pub(super) legacy_angles: bool, diff --git a/crates/formats/src/odt/styles.rs b/crates/formats/src/odt/styles.rs index 1b08c0ea88..41edfdfbb1 100644 --- a/crates/formats/src/odt/styles.rs +++ b/crates/formats/src/odt/styles.rs @@ -262,8 +262,6 @@ struct StyleInfo { columns: Option, /// The section style's `style:column`s, as [`with_widths`] gives them widths. column_specs: Vec, - /// Its `style:columns` states its gap (else the profile's, see [`Styles::columns`]). - column_gap_stated: bool, /// `text:dont-balance-text-columns` of `style:section-properties`, as stated. dont_balance: Option, /// Margins, first-line indent and line spacing of `style:paragraph-properties`. @@ -636,8 +634,7 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool { s.dont_balance = get(a, "text:dont-balance-text-columns").map(|v| v.trim() == "true"); } "columns" if q.starts_with("style:") => { - s.columns = Some(columns_of(a, 0.0)); - s.column_gap_stated = get(a, "fo:column-gap").is_some(); + s.columns = Some(columns_of(a)); } "column" if q.starts_with("style:") && s.columns.is_some() => { if let Some(spec) = column_spec(a).filter(|_| s.column_specs.len() < MAX_COLUMN_SPECS) { @@ -714,9 +711,9 @@ fn cell_borders_and_padding(a: &Attrs, s: &mut StyleInfo) { /// Columns of a `style:columns` element (ODF 1.3 §17.12): count and gap, clamped as the RTF and /// DOCX readers do (each column's own width comes from its `style:column`, see [`with_widths`]). /// Without `fo:column-gap`, whose default the standard doesn't give (§19.233), the gap is -/// `default_gap` (the profile's). -pub(super) fn columns_of(a: &Attrs, default_gap: f32) -> Columns { - let mut c = Columns { space: if default_gap.is_finite() { default_gap.clamp(0.0, 1584.0) } else { 0.0 }, ..Columns::default() }; +/// [`Columns::default`]'s half inch, Word's. +pub(super) fn columns_of(a: &Attrs) -> Columns { + let mut c = Columns::default(); if let Some(n) = get(a, "fo:column-count").and_then(|v| v.trim().parse::().ok()) { c.count = n.clamp(1, 45) as u32; } @@ -1298,10 +1295,7 @@ impl Styles { /// Columns of a section style (whole chain). /// A section style's columns in a text `tw` points wide (whole chain). pub(super) fn columns(&self, name: &str, tw: f32) -> Option { - self.chain(name).iter().find_map(|(_, s)| { - let gap = if s.column_gap_stated { None } else { Some(self.defs.profile.column_gap) }; - s.columns.clone().map(|c| with_widths(Columns { space: gap.unwrap_or(c.space), ..c }, &s.column_specs, tw)) - }) + self.chain(name).iter().find_map(|(_, s)| s.columns.clone().map(|c| with_widths(c, &s.column_specs, tw))) } /// Whether a section style's columns are balanced at its end, when it says (the attribute's @@ -1311,13 +1305,6 @@ impl Styles { self.chain(name).iter().find_map(|(_, s)| s.dont_balance).map(|d| !d) } - /// Styles to be read, filling in what the standard leaves open as `profile` says. - pub(super) fn with_profile(profile: &wordcraft_doc::odf_profile::OdfProfile) -> Styles { - let mut s = Styles::default(); - s.defs.profile = profile.clone(); - s - } - /// Width in points of a table-column style (whole chain). pub(super) fn column_width(&self, name: &str) -> Option { self.chain(name).iter().find_map(|(_, s)| s.column_width) diff --git a/crates/formats/src/odt/tests.rs b/crates/formats/src/odt/tests.rs index 2d4415ed4a..bd51c6dd77 100644 --- a/crates/formats/src/odt/tests.rs +++ b/crates/formats/src/odt/tests.rs @@ -1,10 +1,4 @@ use super::*; -use wordcraft_doc::odf_profile::OdfProfile; - -/// Open ODT `bytes` filling in what the standard leaves open as `profile` says. -pub(crate) fn import_as(bytes: &[u8], profile: &OdfProfile) -> Document { - super::import_with(bytes, &wordcraft_doc::SectionProps::default(), profile).unwrap() -} #[test] fn escaper_drops_noncharacters() { @@ -304,7 +298,7 @@ fn page_layout_follows_the_first_master_page() { let mut sec = wordcraft_doc::SectionProps::default(); apply_page_layout(xml, &mut sec); assert!(sec.landscape); - // A margin left unsaid is XSL's initial value, 0 (the standard's, whatever the profile). + // A margin left unsaid is XSL's initial value, 0. assert_eq!((sec.page_w, sec.page_h, sec.margin_left, sec.margin_top), (720.0, 360.0, 72.0, 0.0)); } @@ -1337,16 +1331,14 @@ fn unequal_columns_keep_their_widths_and_columns_without_a_gap_have_none() { let out = entry(&export(&doc).unwrap(), "styles.xml"); assert!(out.contains(r#""#, ""); let c = import(&package(&content, Some(&bare))).unwrap().last_section.columns; assert_eq!((c.count, c.space, c.widths.len()), (2, 36.0, 0)); - let c = import_as(&package(&content, Some(&bare)), &OdfProfile::libreoffice()).last_section.columns; - assert_eq!((c.count, c.space, c.widths.len()), (2, 0.0, 0)); } #[test] diff --git a/crates/formats/src/odt/tests_more.rs b/crates/formats/src/odt/tests_more.rs index 32649e89c9..7488f85ef3 100644 --- a/crates/formats/src/odt/tests_more.rs +++ b/crates/formats/src/odt/tests_more.rs @@ -34,7 +34,6 @@ mod para_borders; mod parts; mod placeholders; mod polygons; -mod profile; mod rotation; mod rtl; mod ruby; @@ -47,3 +46,4 @@ mod tables; mod tabs; mod text_boxes; mod text_direction; +mod unstated; diff --git a/crates/formats/src/odt/tests_more/floats.rs b/crates/formats/src/odt/tests_more/floats.rs index b6f25438f2..482f6d32b2 100644 --- a/crates/formats/src/odt/tests_more/floats.rs +++ b/crates/formats/src/odt/tests_more/floats.rs @@ -10,16 +10,11 @@ use wordcraft_doc::para::{Anchor, Float, FloatAlign, InlineObject, Wrap, WrapTex const NS_D: &str = r#"xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#; fn open(graphic: &str, frame: &str) -> Document { - open_as(graphic, frame, &wordcraft_doc::odf_profile::OdfProfile::default()) -} - -/// [`open`], filling in what the standard leaves open as `profile` says. -fn open_as(graphic: &str, frame: &str, profile: &wordcraft_doc::odf_profile::OdfProfile) -> Document { let png = [b"\x89PNG\r\n\x1a\n".as_slice(), &[7u8; 64]].concat(); let content = format!( r#"Text beside."# ); - crate::odt::tests::import_as(&zip_of(&[("content.xml", content.as_bytes()), ("Pictures/a.png", &png)]), profile) + crate::odt::import(&zip_of(&[("content.xml", content.as_bytes()), ("Pictures/a.png", &png)])).unwrap() } fn float(d: &Document) -> Float { @@ -109,7 +104,7 @@ fn float_doc_inline() -> Document { fn a_picture_alone_in_its_paragraph_keeps_its_raise_and_the_paragraphs_size() { // LibreOffice's elegant agenda: a rule raised 0.2 in above the baseline in a 9 pt paragraph. // Its parent style's `vertical-rel="paragraph"` is no in-line frame's (the standard doesn't - // define it): LibreOffice places it from the baseline anyway, WordCraft stands it on it. + // define it): it stands on the baseline. Placed against the baseline, it is raised. let pic = { let img = image::RgbaImage::from_fn(4, 4, |_, _| image::Rgba([10, 20, 30, 255])); let mut png = Vec::new(); @@ -131,41 +126,31 @@ fn a_picture_alone_in_its_paragraph_keeps_its_raise_and_the_paragraphs_size() { let props = p.props_at(off + wordcraft_doc::para::OBJ.len_utf8()); (props.position.map(|v| (v * 10.0).round() / 10.0), props.size, p.mark.size) }; - let doc = crate::odt::tests::import_as(&pkg, &wordcraft_doc::odf_profile::OdfProfile::libreoffice()); + let doc = crate::odt::import(&pkg).unwrap(); + assert_eq!(look(&doc), (None, Some(9.0), Some(9.0))); + let baseline = styles.replace(r#"style:vertical-rel="paragraph""#, r#"style:vertical-rel="baseline""#); + let pkg = crate::odt::tests::zip_of(&[("content.xml", content.as_bytes()), ("styles.xml", baseline.as_bytes()), ("Pictures/p.png", &pic)]); + let doc = crate::odt::import(&pkg).unwrap(); assert_eq!(look(&doc), (Some(10.8), Some(9.0), Some(9.0))); // Saved, it says so in terms every reader takes the same way. let again = crate::odt::import(&crate::odt::tests::export_model(&doc).unwrap()).unwrap(); assert_eq!(look(&again), (Some(10.8), Some(9.0), Some(9.0))); - let doc = crate::odt::import(&pkg).unwrap(); - assert_eq!(look(&doc), (None, Some(9.0), Some(9.0))); } #[test] -fn a_page_anchored_frames_paragraph_relations_are_the_pages() { - // LibreOffice's letter templates: a page-anchored frame "from the paragraph" (a combination - // the standard doesn't define) is placed from the page's edge in LibreOffice, from the - // page's text area in WordCraft; "from the paragraph's content" from its text area in both. - let paragraph = |profile| { - float(&open_as( - r#"style:wrap="run-through" style:horizontal-pos="from-left" style:horizontal-rel="paragraph" style:vertical-pos="from-top" style:vertical-rel="paragraph""#, - r#"text:anchor-type="page" svg:x="2cm" svg:y="2cm""#, - &profile, - )) - }; - let f = paragraph(wordcraft_doc::odf_profile::OdfProfile::libreoffice()); - assert_eq!((f.h_rel, f.v_rel), (Anchor::Page, Anchor::Page)); - let f = paragraph(wordcraft_doc::odf_profile::OdfProfile::word()); +fn a_page_anchored_frames_paragraph_relations_are_its_text_area() { + // LibreOffice's letter templates: a page-anchored frame "from the paragraph" or "from the + // paragraph's content" (combinations the standard doesn't define) is placed from the page's + // text area. + let f = float(&open( + r#"style:wrap="run-through" style:horizontal-pos="from-left" style:horizontal-rel="paragraph" style:vertical-pos="from-top" style:vertical-rel="paragraph""#, + r#"text:anchor-type="page" svg:x="2cm" svg:y="2cm""#, + )); assert_eq!((f.h_rel, f.v_rel), (Anchor::Margin, Anchor::Margin)); - let content = |profile| { - float(&open_as( - r#"style:wrap="run-through" style:horizontal-pos="from-left" style:horizontal-rel="paragraph-content" style:vertical-rel="char""#, - r#"text:anchor-type="page" svg:x="2cm""#, - &profile, - )) - }; - let f = content(wordcraft_doc::odf_profile::OdfProfile::libreoffice()); - assert_eq!((f.h_rel, f.v_rel), (Anchor::Margin, Anchor::Page)); - let f = content(wordcraft_doc::odf_profile::OdfProfile::word()); + let f = float(&open( + r#"style:wrap="run-through" style:horizontal-pos="from-left" style:horizontal-rel="paragraph-content" style:vertical-rel="char""#, + r#"text:anchor-type="page" svg:x="2cm""#, + )); assert_eq!((f.h_rel, f.v_rel), (Anchor::Margin, Anchor::Margin)); // Anchored to a paragraph, they stay the paragraph's. let f = float(&open(r#"style:horizontal-rel="paragraph-content" style:vertical-rel="char""#, r#"text:anchor-type="char""#)); diff --git a/crates/formats/src/odt/tests_more/headers.rs b/crates/formats/src/odt/tests_more/headers.rs index e0c0be2eb8..57ff01970a 100644 --- a/crates/formats/src/odt/tests_more/headers.rs +++ b/crates/formats/src/odt/tests_more/headers.rs @@ -120,8 +120,8 @@ fn cm(v: f32) -> f32 { 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 band; Word measures // both from the page edge. Whether the band's height takes in its spacing the standard - // doesn't say: LibreOffice's does (a 2 cm header with 1 cm spacing puts the text 2 cm below - // the margin), WordCraft's adds it. + // doesn't say: WordCraft adds it (a 2 cm header with 1 cm spacing puts the text 3 cm below + // the margin). let content = format!( r#"x"#, crate::odt::tests::NS_T @@ -130,10 +130,7 @@ fn a_header_starts_at_the_page_margin_and_the_text_after_it() { &content, Some(&styles_with_header("HF")), ); - let s = crate::odt::tests::import_as(&with, &wordcraft_doc::odf_profile::OdfProfile::libreoffice()).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.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); let s = import(&with).unwrap().last_section; 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); @@ -148,43 +145,30 @@ fn a_header_starts_at_the_page_margin_and_the_text_after_it() { fn a_headers_spacing_stays_below_content_taller_than_it() { // LibreOffice's report templates: a header taller than its band keeps its spacing below it, // unless the spacing is dynamic (taken by the content first, as in Word). A style that - // doesn't say is the profile's: both presets keep it; a custom profile may grow into it. + // doesn't say keeps it. let content = format!( r#"x"#, crate::odt::tests::NS_T ); let hf = "HF"; - let lo = wordcraft_doc::odf_profile::OdfProfile::libreoffice(); - let d = crate::odt::tests::import_as(&crate::odt::tests::package(&content, Some(&styles_with_header(hf))), &lo); + let d = import(&crate::odt::tests::package(&content, Some(&styles_with_header(hf)))).unwrap(); let near = |a: f32, b: f32| (a - b).abs() < 0.05; let s = &d.last_section; assert!(near(s.header_gap, cm(0.25)) && near(s.footer_gap, cm(0.5)), "{} {}", s.header_gap, s.footer_gap); // Written back as the band's spacing, saying it is kept: the same whatever reads it. let saved = export(&d).unwrap(); assert!(crate::odt::tests::entry(&saved, "styles.xml").contains(r#"style:dynamic-spacing="false""#)); - let back = crate::odt::tests::import_as(&saved, &lo).last_section; - assert!(near(back.header_gap, cm(0.25)) && near(back.footer_gap, cm(0.5)) && near(back.margin_top, s.margin_top), "{back:?}"); let back = import(&saved).unwrap().last_section; - assert!(near(back.header_gap, cm(0.25)) && near(back.footer_gap, cm(0.5)), "{back:?}"); + assert!(near(back.header_gap, cm(0.25)) && near(back.footer_gap, cm(0.5)) && near(back.margin_top, s.margin_top), "{back:?}"); let dynamic = styles_with_header(hf).replace(r#"fo:margin-bottom="0.25cm""#, r#"fo:margin-bottom="0.25cm" style:dynamic-spacing="true""#); - let s = crate::odt::tests::import_as(&crate::odt::tests::package(&content, Some(&dynamic)), &lo).last_section; + let s = import(&crate::odt::tests::package(&content, Some(&dynamic))).unwrap().last_section; assert_eq!((s.header_gap, near(s.footer_gap, cm(0.5))), (0.0, true)); - // Unstated: WordCraft's keeps it too; a custom profile can grow into it. - let s = import(&crate::odt::tests::package(&content, Some(&styles_with_header(hf)))).unwrap().last_section; - assert!(near(s.header_gap, cm(0.25)) && near(s.footer_gap, cm(0.5)), "{s:?}"); - let grow = wordcraft_doc::odf_profile::OdfProfile { - preset: wordcraft_doc::odf_profile::OdfPreset::Custom, - dynamic_spacing: true, - ..wordcraft_doc::odf_profile::OdfProfile::word() - }; - let s = crate::odt::tests::import_as(&crate::odt::tests::package(&content, Some(&styles_with_header(hf))), &grow).last_section; - assert_eq!((s.header_gap, s.footer_gap), (0.0, 0.0)); } #[test] -fn a_header_without_a_stated_height_is_half_a_centimetre() { +fn a_header_without_a_stated_height_has_no_band() { // LibreOffice's letter templates: a header style stating no height, or none at all (the - // standard gives none), is LibreOffice's default 0.5 cm band (measured); WordCraft's is none. + // standard gives none), has no band: the header is as tall as its text. let content = format!( r#"x"#, crate::odt::tests::NS_T @@ -197,8 +181,6 @@ fn a_header_without_a_stated_height_is_half_a_centimetre() { crate::odt::tests::NS_T ); let pkg = crate::odt::tests::package(&content, Some(&styles)); - let s = crate::odt::tests::import_as(&pkg, &wordcraft_doc::odf_profile::OdfProfile::libreoffice()).last_section; - assert!(near(s.header, cm(2.0)) && near(s.margin_top, cm(2.5)), "{band}: {} {}", s.header, s.margin_top); let s = import(&pkg).unwrap().last_section; assert!(near(s.header, cm(2.0)) && near(s.margin_top, cm(2.0)), "{band}: {} {}", s.header, s.margin_top); } diff --git a/crates/formats/src/odt/tests_more/page_setup.rs b/crates/formats/src/odt/tests_more/page_setup.rs index 313069fb67..dcfcd44ff8 100644 --- a/crates/formats/src/odt/tests_more/page_setup.rs +++ b/crates/formats/src/odt/tests_more/page_setup.rs @@ -143,10 +143,10 @@ fn a_page_layout_without_a_size_is_the_default_pages_size() { } #[test] -fn a_document_without_a_page_layout_has_the_default_paper_and_the_profiles_margins() { +fn a_document_without_a_page_layout_has_the_default_paper_and_no_margins() { // No styles.xml at all: the region's paper (the standard leaves the size to the - // application), and the profile's margins (the standard leaves them open too): WordCraft's - // 0, XSL's initial value; LibreOffice's 2 cm. It is laid out as an ODF document. + // application), and no margins (the standard leaves them open too; 0 is XSL's initial value). + // It is laid out as an ODF document. let content = format!( "x", crate::odt::tests::NS_T @@ -157,17 +157,12 @@ fn a_document_without_a_page_layout_has_the_default_paper_and_the_profiles_margi assert_eq!((s.page_w, s.page_h), (595.3, 841.9)); assert_eq!([s.margin_top, s.margin_bottom, s.margin_left, s.margin_right], [0.0; 4], "{s:?}"); assert!(d.settings.odf.is_some()); - let lo = crate::import_with("odt", &crate::odt::tests::package(&content, None), &a4, &wordcraft_doc::odf_profile::OdfProfile::libreoffice()) - .unwrap() - .unwrap(); - let cm2 = 2.0 * 72.0 / 2.54; - assert_eq!([lo.last_section.margin_top, lo.last_section.margin_left], [cm2; 2]); } #[test] -fn a_page_layout_stating_nothing_has_the_profiles_margins() { - // LibreOffice's CV template: a page layout with no properties at all. Its margins are the - // profile's: WordCraft's 0 (XSL's initial value), LibreOffice's 2 cm. +fn a_page_layout_stating_nothing_has_no_margins() { + // LibreOffice's CV template: a page layout with no properties at all. Its margins are XSL's + // initial value, 0. let ns = crate::odt::tests::NS_T; let styles = format!( r#""# @@ -181,15 +176,6 @@ fn a_page_layout_stating_nothing_has_the_profiles_margins() { let one = styles.replace("", r#""#); let d = crate::import("odt", &crate::odt::tests::package(&content, Some(&one))).unwrap().unwrap(); assert_eq!((d.last_section.margin_left, d.last_section.margin_top), (72.0, 0.0)); - let lo = |styles: &str| { - crate::odt::tests::import_as(&crate::odt::tests::package(&content, Some(styles)), &wordcraft_doc::odf_profile::OdfProfile::libreoffice()) - .last_section - }; - let cm2 = 2.0 * 72.0 / 2.54; - let s = lo(&styles); - assert_eq!([s.margin_top, s.margin_bottom, s.margin_left, s.margin_right], [cm2; 4]); - let s = lo(&one); - assert_eq!((s.margin_left, s.margin_top), (72.0, cm2), "the stated one is the file's under every preset"); } #[test] diff --git a/crates/formats/src/odt/tests_more/shapes.rs b/crates/formats/src/odt/tests_more/shapes.rs index f17ac8848e..67032ca8fb 100644 --- a/crates/formats/src/odt/tests_more/shapes.rs +++ b/crates/formats/src/odt/tests_more/shapes.rs @@ -56,7 +56,7 @@ fn open() -> Document { #[test] fn shapes_open_with_their_kind_size_look_and_text() { let d = open(); - // A line width no style states is the profile's (the standard gives none): WordCraft's 0.25 pt. + // A line width no style states (the standard gives none): WordCraft's 0.25 pt. let blue = Some((Rgb(0x34, 0x65, 0xa4), 0.25)); assert_eq!( shapes(&d), diff --git a/crates/formats/src/odt/tests_more/tabs.rs b/crates/formats/src/odt/tests_more/tabs.rs index f47c815ac9..f03f4829fb 100644 --- a/crates/formats/src/odt/tests_more/tabs.rs +++ b/crates/formats/src/odt/tests_more/tabs.rs @@ -1,22 +1,26 @@ //! Tab stops (ODF 1.3 §17.7–17.8 `style:tab-stops`): position, alignment and leader, read from -//! paragraph styles and written back. LibreOffice measures them from the paragraph's indent when -//! its `TabsRelativeToIndent` setting is on (its default); WordCraft and Word from the margin. +//! paragraph styles and written back. A file whose `TabsRelativeToIndent` setting is on measures +//! them from the paragraph's indent (LibreOffice's default); otherwise from the margin, as Word. -use crate::odt::tests::{NS_T, import_as, package}; +use crate::odt::tests::{NS_T, package}; use crate::odt::{export, import}; -use wordcraft_doc::odf_profile::OdfProfile; use wordcraft_doc::props::{ParaProps, TabAlign, TabLeader, TabStop}; use wordcraft_doc::{Document, Paragraph, para_block}; const TABS: &str = r#""#; +/// A `settings.xml` saying whether tab stops are measured from the indent. +fn settings_xml(relative: bool) -> String { + format!( + r#"{relative}"# + ) +} + fn with_settings(content: &str, relative: Option) -> Vec { let mut bytes = package(content, None); if let Some(r) = relative { // Re-pack with a settings.xml. - let settings = format!( - r#"{r}"# - ); + let settings = settings_xml(r); let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content), ("settings.xml", settings.as_str())] { @@ -61,18 +65,14 @@ fn tab_stops_are_read() { #[test] fn tabs_relative_to_the_indent_move_with_it() { - // The document's own setting rules; without one (the standard doesn't say) the profile's: - // LibreOffice's from the paragraph's indent, WordCraft's from the margin. - let first = |relative: Option, profile: &OdfProfile| { - let d = import_as(&with_settings(&content(r#"fo:margin-left="0.5in""#), relative), profile); + // The document's own setting rules; without one (the standard doesn't say) from the margin. + let first = |relative: Option| { + let d = import(&with_settings(&content(r#"fo:margin-left="0.5in""#), relative)).unwrap(); tabs(&d).first().map(|t| t.pos) }; - for profile in [OdfProfile::libreoffice(), OdfProfile::word()] { - assert_eq!(first(Some(true), &profile), Some(108.0)); - assert_eq!(first(Some(false), &profile), Some(72.0)); - } - assert_eq!(first(None, &OdfProfile::libreoffice()), Some(108.0)); - assert_eq!(first(None, &OdfProfile::word()), Some(72.0)); + assert_eq!(first(Some(true)), Some(108.0)); + assert_eq!(first(Some(false)), Some(72.0)); + assert_eq!(first(None), Some(72.0)); } #[test] @@ -131,8 +131,14 @@ fn tabs_are_measured_from_the_indent_a_style_gives() { let content = format!( r#"abcd"# ); - // LibreOffice's measure (its default when the document doesn't say). - let d = import_as(&package(&content, Some(&styles)), &OdfProfile::libreoffice()); + // Measured from the indent, as the document says. + let settings = settings_xml(true); + let d = import(&crate::odt::tests::zip_of(&[ + ("content.xml", content.as_bytes()), + ("styles.xml", styles.as_bytes()), + ("settings.xml", settings.as_bytes()), + ])) + .unwrap(); let own = d.body[0].as_para().unwrap().props.tabs.clone().unwrap_or_default(); assert_eq!(own.iter().map(|t| t.pos).collect::>(), [252.0], "3in from a 0.5in indent"); let style = d.styles.get("Indented").unwrap().para.tabs.clone().unwrap_or_default(); @@ -163,7 +169,7 @@ fn tabs_measured_from_the_indent_are_written_so() { // A file measuring tabs from the indent (LibreOffice's own) is written back so, its stops // from the indent again: LibreOffice's default stops then count from the indent as before // (measured: with the setting false they moved to the margin's grid). - let d = import_as(&with_settings(&content(r#"fo:margin-left="0.5in""#), Some(true)), &OdfProfile::word()); + let d = import(&with_settings(&content(r#"fo:margin-left="0.5in""#), Some(true))).unwrap(); assert!(d.settings.odf.is_some_and(|o| o.tabs_from_indent)); let saved = export(&d).unwrap(); let s = crate::odt::tests::entry(&saved, "settings.xml"); @@ -173,7 +179,7 @@ fn tabs_measured_from_the_indent_are_written_so() { let back = import(&saved).unwrap(); assert_eq!(tabs(&back).first().map(|t| t.pos), Some(108.0)); // One measuring them from the margin stays so. - let d = import_as(&with_settings(&content(r#"fo:margin-left="0.5in""#), Some(false)), &OdfProfile::word()); + let d = import(&with_settings(&content(r#"fo:margin-left="0.5in""#), Some(false))).unwrap(); let s = crate::odt::tests::entry(&export(&d).unwrap(), "settings.xml"); assert!(s.contains(r#"config:name="TabsRelativeToIndent" config:type="boolean">false<"#), "{s}"); } diff --git a/crates/formats/src/odt/tests_more/text_boxes.rs b/crates/formats/src/odt/tests_more/text_boxes.rs index 2461acf992..a32e212e00 100644 --- a/crates/formats/src/odt/tests_more/text_boxes.rs +++ b/crates/formats/src/odt/tests_more/text_boxes.rs @@ -316,12 +316,12 @@ fn a_frames_drawn_stroke_is_its_line_when_it_has_no_border() { let content = format!( r#"Logo"# ); - crate::odt::tests::import_as(&package(&content, None), &wordcraft_doc::odf_profile::OdfProfile::libreoffice()) + import(&package(&content, None)).unwrap() }; let line = |d: &Document| looks(d).first().map(|l| l.1).expect("a box"); let stroke = r##"draw:stroke="solid" svg:stroke-width="0cm" svg:stroke-color="#000000""##; - // A 0 width draws at the profile's hairline: LibreOffice's 0.5 pt (WordCraft's is 0.25). - assert_eq!(line(&open_with(stroke)), Some((wordcraft_doc::props::Rgb::BLACK, 0.5))); + // A 0 width draws at a hairline, 0.25 pt. + assert_eq!(line(&open_with(stroke)), Some((wordcraft_doc::props::Rgb::BLACK, 0.25))); assert_eq!(line(&open_with(&format!(r#"{stroke} fo:border="none""#))), None); assert_eq!(line(&open_with("")), None); } diff --git a/crates/formats/src/odt/tests_more/profile.rs b/crates/formats/src/odt/tests_more/unstated.rs similarity index 58% rename from crates/formats/src/odt/tests_more/profile.rs rename to crates/formats/src/odt/tests_more/unstated.rs index bff6cabb70..1028bd3ff0 100644 --- a/crates/formats/src/odt/tests_more/profile.rs +++ b/crates/formats/src/odt/tests_more/unstated.rs @@ -1,8 +1,8 @@ -//! The ODF interpretation profile (`docs/odf-interpretation.md`) fills in only what a file leaves -//! unsaid: whatever the preset, a value the file states is the file's. +//! WordCraft's choices where the standard leaves something open (`docs/odf-interpretation.md`) +//! fill in only what a file leaves unsaid: a value the file states is the file's. -use crate::odt::tests::{NS_T, import_as}; -use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile}; +use crate::odt::import; +use crate::odt::tests::NS_T; use wordcraft_doc::para::InlineObject; use wordcraft_doc::props::Rgb; @@ -14,9 +14,9 @@ fn cm(v: f32) -> f32 { } #[test] -fn values_a_file_states_are_kept_under_every_preset() { - // A file that states every value a preset could fill in: header height and spacing and - // whether it is dynamic, page columns and their gap, tabs from the margin, a shape's fill and +fn values_a_file_states_are_kept() { + // A file that states every value WordCraft could fill in: header height and spacing and + // whether it is dynamic, page columns and their gap, tabs from the indent, a shape's fill and // line. let styles = format!( r##"H"## @@ -26,7 +26,7 @@ fn values_a_file_states_are_kept_under_every_preset() { ); let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); - let settings = r#"false"#; + let settings = r#"true"#; for (name, data) in [ ("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str()), @@ -37,57 +37,40 @@ fn values_a_file_states_are_kept_under_every_preset() { std::io::Write::write_all(&mut z, data.as_bytes()).unwrap(); } let bytes = z.finish().unwrap().into_inner(); - let odd = OdfProfile { - preset: OdfPreset::Custom, - page_margin: 99.0, - header_min_height: 99.0, - dynamic_spacing: true, - tabs_from_indent: true, - column_gap: 99.0, - shape_fill: Rgb(1, 2, 3), - shape_line: Rgb(4, 5, 6), - hairline: 9.0, - ..OdfProfile::word() - }; let near = |a: f32, b: f32| (a - b).abs() < 0.05; - for profile in [OdfProfile::word(), OdfProfile::libreoffice(), odd] { - let d = import_as(&bytes, &profile); - let s = &d.last_section; - assert!(near(s.header_gap, cm(0.5)), "{:?}: the stated spacing, kept: {}", profile.preset, s.header_gap); - assert!(s.margin_top >= cm(3.0) - 0.05, "{:?}: at least the stated height under the margin: {}", profile.preset, s.margin_top); - assert!(near(s.margin_left, cm(2.0)) && near(s.margin_bottom, cm(2.0)), "{:?}: the stated margins", profile.preset); - assert_eq!(s.columns.count, 2); - assert!(near(s.columns.space, cm(1.0)), "{:?}: {}", profile.preset, s.columns.space); - let p = d.body[0].as_para().unwrap(); - assert_eq!(p.props.tabs.clone().unwrap_or_default().first().map(|t| t.pos), Some(72.0), "{:?}", profile.preset); - let shape = p.objects.iter().find_map(|o| match o { - InlineObject::Shape { fill, stroke, stroke_width, .. } => Some((fill.as_ref().map(|f| f.main_color()), *stroke, *stroke_width)), - _ => None, - }); - assert_eq!(shape, Some((Some(Rgb(0x11, 0x22, 0x33)), Some(Rgb(0x44, 0x55, 0x66)), 2.0)), "{:?}", profile.preset); - } + let d = import(&bytes).unwrap(); + let s = &d.last_section; + assert!(near(s.header_gap, cm(0.5)), "the stated spacing, kept: {}", s.header_gap); + assert!(near(s.margin_top, cm(3.5)), "the stated height and spacing under the margin: {}", s.margin_top); + assert!(near(s.margin_left, cm(2.0)) && near(s.margin_bottom, cm(2.0)), "the stated margins"); + assert_eq!(s.columns.count, 2); + assert!(near(s.columns.space, cm(1.0)), "{}", s.columns.space); + let p = d.body[0].as_para().unwrap(); + assert_eq!(p.props.tabs.clone().unwrap_or_default().first().map(|t| t.pos), Some(108.0)); + let shape = p.objects.iter().find_map(|o| match o { + InlineObject::Shape { fill, stroke, stroke_width, .. } => Some((fill.as_ref().map(|f| f.main_color()), *stroke, *stroke_width)), + _ => None, + }); + assert_eq!(shape, Some((Some(Rgb(0x11, 0x22, 0x33)), Some(Rgb(0x44, 0x55, 0x66)), 2.0))); } #[test] -fn a_sections_column_balance_is_its_own_else_the_profiles_and_is_written() { - // §20.428 `text:dont-balance-text-columns`: read as its name says; unstated, the profile's - // (LibreOffice balances; WordCraft, as Word, only when text follows on the page). +fn a_sections_column_balance_is_its_own_and_is_written() { + // §20.428 `text:dont-balance-text-columns`: read as its name says; unstated, as Word: balanced + // only when text follows on the page. let doc = |props: &str| { let content = format!( r#"ab"# ); crate::odt::tests::package(&content, None) }; - let balance = |props: &str, profile: &OdfProfile| import_as(&doc(props), profile).last_section.balance_columns; - for p in [OdfProfile::word(), OdfProfile::libreoffice()] { - assert_eq!(balance(r#"text:dont-balance-text-columns="true""#, &p), Some(false)); - assert_eq!(balance(r#"text:dont-balance-text-columns="false""#, &p), Some(true)); - } - assert_eq!(balance("", &OdfProfile::libreoffice()), Some(true)); - assert_eq!(balance("", &OdfProfile::word()), None); + let balance = |props: &str| import(&doc(props)).unwrap().last_section.balance_columns; + assert_eq!(balance(r#"text:dont-balance-text-columns="true""#), Some(false)); + assert_eq!(balance(r#"text:dont-balance-text-columns="false""#), Some(true)); + assert_eq!(balance(""), None); // Written as WordCraft lays it out, so every reader balances it the same. for (props, written) in [("", "true"), (r#"text:dont-balance-text-columns="false""#, "false")] { - let d = import_as(&doc(props), &OdfProfile::word()); + let d = import(&doc(props)).unwrap(); let saved = crate::odt::export(&d).unwrap(); let content = crate::odt::tests::entry(&saved, "content.xml"); assert!(content.contains(&format!(r#"text:dont-balance-text-columns="{written}""#)), "{props}: {content}"); @@ -95,11 +78,10 @@ fn a_sections_column_balance_is_its_own_else_the_profiles_and_is_written() { } #[test] -fn paragraph_spacing_adds_up_as_the_document_says_else_as_the_profile_does() { +fn paragraph_spacing_adds_up_as_the_document_says_else_takes_the_larger() { // LibreOffice's `AddParaTableSpacing` (settings.xml; false: the larger of the two, observed) - // says how the space between paragraphs adds up; without it, the profile decides - // (LibreOffice adds them, WordCraft as Word takes the larger). Written back as WordCraft lays - // the document out, in a set LibreOffice reads (the `ooo` namespace declared). + // says how the space between paragraphs adds up; without it, the larger, as Word. Written back + // as WordCraft lays the document out, in a set LibreOffice reads (the `ooo` namespace declared). let content = format!("a"); let with = |add: Option| { @@ -115,14 +97,11 @@ fn paragraph_spacing_adds_up_as_the_document_says_else_as_the_profile_does() { } z.finish().unwrap().into_inner() }; - for p in [OdfProfile::word(), OdfProfile::libreoffice()] { - assert!(import_as(&with(Some(true)), &p).settings.sum_paragraph_spacing); - assert!(!import_as(&with(Some(false)), &p).settings.sum_paragraph_spacing); - } - assert!(import_as(&with(None), &OdfProfile::libreoffice()).settings.sum_paragraph_spacing); - assert!(!import_as(&with(None), &OdfProfile::word()).settings.sum_paragraph_spacing); + assert!(import(&with(Some(true))).unwrap().settings.sum_paragraph_spacing); + assert!(!import(&with(Some(false))).unwrap().settings.sum_paragraph_spacing); + assert!(!import(&with(None)).unwrap().settings.sum_paragraph_spacing); for (add, written) in [(Some(true), "true"), (None, "false")] { - let d = import_as(&with(add), &OdfProfile::word()); + let d = import(&with(add)).unwrap(); let saved = crate::odt::export(&d).unwrap(); let settings = crate::odt::tests::entry(&saved, "settings.xml"); assert!(settings.contains(&format!(r#"config:name="AddParaTableSpacing" config:type="boolean">{written}<"#)), "{add:?}: {settings}"); @@ -132,12 +111,11 @@ fn paragraph_spacing_adds_up_as_the_document_says_else_as_the_profile_does() { } #[test] -fn widow_control_is_the_files_else_the_profiles() { +fn widow_control_is_the_files_else_on() { // `fo:widows`/`fo:orphans` refer to XSL's (initial value 2); LibreOffice reads none stated as - // none, so a first line alone at a page's foot stayed there in LibreOffice and moved on after - // a save. Stated in the default style (or Standard), it is the file's under every preset; - // written from the model (after an edit), stated either way. An unedited file keeps its - // own statement, or its silence (see `kept`). + // none. Stated in the default style (or Standard), it is the file's; unstated, on; written + // from the model (after an edit), stated either way, so every reader takes it the same. An + // unedited file keeps its own statement, or its silence (see `kept`). let doc = |default: &str| { let styles = format!( r#""# @@ -147,17 +125,15 @@ fn widow_control_is_the_files_else_the_profiles() { ); crate::odt::tests::package(&content, Some(&styles)) }; - let on = |bytes: &[u8], p: &OdfProfile| import_as(bytes, p).styles.resolve_para(&Default::default()).widow_control; - for p in [OdfProfile::word(), OdfProfile::libreoffice()] { - assert!(on(&doc(r#"fo:widows="2" fo:orphans="2""#), &p)); - assert!(!on(&doc(r#"fo:widows="0" fo:orphans="0""#), &p)); - } - assert!(on(&doc(""), &OdfProfile::word())); - assert!(!on(&doc(""), &OdfProfile::libreoffice())); - for (p, written) in [(OdfProfile::word(), r#"fo:widows="2" fo:orphans="2""#), (OdfProfile::libreoffice(), r#"fo:widows="0" fo:orphans="0""#)] { - let saved = crate::odt::tests::export_model(&import_as(&doc(""), &p)).unwrap(); + let on = |bytes: &[u8]| import(bytes).unwrap().styles.resolve_para(&Default::default()).widow_control; + let stated_off = r#"fo:widows="0" fo:orphans="0""#; + assert!(on(&doc(r#"fo:widows="2" fo:orphans="2""#))); + assert!(!on(&doc(stated_off))); + assert!(on(&doc(""))); + for (default, written, widows) in [("", r#"fo:widows="2" fo:orphans="2""#, true), (stated_off, stated_off, false)] { + let saved = crate::odt::tests::export_model(&import(&doc(default)).unwrap()).unwrap(); let styles = crate::odt::tests::entry(&saved, "styles.xml"); - assert!(styles.contains(written), "{:?}: {styles}", p.preset); - assert_eq!(on(&saved, &OdfProfile::of(OdfPreset::Custom)), p.widow_control); + assert!(styles.contains(written), "{default}: {styles}"); + assert_eq!(on(&saved), widows); } } diff --git a/crates/layout/src/para/lines.rs b/crates/layout/src/para/lines.rs index b19ec3baa1..b98f0fccb1 100644 --- a/crates/layout/src/para/lines.rs +++ b/crates/layout/src/para/lines.rs @@ -42,9 +42,6 @@ struct Breaker<'e> { rtl_para: bool, /// The document grid's line pitch, for paragraphs that snap to it. grid: Option, - /// A line of only pictures is as tall as they are (an ODF document opened with that choice, - /// [`wordcraft_doc::odf_profile::OdfProfile::picture_lines_fit_pictures`]). - picture_lines_fit: bool, bidi: bool, est_h: f32, table_h: f32, @@ -199,7 +196,6 @@ impl<'e> Breaker<'e> { widths: word_widths(&pl.clusters, &pl.hyph_after), rtl_para: rp.bidi, grid: Some(env.grid).filter(|g| g.line.is_some() && rp.snap_to_grid), - picture_lines_fit: settings.odf.is_some_and(|o| o.picture_lines_fit_pictures), bidi: rp.bidi || !pl.bidi_levels.is_empty(), est_h, // Exact spacing fixes the table-contact boundary even when the mark has other metrics. @@ -413,9 +409,6 @@ impl<'e> Breaker<'e> { let rp = &self.rp; let mut ex = Extents { asc: 0.0, desc: 0.0, obj_asc: 0.0 }; let mut any = false; - // Only pictures on the line, and the document lays such lines out to fit them. - let mut only_pictures = self.picture_lines_fit; - let mut pictures = Extents { asc: 0.0, desc: 0.0, obj_asc: 0.0 }; let dropped = pl.drop_cap.map_or(0, |d| d.0); for (k, c) in pl.clusters.get(c0..c1).into_iter().flatten().enumerate() { // Spaces and tabs never make a line taller, whatever their size: Word sizes a line by @@ -431,19 +424,15 @@ impl<'e> Breaker<'e> { // the line keeps that font's ascent and descent, and grows to fit it. let (a, d) = if matches!(c.kind, ClKind::Object(_)) && c.obj_h + c.obj_d > 0.0 { if pl.maths.iter().any(|(k, _)| c.kind == ClKind::Object(*k)) { - only_pictures = false; // Exact spacing keeps the run's baseline; tall math may overflow the // fixed line box without moving adjacent text up by its denominator. if matches!(rp.line_spacing, LineSpacing::Exactly(_)) { (st.ascent, st.descent) } else { (c.obj_h, c.obj_d) } } else { ex.obj_asc = ex.obj_asc.max(c.obj_h); - pictures.asc = pictures.asc.max(c.obj_h); - pictures.desc = pictures.desc.max(c.obj_d); // A lowered picture reaches below the baseline: the line's descent grows. (st.ascent, st.descent.max(c.obj_d)) } } else { - only_pictures = false; // `obj_h` is set on text clusters only under ruby text, which the line grows to fit. let (a, d) = st.line_extent(); (a.max(c.obj_h + st.shift), d) @@ -452,10 +441,6 @@ impl<'e> Breaker<'e> { ex.desc = ex.desc.max(d); any = true; } - if any && only_pictures && pictures.asc + pictures.desc > 0.0 { - pictures.obj_asc = pictures.asc; - return pictures; - } if (!any || (ex.asc == 0.0 && ex.desc == 0.0)) && let Some(st) = pl.styles.get(self.mark_style as usize) { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 22cfedce2e..1adb9ff59a 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -5211,12 +5211,12 @@ fn list_label_does_not_inflate_an_inline_picture_floor() { } #[test] -fn a_line_of_only_pictures_fits_them_in_odt_documents() { - // LibreOffice (ODT documents): a line holding only a picture is as tall as the picture; - // Word keeps the font's ascent and descent around it. - let height = |fit: bool, h: f32| { +fn a_line_of_only_pictures_keeps_its_fonts_height_in_odt_documents() { + // The standard has no line-height algorithm: as in Word, the line keeps the font's height, and + // its descent below a taller picture. + let height = |h: f32| { let mut d = Document::from_text(""); - d.settings.odf = Some(wordcraft_doc::odf_profile::OdfLayout { picture_lines_fit_pictures: fit, ..Default::default() }); + d.settings.odf = Some(wordcraft_doc::OdfLayout::default()); if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) { p.props.space_after = Some(0.0); p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.0)); @@ -5226,20 +5226,8 @@ fn a_line_of_only_pictures_fits_them_in_odt_documents() { let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() }; para.lines[0].height }; - assert!((height(true, 3.5) - 3.5).abs() < 0.01, "{}", height(true, 3.5)); - assert!((height(true, 40.0) - 40.0).abs() < 0.01, "{}", height(true, 40.0)); - // Word's: the font's height at least, and its descent below a taller picture. - assert!(height(false, 3.5) > 10.0); - assert!(height(false, 40.0) > 42.0); - // Text on the line: its font counts either way. - let mut d = Document::from_text("Text"); - d.settings.odf = Some(wordcraft_doc::odf_profile::OdfLayout { picture_lines_fit_pictures: true, ..Default::default() }); - if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) { - p.insert_object(0, picture(20.0, 3.5, Default::default()), &Default::default()).unwrap(); - } - let l = lay(&d); - let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() }; - assert!(para.lines[0].height > 10.0); + assert!(height(3.5) > 10.0); + assert!(height(40.0) > 42.0); } #[test] @@ -5247,7 +5235,7 @@ fn padding_without_a_border_takes_room_in_odt_documents() { // LibreOffice keeps a paragraph's padding on a side without a line; Word drops it. let place = |odf: bool| { let mut d = Document::from_text("Above\nPadded\nBelow"); - d.settings.odf = odf.then_some(wordcraft_doc::odf_profile::OdfLayout::default()); + d.settings.odf = odf.then_some(wordcraft_doc::OdfLayout::default()); for b in d.body.iter_mut() { if let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) { (p.props.space_before, p.props.space_after) = (Some(0.0), Some(0.0)); @@ -13037,7 +13025,7 @@ fn default_tab_stops_count_from_the_indent_in_odt_documents_that_say_so() { let x_of_b = |from_indent: bool, left: f32, first: f32| { let mut d = Document::from_text("a\tb"); d.settings.default_tab = 36.0; - d.settings.odf = Some(wordcraft_doc::odf_profile::OdfLayout { tabs_from_indent: from_indent, ..Default::default() }); + d.settings.odf = Some(wordcraft_doc::OdfLayout { tabs_from_indent: from_indent }); d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| { p.indent_left = Some(left); p.indent_first = Some(first); diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index 06f66af8a7..0e9a8f430e 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -652,9 +652,6 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) { templates_folder(app, ui); } ui.add_space(8.0); - section(ui, "OpenDocument files"); - odf_files(app, ui); - ui.add_space(8.0); section(ui, "Customize"); ui.horizontal(|ui| { ui.label(tl!("Keyboard shortcuts:")); @@ -670,98 +667,6 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) { }); } -/// How OpenDocument files opened from now on fill in what the standard leaves to the -/// application (docs/odf-interpretation.md): in Microsoft Word's style, in LibreOffice's, or with -/// the user's own choice for each case. -fn odf_files(app: &mut WordApp, ui: &mut Ui) { - use wordcraft_doc::odf_profile::{MAX_LENGTH, OdfPreset}; - let profile = app.session.prefs.odf.clone(); - // The preset as a drop-down, like the units and region above. - // The two display styles, then Custom for the user's own choices. - let presets = [OdfPreset::Word, OdfPreset::LibreOffice, OdfPreset::Custom]; - let shown = profile.preset.label(); - let mut chosen = None; - ui.horizontal(|ui| { - let label = ui.label(tl!("Where the standard leaves the look open, follow:")); - let combo = craft_ui::ComboBox::from_id_salt("options_odf_preset").selected_text(tl!(shown)).width(200.0).show_ui(ui, |ui| { - for preset in presets { - if ui.selectable_label(profile.preset == preset, tl!(preset.label())).clicked() && profile.preset != preset { - chosen = Some(preset); - } - } - }); - combo.response.labelled_by(label.id); - }); - if let Some(preset) = chosen { - // Custom starts from the choices in use. - let mut odf = if preset == OdfPreset::Custom { serde_json::to_value(profile.effective()).unwrap_or_default() } else { json!({}) }; - if let Some(o) = odf.as_object_mut() { - o.insert("preset".into(), json!(preset.id())); - } - set(app, json!({ "odf": odf })); - } - note(ui, "Applies to OpenDocument files opened from now on. What the standard defines is always followed."); - if profile.preset != OdfPreset::Custom { - return; - } - let e = profile.effective(); - let u = unit(); - let k = u.pt_per_unit(); - egui::Grid::new("options_odf").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { - for (key, label, pt) in [ - ("pageMargin", "Page margins:", e.page_margin), - ("headerMinHeight", "Header and footer height:", e.header_min_height), - ("columnGap", "Gap between columns:", e.column_gap), - ] { - let id = ui.label(tl!(label)).id; - let mut v = pt / k; - if ui.add(egui::DragValue::new(&mut v).speed(0.5 / k).range(0.0..=MAX_LENGTH / k).measure(u).max_decimals(2)).labelled_by(id).changed() { - set(app, json!({"odf": {key: (v * k).clamp(0.0, MAX_LENGTH)}})); - } - ui.end_row(); - } - // Line widths in points, as the ribbon shows them: a hairline is a fraction of a point. - let id = ui.label(tl!("Shape line width:")).id; - let mut w = e.hairline; - if ui - .add(egui::DragValue::new(&mut w).speed(0.05).range(0.05..=24.0).max_decimals(2).suffix(format!(" {}", tl!("pt")))) - .labelled_by(id) - .changed() - { - set(app, json!({"odf": {"hairline": w}})); - } - ui.end_row(); - for (key, label, rgb) in [("shapeFill", "Shape fill colour:", e.shape_fill), ("shapeLine", "Shape line colour:", e.shape_line)] { - let id = ui.label(tl!(label)).id; - let mut c = [rgb.0, rgb.1, rgb.2]; - if egui::color_picker::color_edit_button_srgb(ui, &mut c).labelled_by(id).changed() { - set(app, json!({"odf": {key: wordcraft_doc::props::Rgb(c[0], c[1], c[2]).hex()}})); - } - ui.end_row(); - } - }); - for (key, label, value) in [ - ("headerHeightIncludesSpacing", "A header's height includes its spacing from the text", e.header_height_includes_spacing), - ("dynamicSpacing", "A tall header uses up its spacing from the text", e.dynamic_spacing), - ("tabsFromIndent", "Tab stops are measured from the paragraph's indent", e.tabs_from_indent), - ("pageFrameParagraphIsPage", "A page frame placed against a paragraph is placed against the page edge", e.page_frame_paragraph_is_page), - ( - "inlineInvalidRelationIsBaseline", - "An in-line frame placed against a paragraph or the page is placed against the baseline", - e.inline_invalid_relation_is_baseline, - ), - ("pictureLinesFitPictures", "A line holding only pictures is as tall as the pictures", e.picture_lines_fit_pictures), - ("balanceSectionColumns", "A section's columns end balanced at the end of the document too", e.balance_section_columns), - ("sumParagraphSpacing", "The space between paragraphs is the space after plus the space before", e.sum_paragraph_spacing), - ("widowControl", "Widow and orphan control when no style says", e.widow_control), - ] { - let mut v = value; - if ui.checkbox(&mut v, tl!(label)).changed() { - set(app, json!({"odf": {key: v}})); - } - } -} - /// The user templates folder (#2852): where File › New finds personal templates and Save as Word /// Template starts. Empty is the app's own folder, shown as the hint. fn templates_folder(app: &mut WordApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/options/tests.rs b/crates/ui-egui/src/options/tests.rs index a01ea93e4d..a5b9851319 100644 --- a/crates/ui-egui/src/options/tests.rs +++ b/crates/ui-egui/src/options/tests.rs @@ -482,46 +482,3 @@ fn length_units_follow_the_interface_language() { h.run_steps(4); assert_eq!(length(72.0), "25,4 mm", "English keeps the English names"); } - -/// File › Options › Advanced › OpenDocument files: the preset drop-down is `file.options` `odf`, and -/// Custom starts from the choices in use and shows each case. -#[test] -fn advanced_opendocument_presets_and_custom_cases() { - use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile}; - let mut a = app(); - a.run("file.options", json!({"pane": "advanced"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 1600.0)).build_ui_state( - |ui, app: &mut WordApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - a, - ); - h.run_steps(4); - assert!(h.query_by_label("Tab stops are measured from the paragraph's indent").is_none(), "a preset shows no cases"); - let pick = |h: &mut egui_kittest::Harness<'static, WordApp>, name: &str| { - h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Where the standard leaves the look open, follow:").click(); - h.run_steps(4); - h.get_by_label(name).click(); - h.run_steps(4); - }; - let preset = |h: &egui_kittest::Harness<'static, WordApp>| { - h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Where the standard leaves the look open, follow:").value() - }; - // Two display styles: Microsoft Word's (the default) and LibreOffice's. - assert_eq!(preset(&h).as_deref(), Some("Microsoft Word style")); - pick(&mut h, "LibreOffice style"); - assert_eq!(preset(&h).as_deref(), Some("LibreOffice style")); - assert_eq!(h.state().session.prefs.odf.effective(), OdfProfile::libreoffice()); - pick(&mut h, "Custom"); - let odf = &h.state().session.prefs.odf; - assert_eq!(odf.preset, OdfPreset::Custom); - assert_eq!(OdfProfile { preset: OdfPreset::LibreOffice, ..odf.effective() }, OdfProfile::libreoffice(), "starting from LibreOffice's"); - h.get_by_label("Tab stops are measured from the paragraph's indent").click(); - h.run_steps(4); - assert!(!h.state().session.prefs.odf.effective().tabs_from_indent, "a case turned off"); - pick(&mut h, "Microsoft Word style"); - assert_eq!(preset(&h).as_deref(), Some("Microsoft Word style")); - assert_eq!(h.state().session.prefs.odf.effective(), OdfProfile::word()); -} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 99f4952c7d..09f836b0ee 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-11-04 · **Change:** trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (Replace and Remove Table of Contents treat Word's title above a section break as Word does, #4041); previously trivial (a password-protected .docx opened from the file manager asks for its password also when the startup document was replaced, #5299); previously minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously trivial (a VML horizontal rule (`o:hr`) is drawn across its line at Word's measured width and kept on save, with rotation and flips, #3956); previously trivial (soft edges on a see-through solid fill fade from the fill's own opacity, #1600); previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-04 · **Change:** trivial (ODT: no settings for what the standard leaves open); previously trivial (picture brightness, contrast and transparency); previously trivial (ODT paragraph groups kept as read); previously trivial (ODT paragraphs with charts kept as read); previously trivial (ODT sections in cells and at empty paragraphs); previously trivial (ODT element audit); previously trivial (ODT reference marks written as such); previously trivial (ODT hidden sections stay hidden; lists kept as read); previously trivial (ODT manifest lists every file); previously trivial (ODT paragraphs with bookmarks, notes, objects and page-anchored frames written as read); previously trivial (ODT paragraphs with text boxes written as read); previously trivial (ODT paragraphs with comments and tracked changes written as read); previously trivial (ODT paragraphs with pictures and shapes written as read); previously trivial (ODT sections inside text boxes); previously trivial (ODT definitions sharing a name, sections around tables, chart replacement pictures, note citations, stacking order); previously trivial (ODT form definitions and indexes written as read); previously trivial (ODT gradient angles by the file's version; deviation documented); previously minor (ODT: unedited tables, pages, sections, properties and declarations written as read; no-edit loss gate); previously minor (ODT: unedited paragraphs and style definitions written as read); previously trivial (ODT author fields said not fixed); previously trivial (ODT default text colour; covered cells' content); previously trivial (ODT widow control profile case; tabs from the indent kept); previously trivial (ODT span kerning; note reference formatting; notes starting with a tab); previously trivial (ODT fixed and sender fields); previously trivial (ODT: table styles saved into their cells; ODT tables carry no Word table style; section column balance); previously trivial (ODT interpretation profile: the standard is law, what it leaves open (including unstated page margins) follows WordCraft, LibreOffice or custom settings); previously trivial (ODT form controls keep their place on the line and text areas their lines; custom shapes keep their geometry); previously trivial (ODT in-line frames placed against the text; in-line pictures saved standing on the baseline); previously trivial (ODT charts written back as they were while unchanged; frames' drawn outline; default header height); previously trivial (ODT header rows only where they repeat); previously trivial (ODT padding without a border; unequal columns; column breaks); previously trivial (ODT lines of only pictures laid out as LibreOffice does; default page without a layout); previously trivial (ODT pictures alone in a paragraph keep its size; in-line frames raised under an inherited paragraph relation); previously trivial (ODT tables fit the margins and keep their space above and below; header spacing kept); previously trivial (ODT frames anchored and placed by their style); previously trivial (ODT default font size saved; page-anchored frames placed from the page; text box paragraph styles kept; empty paragraphs keep their size); previously trivial (ODT default font size; locale-ordered dates); previously trivial (ODT page background pictures); previously trivial (ODT tables fixed layout; tables starting a page); previously trivial (emphasis marks: model, layout, DOCX `w:em`, ODT `style:text-emphasize`, `format.emphasisMark`); previously trivial (ODT variables as SET/REF fields; character styles keep what they turn off); previously trivial (ODT styles keep what they turn off; WMF/EMF pictures kept; pictures in text boxes shown by LibreOffice); previously trivial (ODT unstated page margins are LibreOffice's 2 cm; drawings outside a paragraph join the next one); previously trivial (ODT placeholders as content controls, template dates, user name fields, unsized pages, style-only templates, found with LibreOffice's own templates); previously trivial (ODT hidden form controls kept; `.ott` templates opened and saved); previously trivial (ODT fidelity re-estimated at ~90%; a kept form control made another kind is saved as that kind); previously trivial (ODT form control sizes and buttons kept); previously trivial (ODT turned text in cells and text boxes); previously trivial (ODT macros and unknown package parts kept); previously trivial (ODT table cell formulas as formula fields); previously trivial (ODT custom shapes drawn from their own geometry); previously trivial (ODT bibliography marks as citations of sources); previously trivial (ODT linked text boxes); previously trivial (ODT picture crops and contour wrapping); previously trivial (ODT fidelity re-estimated at ~85%); previously trivial (ODT custom document properties and user fields as DOCPROPERTY); previously trivial (ODT connectors read and written glued); previously trivial (ODT borders around text read and written); previously trivial (ODT index marks read and written as XE fields; an index held around its entries updates); previously trivial (ODT ruby read and written); previously trivial (ODT page breaks after a paragraph, kept lines, widow control, hyphenation, table alignment and width and row heights); previously trivial (in-line drawings raised or lowered by their run's position; ODT in-line frames keep their place on the baseline; leading's space after the last line; text box borders shown by LibreOffice); previously trivial (ODT form controls opened and saved as content controls); previously trivial (password-protected ODT opened and saved); previously trivial (ODT headers and footers keep their shapes, text boxes, charts and embedded objects on save); previously trivial (ODT embedded spreadsheets and OLE objects kept whole instead of lost; flat ODT sub-documents valid `office:document`s); previously trivial (ODT charts in drawing groups drawn on open); previously trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (Replace and Remove Table of Contents treat Word's title above a section break as Word does, #4041); previously trivial (a password-protected .docx opened from the file manager asks for its password also when the startup document was replaced, #5299); previously minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously trivial (a VML horizontal rule (`o:hr`) is drawn across its line at Word's measured width and kept on save, with rotation and flips, #3956); previously trivial (soft edges on a see-through solid fill fade from the fill's own opacity, #1600); previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats 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; @@ -59,7 +59,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 (and flat `.fodt`, templates `.ott`) | 2% | R/W | 🟡 | 🟡 | ~90% | 20–35 | `crates/formats/src/tests.rs`, `crates/formats/src/odt/tests.rs` and one file per feature in `crates/formats/src/odt/tests_more/`, `crates/engine/tests/it/odt_sections.rs`, `odt_toc.rs`, `odt_fields.rs`, `odt_charts.rs`, `odt_objects.rs`, `odt_password.rs`, `odt_forms.rs`; checked against LibreOffice's own documents (round trips compared page by page) and the ODF 1.3 schema; what the standard leaves open follows the ODF interpretation profile ([odf-interpretation.md](odf-interpretation.md)) | Ruby (`text:ruby`, §6.4: a base text with ruby text over it) is read as WordCraft's Phonetic Guide and written back as ODF ruby above its base, centred (one inside a tracked change is written as its base text, which the change marks); its own position and alignment aren't kept. A border around text (LibreOffice's `loext:border` or its sides, with `loext:padding`: ODF 1.3 has no attribute for it) is Word's text border, one line all round, and is written back so (a document with one is then extended ODF, not strict). Emphasis marks (`style:text-emphasize`) and rotated text (`style:text-rotation-angle`) aren't read. How text flows: a page break after a paragraph (`fo:break-after`, a page break before the next paragraph as Word has it), lines kept together (`fo:keep-together`), widow and orphan control (`fo:widows`/`fo:orphans`: on with any line count, off with none; the counts themselves aren't kept) and hyphenation (`fo:hyphenate`, a paragraph's in ODF and the document's in WordCraft: a document with a hyphenated paragraph hyphenates, its other paragraphs opting out) are read and written; a table's alignment and width (`table:align`, `style:rel-width`, or `style:width` for an aligned table) and its rows' heights (`style:row-height` exact, `style:min-row-height` at least) are read and written. Form controls (`office:forms` controls placed by `draw:control`, ODF 1.3 §13) open as content controls — text fields and text areas as plain text controls, check boxes as check boxes, radio buttons as check boxes with round symbols, list boxes as drop-down lists, combo boxes as combo boxes, date fields as date controls, each with its value, entries and selection, its name as the control's tag and a label as its title — and content controls of those kinds in the body are written back as form controls LibreOffice shows with their values (in headers and footers, as their text); a control keeps its size (`draw:control` `svg:width`/`svg:height`); buttons, image buttons, labels and the other controls WordCraft has no kind for show their label, locked, and are kept verbatim and written back as they were (one naming a namespace WordCraft doesn't declare is left out); hidden controls (`form:hidden`) are kept and written back with their form; a control's look (colours, font) isn't kept. Placeholders (`text:placeholder`, §7.7.2, as LibreOffice's templates use: "") open as plain text content controls showing them, so a click selects one and typing replaces it, and are written back as placeholders of their type (their description as the control's title) while they show it. A master page followed by another (`style:next-style-name`: LibreOffice's "First Page" before "Default") is a section with a different first page, its own headers and footers on the first page and the next one's after it. The default paragraph style's font size (`fo:font-size` in `style:default-style`, or the Standard style's: the document's defaults) is the document's default size (it stayed 12 pt), a style with no parent keeping the default style's, and a date style ordered by the locale (`number:automatic-order`) shows in US English order ("November 2, 2026", as LibreOffice shows it here). A page background picture (`style:background-image` in the page layout, §17.3) is WordCraft's page picture (Design › Page Color › Fill Effects): repeated as tiled, stretched as stretched, not repeated as centred at its own size (a fill WordCraft draws so; DOCX writes it stretched), and is written back with its picture. Tables are fixed layout (Word's `w:tblLayout`), as LibreOffice's columns never fit their content, and a table starting a page (`fo:break-before` on the table) starts it: its first paragraph has a page break before it, which moves the table, and is written back on the table. The columns of a table filling the margins are written with their share of the table (`style:rel-column-width`) too, so on a narrower page they keep their proportions in LibreOffice (an aligned table's aren't: LibreOffice lays one out oddly with shares). A fixed date or time with neither a value nor text (LibreOffice's templates) is a date field showing the day; `text:author-name` is `USERNAME`, which WordCraft now fills with the user's name. A page layout that states no size is the default page's (the user's region's, as LibreOffice uses its default paper), and a margin it leaves unsaid is LibreOffice's 2 cm (a layout with no properties at all included); Emphasis marks (`style:text-emphasize`, §20.370) open as Word's (dot, accent as comma, circle, disc as dot; below as dot below) and are written back. Variables (`text:variable-set`, `text:variable-get`, §7.4) open as Word's SET and REF fields (a displayed set as SET then REF), which Update Field works out as LibreOffice shows them, and are written back declared (`text:variable-decls`), sets hidden with a get after them. What a named paragraph or character style turns off that the style it is based on turns on (`fo:font-weight="normal"` under a bold parent; italic, underline and strike-through too) is kept and written back, so it doesn't show through. Windows metafile pictures (WMF, EMF: LibreOffice's clip art and logos) open as pictures, at their own size when the frame gives none, instead of being dropped; picture frames are written derived from the Graphics style, without which LibreOffice doesn't show a floating picture inside a text box; drawings anchored outside any paragraph (LibreOffice's page-anchored frames before the first one) join the next paragraph instead of making one of their own, which started the document on the default page style, and a template of styles only (no `content.xml`) opens empty with its styles. OpenDocument text templates (`.ott`) open as documents and save as templates (the template's media type in `mimetype` and the manifest), with a password too. Turned text (`style:writing-mode`, §20.404; LibreOffice's own `loext:writing-mode="bt-lr"` for text running upwards) in table cells and text boxes opens as WordCraft's cell and text box text direction, down or up, and is written back the same way; turned characters (`style:text-rotation-angle`) open upright, as WordCraft has no turned characters. Package parts WordCraft doesn't read — LibreOffice Basic macros and dialogs (`Basic/`, `Dialogs/`), `manifest.rdf`, other applications' files — are kept as they are and written back with their manifest entries, so LibreOffice still finds a document's macros (WordCraft never runs them); the old thumbnail and digital signatures (`META-INF/*signatures*.xml`, which sign the package as it was) are dropped on save, and paths that climb out of the package aren't written. Password-protected ODT (ODF 1.3 Part 2 §3.4) opens with its password — AES-256-CBC with a SHA-256 or SHA-1 start key and PBKDF2, as LibreOffice writes it; the old Blowfish encryption is refused with a message saying so — and a document opened with a password, or saved with one (Save with password), is written encrypted the way LibreOffice encrypts (every file but the `mimetype` and manifest; each with its own salt and initialisation vector; SHA-256 checksums), which LibreOffice opens with the password. Flat OpenDocument text (`.fodt`: the whole document in one `office:document`, pictures inline as base64 `office:binary-data`, formulas inline) is opened and saved as a package is, both validating against the schema. Tables of contents and other indexes (`text:table-of-content`, `text:illustration-index`/`text:table-index`, `text:alphabetical-index`, `text:bibliography`) open as Word's `TOC` (levels from the source's `text:outline-level`; `\c` with the caption name for a table of figures), `INDEX` and `BIBLIOGRAPHY` fields — in the title paragraph when the index has one, as WordCraft inserts a table, else around the entries — so References › Update Table regenerates them; LibreOffice's Contents 1–10, Contents Heading and Figure Index 1 styles are WordCraft's TOC 1–9, TOC Heading and Table of Figures; WordCraft's tables are written back as ODF indexes with their source and entry templates (LibreOffice updates them), entries' links in the Index Link style; user and object indexes are read as their paragraphs; index marks are Word's `XE` fields — a mark at a point (`text:alphabetical-index-mark`, its `text:key1`/`text:key2` the levels above its entry) and one over text (`-mark-start`…`-mark-end` in one paragraph, its entry the marked text) — and `XE` fields are written as point marks, so References › Update Index rebuilds an ODT index from its entries (an index or other generated list held as a field around its entries, as ODT and Word files hold it, is first made the field's own paragraph, as WordCraft inserts one). Custom character styles (a common text style other than LibreOffice's link, note-mark and list-label styles kept as a character style with its name, parent and own formatting, its text in it; written back as common text styles), custom paragraph styles (a common style other than Standard, the kinds' and the note texts' kept as a document style with its name, parent and own formatting, its paragraphs in it rather than given its formatting directly; written back as common styles, named so LibreOffice keeps them), paragraph borders (each side's line and padding) and background, heading numbering (`text:outline-style`: each level's format, prefix, suffix, levels shown and start number the headings through their styles, as Word's numbered headings, and are written back), styles (the file's own Heading 1–6, Title, Quotations and Preformatted Text definitions give those styles' look on open — font, size, percent sizes included, bold, colour, spacing, alignment, keep with next — and saving writes the document's own style definitions rather than fixed ones), tab stops (left, centre, right, decimal; dot, hyphen, underline and middle-dot leaders; LibreOffice's indent-relative positions converted, and a `settings.xml` written saying positions run from the margin) and the default tab interval, character formatting (underline styles and colour, double strike, caps, small caps, hidden text kept hidden, letter spacing, scale, raised and lowered text, outline, shadow, emboss, engrave, language, Asian and complex-script fonts, complex-script size, weight and posture and both script languages (per run and as document defaults), a monospaced font by its own name, a `style:font-name` read as the family its `style:font-face` declares, and the default style's pair kerning) both ways, lists (#87; each `text:list` a list of its own unless it continues one by `text:continue-numbering` or `text:continue-list`, an item's `text:start-value` restarting from it; each level's number format — 1, i, I, a, A, none —, prefix, suffix, levels shown, start value, bullet character, indent, hanging and number alignment read from its list style and written back), picture crops (`fo:clip`: top, right, bottom, left in lengths of the picture's own size, its pixels at 96 per inch ↔ WordCraft's crop fractions), floating pictures (a frame anchored to a paragraph, character or page: wrap — square on both sides, left, right or the larger side, top and bottom, behind or in front of text, along its outline (`style:wrap-contour`: `outside` Word's Tight, `full` Through, its `draw:contour-polygon` the wrap polygon; a polygon in another coordinate system than the frame's, as LibreOffice writes for some pictures, is worked out from the picture instead) —, horizontal and vertical alignment or offset and what it is relative to, distances from the text, read into the picture's placement and written back in a graphic style), tables (cell borders — width, style, colour, an edge LibreOffice gives one cell shared by both — and cell padding read and written, each shared edge written once so lines don't double, a table without lines written without; cell formulas — LibreOffice's `table:formula` in its own syntax, `ooow:sum ` — open as Word formula fields, `=SUM(A1:A3)`, showing the cell's value, so Update Field recalculates them, and formula fields in cells, `ABOVE`/`LEFT`/`BELOW`/`RIGHT` as the cells they name, are written back as cell formulas with their value; cells holding a plain number are written as numbers (`office:value-type="float"`) so formulas calculate with them; a formula one side can't say keeps its value as text), pictures, page breaks (#86), paragraph spacing, indents and line spacing (proportional, fixed, at least; leading read as an at-least spacing of a 12 pt line and the leading, which puts the extra space above each line as LibreOffice draws it, and as that much more space after the paragraph, where LibreOffice leaves it after the last line) 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`, to bookmarks kept over the text they mark — `text:bookmark-start`…`-end`, in a paragraph or across several — so a reference updates to that text) read as fields and written back as ODF fields; dates and times in their date style (`number:date-style`/`time-style` ↔ Word's `\@` picture, both ways), sequences numbering figures and tables (`text:sequence` ↔ `SEQ` with its number format, declared on save; a bookmark over the caption's label and number, which references to it show), chapter fields (`text:chapter` ↔ `STYLEREF "Heading n"`, by name or number), reference marks (as bookmarks) and references to them and to sequences (`text:reference-ref`, `text:sequence-ref` ↔ `REF`/`PAGEREF`); citations (`text:bibliography-mark`) are `CITATION` fields of the source each carries, which becomes one of the document's bibliography sources (its identifier the tag; its type, author, title, year, publisher, address, journal, volume, pages, URL, editor, edition, number and month), so a bibliography updates from them, and are written back as marks carrying their source; custom document properties (`meta:user-defined`: string, number, true/false and date) are WordCraft's custom document properties, read and written, and the fields showing one (`text:user-defined`) or a user field (`text:user-field-get`, its declaration a custom property too, so saved back as one) are `DOCPROPERTY` fields, which update from them and are written back as `text:user-defined`; fixed dates and fields without a Word equivalent (variables — `text:variable-set`/`-get`, whose value changes through the document —, references to a caption's text) read as their text, and Word fields ODF lacks written as their result, keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099); charts (a `draw:object` whose sub-document is an `office:chart`: column, bar, stacked, line, area, pie, doughnut and scatter charts; categories, series names and values — gaps included — from the chart's own table through their cell range addresses, title, legend position, data labels) open as WordCraft charts (in a drawing group too), drawn in the theme's colours and edited with Chart Design's commands, and every WordCraft chart is written back as an ODF chart object shaped like LibreOffice's (its data table, each series in its accent colour, slices clockwise from the top), in line or floating, inline in a flat document; a chart WordCraft can't edit as it is (3D, percent-stacked, stacked lines or areas, radar, stock or bubble charts, mixed plot types, a second value axis, one with no series) keeps its replacement picture, and fonts and axis scaling aren't kept; other embedded objects (`draw:object` sub-documents such as spreadsheets, drawings and presentations, and `draw:object-ole` OLE files) are kept whole — their files, media type and replacement picture — and saved back as the same objects, which LibreOffice opens as before; WordCraft shows one by its replacement picture, or, when that is in LibreOffice's own metafile format, by a placeholder of its size it draws itself (ruled for a spreadsheet), and doesn't edit or draw its contents; DOCX and other formats get the picture alone; in a flat document each object goes inline with its replacement picture — a sub-document as an `office:document` of its media type, its styles and settings with it, an OLE file as base64 — and is taken apart into its files again on opening; export writes each equation as a formula object of its own (MathML, with its linear text as an annotation), its frame sized from the equation's structure and centred on the line, so LibreOffice shows and edits it, page size, orientation and margins (#810), page number format (the page style's, and a page number field's own as `PAGE \* Arabic`/`roman`/…), page colour, page borders (ODF's at the margin with the text inside their padding ↔ WordCraft's measured from the page edge), footnote and endnote numbering (`text:notes-configuration`: format, first number, restart, page foot or document end) and line numbering (`text:linenumbering-configuration`, on unless it says otherwise: every how many lines, distance, restart on each page), 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 (`text:format-change`) as formatting revisions with their author and date — ODF records no old formatting, so the old is the text's own — and WordCraft's formatting revisions written as format changes; regions named by `xml:id` as ODF 1.3 requires; 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; text boxes (`draw:text-box` in a frame) open as WordCraft text boxes with their text, in line or floating, and are written back; linked text boxes (`draw:chain-next-name` naming the frame the text continues in) stay linked both ways (a link to a missing box, to itself or round in a loop is dropped; a linked box keeps its own height where LibreOffice grows it to the rest of the text), keeping the frame's line (colour and width), fill, padding (through the frame's graphic style and its parents, LibreOffice's Frame style included) and a frame without a height growing to its text (`fo:min-height`), which WordCraft lays out floating as well as in line. Right-to-left paragraphs (`style:writing-mode` `rl-tb`) read and written. Drop caps (`style:drop-cap`: lines and distance; WordCraft drops one letter, so a longer drop cap opens as its first letter) read and written. Drawing shapes (`draw:rect`, `draw:ellipse`, `draw:line`, and `draw:custom-shape` of a predefined geometry by its `draw:type`: rounded rectangle, triangles, diamond, the four block arrows, star, heart, flowchart or; LibreOffice's names and the `ooxml-` preset names) open as WordCraft shapes with their size, placement (in line or floating, as frames are), fill, line (colour and width; LibreOffice's default blue line and fill where the styles say nothing), text and a line's direction, and are written back, a custom shape by its type alone, which LibreOffice rebuilds the geometry from; a geometry WordCraft has no shape for (hexagon, octagon, smiley, can, moon, lightning, block arc, cross, pentagon, flowchart shapes and the rest) is drawn from its own path (`draw:enhanced-path`, its values worked out from the shape's `draw:equation` formulas and `draw:modifiers` adjustment values; arcs, ellipses and quarter ellipses followed in short straight steps) as a freeform, and only one with no path opens as a rectangle; a freeform with both closed and open paths is written as a custom shape whose path leaves the open ones unfilled (LibreOffice fills no part of a `draw:path` with an open part); shapes and pictures turned by `draw:transform` (rotate, translate and scale; anticlockwise about the corner in the file, clockwise about the centre in WordCraft) and custom shapes mirrored (`draw:mirror-horizontal`/`-vertical`) keep their turn and mirroring both ways, in line or floating, a turned line by its turned ends; groups (`draw:g`) of shapes, text boxes and pictures open as one WordCraft group anchored and wrapped as the group is, its members placed (and turned) as they are in it, groups in it flattened into it, and are written back as a group; a frame, shape or group whose styles state no wrap has text on both sides, as LibreOffice wraps it (§20.400 states no default), and `biggest` wraps on the larger side; arrowheads (`draw:marker-start`/`-end`, written with WordCraft's own triangle marker), dashes (`draw:stroke-dash`: up to two kinds of dash and a space, in line widths or lengths, matched to WordCraft's named dashes; a pattern with more kinds is written as its first two) and gradient fills (`draw:gradient`: linear and axial along their angle, radial, ellipsoid, square and rectangular out from their centre, border and intensity; more than two colours written as the first and last) are read and written, a line's direction (and so its arrowheads' ends) kept from its two ends; polygons, polylines, paths (SVG path data: moves, lines, cubic and quadratic curves, smooth ones and elliptical arcs, absolute and relative, curves followed in short straight steps) and regular polygons (stars too) open as WordCraft freeforms in their view box, circles as ellipses, and freeforms (ink too) are written as `draw:path`; connectors (`draw:connector`: straight, curved, or with bends) open as WordCraft connectors glued to the shapes their ends are glued to (a shape's four default glue points as its connection sites; a glue point of its own or a shape not read leaves that end unglued), placed between the shapes' sites when both are anchored in the connector's paragraph (a connector's own coordinates are LibreOffice's drawing layer's), and are written back glued, the shapes they glue to given ids, which LibreOffice re-routes; captions and measures are left out, a custom shape whose type WordCraft has no shape for opens as a rectangle; an in-line frame or shape stands on the baseline, is centred on it or hangs from it as its `style:vertical-pos` relative to the baseline says (`top`, `middle`, `bottom`, or its top `svg:y` from it with `from-top`), read as its run's raise or lowering (Word's `w:position`, which WordCraft now applies to in-line pictures and shapes too) and written back as `from-top`; placement relative to the character or line stays on the baseline; a text box's frame derives from LibreOffice's Frame style, so LibreOffice draws its border | +| `.odt` OpenDocument Text (and flat `.fodt`, templates `.ott`) | 2% | R/W | 🟡 | 🟡 | ~90% | 20–35 | `crates/formats/src/tests.rs`, `crates/formats/src/odt/tests.rs` and one file per feature in `crates/formats/src/odt/tests_more/`, `crates/engine/tests/it/odt_sections.rs`, `odt_toc.rs`, `odt_fields.rs`, `odt_charts.rs`, `odt_objects.rs`, `odt_password.rs`, `odt_forms.rs`; checked against LibreOffice's own documents (round trips compared page by page) and the ODF 1.3 schema; what the standard leaves open is WordCraft's one choice for each case ([odf-interpretation.md](odf-interpretation.md)) | Ruby (`text:ruby`, §6.4: a base text with ruby text over it) is read as WordCraft's Phonetic Guide and written back as ODF ruby above its base, centred (one inside a tracked change is written as its base text, which the change marks); its own position and alignment aren't kept. A border around text (LibreOffice's `loext:border` or its sides, with `loext:padding`: ODF 1.3 has no attribute for it) is Word's text border, one line all round, and is written back so (a document with one is then extended ODF, not strict). Emphasis marks (`style:text-emphasize`) and rotated text (`style:text-rotation-angle`) aren't read. How text flows: a page break after a paragraph (`fo:break-after`, a page break before the next paragraph as Word has it), lines kept together (`fo:keep-together`), widow and orphan control (`fo:widows`/`fo:orphans`: on with any line count, off with none; the counts themselves aren't kept) and hyphenation (`fo:hyphenate`, a paragraph's in ODF and the document's in WordCraft: a document with a hyphenated paragraph hyphenates, its other paragraphs opting out) are read and written; a table's alignment and width (`table:align`, `style:rel-width`, or `style:width` for an aligned table) and its rows' heights (`style:row-height` exact, `style:min-row-height` at least) are read and written. Form controls (`office:forms` controls placed by `draw:control`, ODF 1.3 §13) open as content controls — text fields and text areas as plain text controls, check boxes as check boxes, radio buttons as check boxes with round symbols, list boxes as drop-down lists, combo boxes as combo boxes, date fields as date controls, each with its value, entries and selection, its name as the control's tag and a label as its title — and content controls of those kinds in the body are written back as form controls LibreOffice shows with their values (in headers and footers, as their text); a control keeps its size (`draw:control` `svg:width`/`svg:height`); buttons, image buttons, labels and the other controls WordCraft has no kind for show their label, locked, and are kept verbatim and written back as they were (one naming a namespace WordCraft doesn't declare is left out); hidden controls (`form:hidden`) are kept and written back with their form; a control's look (colours, font) isn't kept. Placeholders (`text:placeholder`, §7.7.2, as LibreOffice's templates use: "") open as plain text content controls showing them, so a click selects one and typing replaces it, and are written back as placeholders of their type (their description as the control's title) while they show it. A master page followed by another (`style:next-style-name`: LibreOffice's "First Page" before "Default") is a section with a different first page, its own headers and footers on the first page and the next one's after it. The default paragraph style's font size (`fo:font-size` in `style:default-style`, or the Standard style's: the document's defaults) is the document's default size (it stayed 12 pt), a style with no parent keeping the default style's, and a date style ordered by the locale (`number:automatic-order`) shows in US English order ("November 2, 2026", as LibreOffice shows it here). A page background picture (`style:background-image` in the page layout, §17.3) is WordCraft's page picture (Design › Page Color › Fill Effects): repeated as tiled, stretched as stretched, not repeated as centred at its own size (a fill WordCraft draws so; DOCX writes it stretched), and is written back with its picture. Tables are fixed layout (Word's `w:tblLayout`), as LibreOffice's columns never fit their content, and a table starting a page (`fo:break-before` on the table) starts it: its first paragraph has a page break before it, which moves the table, and is written back on the table. The columns of a table filling the margins are written with their share of the table (`style:rel-column-width`) too, so on a narrower page they keep their proportions in LibreOffice (an aligned table's aren't: LibreOffice lays one out oddly with shares). A fixed date or time with neither a value nor text (LibreOffice's templates) is a date field showing the day; `text:author-name` is `USERNAME`, which WordCraft now fills with the user's name. A page layout that states no size is the default page's (the user's region's, as LibreOffice uses its default paper), and a margin it leaves unsaid is 0, XSL's initial value (a layout with no properties at all included); Emphasis marks (`style:text-emphasize`, §20.370) open as Word's (dot, accent as comma, circle, disc as dot; below as dot below) and are written back. Variables (`text:variable-set`, `text:variable-get`, §7.4) open as Word's SET and REF fields (a displayed set as SET then REF), which Update Field works out as LibreOffice shows them, and are written back declared (`text:variable-decls`), sets hidden with a get after them. What a named paragraph or character style turns off that the style it is based on turns on (`fo:font-weight="normal"` under a bold parent; italic, underline and strike-through too) is kept and written back, so it doesn't show through. Windows metafile pictures (WMF, EMF: LibreOffice's clip art and logos) open as pictures, at their own size when the frame gives none, instead of being dropped; picture frames are written derived from the Graphics style, without which LibreOffice doesn't show a floating picture inside a text box; drawings anchored outside any paragraph (LibreOffice's page-anchored frames before the first one) join the next paragraph instead of making one of their own, which started the document on the default page style, and a template of styles only (no `content.xml`) opens empty with its styles. OpenDocument text templates (`.ott`) open as documents and save as templates (the template's media type in `mimetype` and the manifest), with a password too. Turned text (`style:writing-mode`, §20.404; LibreOffice's own `loext:writing-mode="bt-lr"` for text running upwards) in table cells and text boxes opens as WordCraft's cell and text box text direction, down or up, and is written back the same way; turned characters (`style:text-rotation-angle`) open upright, as WordCraft has no turned characters. Package parts WordCraft doesn't read — LibreOffice Basic macros and dialogs (`Basic/`, `Dialogs/`), `manifest.rdf`, other applications' files — are kept as they are and written back with their manifest entries, so LibreOffice still finds a document's macros (WordCraft never runs them); the old thumbnail and digital signatures (`META-INF/*signatures*.xml`, which sign the package as it was) are dropped on save, and paths that climb out of the package aren't written. Password-protected ODT (ODF 1.3 Part 2 §3.4) opens with its password — AES-256-CBC with a SHA-256 or SHA-1 start key and PBKDF2, as LibreOffice writes it; the old Blowfish encryption is refused with a message saying so — and a document opened with a password, or saved with one (Save with password), is written encrypted the way LibreOffice encrypts (every file but the `mimetype` and manifest; each with its own salt and initialisation vector; SHA-256 checksums), which LibreOffice opens with the password. Flat OpenDocument text (`.fodt`: the whole document in one `office:document`, pictures inline as base64 `office:binary-data`, formulas inline) is opened and saved as a package is, both validating against the schema. Tables of contents and other indexes (`text:table-of-content`, `text:illustration-index`/`text:table-index`, `text:alphabetical-index`, `text:bibliography`) open as Word's `TOC` (levels from the source's `text:outline-level`; `\c` with the caption name for a table of figures), `INDEX` and `BIBLIOGRAPHY` fields — in the title paragraph when the index has one, as WordCraft inserts a table, else around the entries — so References › Update Table regenerates them; LibreOffice's Contents 1–10, Contents Heading and Figure Index 1 styles are WordCraft's TOC 1–9, TOC Heading and Table of Figures; WordCraft's tables are written back as ODF indexes with their source and entry templates (LibreOffice updates them), entries' links in the Index Link style; user and object indexes are read as their paragraphs; index marks are Word's `XE` fields — a mark at a point (`text:alphabetical-index-mark`, its `text:key1`/`text:key2` the levels above its entry) and one over text (`-mark-start`…`-mark-end` in one paragraph, its entry the marked text) — and `XE` fields are written as point marks, so References › Update Index rebuilds an ODT index from its entries (an index or other generated list held as a field around its entries, as ODT and Word files hold it, is first made the field's own paragraph, as WordCraft inserts one). Custom character styles (a common text style other than LibreOffice's link, note-mark and list-label styles kept as a character style with its name, parent and own formatting, its text in it; written back as common text styles), custom paragraph styles (a common style other than Standard, the kinds' and the note texts' kept as a document style with its name, parent and own formatting, its paragraphs in it rather than given its formatting directly; written back as common styles, named so LibreOffice keeps them), paragraph borders (each side's line and padding) and background, heading numbering (`text:outline-style`: each level's format, prefix, suffix, levels shown and start number the headings through their styles, as Word's numbered headings, and are written back), styles (the file's own Heading 1–6, Title, Quotations and Preformatted Text definitions give those styles' look on open — font, size, percent sizes included, bold, colour, spacing, alignment, keep with next — and saving writes the document's own style definitions rather than fixed ones), tab stops (left, centre, right, decimal; dot, hyphen, underline and middle-dot leaders; LibreOffice's indent-relative positions converted, and a `settings.xml` written saying positions run from the margin) and the default tab interval, character formatting (underline styles and colour, double strike, caps, small caps, hidden text kept hidden, letter spacing, scale, raised and lowered text, outline, shadow, emboss, engrave, language, Asian and complex-script fonts, complex-script size, weight and posture and both script languages (per run and as document defaults), a monospaced font by its own name, a `style:font-name` read as the family its `style:font-face` declares, and the default style's pair kerning) both ways, lists (#87; each `text:list` a list of its own unless it continues one by `text:continue-numbering` or `text:continue-list`, an item's `text:start-value` restarting from it; each level's number format — 1, i, I, a, A, none —, prefix, suffix, levels shown, start value, bullet character, indent, hanging and number alignment read from its list style and written back), picture crops (`fo:clip`: top, right, bottom, left in lengths of the picture's own size, its pixels at 96 per inch ↔ WordCraft's crop fractions), floating pictures (a frame anchored to a paragraph, character or page: wrap — square on both sides, left, right or the larger side, top and bottom, behind or in front of text, along its outline (`style:wrap-contour`: `outside` Word's Tight, `full` Through, its `draw:contour-polygon` the wrap polygon; a polygon in another coordinate system than the frame's, as LibreOffice writes for some pictures, is worked out from the picture instead) —, horizontal and vertical alignment or offset and what it is relative to, distances from the text, read into the picture's placement and written back in a graphic style), tables (cell borders — width, style, colour, an edge LibreOffice gives one cell shared by both — and cell padding read and written, each shared edge written once so lines don't double, a table without lines written without; cell formulas — LibreOffice's `table:formula` in its own syntax, `ooow:sum ` — open as Word formula fields, `=SUM(A1:A3)`, showing the cell's value, so Update Field recalculates them, and formula fields in cells, `ABOVE`/`LEFT`/`BELOW`/`RIGHT` as the cells they name, are written back as cell formulas with their value; cells holding a plain number are written as numbers (`office:value-type="float"`) so formulas calculate with them; a formula one side can't say keeps its value as text), pictures, page breaks (#86), paragraph spacing, indents and line spacing (proportional, fixed, at least; leading read as an at-least spacing of a 12 pt line and the leading, which puts the extra space above each line as LibreOffice draws it, and as that much more space after the paragraph, where LibreOffice leaves it after the last line) 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`, to bookmarks kept over the text they mark — `text:bookmark-start`…`-end`, in a paragraph or across several — so a reference updates to that text) read as fields and written back as ODF fields; dates and times in their date style (`number:date-style`/`time-style` ↔ Word's `\@` picture, both ways), sequences numbering figures and tables (`text:sequence` ↔ `SEQ` with its number format, declared on save; a bookmark over the caption's label and number, which references to it show), chapter fields (`text:chapter` ↔ `STYLEREF "Heading n"`, by name or number), reference marks (as bookmarks) and references to them and to sequences (`text:reference-ref`, `text:sequence-ref` ↔ `REF`/`PAGEREF`); citations (`text:bibliography-mark`) are `CITATION` fields of the source each carries, which becomes one of the document's bibliography sources (its identifier the tag; its type, author, title, year, publisher, address, journal, volume, pages, URL, editor, edition, number and month), so a bibliography updates from them, and are written back as marks carrying their source; custom document properties (`meta:user-defined`: string, number, true/false and date) are WordCraft's custom document properties, read and written, and the fields showing one (`text:user-defined`) or a user field (`text:user-field-get`, its declaration a custom property too, so saved back as one) are `DOCPROPERTY` fields, which update from them and are written back as `text:user-defined`; fixed dates and fields without a Word equivalent (variables — `text:variable-set`/`-get`, whose value changes through the document —, references to a caption's text) read as their text, and Word fields ODF lacks written as their result, keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099); charts (a `draw:object` whose sub-document is an `office:chart`: column, bar, stacked, line, area, pie, doughnut and scatter charts; categories, series names and values — gaps included — from the chart's own table through their cell range addresses, title, legend position, data labels) open as WordCraft charts (in a drawing group too), drawn in the theme's colours and edited with Chart Design's commands, and every WordCraft chart is written back as an ODF chart object shaped like LibreOffice's (its data table, each series in its accent colour, slices clockwise from the top), in line or floating, inline in a flat document; a chart WordCraft can't edit as it is (3D, percent-stacked, stacked lines or areas, radar, stock or bubble charts, mixed plot types, a second value axis, one with no series) keeps its replacement picture, and fonts and axis scaling aren't kept; other embedded objects (`draw:object` sub-documents such as spreadsheets, drawings and presentations, and `draw:object-ole` OLE files) are kept whole — their files, media type and replacement picture — and saved back as the same objects, which LibreOffice opens as before; WordCraft shows one by its replacement picture, or, when that is in LibreOffice's own metafile format, by a placeholder of its size it draws itself (ruled for a spreadsheet), and doesn't edit or draw its contents; DOCX and other formats get the picture alone; in a flat document each object goes inline with its replacement picture — a sub-document as an `office:document` of its media type, its styles and settings with it, an OLE file as base64 — and is taken apart into its files again on opening; export writes each equation as a formula object of its own (MathML, with its linear text as an annotation), its frame sized from the equation's structure and centred on the line, so LibreOffice shows and edits it, page size, orientation and margins (#810), page number format (the page style's, and a page number field's own as `PAGE \* Arabic`/`roman`/…), page colour, page borders (ODF's at the margin with the text inside their padding ↔ WordCraft's measured from the page edge), footnote and endnote numbering (`text:notes-configuration`: format, first number, restart, page foot or document end) and line numbering (`text:linenumbering-configuration`, on unless it says otherwise: every how many lines, distance, restart on each page), 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 (`text:format-change`) as formatting revisions with their author and date — ODF records no old formatting, so the old is the text's own — and WordCraft's formatting revisions written as format changes; regions named by `xml:id` as ODF 1.3 requires; 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; text boxes (`draw:text-box` in a frame) open as WordCraft text boxes with their text, in line or floating, and are written back; linked text boxes (`draw:chain-next-name` naming the frame the text continues in) stay linked both ways (a link to a missing box, to itself or round in a loop is dropped; a linked box keeps its own height where LibreOffice grows it to the rest of the text), keeping the frame's line (colour and width), fill, padding (through the frame's graphic style and its parents, LibreOffice's Frame style included) and a frame without a height growing to its text (`fo:min-height`), which WordCraft lays out floating as well as in line. Right-to-left paragraphs (`style:writing-mode` `rl-tb`) read and written. Drop caps (`style:drop-cap`: lines and distance; WordCraft drops one letter, so a longer drop cap opens as its first letter) read and written. Drawing shapes (`draw:rect`, `draw:ellipse`, `draw:line`, and `draw:custom-shape` of a predefined geometry by its `draw:type`: rounded rectangle, triangles, diamond, the four block arrows, star, heart, flowchart or; LibreOffice's names and the `ooxml-` preset names) open as WordCraft shapes with their size, placement (in line or floating, as frames are), fill, line (colour and width; where the styles say nothing, WordCraft's default fill `#156082` and a 0.25 pt line in `#0E405A`), text and a line's direction, and are written back, a custom shape by its type alone, which LibreOffice rebuilds the geometry from; a geometry WordCraft has no shape for (hexagon, octagon, smiley, can, moon, lightning, block arc, cross, pentagon, flowchart shapes and the rest) is drawn from its own path (`draw:enhanced-path`, its values worked out from the shape's `draw:equation` formulas and `draw:modifiers` adjustment values; arcs, ellipses and quarter ellipses followed in short straight steps) as a freeform, and only one with no path opens as a rectangle; a freeform with both closed and open paths is written as a custom shape whose path leaves the open ones unfilled (LibreOffice fills no part of a `draw:path` with an open part); shapes and pictures turned by `draw:transform` (rotate, translate and scale; anticlockwise about the corner in the file, clockwise about the centre in WordCraft) and custom shapes mirrored (`draw:mirror-horizontal`/`-vertical`) keep their turn and mirroring both ways, in line or floating, a turned line by its turned ends; groups (`draw:g`) of shapes, text boxes and pictures open as one WordCraft group anchored and wrapped as the group is, its members placed (and turned) as they are in it, groups in it flattened into it, and are written back as a group; a frame, shape or group whose styles state no wrap has text on both sides, as LibreOffice wraps it (§20.400 states no default), and `biggest` wraps on the larger side; arrowheads (`draw:marker-start`/`-end`, written with WordCraft's own triangle marker), dashes (`draw:stroke-dash`: up to two kinds of dash and a space, in line widths or lengths, matched to WordCraft's named dashes; a pattern with more kinds is written as its first two) and gradient fills (`draw:gradient`: linear and axial along their angle, radial, ellipsoid, square and rectangular out from their centre, border and intensity; more than two colours written as the first and last) are read and written, a line's direction (and so its arrowheads' ends) kept from its two ends; polygons, polylines, paths (SVG path data: moves, lines, cubic and quadratic curves, smooth ones and elliptical arcs, absolute and relative, curves followed in short straight steps) and regular polygons (stars too) open as WordCraft freeforms in their view box, circles as ellipses, and freeforms (ink too) are written as `draw:path`; connectors (`draw:connector`: straight, curved, or with bends) open as WordCraft connectors glued to the shapes their ends are glued to (a shape's four default glue points as its connection sites; a glue point of its own or a shape not read leaves that end unglued), placed between the shapes' sites when both are anchored in the connector's paragraph (a connector's own coordinates are LibreOffice's drawing layer's), and are written back glued, the shapes they glue to given ids, which LibreOffice re-routes; captions and measures are left out, a custom shape whose type WordCraft has no shape for opens as a rectangle; an in-line frame or shape stands on the baseline, is centred on it or hangs from it as its `style:vertical-pos` relative to the baseline says (`top`, `middle`, `bottom`, or its top `svg:y` from it with `from-top`), read as its run's raise or lowering (Word's `w:position`, which WordCraft now applies to in-line pictures and shapes too) and written back as `from-top`; placement relative to the character or line stays on the baseline; a text box's frame derives from LibreOffice's Frame style, so LibreOffice draws its border | | `.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 (``, ``) or a Single File Web Page (MHT) 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`, `tests::a_single_file_web_page_saved_as_doc_opens_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 | 🟡 | 🟡 | ~65% | 5–9 | `crates/formats/src/tests.rs`, `html.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154); the empty comments `` and `` end at once instead of swallowing the rest of the page (#5040). `style` element rules with simple selectors (`p`, `.x`, `p.x`; class names may start with a digit, as Word accepts) in cascade order; paragraph `margin`, `text-indent`, `line-height`; cell `width` and borders. Header cells (``) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). An opened page looks as Word opens one: Times New Roman 12 pt, single lines, browser-sized black headings, automatic 14 pt spacing where a `p` or heading states no margin (none before the first), a span's margin replacing its paragraph's. HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do (#1381). No `@page`, descendant selectors or `div` margins. No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `
` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `basereading` in the base text's formatting and read back from ``/``/`` (`` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). | | `.mht/.mhtml` Single File Web Page | <1% | R/W | 🟡 | ❌ | ~60% | 2–4 | `crates/formats/src/mht.rs` | Opens: the MIME message's page (its `start` part, else the first HTML part) in its part's charset, quoted-printable or base64, pictures found by `Content-Location` (also relative to the page) or `cid:`; then read as an HTML page, on the region's paper. Not saved | @@ -303,6 +303,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p | 2026-11-02 | trivial | ODT: a chart lost the look WordCraft doesn't model (its fonts and sizes, axis and grid lines) on save, written anew from WordCraft's chart. The chart object as read is kept (through drawing it again when it opens or is resized), and written back whole while the chart reads the same; an edited chart is written from WordCraft's chart as before. The chart documents round-trip pixel-identical (`charts::an_unchanged_chart_is_written_back_as_it_was_and_an_edited_one_anew`, `engine::tests::redrawing_a_chart_keeps_the_chart_as_read`). Found with LibreOffice's serif business letter: a text frame outlined by its drawn stroke (`draw:stroke`, no `fo:border`) lost its outline; it's the frame's line. A header or footer whose style states no height (or that has none) was 0 tall; it's LibreOffice's 0.5 cm (measured), so the text no longer moves up. The letter round-trips to 116 differing pixels (from 4,223) (`text_boxes::a_frames_drawn_stroke_is_its_line_when_it_has_no_border`, `headers::a_header_without_a_stated_height_is_half_a_centimetre`) | | 2026-11-02 | trivial | ODT, found with LibreOffice's agendas: saving wrote every table's first row as a header row (`table:table-header-rows`), repeated at the top of each page, because a table style's header row (on by default in Word's table look) was taken for Repeat as header row; a table crossing a page showed its first row twice. Only rows set to repeat are written so. The agendas round-trip to about 145 differing pixels (from 5,269 and 5,086), their text the same (`tables::only_rows_set_to_repeat_are_written_as_header_rows`) | | 2026-11-02 | trivial | ODT, found with LibreOffice's report templates and one-page résumé. A paragraph's padding on a side without a border (measured: LibreOffice gives it room) was dropped; it's kept, laid out (see layout-parity) and written back. Unequal columns (`style:column` `style:rel-width` and indents, on pages and sections) were made equal; they keep their widths, written back as shares, without the gap LibreOffice ignores them beside. Columns with no stated gap have none (it had been Word's 0.5 in). A paragraph starting a column (`fo:break-before="column"`) didn't; it starts with Word's column break, written back on the paragraph. The report templates round-trip to 0–11 differing pixels (from 9,600), the résumé to 24 (from 6,286) (`para_borders::padding_without_a_border_keeps_its_room`, `odt::tests::unequal_columns_keep_their_widths_and_columns_without_a_gap_have_none`, `odt::tests::a_paragraph_starting_a_column_starts_with_a_column_break`) | +| 2026-11-04 | trivial | ODT: the settings for what the standard leaves open are gone (File › Options › Advanced › OpenDocument files, `file.options` `odf`, `WORDCRAFT_ODF`, the LibreOffice style and custom values): WordCraft makes one choice for each case, the one the default made. What a file states is still followed | | 2026-11-04 | trivial | ODT: paragraphs tied together are written back as read as a group: a comment over text from one paragraph into another, a tracked change or an index mark likewise, a text box continuing in another's. While every paragraph of the group is as read, all are written so; one edited (or copied), all are written from the model, which names what ties them afresh. | | 2026-11-04 | trivial | ODT: a paragraph showing a chart is written back as read while unchanged: drawing the chart from its data on opening (which changes nothing) no longer stops its markup matching. | | 2026-11-04 | trivial | ODT: a named section inside a table's cell (StarOffice's specification template hides some there) is written as read around the cell's paragraphs, closed in the cell; one starting or ending at a paragraph with twins elsewhere (an empty one, as in LibreOffice's agenda templates) is kept too, by that paragraph's own identity, and opened once whatever is copied. Over the corpus 126 of 133 files round-trip pixel-identical (124); over the 220 from other applications 176 (175), 218 with the same page count (216). | diff --git a/docs/gaps.md b/docs/gaps.md index 5a9a5c06d5..29a88606b2 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-11-04 · **Change:** trivial (#19: ODT element audit; files from other applications checked); previously trivial (#11: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (#4: fonts that aren't installed are flagged in the font box and status bar); previously trivial (#19: ODT sections inside text boxes kept as read); previously trivial (#19: an ODT saved without edits keeps what WordCraft doesn't model); previously trivial (#19: ODT interpretation profile, WordCraft / LibreOffice / custom); previously trivial (#19: ODT form controls placed and multi-line; custom shapes keep their geometry); previously trivial (#19: ODT in-line frames placed against the text; template instantiation listed as left); previously trivial (#19: ODT charts kept as read; frame outlines; default header height); previously trivial (#19: ODT tables no longer gain repeated header rows); previously trivial (#19: ODT padding without a border, unequal columns, column breaks, picture-only lines); previously trivial (#19: ODT tables' margins and spacing, header spacing, pictures alone in a paragraph); previously trivial (#19: no LibreOffice template changes page count through a save); previously trivial (#19: ODT variables, emphasis marks, page backgrounds, fixed tables, default size); previously trivial (#19: ODT checked against LibreOffice's own templates: placeholders, first-page styles, unsized pages, metafile logos, `.ott`); previously trivial (#19: ODT at ~90%: custom shapes, cell formulas, macros kept, turned text, control sizes and buttons); previously trivial (#19 narrowed: ODT read and written nearly whole; estimate 18–27 h → 10–16 h); previously trivial (#10 narrowed: text in a turned text box is drawn turned with it); previously trivial (gap 19 narrowed: ODT heading numbering read and written); previously trivial (gap 19 narrowed: ODT table borders and padding read and written); previously trivial (gap 19 narrowed: ODT heading style looks read and written); previously trivial (gap 19 narrowed: ODT tab stops read and written); previously trivial (gap 19 narrowed: ODT character formatting read and written); previously trivial (gap 19 narrowed: ODT list formats read and written); previously 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 (#8: Options › Language lists Your languages and Add a language… replaces the Language packs table); previously trivial (#8: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (#8: Language pack languages are in Set Proofing Language, Review › Language and the status bar; built-in proofing is named as such); previously trivial (#20: layout resource caps and linear-time passes (#3867, #3866)); previously trivial (#3 narrowed: a pasted text box story is charged for the formatting it copies); previously trivial (#3 narrowed: a .doc table row keeps one override per cell); previously trivial (#3 narrowed: a .doc file's sections find their paragraphs by index); previously minor (#11: Arabic interface with mirrored shell landed, #1195; hi/id/pt-PT and Hebrew UI remain); previously trivial (#8: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); no ranked gap changed); previously trivial (#8: language-packs-1 is published and pinned, 61 packs: Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian install as Language packs; Dutch, German and the other compounding languages wait on craft-proof; no ranked gap changed); previously minor (#8: Language packs: every other language's spelling and hyphenation installs from File › Options › Language or the missing-pack bar, through the font store; Lithuanian and Romanian are available as a Language pack once language-packs-1 is published; Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs wait on craft-proof engine support); previously trivial (gap 8: English spelling reads SCOWL 2026, one dictionary per English, #40, #520, #5205; no ranked gap changed);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (#26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains) previously trivial (gap 8 points at craft-proof in craft-libs, its data in craft-i18n, #5202; no ranked gap changed); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); 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-11-04 · **Change:** trivial (#19: ODT settings for what the standard leaves open removed); previously trivial (#19: ODT element audit; files from other applications checked); previously trivial (#11: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (#4: fonts that aren't installed are flagged in the font box and status bar); previously trivial (#19: ODT sections inside text boxes kept as read); previously trivial (#19: an ODT saved without edits keeps what WordCraft doesn't model); previously trivial (#19: ODT interpretation profile, WordCraft / LibreOffice / custom); previously trivial (#19: ODT form controls placed and multi-line; custom shapes keep their geometry); previously trivial (#19: ODT in-line frames placed against the text; template instantiation listed as left); previously trivial (#19: ODT charts kept as read; frame outlines; default header height); previously trivial (#19: ODT tables no longer gain repeated header rows); previously trivial (#19: ODT padding without a border, unequal columns, column breaks, picture-only lines); previously trivial (#19: ODT tables' margins and spacing, header spacing, pictures alone in a paragraph); previously trivial (#19: no LibreOffice template changes page count through a save); previously trivial (#19: ODT variables, emphasis marks, page backgrounds, fixed tables, default size); previously trivial (#19: ODT checked against LibreOffice's own templates: placeholders, first-page styles, unsized pages, metafile logos, `.ott`); previously trivial (#19: ODT at ~90%: custom shapes, cell formulas, macros kept, turned text, control sizes and buttons); previously trivial (#19 narrowed: ODT read and written nearly whole; estimate 18–27 h → 10–16 h); previously trivial (#10 narrowed: text in a turned text box is drawn turned with it); previously trivial (gap 19 narrowed: ODT heading numbering read and written); previously trivial (gap 19 narrowed: ODT table borders and padding read and written); previously trivial (gap 19 narrowed: ODT heading style looks read and written); previously trivial (gap 19 narrowed: ODT tab stops read and written); previously trivial (gap 19 narrowed: ODT character formatting read and written); previously trivial (gap 19 narrowed: ODT list formats read and written); previously 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 (#8: Options › Language lists Your languages and Add a language… replaces the Language packs table); previously trivial (#8: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (#8: Language pack languages are in Set Proofing Language, Review › Language and the status bar; built-in proofing is named as such); previously trivial (#20: layout resource caps and linear-time passes (#3867, #3866)); previously trivial (#3 narrowed: a pasted text box story is charged for the formatting it copies); previously trivial (#3 narrowed: a .doc table row keeps one override per cell); previously trivial (#3 narrowed: a .doc file's sections find their paragraphs by index); previously minor (#11: Arabic interface with mirrored shell landed, #1195; hi/id/pt-PT and Hebrew UI remain); previously trivial (#8: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); no ranked gap changed); previously trivial (#8: language-packs-1 is published and pinned, 61 packs: Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian install as Language packs; Dutch, German and the other compounding languages wait on craft-proof; no ranked gap changed); previously minor (#8: Language packs: every other language's spelling and hyphenation installs from File › Options › Language or the missing-pack bar, through the font store; Lithuanian and Romanian are available as a Language pack once language-packs-1 is published; Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs wait on craft-proof engine support); previously trivial (gap 8: English spelling reads SCOWL 2026, one dictionary per English, #40, #520, #5205; no ranked gap changed);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (#26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains) previously trivial (gap 8 points at craft-proof in craft-libs, its data in craft-i18n, #5202; no ranked gap changed); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); 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 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -82,7 +82,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); mirrored Arabic UI landed (#1195); PDF export's Hebrew and Arabic text copies and searches as whole words (#1383) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 5–9 | [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; ODT formatting revisions done; RTF section/table revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT is read and written nearly whole (~90%, see [file-format-parity.md](file-format-parity.md)): sections, headers and footers, page setup, styles (named paragraph and character styles), character and paragraph formatting, lists and heading numbering, tab stops, tables (borders, padding, alignment, width, row heights, header rows), notes, comments, tracked changes, fields (dates, sequences, chapters, references, index marks), bookmarks, tables of contents and indexes, pictures, text boxes, shapes, groups, rotation, strokes and gradients, equations, charts, embedded objects kept whole, form controls, ruby, drop caps, password protection, flat `.fodt`; checked against LibreOffice by round trips compared page by page and the ODF 1.3 schema. Left in ODT: turned characters (no Word equivalent), conditional text as fields (variables done, as SET/REF), control looks, drawing embedded spreadsheets' contents, dates in locales other than US English (`number:automatic-order`). Emphasis marks, fixed-width tables, tables starting a page, page background pictures, the default font size and styles turning things off are done. Checked against LibreOffice's 78 bundled templates as well as synthetic documents: all open and round-trip; placeholders, first-page styles, unsized pages, WMF/EMF logos and `.ott` templates done; what ODF leaves to the application follows a setting (WordCraft's by default, LibreOffice's or custom; [odf-interpretation.md](odf-interpretation.md)); WordCraft's own documents (templates, themes, every feature) saved as ODT open in LibreOffice with the same pages, valid against the ODF 1.3 schema, with table styles, SmartArt, watermarks and cell pictures kept; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259); an ODT saved without edits keeps what WordCraft doesn't model: unedited paragraphs, tables, named sections, indexes, forms, style definitions, page layouts and document properties are written back as read, checked by a no-edit loss test and a loss check over the corpus (left: paragraphs showing form controls are written from the model; every schema element audited in [odt-element-audit.md](odt-element-audit.md)) | | ~2,100 (RTF) and ~11,000 (ODT, plus ~7,000 of tests) lines in `crates/formats`; RTF code pages (#896) and page setup (#902) landed; open PRs: RTF notes and comments (#1056) | Users exchanging with LibreOffice or older tools | 10–16 | [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; ODT formatting revisions done; RTF section/table revisions left), sections (RTF and ODT import and export done, #1378, #1379), RTL. ODT is read and written nearly whole (~90%, see [file-format-parity.md](file-format-parity.md)): sections, headers and footers, page setup, styles (named paragraph and character styles), character and paragraph formatting, lists and heading numbering, tab stops, tables (borders, padding, alignment, width, row heights, header rows), notes, comments, tracked changes, fields (dates, sequences, chapters, references, index marks), bookmarks, tables of contents and indexes, pictures, text boxes, shapes, groups, rotation, strokes and gradients, equations, charts, embedded objects kept whole, form controls, ruby, drop caps, password protection, flat `.fodt`; checked against LibreOffice by round trips compared page by page and the ODF 1.3 schema. Left in ODT: turned characters (no Word equivalent), conditional text as fields (variables done, as SET/REF), control looks, drawing embedded spreadsheets' contents, dates in locales other than US English (`number:automatic-order`). Emphasis marks, fixed-width tables, tables starting a page, page background pictures, the default font size and styles turning things off are done. Checked against LibreOffice's 78 bundled templates as well as synthetic documents: all open and round-trip; placeholders, first-page styles, unsized pages, WMF/EMF logos and `.ott` templates done; what ODF leaves to the application is WordCraft's one choice for each case ([odf-interpretation.md](odf-interpretation.md)); WordCraft's own documents (templates, themes, every feature) saved as ODT open in LibreOffice with the same pages, valid against the ODF 1.3 schema, with table styles, SmartArt, watermarks and cell pictures kept; RTF export writes every section (`\sect`, `\sectd`); RTF reads and writes WMF and EMF pictures (#4259); an ODT saved without edits keeps what WordCraft doesn't model: unedited paragraphs, tables, named sections, indexes, forms, style definitions, page layouts and document properties are written back as read, checked by a no-edit loss test and a loss check over the corpus (left: paragraphs showing form controls are written from the model; every schema element audited in [odt-element-audit.md](odt-element-audit.md)) | | ~2,100 (RTF) and ~11,000 (ODT, plus ~7,000 of tests) lines in `crates/formats`; RTF code pages (#896) and page setup (#902) landed; open PRs: RTF notes and comments (#1056) | Users exchanging with LibreOffice or older tools | 10–16 | [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); repeated table headers are capped by item counts, the wrap cache by decoded pixels and linked text box layout by its lines, items and story text, and PDF export, hit testing, tracked changes, clipped tables and long endnote selections cost linear time (#3867, #3866; not measured on real documents) | 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) | @@ -161,6 +161,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p | Date | Change | Summary | |---|---|---| +| 2026-11-04 | trivial | #19: ODT settings for what the standard leaves open removed: one choice for each case | | 2026-11-04 | trivial | #19: ODT element audit; files from other applications checked | | 2026-11-01 | trivial | #19 narrowed: ODT read and written nearly whole on this branch (styles, tables, notes, comments, tracked changes including formatting, fields, indexes and marks, drawings, charts, embedded objects, forms, ruby, password protection, flat ODT), checked against LibreOffice; what's left listed; estimate 18–27 h → 10–16 h | | 2026-11-01 | trivial | #10 narrowed: text in a turned text box is drawn turned with it on screen and in PNG and PDF (upside down when flipped across, never mirrored); editing it still goes by its upright text; estimate unchanged | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index fa611b4e07..b8e55b9b1d 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -1,6 +1,6 @@ # Layout and pagination parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** trivial (ODT documents: padding is the standard's box model always; picture-only line height is an ODF interpretation setting); previously trivial (行頭禁則 also covers the small kana and the prolonged sound mark at ordinary break opportunities, as JIS X 4051 §4 specifies, inferred from the standard and synthetic tests rather than checked in Word); previously trivial (a missing font is drawn in an installed metric twin before any other substitute: Arial for Helvetica on Windows, Tinos before macOS Times for Times New Roman); previously trivial (ODT documents: padding without a border takes room); previously trivial (ODT documents: a line of only pictures is as tall as they are); previously trivial (ODT documents: a table's own space above and below; header and footer spacing kept past a tall header); previously trivial (a page break before a table's first row moves the table; emphasis marks drawn with room in the line); previously trivial (text in a turned text box turns with it; previously trivial: an in-line picture, shape or chart moves with its run's raise or lowering, Word's `w:position`; previously trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (ODT documents: a line of only pictures keeps its font's height; no setting for it any more); previously trivial (ODT documents: padding is the standard's box model always; picture-only line height is an ODF interpretation setting); previously trivial (行頭禁則 also covers the small kana and the prolonged sound mark at ordinary break opportunities, as JIS X 4051 §4 specifies, inferred from the standard and synthetic tests rather than checked in Word); previously trivial (a missing font is drawn in an installed metric twin before any other substitute: Arial for Helvetica on Windows, Tinos before macOS Times for Times New Roman); previously trivial (ODT documents: padding without a border takes room); previously trivial (ODT documents: a line of only pictures is as tall as they are); previously trivial (ODT documents: a table's own space above and below; header and footer spacing kept past a tall header); previously trivial (a page break before a table's first row moves the table; emphasis marks drawn with room in the line); previously trivial (text in a turned text box turns with it; previously trivial: an in-line picture, shape or chart moves with its run's raise or lowering, Word's `w:position`; previously trivial (layout work bounds: header-row repeats budgeted per layout, cached picture outlines charged to the decode budget, linked-box chain widths exact up to a work cap, #3867); previously trivial (the line between columns runs as tall as the section's text, so columns for selected text have it, #5178); previously trivial (a table row that would leave a cell empty on a page that already holds something, its picture or first line not fitting there, moves on whole instead of leaving the other cells' text beside the empty cell, #4272); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (tab leader dots spaced and placed as in Word); previously trivial (a level above that hasn't counted yet takes its first number at the first deeper item); previously trivial (paragraph side borders lie beyond their distance from the text, #5036); previously trivial (with a header taller than the top margin, Margin-anchored drawings, header-cell pins and header-table obstacles use the rendered body top; TopMargin and InsideMargin keep their declared band, #2485; previously trivial: connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (field results take the `\* DollarText`, `\* Hex` and `\* ArabicDash` switches, #4226; previously trivial: a cell spanning columns shares its extra minimum width by the columns' preferred widths, equally in automatic tables, #1883; previously trivial: continuous sections one after another go onto new pages instead of running off the page; empty marks after another empty section mark take a line each until the page is full, then stay at its bottom drawing nothing, not even a list label; an empty mark that takes no room draws no list label either; a kept-together paragraph is measured against the page it would move to, so a tall different first-page header no longer splits it, #3907; previously trivial: a justified line ending in a manual line break is stretched to the margin unless `w:doNotExpandShiftReturn` is set, #3924; previously trivial: a no-break space is as wide as a space, #4192; previously trivial: a bookmark or other zero-width marker is no line-break opportunity, #4193; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: Preserve grapheme edit/wrap boundaries across independently drawn font/style pieces; Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: cu20we: outline caps; previously trivial: cu20we: box float paragraph origin; previously trivial: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: an empty paragraph whose mark is hidden shows nothing, not even its list number, which is still counted, #3982; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: symbol fonts keep their line height without the font; previously trivial: an equation-array row without `&` is centred, #2617; previously trivial: a superscript or subscript sizes its line at the full font size, without its raise or drop, as Word does; previously trivial: an object dragged near the bottom of a page lands where it is dropped, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: justified lines shrink their spaces by Word's measured limits; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: a click below a floating table goes to the text beside or after it, not to a cell of its last row, #2038; previously trivial: shapes drawn with their gradients and transparency, #1500; previously trivial: paragraphs above a float's anchor wrap around it, also when that pushes the anchor onto the next page, #2048; previously trivial: triple borders draw three lines, #1740; previously trivial: the caret, clicks and selection edges fall between the letters of a ligature, #1974; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: VML header-cell floats always on the page, user-checked in Word for Mac, #1775; previously trivial: an at-least row with no room for its minimum starts the next page whole, #1742; previously trivial: AutoFit donors lose equal widths down to their content minima; previously trivial: automatic space between Asian text and Latin text or digits, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: positional tabs go to the margins or indents, #2367; previously trivial: field results with right-to-left text read right to left, #1670; previously trivial: a list bullet no installed font can draw shows the level's default bullet, #1683; previously trivial: a missing Thai font is drawn in one Thai face for the whole run, #1549; previously trivial: the caret after spaces hanging past the margin stays at the margin, #2146; previously trivial: covered black-circle fallback before UI sans; previously trivial: resolved split-cell continuation top insets; previously trivial: Symbol PCL6 alias uses Symbol encoding; previously trivial: exact full font names select their installed face; previously trivial: Arial Unicode MS uses proportional fallbacks; previously trivial: DrawingML outline dashes, including zero-width paths; previously trivial: TOCs include table-cell headings and retain full targets; previously trivial: kept table rows follow the next row; previously trivial: consistent cell widths override a stale table grid; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: paragraph spacing adds up when the document turns HTML auto spacing off; previously trivial: an inherited footer sizes its percentage tables by its own section; previously trivial: an empty section mark stays on a full page; previously trivial: a fitting row with less room than its minimum height moves on whole; oversized rows still split; previously trivial: text past an exact-height row is cut off; previously trivial: a list number stays with text after a leading page break; previously trivial: a bookmark after a page break takes no line; previously trivial: a page break followed by a page break before or a next-page section starts one page; previously trivial: All Caps and Small Caps draw ß as SS, #1581; previously trivial: justified vertical page alignment, #1320; previously trivial: page numbers restart at 0; previously trivial: a list label moves with its first line when the paragraph is centred or end-aligned (#1159); previously trivial: PAGE fields in a paragraph split across pages show the page they are on, #1352; previously trivial: a dragged floating object anchors in the column it lands in, #884; previously trivial: a paragraph ending on a page with footnotes keeps its space after above the separator, #1116; previously trivial: symbol-font text drawn as Unicode symbols, #776; previously trivial: large-table scrolling measured and fixed, #811; previously trivial: text boxes use their own margins; Resize shape to fit text never cuts text, #743; previously trivial: complex-script shaping and Myanmar line breaking, #604; previously trivial: body page totals, table-cell footnotes and bookmark link pages fixed, #450 #451 #455; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 A document only "opens correctly" if its pages break where Word breaks them: the same lines, the same page count, footnotes and pictures on the same pages. This checklist covers line breaking, @@ -461,6 +461,7 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. | 2026-10-10 | major | First version: layout and pagination checklist with evidence from `crates/layout`, PRs and issues | | 2026-10-16 | trivial | OpenType ligatures follow `w14:ligatures` (none, standard, contextual, historical, discretional and their combinations); without it WordCraft's default stays | | 2026-10-21 | trivial | Superscript and subscript lines: Word for Mac sizes them at the full font size and leaves out the raise or drop (an 11pt subscript beside 11pt Arial keeps the 12.675pt line; a line of 24pt subscript is as tall as plain 24pt text); a `w:position` raise or lowering still grows the line. WordCraft added the drop to the line and sized the run at its shrunk size. On a private corpus of 181 Word documents, 26 change: 52 more paragraphs start on Word's page (9 documents better, 4 worse by 1–2 paragraphs, all with substituted fonts or already a page off), page counts unchanged. `layout::tests::superscript_and_subscript_lines_are_as_tall_as_full_size_text` | +| 2026-11-04 | trivial | ODT documents: a line holding only in-line pictures keeps its font's height, as in Word documents; the setting that made it as tall as the pictures is gone ([odf-interpretation.md](odf-interpretation.md)). `Settings::odf` still applies the standard's padding and a file's `TabsRelativeToIndent` | | 2026-11-02 | trivial | ODT documents (`Settings::odf`, an `OdfLayout`): a paragraph's padding without a border takes room in every ODT document (XSL's box model, which the standard refers to); a line holding only in-line pictures is as tall as they are only when the ODF interpretation profile says so (LibreOffice's; WordCraft's own keeps the font's height; see [odf-interpretation.md](odf-interpretation.md)). | | 2026-11-02 | trivial | For ODT documents (`Settings::odf_layout`), LibreOffice's layout: a paragraph's padding on a side without a border takes room (added to its spacing above or below, or its indent), as measured in LibreOffice; Word drops it, so other documents are unchanged (`ResolvedPara::padding`, `tests::padding_without_a_border_takes_room_in_odt_documents`) | | 2026-11-02 | trivial | For ODT documents, LibreOffice's layout: a line holding only in-line pictures is as tall as they are, without its font's ascent and descent (`Settings::odf_layout`, set when an ODT opens; Word keeps the font's, so other documents are unchanged). Measured in LibreOffice: a 3.6 pt rule's line is 3.6 pt; WordCraft's had been 15.5 pt (`tests::a_line_of_only_pictures_fits_them_in_odt_documents`) | diff --git a/docs/odf-interpretation.md b/docs/odf-interpretation.md index 6c65dd7c71..9dc75908f9 100644 --- a/docs/odf-interpretation.md +++ b/docs/odf-interpretation.md @@ -1,6 +1,6 @@ # How WordCraft interprets OpenDocument files -> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-04 · **Change:** trivial (what is written as read: unchanged paragraphs with drawings, comments, changes or marks too); previously minor (the two presets are Microsoft Word style and LibreOffice style; CLI values listed one by one); previously minor (deviations from the standard listed; ODF 1.2 → 1.3 checked); previously major (new: the ODF interpretation profile and the list of every case) +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-04 · **Change:** major (no more settings: one choice for each case the standard leaves open); previously trivial (what is written as read: unchanged paragraphs with drawings, comments, changes or marks too); previously minor (the two presets are Microsoft Word style and LibreOffice style; CLI values listed one by one); previously minor (deviations from the standard listed; ODF 1.2 → 1.3 checked); previously major (new: the ODF interpretation profile and the list of every case) OpenDocument (ODF 1.3, OASIS) leaves some of a document's look to the application. Part 3 §16.2 says that when no style sets a property and the standard gives no default, "an @@ -10,62 +10,47 @@ knows of, so none gets lost. ## The rule -1. **What the standard defines is law.** WordCraft follows it whatever the settings say. That - includes `fo:` properties, which the standard defines by referring to XSL 1.0, for their - meaning. Whether such a reference also brings XSL's initial value as the default the standard - doesn't say; where an application's choice differs from XSL's (page margins), the case is a - setting, with XSL's value as WordCraft's. -2. **What the standard leaves open is a setting.** Each such case is one value of the ODF - interpretation profile (`OdfProfile`, `crates/doc/src/odf_profile.rs`): - Two of them are the styles a document can be shown in: - - **Microsoft Word style** (`word`), the default: what WordCraft does for its own and Word - documents. (`wordcraft`, its earlier id, is still taken.) - - **LibreOffice style** (`libreoffice`): what LibreOffice (and Collabora, which shares its engine) does. Measured - black-box in LibreOffice 24 where it isn't documented. - - **Custom**: the user's own value for each case. It starts from the preset that was in use. -3. **A value the file states is the file's.** A preset never replaces it: the profile only fills - in what the file (its styles, defaults and `settings.xml`) leaves unsaid. Three cases are not - missing values but stated ones whose meaning the standard leaves open: how a header's stated - height and spacing combine, what a stated width of 0 draws at, and a frame position stated - against something the standard doesn't define for its anchor. They are marked *(meaning)* - below. `formats::odt::tests_more::profile` checks that stated values survive every preset. +1. **What the standard defines is law.** WordCraft follows it. That includes `fo:` properties, + which the standard defines by referring to XSL 1.0, for their meaning. Whether such a reference + also brings XSL's initial value as the default the standard doesn't say; where an application's + choice differs from XSL's (page margins), the case is listed below, with XSL's value as + WordCraft's. +2. **What the standard leaves open, WordCraft decides once.** Each case listed below has one + choice, the one WordCraft makes for its own and Word documents, and no setting. LibreOffice's + choice (and Collabora's, which shares its engine) is listed beside it, measured black-box in + LibreOffice 24 where it isn't documented, because a file made there can look different here. +3. **A value the file states is the file's.** WordCraft's choices fill in only what the file (its + styles, defaults and `settings.xml`) leaves unsaid. Three cases are not missing values but + stated ones whose meaning the standard leaves open: how a header's stated height and spacing + combine, what a stated width of 0 draws at, and a frame position stated against something the + standard doesn't define for its anchor. They are marked *(meaning)* below. + `formats::odt::tests_more::unstated` checks that stated values are kept. 4. **A deviation from the standard is made only out of necessity**: where the files in circulation contradict it and following it would damage every such file. Each one is listed under "Deviations from the standard, out of necessity" below, with the standard's rule, what WordCraft does instead and why; WordCraft never writes the deviating form. -5. The profile applies to ODF files (`.odt`, `.ott`, `.fodt`) *as they are opened*. A document - keeps what it was opened with. Changing the setting affects the next file opened. -Where to set it: +## Cases the standard leaves open -| Interface | How | -|---|---| -| App | File › Options › Advanced › OpenDocument files | -| Command | `file.options` `{"odf": {"preset": "word"}}` or `{"odf": {"preset": "libreoffice"}}`, or one case's value: `{"odf": {"columnGap": 18}}` (this makes the profile custom) | -| MCP | the same command | -| CLI | `WORDCRAFT_ODF=word` (the default) or `WORDCRAFT_ODF=libreoffice` | +| Case | Standard | What WordCraft does | What LibreOffice does | +|---|---|---|---| +| Page margins a page layout doesn't state | §20.206–20.209 refer to XSL §7.10 (initial value 0); whether that default comes with the reference is not said | 0 (XSL's initial value) | 2 cm (its own files always state theirs; some of its templates don't) | +| Height of a header or footer whose style states none | §20.212.2 `fo:min-height`: no default | 0 (as tall as its text) | 0.5 cm | +| *(meaning)* Whether a header's stated minimum height includes its stated spacing from the body | §20.212.2, §20.207: not said | no: spacing comes after the height | yes | +| A header taller than its height with `style:dynamic-spacing` unstated | §20.264: no default | off: the spacing is kept (on, with no height stated, would always drop the spacing the file states) | off | +| Where paragraph tab stops, default ones too, are measured from when `settings.xml` doesn't say | §19.512.3 `style:position` doesn't say; §19.861 covers only indexes | the margin (as Word; ONLYOFFICE's files mean this too) | the paragraph's left indent (its `TabsRelativeToIndent` default): explicit stops, the default stops every interval and the hanging indent's stop all move with the indent (measured) | +| Gap between columns when `style:columns` states none | §19.233 `fo:column-gap`: no default (and explicitly not XSL's) | 0.5 in (Word's) | 0 | +| A drawing shape's fill colour when no style gives one | §20.119 `draw:fill-color`: no default | `#156082` (WordCraft's shape blue) | `#729FCF` | +| A drawing shape's line colour when no style gives one | §20.408 `svg:stroke-color`: no default | `#0E405A` | `#3465A4` | +| A shape's line width when unstated, and *(meaning)* what a stated width of 0 (a hairline) draws at | §20.410 `svg:stroke-width`: no default | 0.25 pt | 0.5 pt | +| *(meaning)* A page-anchored frame positioned against a paragraph or character | §20.299, §20.398 Table 23: combination not defined | the page's text area | the page's edge | +| *(meaning)* An in-line frame positioned against a paragraph or page | §20.398 Table 23: combination not defined | stands on the baseline | positioned against the baseline (raised by its offset) | +| Height of a line holding only in-line pictures | no line-height algorithm in the standard | as tall as its text would be too | as tall as the pictures | +| How the space between two paragraphs adds up when the document doesn't say | not defined (`fo:margin-top`/`-bottom` refer to XSL's margins; whether XSL's space resolution, which keeps the larger, comes with them is not said); LibreOffice's `AddParaTableSpacing` setting says when present (false: the larger) | the larger of the two (as Word) | their sum | +| Widow and orphan control when neither the default paragraph style, Standard nor the paragraph's style states `fo:widows`/`fo:orphans` | §20.312, §20.244 refer to XSL's widows and orphans (initial value 2); whether that default comes with the reference is not said | on: 2 lines (XSL's initial value, and Word's) | off (a first or last line can stand alone at a page's edge) | +| *(meaning)* Whether a columned section's columns end balanced when nothing follows it on the page, and when it doesn't say | §20.428 `text:dont-balance-text-columns`: no default, and its value descriptions contradict its name (WordCraft reads the name; so does LibreOffice) | as Word: only when text follows on the same page | balanced | -## Cases the standard leaves open (the profile) - -| Case | Standard | Setting (`file.options` `odf.*`) | Microsoft Word style | LibreOffice style | -|---|---|---|---|---| -| Page margins a page layout doesn't state | §20.206–20.209 refer to XSL §7.10 (initial value 0); whether that default comes with the reference is not said | `pageMargin` | 0 (XSL's initial value) | 2 cm (its own files always state theirs; some of its templates don't) | -| Height of a header or footer whose style states none | §20.212.2 `fo:min-height`: no default | `headerMinHeight` | 0 (as tall as its text) | 0.5 cm | -| *(meaning)* Whether a header's stated minimum height includes its stated spacing from the body | §20.212.2, §20.207: not said | `headerHeightIncludesSpacing` | no: spacing comes after the height | yes | -| A header taller than its height with `style:dynamic-spacing` unstated | §20.264: no default | `dynamicSpacing` | off: the spacing is kept (on, with no height stated, would always drop the spacing the file states) | off | -| Where paragraph tab stops, default ones too, are measured from when `settings.xml` doesn't say | §19.512.3 `style:position` doesn't say; §19.861 covers only indexes | `tabsFromIndent` | the margin (as Word; ONLYOFFICE's files mean this too) | the paragraph's left indent (its `TabsRelativeToIndent` default): explicit stops, the default stops every interval and the hanging indent's stop all move with the indent (measured) | -| Gap between columns when `style:columns` states none | §19.233 `fo:column-gap`: no default (and explicitly not XSL's) | `columnGap` | 0.5 in (Word's) | 0 | -| A drawing shape's fill colour when no style gives one | §20.119 `draw:fill-color`: no default | `shapeFill` | `#156082` (WordCraft's shape blue) | `#729FCF` | -| A drawing shape's line colour when no style gives one | §20.408 `svg:stroke-color`: no default | `shapeLine` | `#0E405A` | `#3465A4` | -| A shape's line width when unstated, and *(meaning)* what a stated width of 0 (a hairline) draws at | §20.410 `svg:stroke-width`: no default | `hairline` | 0.25 pt | 0.5 pt | -| *(meaning)* A page-anchored frame positioned against a paragraph or character | §20.299, §20.398 Table 23: combination not defined | `pageFrameParagraphIsPage` | the page's text area | the page's edge | -| *(meaning)* An in-line frame positioned against a paragraph or page | §20.398 Table 23: combination not defined | `inlineInvalidRelationIsBaseline` | stands on the baseline | positioned against the baseline (raised by its offset) | -| Height of a line holding only in-line pictures | no line-height algorithm in the standard | `pictureLinesFitPictures` | as tall as its text would be too | as tall as the pictures | -| How the space between two paragraphs adds up when the document doesn't say | not defined (`fo:margin-top`/`-bottom` refer to XSL's margins; whether XSL's space resolution, which keeps the larger, comes with them is not said); LibreOffice's `AddParaTableSpacing` setting says when present (false: the larger) | `sumParagraphSpacing` | the larger of the two (as Word) | their sum | -| Widow and orphan control when neither the default paragraph style, Standard nor the paragraph's style states `fo:widows`/`fo:orphans` | §20.312, §20.244 refer to XSL's widows and orphans (initial value 2); whether that default comes with the reference is not said | `widowControl` | on: 2 lines (XSL's initial value, and Word's) | off (a first or last line can stand alone at a page's edge) | -| *(meaning)* Whether a columned section's columns end balanced when nothing follows it on the page, and when it doesn't say | §20.428 `text:dont-balance-text-columns`: no default, and its value descriptions contradict its name (WordCraft reads the name; so does LibreOffice) | `balanceSectionColumns` | as Word: only when text follows on the same page | balanced | - -## Law: what the standard defines and WordCraft follows whatever the profile +## Law: what the standard defines and WordCraft follows | Case | Rule | Note | |---|---|---| @@ -73,7 +58,7 @@ Where to set it: | A frame's `style:run-through` when unstated | §20.351: `foreground` | | | `style:dynamic-spacing="true"/"false"` when stated | §20.264 | WordCraft writes `false` when a header or footer has spacing, so every reader keeps it. | -## Open in the standard, and the same in every preset +## Other cases open in the standard These are documented so they aren't mistaken for law: @@ -81,7 +66,7 @@ These are documented so they aren't mistaken for law: |---|---|---| | Paper size when no page layout states one | not defined | the region's paper (Letter in the US, A4 elsewhere), as for new documents | | `style:wrap` unstated on a frame | §20.400: no default | text wraps on both sides (LibreOffice too) | -| A drawing shape's `draw:fill` / `draw:stroke` unstated | §20.118, §20.167: no default | solid (LibreOffice too); a text frame's are none; the colours are in the profile | +| A drawing shape's `draw:fill` / `draw:stroke` unstated | §20.118, §20.167: no default | solid (LibreOffice too); a text frame's are none; the colours are listed above | | A document's default font and size when no default style gives them | not defined | WordCraft's defaults | ## How WordCraft writes ODF @@ -136,7 +121,7 @@ such file. Each is kept as narrow as possible, and WordCraft never writes the de ## Known differences between WordCraft's and LibreOffice's layout of the same file Found by saving WordCraft's own documents as ODT and opening them in LibreOffice. These aren't -choices the file can state, so the presets don't cover them: +choices the file can state: - **Footers.** Word, and WordCraft for its own documents, places a footer's text so it ends at the footer's distance from the bottom of the page. ODF places it at the top of the footer area. A @@ -150,7 +135,7 @@ choices the file can state, so the presets don't cover them: small gap in WordCraft. - **Line breaks after a slash.** LibreOffice breaks a long path after a `/`; WordCraft, as Word, keeps the text after a slash with it and breaks such a word where it must. The standard - doesn't define line breaking; this could become a setting. + doesn't define line breaking. - **Objects that may not overlap text** (`style:wrap="none"` with several on one paragraph): LibreOffice moves them down by its own rules (`draw:wrap-influence-on-position`). @@ -168,18 +153,16 @@ from public bug reports and ONLYOFFICE's converter test snapshots): - **Google Docs** exports through a LibreOffice build (its generator reads `LibreOfficeDev/6.0`). It states its page size and margins, writes `TabsRelativeToIndent=false`, and its default graphic style states LibreOffice's colours. Its headers are empty styles with no content. - Nothing in these files depends on the profile beyond what the LibreOffice preset covers. + Nothing in these files depends on the cases above more than LibreOffice's own files do. - **ONLYOFFICE** states page size and margins only. It has no header styles and no default graphic style. It writes tab stops with no `TabsRelativeToIndent` and means "from the margin" - (its documents come from Word's model), which is the Microsoft Word style, the default. - -So neither needs a preset of its own yet. If later files show cases where they differ, a preset is -one more `OdfPreset` with its values, and the cases above stay the same. + (its documents come from Word's model), which is WordCraft's choice too. ## Revision history | Date | Change | |---|---| +| 2026-11-04 | The settings removed: File › Options › Advanced › OpenDocument files, `file.options` `odf`, `WORDCRAFT_ODF`, the LibreOffice style and custom values. WordCraft makes one choice for each case the standard leaves open, the one the default made, so nothing changes for a document opened with the default. What a file states is still followed, its `TabsRelativeToIndent` and `AddParaTableSpacing` among it. LibreOffice's choices stay listed beside WordCraft's. | | 2026-11-04 | How WordCraft writes ODF: unchanged paragraphs and tables with drawings, comments, tracked changes, notes or marks are written as read too (the text said from the model); what is edited, and the cases left in the element audit, from the model. | | 2026-11-04 | The two presets are the two styles a document can be shown in: Microsoft Word style (`word`, the default; `wordcraft`, its earlier id, still taken) and LibreOffice style (`libreoffice`). The CLI's values listed one by one. | | 2026-11-03 | Deviations from the standard listed: unitless gradient and hatch angles in files older than ODF 1.3 are read as tenths of a degree, as their producers wrote them, out of necessity (in ODF 1.3 files, degrees, as the standard says). The ODF 1.2 → 1.3 changes checked. |