From 53bccd544251da6f370bacf8a8d75171ef76a083 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:55:49 +0100 Subject: [PATCH] ODT: two display styles (Microsoft Word style, LibreOffice style) and review fixes (gap 19) - odf.preset: word (Microsoft Word style, default) or libreoffice; wordcraft still read as word - Options, CLI (WORDCRAFT_ODF), docs and ROADMAP follow the two styles - CodeRabbit: kept groups and story fingerprints follow fill_objects; padding serde default; list field depth; literal doubled quote; ASK/SET results not exported; dc:date offsets; first_pages order; unique picture names; finite raise clamp; PDF rotated-text guard; .ott in the password message; flat element bounded by depth; cell tile fallback; ROADMAP and docs reconciled - Remove clean-room wording from the ODT tests --- ROADMAP.md | 12 ++-- apps/wordcraft-cli/src/main.rs | 10 ++-- crates/doc/src/document.rs | 15 +++++ crates/doc/src/kept.rs | 35 +++++++++++ crates/doc/src/odf_profile.rs | 61 ++++++++++++++------ crates/doc/src/resolve.rs | 16 ++++- crates/engine/src/cmd/citations/generated.rs | 33 +++++++++-- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/cmd/insert/items.rs | 8 ++- crates/engine/src/cmd/insert/tests.rs | 3 + crates/engine/src/cmd/options.rs | 7 +-- crates/engine/src/tests.rs | 10 +++- crates/formats/src/html/export.rs | 2 + crates/formats/src/latex/export.rs | 2 + crates/formats/src/markdown/export.rs | 2 + crates/formats/src/model.rs | 38 ++++++++++++ crates/formats/src/model/from_doc.rs | 10 +++- crates/formats/src/model/types.rs | 9 +++ crates/formats/src/odt/datastyle.rs | 24 +++++++- crates/formats/src/odt/flat.rs | 32 ++++++++-- crates/formats/src/odt/import.rs | 13 +++-- crates/formats/src/odt/tests.rs | 12 ++++ crates/formats/src/odt/tests_more/floats.rs | 4 +- crates/formats/src/odt/tests_more/headers.rs | 28 ++++++++- crates/formats/src/odt/tests_more/kept.rs | 2 +- crates/formats/src/odt/tests_more/profile.rs | 23 ++++---- crates/formats/src/odt/tests_more/tabs.rs | 8 +-- crates/formats/src/odt/writer/drawing.rs | 31 +++++++++- crates/formats/src/rtf/export.rs | 2 + crates/layout/src/para/paragraph.rs | 6 +- crates/layout/src/table.rs | 10 +++- crates/layout/src/tests.rs | 15 +++++ crates/pdf/src/exporter.rs | 9 ++- crates/pdf/src/tags.rs | 25 ++++++++ crates/ui-egui/src/app/commands.rs | 2 +- crates/ui-egui/src/options.rs | 15 ++--- crates/ui-egui/src/options/tests.rs | 12 ++-- docs/odf-interpretation.md | 15 +++-- 38 files changed, 463 insertions(+), 100 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7c04db4a4f..a2674c3529 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-03 · **Change:** 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 element audit, ODT checked against 220 files from other applications, ODT display styles); 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,8 +139,9 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log -- 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; all open and save, and a save without edits keeps them, but for password and grid form controls. Over the 220 files from other applications, 175 round-trip pixel-identical and 199 with the same text. -- 2026-11-04: ODT checked against 220 files from other applications (Microsoft Office, OpenOffice.org, StarOffice, Calligra, Lotus Symphony; local only): every file LibreOffice opens opens again after a WordCraft save (a manifest entry was missing), 166 round-trip pixel-identical and 194 with the same text; hidden sections stay hidden and unedited lists are written as read (gap 19). +- 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; 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). +- 2026-11-04: ODT checked against 220 files from other applications (Microsoft Office, OpenOffice.org, StarOffice, Calligra, Lotus Symphony; local only): every file LibreOffice opens opens again after a WordCraft save (a manifest entry was missing), 166 round-trip pixel-identical and 194 with the same text at the time (an earlier measurement; the element-audit entry above has the current figures); hidden sections stay hidden and unedited lists are written as read (gap 19). - 2026-11-03: ODT paragraphs holding bookmarks, notes, formulas and objects, and the page-anchored frames before a paragraph, are written as read while unchanged (gap 19); styles of different families sharing a name are kept apart. Over the corpus 123 of 133 files round-trip pixel-identical (118), 132 with the same text, and the attributes and values a save loses fall from 539 to under 315. - 2026-11-03: ODT paragraphs showing text boxes and shapes holding text are written as read while that text is unchanged (gap 19). Over the corpus 111 of 133 files now round-trip pixel-identical (103), the round-trip difference falls from 44k to 39k pixels, and the element kinds a save loses fall from 16 to 8. - 2026-11-03: ODT paragraphs holding comments or tracked changes are written as read while those are unchanged (gap 19): a comment edited, resolved or answered, or a change accepted, sends its paragraph to the model; the file's change regions are kept beside WordCraft's own. Over the corpus the words a save loses fall from 29 to 4. @@ -151,7 +152,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-03: ODT saves without edits lose (almost) nothing (gap 19): tables, page layouts and master pages, named sections, document properties and declarations WordCraft doesn't model are written back as read, and a test saves seven samples of our own and finds nothing missing. Over the 133-file corpus 95 files come back pixel-identical (62 before) and the round-trip difference falls to 51k pixels (191k) - 2026-11-03: ODT saves what WordCraft doesn't model (gap 19): a paragraph nobody edited is written back with its own markup, and while no style changed, the file's own style definitions are written, so unknown elements, attributes, unused list styles and note settings survive a save that doesn't touch them. Over the 133-file corpus (LibreOffice, Google Docs and ONLYOFFICE files) the round-trip difference falls by 64% and 62 files come back pixel-identical (46 before) - 2026-11-03: ODT checked the other way round too (gap 19): 42 documents made in WordCraft (its templates, themes, and one per feature) are saved as ODT and opened in LibreOffice; every one opens with the same page count and passes the ODF 1.3 schema. Found and fixed: table styles were left out of the saved file (now written into each cell), ODT tables were given Word's Table Grid style on opening (which changed their spacing), section columns now say whether they end balanced, and SmartArt diagrams are saved as a group of their shapes and text. The LibreOffice round-trip corpus is at its best yet (40k differing pixels over 125 documents, 42 identical). -- 2026-11-02: ODT files follow their standard wherever it says something, and a setting wherever it doesn't (gap 19). File › Options › Advanced › OpenDocument files (and `file.options` `odf`) chooses WordCraft's own behaviour (the default), LibreOffice's, or custom values for each of twelve cases the standard leaves open: header heights and spacing, column gaps, shape colours and hairlines, where tab stops are measured from, frame relations the standard doesn't define, and lines of only pictures. A page layout that states no margins follows the setting too: 0 for WordCraft (XSL's initial value), 2 cm for LibreOffice. Every case is listed in docs/odf-interpretation.md; Google Docs and ONLYOFFICE files checked, and neither needs a preset of its own yet. +- 2026-11-02: ODT files follow their standard wherever it says something, and a setting wherever it doesn't (gap 19). File › Options › Advanced › OpenDocument files (and `file.options` `odf`) chooses between two display styles, Microsoft Word style (the default) and LibreOffice style, or custom values for each of twelve cases the standard leaves open: header heights and spacing, column gaps, shape colours and hairlines, where tab stops are measured from, frame relations the standard doesn't define, and lines of only pictures. A page layout that states no margins follows the setting too: 0 in Microsoft Word style (XSL's initial value), 2 cm for LibreOffice. Every case is listed in docs/odf-interpretation.md; Google Docs and ONLYOFFICE files checked, and neither needs a preset of its own yet. - 2026-11-02: ODT form controls keep their place on the line and multi-line text fields their lines; custom shapes keep their own geometry (LibreOffice draws its own shape after a save). In-line pictures placed against the text are raised from its size, and saved standing on the baseline. The corpus round-trip difference falls to 41k pixels, 90% below this morning's 408k; 42 of 125 documents round-trip pixel-identical (gap 19). Readiness unchanged. - 2026-11-02: ODT charts keep their whole look through a save: the chart object as read is written back while the chart is unchanged (edited, it's written from WordCraft's chart). Frames outlined by a drawn stroke keep it, and a header with no stated height is LibreOffice's 0.5 cm. The corpus round-trip difference falls to 47k pixels (408k this morning); 41 of 125 documents round-trip pixel-identical (gap 19). Readiness unchanged. - 2026-11-02: ODT: saving no longer makes a table's first row repeat on every page (a table style's header row had been taken for Repeat as header row), so LibreOffice's 16 agenda templates round-trip with their text the same; 91 of the 125 corpus documents now do, and the round-trip difference falls to 60k pixels (408k this morning) (gap 19). Readiness unchanged. @@ -751,6 +752,9 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-11-04 | trivial | Progress log: ODT element audit (gap 19) | +| 2026-11-04 | trivial | Progress log: ODT checked against 220 files from other applications (gap 19) | +| 2026-11-04 | trivial | Progress log: OpenDocument files shown in Microsoft Word style or LibreOffice style (gap 19) | | 2026-11-03 | trivial | Progress log: Language pack languages in every language list (CP4.3) | | 2026-11-03 | 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) | | 2026-11-03 | trivial | Progress log: Options › Language and Proofing redesign (CP4.3) | diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 7aa6a4131f..2b2a32771f 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -69,9 +69,9 @@ the system region's paper (A4, or Letter in the US and other Letter regions), re 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 with -WordCraft's own choices; set WORDCRAFT_ODF=libreoffice for LibreOffice's (see -docs/odf-interpretation.md). +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). @@ -177,14 +177,14 @@ fn session(doc: wordcraft_doc::Document) -> Session { } /// How OpenDocument files fill in what their standard leaves open: `WORDCRAFT_ODF` names a preset -/// (`wordcraft`, the default, or `libreoffice`). +/// (`word`, Microsoft Word's style and the default, or `libreoffice`; `wordcraft` is taken too). fn odf_profile() -> Result { use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile}; match std::env::var("WORDCRAFT_ODF").ok().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 wordcraft, libreoffice")), + None => Err(format!("WORDCRAFT_ODF={v}: one of word, libreoffice")), }, } } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index a76e2c469c..56571e4c46 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -741,6 +741,19 @@ impl Document { /// drawing from its data), which doesn't change the document: the markup kept as read for /// the paragraphs and tables holding them (see [`crate::kept`]) follows them. pub fn fill_objects(&mut self, wants: &dyn Fn(&InlineObject) -> bool, f: &mut dyn FnMut(&mut InlineObject)) { + // The stories (text boxes', notes') as read before, when filling changes one: they stay + // as read after it, so the paragraphs showing them keep their markup. + let holds = |b: &Arc| { + let mut any = false; + edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants)); + any + }; + let as_read: Vec = match self.kept.get() { + Some(k) if !k.story_fps.is_empty() && self.parts.values().any(|p| p.blocks.iter().any(holds)) => { + k.story_fps.iter().filter(|(id, fp)| **fp == crate::kept::story_fingerprint(self, **id)).map(|(id, _)| *id).collect() + } + _ => Vec::new(), + }; let mut moves: Vec = Vec::new(); let mut stories: Vec<&mut Blocks> = vec![&mut self.body]; stories.extend(self.parts.values_mut().map(|p| &mut p.blocks)); @@ -761,10 +774,12 @@ impl Document { moves.push((before, crate::kept::identities(b))); } } + let refreshed: Vec<(u32, u64)> = as_read.into_iter().map(|id| (id, crate::kept::story_fingerprint(self, id))).collect(); if let Some(k) = self.kept.get_mut() { for (before, after) in moves { k.follow(&before, &after); } + k.story_fps.extend(refreshed); } } diff --git a/crates/doc/src/kept.rs b/crates/doc/src/kept.rs index 12bfe2946e..cc474565d6 100644 --- a/crates/doc/src/kept.rs +++ b/crates/doc/src/kept.rs @@ -324,6 +324,10 @@ impl KeptXml { if let Some(w) = self.twin_wraps.remove(&(*fp, *rev)) { self.twin_wraps.entry((*nfp, *nrev)).or_insert(w); } + // Its group too: all a group's paragraphs are written as read, or none. + if let Some(g) = self.groups.remove(fp) { + self.groups.entry(*nfp).or_insert(g); + } } if let (Some(t), Some(nt)) = (before.table, after.table) && t != nt @@ -659,4 +663,35 @@ mod tests { k.keep_pending(2, big); assert_eq!(k.pending.len(), 1); } + + /// Filling in what objects show (`Document::fill_objects`, a chart drawn from its data) + /// doesn't change the document: a paragraph's group and a text box's story stay as read. + #[test] + fn filling_objects_keeps_groups_and_stories_as_read() { + use crate::para::InlineObject; + let field = + || InlineObject::Field { instr: "X".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() }; + let mut doc = crate::Document::from_text("Body"); + let mut boxed = Paragraph::with_text("In the box", Default::default()); + boxed.insert_object(0, field(), &Default::default()).unwrap(); + let id = doc.add_part(crate::PartKind::TextBox, vec![crate::para_block(boxed)]); + doc.para_mut(crate::StoryRef::Body, &crate::Path::top(0)).unwrap().insert_object(0, field(), &Default::default()).unwrap(); + let fp = fingerprint(doc.body[0].as_para().unwrap()); + let mut k = KeptXml { format: "odt".into(), ..Default::default() }; + k.paras.insert(fp, Arc::from("Body")); + k.groups.insert(fp, (1, 1)); + k.story_fps.insert(id, story_fingerprint(&doc, id)); + doc.kept = Kept::new(k); + doc.fill_objects(&|o| matches!(o, InlineObject::Field { result, .. } if result.is_empty()), &mut |o| { + if let InlineObject::Field { result, .. } = o { + *result = "filled".into(); + } + }); + let p = doc.body[0].as_para().unwrap(); + assert_ne!(fingerprint(p), fp, "the paragraph's fingerprint moved"); + let k = doc.kept.get().unwrap(); + assert!(k.para(p).is_some()); + assert_eq!(k.group(p), Some((1, 1))); + assert_eq!(k.story_fps.get(&id), Some(&story_fingerprint(&doc, id))); + } } diff --git a/crates/doc/src/odf_profile.rs b/crates/doc/src/odf_profile.rs index 641e8ef7bb..36f3d188da 100644 --- a/crates/doc/src/odf_profile.rs +++ b/crates/doc/src/odf_profile.rs @@ -4,9 +4,9 @@ //! 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: WordCraft's own ([`OdfPreset::WordCraft`], the default), the -//! choices LibreOffice makes ([`OdfPreset::LibreOffice`]), or the user's own -//! ([`OdfPreset::Custom`]). `docs/odf-interpretation.md` lists every case. +//! 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}; @@ -16,9 +16,11 @@ use crate::props::Rgb; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum OdfPreset { - /// WordCraft's own: the behaviour it has for its own and Word documents. + /// Microsoft Word's style: the behaviour WordCraft has for its own and Word documents. + /// (`wordcraft`, its earlier id, is still taken.) #[default] - WordCraft, + #[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). @@ -26,18 +28,36 @@ pub enum OdfPreset { } impl OdfPreset { - pub const ALL: [OdfPreset; 3] = [OdfPreset::WordCraft, OdfPreset::LibreOffice, OdfPreset::Custom]; + pub const ALL: [OdfPreset; 3] = [OdfPreset::Word, OdfPreset::LibreOffice, OdfPreset::Custom]; pub fn id(self) -> &'static str { match self { - OdfPreset::WordCraft => "wordcraft", + 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 { - OdfPreset::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id.trim())) + 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", + } } } @@ -108,15 +128,15 @@ pub struct OdfProfile { impl Default for OdfProfile { fn default() -> Self { - OdfProfile::wordcraft() + OdfProfile::word() } } impl OdfProfile { - /// WordCraft's own choices: as it lays out its own and Word documents. - pub fn wordcraft() -> OdfProfile { + /// Microsoft Word's style: as WordCraft lays out its own and Word documents. + pub fn word() -> OdfProfile { OdfProfile { - preset: OdfPreset::WordCraft, + preset: OdfPreset::Word, page_margin: 0.0, header_min_height: 0.0, header_height_includes_spacing: false, @@ -157,10 +177,10 @@ impl OdfProfile { } } - /// A preset's values (a custom profile starts from WordCraft's). + /// A preset's values (a custom profile starts from Word's style). pub fn of(preset: OdfPreset) -> OdfProfile { match preset { - OdfPreset::WordCraft | OdfPreset::Custom => OdfProfile { preset, ..OdfProfile::wordcraft() }, + OdfPreset::Word | OdfPreset::Custom => OdfProfile { preset, ..OdfProfile::word() }, OdfPreset::LibreOffice => OdfProfile::libreoffice(), } } @@ -208,9 +228,16 @@ mod tests { #[test] fn presets_and_custom_values() { - assert_eq!(OdfProfile::default().preset, OdfPreset::WordCraft); + 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())); @@ -219,10 +246,10 @@ mod tests { 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::wordcraft() }; + 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::wordcraft().layout().picture_lines_fit_pictures); + assert!(!OdfProfile::word().layout().picture_lines_fit_pictures); } } diff --git a/crates/doc/src/resolve.rs b/crates/doc/src/resolve.rs index 92ef2d7562..9eb64721d3 100644 --- a/crates/doc/src/resolve.rs +++ b/crates/doc/src/resolve.rs @@ -182,8 +182,9 @@ pub struct ResolvedPara { pub tabs: Vec, pub shading: Option, pub borders: Option, - /// Padding on the sides without a line (top, left, bottom, right), points: ODF's, which - /// ODF's (XSL's box model) keeps for documents opened from ODF (`Settings::odf`); Word drops it. + /// Padding on the sides without a line (top, left, bottom, right), points. ODF's box model + /// (XSL's) keeps it for documents opened from ODF (`Settings::odf`); Word drops it. + #[serde(default)] pub padding: [f32; 4], pub bidi: bool, pub drop_cap: u8, @@ -577,4 +578,15 @@ mod tests { assert_eq!(p.tabs.len(), 3); assert_eq!(p.tabs[0].pos, 100.0); } + + /// Resolved formatting saved before paragraph padding existed still reads: no padding. + #[test] + fn resolved_para_without_padding_reads() { + let p = StyleSheet::builtin().resolve_para(&ParaProps::default()); + let mut v = serde_json::to_value(&p).unwrap(); + v.as_object_mut().unwrap().remove("padding"); + let back: ResolvedPara = serde_json::from_value(v).unwrap(); + assert_eq!(back.padding, [0.0; 4]); + assert_eq!(back, ResolvedPara { padding: [0.0; 4], ..p }); + } } diff --git a/crates/engine/src/cmd/citations/generated.rs b/crates/engine/src/cmd/citations/generated.rs index c20ff10fdd..0a95bea7db 100644 --- a/crates/engine/src/cmd/citations/generated.rs +++ b/crates/engine/src/cmd/citations/generated.rs @@ -73,11 +73,11 @@ const MAX_WRAPPED: usize = 10_000; /// it ends in. `None` for anything else (a TOC has its own update). fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize)> { let p = s.doc.body.get(i)?.as_para()?; - let (off, instr) = p.objects_with_offsets().find_map(|(off, o)| match o { + let (k, off, instr) = p.objects_with_offsets().enumerate().find_map(|(k, (off, o))| match o { InlineObject::FieldStart { instr, .. } => { let t = instr.trim_start(); (t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || figures_label(t).is_some()) - .then(|| (off, t.to_string())) + .then(|| (k, off, t.to_string())) } _ => None, })?; @@ -85,11 +85,12 @@ fn wrapped_list(s: &Session, i: usize) -> Option<(String, usize)> { if !p.text.get(..off).is_some_and(|t| t.chars().all(|c| c.is_whitespace() || c == wordcraft_doc::para::OBJ)) { return None; } - // Where it ends: the field end that closes it, nested fields counted. + // Where it ends: the field end that closes it, nested fields counted from its own start (a + // field before it in its paragraph, an empty `PAGE`…, closes before it and isn't it). let mut depth = 0usize; for (j, b) in s.doc.body.iter().enumerate().skip(i).take(MAX_WRAPPED) { let Some(q) = b.as_para() else { continue }; - for o in &q.objects { + for o in q.objects.iter().skip(if j == i { k } else { 0 }) { match o { InlineObject::FieldStart { .. } => depth += 1, InlineObject::FieldEnd => { @@ -388,3 +389,27 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> { } out } + +#[cfg(test)] +mod tests { + use super::*; + + /// A field before a list's in its paragraph (an empty `PAGE`) closes first: the list still + /// ends at its own field end, paragraphs below, not in its first paragraph. + #[test] + fn a_wrapped_list_ends_at_its_own_field_end() { + let mut d = Document::from_text("Old one\nOld two\nAfter"); + let props = CharProps::default(); + if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(0)) { + p.insert_object(0, InlineObject::FieldStart { instr: "BIBLIOGRAPHY".into(), locked: false, dirty: false }, &props).unwrap(); + p.insert_object(0, InlineObject::FieldEnd, &props).unwrap(); + p.insert_object(0, InlineObject::FieldStart { instr: "PAGE".into(), locked: false, dirty: false }, &props).unwrap(); + } + if let Ok(p) = d.para_mut(StoryRef::Body, &Path::top(1)) { + let n = p.len(); + p.insert_object(n, InlineObject::FieldEnd, &props).unwrap(); + } + let s = Session::new(d); + assert_eq!(wrapped_list(&s, 0), Some(("BIBLIOGRAPHY".to_string(), 1))); + } +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 6988539886..61b0fb6ebf 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -346,7 +346,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult { }; if !crate::io::can_encrypt(&path.to_string_lossy()) { return Err(CmdError::Params( - "only Word documents (.docx, .docm, .dotx, .dotm) and ODT documents (.odt) can be saved with a password".into(), + "only Word documents (.docx, .docm, .dotx, .dotm) and OpenDocument text files (.odt, .ott) can be saved with a password".into(), )); } wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?; diff --git a/crates/engine/src/cmd/insert/items.rs b/crates/engine/src/cmd/insert/items.rs index d421a4f736..47ae1f33b9 100644 --- a/crates/engine/src/cmd/insert/items.rs +++ b/crates/engine/src/cmd/insert/items.rs @@ -83,7 +83,13 @@ pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32 let mut i = 0; while i < chars.len() { let c = chars.get(i).copied().unwrap_or(' '); - // Text in single quotes is shown as it is; two quotes in a row are one. + // Text in single quotes is shown as it is; two quotes in a row are one, inside quoted + // text or out of it. + if c == '\'' && chars.get(i + 1) == Some(&'\'') { + out.push('\''); + i += 2; + continue; + } if c == '\'' { i += 1; while let Some(&q) = chars.get(i) { diff --git a/crates/engine/src/cmd/insert/tests.rs b/crates/engine/src/cmd/insert/tests.rs index eaa844edae..efcc090121 100644 --- a/crates/engine/src/cmd/insert/tests.rs +++ b/crates/engine/src/cmd/insert/tests.rs @@ -341,6 +341,9 @@ fn date_pictures_keep_quoted_text() { assert_eq!(format_date_parts("'Day' d 'at' h", 2026, 10, 21, (17, 0, 0), 0), "Day 21 at 5"); // An unclosed quote runs to the end. assert_eq!(format_date_parts("yyyy 'and so", 2026, 10, 21, (0, 0, 0), 0), "2026 and so"); + // Two quotes outside quoted text are one too (an apostrophe), not an empty quoted text. + assert_eq!(format_date_parts("yyyy '' MM", 2026, 10, 21, (0, 0, 0), 0), "2026 ' 10"); + assert_eq!(format_date_parts("d''MM", 2026, 10, 21, (0, 0, 0), 0), "21'10"); } #[test] diff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs index 7b4283a083..a0e590759f 100644 --- a/crates/engine/src/cmd/options.rs +++ b/crates/engine/src/cmd/options.rs @@ -23,7 +23,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"?: "wordcraft|libreoffice|custom", "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], "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."#, ) .pure(), CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| { @@ -250,7 +250,7 @@ pub fn current(s: &Session) -> Value { }) } -/// The `odf` setting: `{"preset"?: "wordcraft|libreoffice|custom", …case values}` over `current`. +/// 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`. @@ -270,8 +270,7 @@ fn odf_param(v: &Value, current: &OdfProfile) -> Result, CmdE 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`: wordcraft, libreoffice or custom".into()))?; + 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; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e91f890df2..a9ae6aa195 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -11812,10 +11812,18 @@ fn emphasis_marks_are_set_and_drawn() { 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::wordcraft(), "WordCraft's own is the default"); + 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 [ diff --git a/crates/formats/src/html/export.rs b/crates/formats/src/html/export.rs index 29ce3d2b34..4649400279 100644 --- a/crates/formats/src/html/export.rs +++ b/crates/formats/src/html/export.rs @@ -145,6 +145,8 @@ pub fn inlines_html(inlines: &[Inline]) -> String { | Inline::FieldStart(_) | Inline::FieldEnd | Inline::AnchorEnd(_) => {} + // ASK and SET show nothing (see `model::shows_nothing`). + Inline::Field { instr, .. } if model::shows_nothing(instr) => {} Inline::Field { result, fmt, .. } | Inline::Control { text: result, fmt, .. } => { let (open, close) = fmt_tags(fmt); let at_start = o.is_empty(); diff --git a/crates/formats/src/latex/export.rs b/crates/formats/src/latex/export.rs index 8a371c10f3..942c0cc145 100644 --- a/crates/formats/src/latex/export.rs +++ b/crates/formats/src/latex/export.rs @@ -250,6 +250,8 @@ impl Writer { | Inline::FieldStart(_) | Inline::FieldEnd | Inline::AnchorEnd(_) => {} + // ASK and SET show nothing (see `model::shows_nothing`). + Inline::Field { instr, .. } if model::shows_nothing(instr) => {} Inline::Field { result, fmt, .. } | Inline::Control { text: result, fmt, .. } => { let s = self.span(result, fmt); push_apart(&mut o, &s); diff --git a/crates/formats/src/markdown/export.rs b/crates/formats/src/markdown/export.rs index 2b7f054bd3..0238686c1e 100644 --- a/crates/formats/src/markdown/export.rs +++ b/crates/formats/src/markdown/export.rs @@ -136,6 +136,8 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String { pieces.push((String::new(), Fmt::default(), Some(format!("![{}]()", escape(alt))))) } Inline::Equation { .. } => {} + // ASK and SET show nothing (see `model::shows_nothing`). + Inline::Field { instr, .. } if model::shows_nothing(instr) => {} Inline::Field { result, fmt, .. } | Inline::Control { text: result, fmt, .. } => pieces.push((result.clone(), fmt.clone(), None)), Inline::Note { .. } | Inline::Comment { .. } diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index 4e3c00aa16..e75766732c 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -31,6 +31,7 @@ pub use types::{ Affix, BoxLook, CODE_STYLE, Cell, CellPic, Chain, Chart, Conn, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Group, Img, Inline, Kind, Label, ListInfo, MAX_DEPTH, MAX_FONT_NAME, MAX_GROUP_DEPTH, MAX_LINK, MONO_FONT, Member, Meta, OdfObject, Para, RUBY_BASE_SIZE, RevMark, Spacing, TableExtra, clean_text, equations_as_text, flatten_tables, kept_font, kept_link, ranged_anchors, ruby_as_text, ruby_object, + shows_nothing, }; pub(crate) use types::{Dropped, begin_import, dropped}; @@ -45,6 +46,43 @@ thread_local! { mod tests { use super::*; + /// ASK and SET show nothing (layout shows no result for them): no text export writes the + /// result they hold, from the document or from a flow with fields; ODT keeps it. + #[test] + fn ask_and_set_fields_export_no_text() { + let mut doc = wordcraft_doc::Document::from_text("Before after"); + if let Ok(p) = doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)) { + for instr in ["SET name other", "ASK other \"Prompt\""] { + let field = wordcraft_doc::para::InlineObject::Field { + instr: instr.into(), + result: "SECRETVALUE".into(), + locked: false, + dirty: false, + code: false, + form: String::new(), + }; + p.insert_object(7, field, &Default::default()).unwrap(); + } + } + for (name, out) in [ + ("markdown", crate::markdown::export(&doc)), + ("html", crate::html::export(&doc)), + ("latex", crate::latex::export(&doc)), + ("rtf", crate::rtf::export(&doc)), + ] { + assert!(out.contains("Before"), "{name}: {out}"); + assert!(!out.contains("SECRETVALUE"), "{name}: {out}"); + } + let flow = from_doc_with(&doc, FlowOpts { fields: true, ..Default::default() }); + assert!(!crate::html::export_flow(&flow, "en").contains("SECRETVALUE")); + // The flow keeps the result for ODT. + let kept = flow + .blocks + .iter() + .any(|b| matches!(b, FBlock::Para(p) if p.inlines.iter().any(|i| matches!(i, Inline::Field { result, .. } if result == "SECRETVALUE")))); + assert!(kept); + } + #[test] fn percent_decode_requires_hex_digits() { assert_eq!(percent_decode("%+A"), b"%+A".to_vec()); diff --git a/crates/formats/src/model/from_doc.rs b/crates/formats/src/model/from_doc.rs index 11ef6f4029..230c90eb9b 100644 --- a/crates/formats/src/model/from_doc.rs +++ b/crates/formats/src/model/from_doc.rs @@ -442,6 +442,14 @@ fn direct_spacing(p: &wordcraft_doc::props::ParaProps) -> Spacing { /// A document paragraph as flow paragraphs: a page break character inside the text ends the /// paragraph, and the text after it starts a new one that carries `page_break`. +/// An object's text in the flow: ASK and SET show nothing, whatever result they hold. +fn shown_text(o: &InlineObject) -> &str { + match o { + InlineObject::Field { instr, .. } if super::shows_nothing(instr) => "", + o => o.plain_text(), + } +} + pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec { flow_paras_in(doc, p, FlowOpts::default()) } @@ -599,7 +607,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec { out.inlines.push(Inline::Anchor(name.clone())); } Some(o @ (InlineObject::ControlStart { .. } | InlineObject::ControlEnd)) if control.object(o, opts, &mut buf, &mut out, &f) => {} - Some(o) => buf.push_str(o.plain_text()), + Some(o) => buf.push_str(shown_text(o)), None => {} } raise_drawings(&mut out.inlines, from, props.position.filter(|_| opts.paras)); diff --git a/crates/formats/src/model/types.rs b/crates/formats/src/model/types.rs index 24ddc33290..f4171bac20 100644 --- a/crates/formats/src/model/types.rs +++ b/crates/formats/src/model/types.rs @@ -428,6 +428,14 @@ pub struct FComment { pub blocks: Vec, } +/// A field that never shows its result: `ASK` and `SET` only assign text to a bookmark +/// (ECMA-376 §17.16.5.4, §17.16.5.60), as layout shows them. Its stored result stays in the +/// flow (ODT writes it back); text exports write nothing for it. +pub fn shows_nothing(instr: &str) -> bool { + let name = instr.split_whitespace().next().unwrap_or(""); + name.eq_ignore_ascii_case("ASK") || name.eq_ignore_ascii_case("SET") +} + /// What [`super::from_doc_with`] keeps besides text and formatting. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct FlowOpts { @@ -769,6 +777,7 @@ impl Para { Some(font) if wordcraft_doc::symbol_fonts::is_symbol_font(font) => s.push_str(&wordcraft_doc::symbol_fonts::map_text(font, t)), _ => s.push_str(t), }, + Inline::Field { instr, .. } if shows_nothing(instr) => {} Inline::Equation { linear: t, .. } | Inline::Ruby { base: t, .. } | Inline::Field { result: t, .. } diff --git a/crates/formats/src/odt/datastyle.rs b/crates/formats/src/odt/datastyle.rs index 7cc55799f5..324ad135de 100644 --- a/crates/formats/src/odt/datastyle.rs +++ b/crates/formats/src/odt/datastyle.rs @@ -62,12 +62,13 @@ pub(super) fn picture(parts: &[Part]) -> String { Part::AmPm => s.push_str("AM/PM"), Part::Text(t) => { let t: String = t.chars().filter(|c| !matches!(c, '"' | '\\')).collect(); - if t.chars().any(|c| c.is_alphabetic() || c == '\'') { + // Letters are quoted; an apostrophe is two, inside quoted text or out of it. + if t.chars().any(char::is_alphabetic) { s.push('\''); s.push_str(&t.replace('\'', "''")); s.push('\''); } else { - s.push_str(&t); + s.push_str(&t.replace('\'', "''")); } } } @@ -87,6 +88,12 @@ fn parts_of(pic: &str) -> Vec { } }; while let Some(&c) = chars.get(i) { + // Two quotes outside quoted text are an apostrophe, as inside it. + if c == '\'' && chars.get(i + 1) == Some(&'\'') { + text.push('\''); + i += 2; + continue; + } if c == '\'' { i += 1; while let Some(&q) = chars.get(i) { @@ -337,4 +344,17 @@ mod value_tests { assert_eq!(value_shown("dd/MM/yyyy", ""), None); assert_eq!(value_shown("", "anything"), None); } + + /// An apostrophe in a date style's text is two quotes in the picture, quoted or not, and + /// reads back as one. + #[test] + fn an_apostrophe_in_a_date_styles_text_round_trips() { + use super::{Part, parts_of, picture}; + for text in ["'", "''", "o'clock", "'tis", " ' "] { + let parts = vec![Part::Text(text.to_string())]; + let pic = picture(&parts); + assert_eq!(parts_of(&pic), parts, "{text:?} as {pic:?}"); + } + assert_eq!(picture(&[Part::Text("'".into())]), "''"); + } } diff --git a/crates/formats/src/odt/flat.rs b/crates/formats/src/odt/flat.rs index 689074a2cd..f9c8c9c7db 100644 --- a/crates/formats/src/odt/flat.rs +++ b/crates/formats/src/odt/flat.rs @@ -40,11 +40,14 @@ fn element<'a>(xml: &'a str, name: &str) -> &'a str { let close = format!(""); let mut depth = 0usize; let mut at = tag_end; - // Nesting is bounded like any XML here: deeper is malformed. - for _ in 0..super::limits::MAX_XML_DEPTH.saturating_mul(2) { + // Every step moves past a tag, so this ends; nesting is bounded like any XML here (deeper is + // malformed), however many elements of the name sit side by side within. + loop { let rest = xml.get(at..).unwrap_or(""); let next_close = rest.find(&close); - let next_open = rest.match_indices(&open).map(|(i, _)| i).find(|&i| { + // Only opens before that close count (and only they are looked for). + let before = next_close.and_then(|c| rest.get(..c)).unwrap_or(rest); + let next_open = before.match_indices(&open).map(|(i, _)| i).find(|&i| { let after = rest.get(i + open.len()..).unwrap_or(""); after.chars().next().is_some_and(|c| c == '>' || c == '/' || c.is_whitespace()) // Not self-closed. @@ -53,6 +56,9 @@ fn element<'a>(xml: &'a str, name: &str) -> &'a str { match (next_open, next_close) { (Some(o), Some(c)) if o < c => { depth += 1; + if depth > super::limits::MAX_XML_DEPTH { + return xml.get(start..).unwrap_or(""); + } at += o + open.len(); } (_, Some(c)) if depth > 0 => { @@ -64,7 +70,6 @@ fn element<'a>(xml: &'a str, name: &str) -> &'a str { (_, None) => return xml.get(start..).unwrap_or(""), } } - xml.get(start..).unwrap_or("") } /// What is inside element `el` (an element string as [`element`] gives). @@ -401,3 +406,22 @@ fn namespaces(xml: &str) -> Vec<&str> { } out } + +#[cfg(test)] +mod element_tests { + use super::element; + + /// An element ends at its own close tag however many elements of its name it holds side by + /// side (inline objects' own `office:body`s), not after a count of tags. + #[test] + fn an_element_holding_many_of_its_name_ends_at_its_own_close() { + let inner = "x".repeat(1200); + let outer = format!("{inner}"); + let xml = format!("{outer}"); + assert_eq!(element(&xml, "office:body"), outer); + // Nested deeper than any XML here: malformed, to the end, as an unclosed one. + let deep = format!("{}{}", "".repeat(600), "".repeat(600)); + assert_eq!(element(&deep, "office:body"), deep); + assert_eq!(element("", "office:body"), ""); + } +} diff --git a/crates/formats/src/odt/import.rs b/crates/formats/src/odt/import.rs index 25fd06c9ec..f4db12a34b 100644 --- a/crates/formats/src/odt/import.rs +++ b/crates/formats/src/odt/import.rs @@ -77,11 +77,9 @@ pub(super) fn read_changes(xml: &[u8]) -> (String, String, u32) { }, }); let date = date.trim(); - let date = if date.is_empty() || date.ends_with(['Z', 'z']) || date.contains('+') { - date.to_string() - } else { - format!("{}Z", date.split('.').next().unwrap_or("")) - }; + // Zoned already: `Z`, or an offset (`+hh:mm`, or `-hh:mm` in the time part), as `read_meta`. + let zoned = date.ends_with(['Z', 'z']) || date.contains('+') || date.split_once('T').is_some_and(|(_, time)| time.contains('-')); + let date = if date.is_empty() || zoned { date.to_string() } else { format!("{}Z", date.split('.').next().unwrap_or("")) }; (by.trim().to_string(), date, cycles.trim().parse::().unwrap_or(0)) } @@ -1243,8 +1241,11 @@ fn read_pages(xml: &[u8], paper: (f32, f32), profile: &OdfProfile) -> Pages { /// page size and margins are its own (the first page's). fn first_pages(pages: &mut Pages) { let names: Vec<(String, String)> = pages.next.iter().map(|(a, b)| (a.clone(), b.clone())).collect(); + // The masters as read: a master the loop has already changed (Left and Right pages naming each + // other) gives the next one its own headers, whatever order the map yields them in. + let read = pages.masters.clone(); for (name, next) in names { - let Some(after) = pages.masters.get(&next).cloned() else { continue }; + let Some(after) = read.get(&next).cloned() else { continue }; let Some(sec) = pages.masters.get_mut(&name) else { continue }; let (own_header, own_footer) = (sec.headers.default, sec.footers.default); sec.headers = after.headers; diff --git a/crates/formats/src/odt/tests.rs b/crates/formats/src/odt/tests.rs index 3a57bdc3e3..5241e7d2c1 100644 --- a/crates/formats/src/odt/tests.rs +++ b/crates/formats/src/odt/tests.rs @@ -1313,3 +1313,15 @@ fn a_paragraph_starting_a_column_starts_with_a_column_break() { assert!(out.contains(r#"fo:break-before="column""#) && !out.contains('\u{e}'), "{out}"); assert_eq!(text(&import(&export(&doc).unwrap()).unwrap()), ["Side", "\u{e}Education"]); } + +#[test] +fn meta_modified_date_keeps_a_negative_offset() { + let meta = |date: &str| format!("{date}"); + // A `-hh:mm` offset is a zone: no `Z` after it (an invalid xsd:dateTime written back on save). + assert_eq!(super::import::read_changes(meta("2024-03-05T13:45:00-05:00").as_bytes()).1, "2024-03-05T13:45:00-05:00"); + assert_eq!(super::import::read_changes(meta("2024-03-05T13:45:00+01:00").as_bytes()).1, "2024-03-05T13:45:00+01:00"); + assert_eq!(super::import::read_changes(meta("2024-03-05T13:45:00Z").as_bytes()).1, "2024-03-05T13:45:00Z"); + // Zoneless (the date's own `-`s are not an offset): UTC, fraction dropped. + assert_eq!(super::import::read_changes(meta("2024-03-05T13:45:00.25").as_bytes()).1, "2024-03-05T13:45:00Z"); + assert_eq!(super::import::read_changes(meta("").as_bytes()).1, ""); +} diff --git a/crates/formats/src/odt/tests_more/floats.rs b/crates/formats/src/odt/tests_more/floats.rs index 4332c27ed3..b6f25438f2 100644 --- a/crates/formats/src/odt/tests_more/floats.rs +++ b/crates/formats/src/odt/tests_more/floats.rs @@ -154,7 +154,7 @@ fn a_page_anchored_frames_paragraph_relations_are_the_pages() { }; 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::wordcraft()); + let f = paragraph(wordcraft_doc::odf_profile::OdfProfile::word()); assert_eq!((f.h_rel, f.v_rel), (Anchor::Margin, Anchor::Margin)); let content = |profile| { float(&open_as( @@ -165,7 +165,7 @@ fn a_page_anchored_frames_paragraph_relations_are_the_pages() { }; 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::wordcraft()); + let f = content(wordcraft_doc::odf_profile::OdfProfile::word()); 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 4b166008be..e0c0be2eb8 100644 --- a/crates/formats/src/odt/tests_more/headers.rs +++ b/crates/formats/src/odt/tests_more/headers.rs @@ -175,7 +175,7 @@ fn a_headers_spacing_stays_below_content_taller_than_it() { let grow = wordcraft_doc::odf_profile::OdfProfile { preset: wordcraft_doc::odf_profile::OdfPreset::Custom, dynamic_spacing: true, - ..wordcraft_doc::odf_profile::OdfProfile::wordcraft() + ..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)); @@ -250,6 +250,32 @@ fn a_first_page_master_followed_by_another_is_a_different_first_page() { assert!(first.trim().starts_with("Left headRight head"# + ); + let content = format!( + r#"Recto.More."# + ); + let file = crate::odt::tests::package(&content, Some(&styles)); + for _ in 0..24 { + let d = import(&file).unwrap(); + let text = |id: Option| -> String { + id.and_then(|id| d.parts.get(&id)) + .map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect()) + .unwrap_or_default() + }; + let sec = &d.last_section; + assert_eq!(text(sec.headers.first), "Right head", "the first page is the Right Page's own"); + assert_eq!(text(sec.headers.default), "Left head", "the pages after it the Left Page's"); + } +} + #[test] fn a_watermark_is_a_shape_in_the_header_and_reads_back_as_one() { // Written as LibreOffice and Word write theirs (a fontwork shape behind the text in every diff --git a/crates/formats/src/odt/tests_more/kept.rs b/crates/formats/src/odt/tests_more/kept.rs index 09888729fb..b02cbfa08b 100644 --- a/crates/formats/src/odt/tests_more/kept.rs +++ b/crates/formats/src/odt/tests_more/kept.rs @@ -4,7 +4,7 @@ //! //! Every sample here (and any sample file added for the no-edit loss gate) is written by us for //! the purpose, with our own text: never a file or text taken from LibreOffice, a bug tracker, -//! another application's templates or anyone else's document (CLAUDE.md, clean room). +//! another application's templates or anyone else's document, so every sample can be committed. use crate::odt::tests::{NS_T, content_of, package, styles_of}; use crate::odt::{export, import}; diff --git a/crates/formats/src/odt/tests_more/profile.rs b/crates/formats/src/odt/tests_more/profile.rs index b140b827be..bff6cabb70 100644 --- a/crates/formats/src/odt/tests_more/profile.rs +++ b/crates/formats/src/odt/tests_more/profile.rs @@ -47,10 +47,10 @@ fn values_a_file_states_are_kept_under_every_preset() { shape_fill: Rgb(1, 2, 3), shape_line: Rgb(4, 5, 6), hairline: 9.0, - ..OdfProfile::wordcraft() + ..OdfProfile::word() }; let near = |a: f32, b: f32| (a - b).abs() < 0.05; - for profile in [OdfProfile::wordcraft(), OdfProfile::libreoffice(), odd] { + 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); @@ -79,15 +79,15 @@ fn a_sections_column_balance_is_its_own_else_the_profiles_and_is_written() { 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::wordcraft(), OdfProfile::libreoffice()] { + 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::wordcraft()), None); + assert_eq!(balance("", &OdfProfile::word()), 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::wordcraft()); + let d = import_as(&doc(props), &OdfProfile::word()); 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}"); @@ -115,14 +115,14 @@ fn paragraph_spacing_adds_up_as_the_document_says_else_as_the_profile_does() { } z.finish().unwrap().into_inner() }; - for p in [OdfProfile::wordcraft(), OdfProfile::libreoffice()] { + 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::wordcraft()).settings.sum_paragraph_spacing); + assert!(!import_as(&with(None), &OdfProfile::word()).settings.sum_paragraph_spacing); for (add, written) in [(Some(true), "true"), (None, "false")] { - let d = import_as(&with(add), &OdfProfile::wordcraft()); + let d = import_as(&with(add), &OdfProfile::word()); 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}"); @@ -148,14 +148,13 @@ 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::wordcraft(), OdfProfile::libreoffice()] { + 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::wordcraft())); + assert!(on(&doc(""), &OdfProfile::word())); assert!(!on(&doc(""), &OdfProfile::libreoffice())); - for (p, written) in [(OdfProfile::wordcraft(), r#"fo:widows="2" fo:orphans="2""#), (OdfProfile::libreoffice(), r#"fo:widows="0" fo:orphans="0""#)] - { + 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 styles = crate::odt::tests::entry(&saved, "styles.xml"); assert!(styles.contains(written), "{:?}: {styles}", p.preset); diff --git a/crates/formats/src/odt/tests_more/tabs.rs b/crates/formats/src/odt/tests_more/tabs.rs index 0fb52c35e2..f47c815ac9 100644 --- a/crates/formats/src/odt/tests_more/tabs.rs +++ b/crates/formats/src/odt/tests_more/tabs.rs @@ -67,12 +67,12 @@ fn tabs_relative_to_the_indent_move_with_it() { let d = import_as(&with_settings(&content(r#"fo:margin-left="0.5in""#), relative), profile); tabs(&d).first().map(|t| t.pos) }; - for profile in [OdfProfile::libreoffice(), OdfProfile::wordcraft()] { + 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::wordcraft()), Some(72.0)); + assert_eq!(first(None, &OdfProfile::word()), Some(72.0)); } #[test] @@ -163,7 +163,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::wordcraft()); + let d = import_as(&with_settings(&content(r#"fo:margin-left="0.5in""#), Some(true)), &OdfProfile::word()); 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 +173,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::wordcraft()); + let d = import_as(&with_settings(&content(r#"fo:margin-left="0.5in""#), Some(false)), &OdfProfile::word()); 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/writer/drawing.rs b/crates/formats/src/odt/writer/drawing.rs index a5560d306b..4b683a2df2 100644 --- a/crates/formats/src/odt/writer/drawing.rs +++ b/crates/formats/src/odt/writer/drawing.rs @@ -189,10 +189,11 @@ pub(in crate::odt) const DEFAULT_BOX_INSET: [f32; 4] = [7.2, 3.6, 7.2, 3.6]; impl Writer { pub(in crate::odt) fn picture(&mut self, img: &model::Img) -> String { let ext = if img.ext == "jpeg" { "jpg".to_string() } else { img.ext.clone() }; - // Not a name the file's own pictures, kept as read, have. + // Not a name the file's own pictures, kept as read, have, nor one an earlier picture + // took (after skipping a reserved name, the count is behind the names taken). let mut n = self.pictures.len() + 1; let mut name = format!("Pictures/{}image{n}.{ext}", self.prefix); - while self.reserved.contains(&name) && n < usize::MAX { + while (self.reserved.contains(&name) || self.pictures.iter().any(|(taken, _, _)| *taken == name)) && n < usize::MAX { n += 1; name = format!("Pictures/{}image{n}.{ext}", self.prefix); } @@ -618,3 +619,29 @@ impl Writer { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_picture_never_takes_a_name_an_earlier_one_took() { + // Re-saving a file WordCraft saved: its `wcimage1.png` is kept as read, so the first new + // picture skips to 2, and the second must not take 2 again (a duplicate zip entry fails + // the save). + let mut w = Writer::with_prefix("wc"); + w.reserved = std::sync::Arc::new(std::iter::once("Pictures/wcimage1.png".to_string()).collect()); + let img = model::Img { + data: std::sync::Arc::new(vec![1, 2, 3]), + ext: "png".into(), + w: 10.0, + h: 10.0, + alt: String::new(), + float: None, + object: None, + crop: [0.0; 4], + }; + let names: Vec = (0..3).map(|_| w.picture(&img)).collect(); + assert_eq!(names, ["Pictures/wcimage2.png", "Pictures/wcimage3.png", "Pictures/wcimage4.png"]); + } +} diff --git a/crates/formats/src/rtf/export.rs b/crates/formats/src/rtf/export.rs index 23a52afbfa..1b2b34f690 100644 --- a/crates/formats/src/rtf/export.rs +++ b/crates/formats/src/rtf/export.rs @@ -399,6 +399,8 @@ impl Writer { } // RTF keeps no ruby yet: its base text, in the run's formatting. Inline::Ruby { base, fmt, .. } => self.run(base, fmt, out), + // ASK and SET show nothing (see `model::shows_nothing`). + Inline::Field { instr, .. } if model::shows_nothing(instr) => {} Inline::Field { result, fmt, .. } | Inline::Control { text: result, fmt, .. } => self.run(result, fmt, out), Inline::Figure(_) | Inline::Chart(_) diff --git a/crates/layout/src/para/paragraph.rs b/crates/layout/src/para/paragraph.rs index 62acc591a2..82f5a69b0c 100644 --- a/crates/layout/src/para/paragraph.rs +++ b/crates/layout/src/para/paragraph.rs @@ -361,8 +361,10 @@ impl<'a> Shaped<'a> { let [el, et, er, eb] = float.effect_extent(); let (px, py) = float.spin_pad(w * s, h * s); let tall = h * s + et + eb + 2.0 * py; - // Lowered at most until its top is on the baseline. - let raise = if raise.is_finite() { raise.clamp(-tall, 1584.0) } else { 0.0 }; + // Lowered at most until its top is on the baseline. The bound is made finite and + // not above `clamp`'s top whatever the extents give (`clamp` panics otherwise). + let floor = if tall.is_finite() { -tall.max(0.0) } else { 0.0 }; + let raise = if raise.is_finite() { raise.clamp(floor, 1584.0) } else { 0.0 }; self.b.clusters.push(slot.cluster(ClKind::Object(k), w * s + el + er + 2.0 * px, (tall + raise, -raise))); } else { self.push(slot, ClKind::Object(k), 0.0, 0.0); diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 6fe26a83e6..a3ddbf824d 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -1269,10 +1269,16 @@ fn cell_picture(pic: &wordcraft_doc::props::CellPicture, rect: Rect, story: Stor match pic.repeat { PictureRepeat::Stretch => vec![image(rect)], PictureRepeat::Tile if sized => { - let (cols, rows) = ((rect.w / w).ceil().clamp(1.0, 400.0) as usize, (rect.h / h).ceil().clamp(1.0, 400.0) as usize); + let (cols, rows) = ((rect.w / w).ceil().max(1.0), (rect.h / h).ceil().max(1.0)); + // Too many tiles to draw (or a cell of no finite size): one picture stretched over + // the cell, as a page's picture is, so the cell is always covered. + let tiles = cols * rows; + if !tiles.is_finite() || tiles > MAX_TILES as f32 { + return vec![image(rect)]; + } + let (cols, rows) = (cols as usize, rows as usize); (0..rows) .flat_map(|r| (0..cols).map(move |c| (r, c))) - .take(MAX_TILES) .map(|(r, c)| image(Rect::new(rect.x + c as f32 * w, rect.y + r as f32 * h, w, h))) .collect() } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 69dc730dda..ac43f1ade3 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -12903,6 +12903,21 @@ fn a_cells_background_picture_is_drawn_clipped_to_the_cell() { let (cell, images) = &c[0]; let want = (cell.w / 30.0).ceil() as usize * (cell.h / 6.0).ceil() as usize; assert_eq!(images.len(), want, "{cell:?}"); + // More tiles than are drawn (a small pattern in a big cell): stretched over the whole cell, + // not tiled over its top rows only. + let mut t = wordcraft_doc::Table::new(1, 1, 400.0); + t.rows[0].cells[0].props.picture = + Some(Box::new(CellPicture { media: media.clone(), size: [2.0, 1.0], repeat: PictureRepeat::Tile, align: [0.0, 0.0] })); + d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Table(t)), wordcraft_doc::para_block(wordcraft_doc::Paragraph::new())]; + let c = clips(&lay(&d)); + let (cell, images) = &c[0]; + assert!((cell.w / 2.0).ceil() * (cell.h / 1.0).ceil() > 400.0, "{cell:?}"); + assert_eq!(images.len(), 1, "{cell:?}"); + let i = images[0]; + assert!( + (i.x - cell.x).abs() < 0.01 && (i.y - cell.y).abs() < 0.01 && (i.w - cell.w).abs() < 0.01 && (i.h - cell.h).abs() < 0.01, + "{i:?} in {cell:?}" + ); } #[test] diff --git a/crates/pdf/src/exporter.rs b/crates/pdf/src/exporter.rs index f409c07813..26a0c87acb 100644 --- a/crates/pdf/src/exporter.rs +++ b/crates/pdf/src/exporter.rs @@ -3,7 +3,7 @@ use super::glyphs::{baseline, cluster_ends, glyph_id, glyph_outlines, left_to_ri use super::options::{PdfError, PdfOptions}; use super::paint::{append_path, fill, grown, norm, ok, paint_fill, pdf_line_cap, rect_path, shape_effects, shape_path}; use super::picture::{Vector, build_vector, load_image, paint_of, vector_picture}; -use super::tags::{Role, TagEntry, figure_alt, graphic_alt, holds_picture}; +use super::tags::{Role, TagEntry, figure_alt, graphic_alt, holds_picture, holds_text}; use super::{ Annotation, Arc, Artifact, ArtifactType, ClKind, ContentTag, DisplayOptions, DocLayout, DocPath, Document, Draw, FaceRef, Fill, FillRule, Font, HashMap, HashSet, Identifier, Image, InlineObject, ItemDisplay, KrillaGlyph, LineCap, LineEnd, LineJoin, LineStyle, LinkAnnotation, Node, @@ -223,8 +223,11 @@ impl Exporter<'_> { // A rotated inline picture, or a rotated group holding pictures: turned as a // whole, each picture tagged on its own (see `rotated_pictures`). Draw::Rotated { items, .. } if items.iter().any(holds_picture) => self.rotated_pictures(s, d), - // A turned text box's line: still the paragraph's text. - Draw::Rotated { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)), + // A turned text box's line: still the paragraph's text. A rotated shape + // without text falls to the artifacts below. + Draw::Rotated { items, .. } if items.iter().any(holds_text) => { + self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)) + } // An inline chart, diagram or described shape: its text is part of the figure, not the paragraph. Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(figure_alt(alt, *kind)), |me, s| { for d in inner { diff --git a/crates/pdf/src/tags.rs b/crates/pdf/src/tags.rs index f8186be42a..c8a7254fe4 100644 --- a/crates/pdf/src/tags.rs +++ b/crates/pdf/src/tags.rs @@ -14,6 +14,15 @@ pub(crate) fn holds_picture(d: &Draw) -> bool { } } +/// Text, or a rotation (or clip) holding it at any depth: a turned text box's lines. +pub(crate) fn holds_text(d: &Draw) -> bool { + match d { + Draw::Glyphs { .. } | Draw::Turned { .. } => true, + Draw::Rotated { items, .. } | Draw::Clip { items, .. } => items.iter().any(holds_text), + _ => false, + } +} + /// The alt text of an inline figure: a graphic's (see [`graphic_alt`]) or a described shape's. pub(crate) fn figure_alt(alt: &str, kind: Option) -> &str { match kind { @@ -38,3 +47,19 @@ pub(crate) enum Role { /// A floating picture (its own Figure). Figure, } + +#[cfg(test)] +mod holds_tests { + use super::{Draw, holds_text}; + + /// A rotated shape with no text isn't the paragraph's text; a turned text box's lines are. + #[test] + fn only_a_rotation_holding_text_holds_text() { + let turned = |items| Draw::Rotated { cx: 0.0, cy: 0.0, spin: Default::default(), items }; + let text = Draw::Turned { x: 0.0, y: 0.0, turn: Default::default(), items: Vec::new() }; + assert!(!holds_text(&turned(Vec::new()))); + assert!(!holds_text(&turned(vec![turned(Vec::new())]))); + assert!(holds_text(&turned(vec![text.clone()]))); + assert!(holds_text(&turned(vec![Draw::Clip { rect: Default::default(), items: vec![turned(vec![text])] }]))); + } +} diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 5983411c38..5b28569cef 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -141,7 +141,7 @@ impl WordApp { && let Some(pw) = params.get("password").filter(|v| !v.is_null()) { if !wordcraft_engine::io::can_encrypt(&name) || pw.as_str().is_none_or(str::is_empty) { - return Err("only Word documents (.docx, .docm, .dotx, .dotm) and ODT documents (.odt) can be saved with a password, and it can't be empty".into()); + return Err("only Word documents (.docx, .docm, .dotx, .dotm) and OpenDocument text files (.odt, .ott) can be saved with a password, and it can't be empty".into()); } self.session.run("file.encrypt", &json!({"password": pw})).map_err(|e| e.to_string())?; } diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index 07cdefb630..06f66af8a7 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -671,20 +671,21 @@ 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): WordCraft's choices, LibreOffice's, or the user's -/// own, one setting per case. +/// 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. - let presets = [(OdfPreset::WordCraft, "WordCraft"), (OdfPreset::LibreOffice, "LibreOffice"), (OdfPreset::Custom, "Custom")]; - let shown = presets.iter().find(|(p, _)| *p == profile.preset).map_or("WordCraft", |(_, l)| l); + // 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(160.0).show_ui(ui, |ui| { - for (preset, name) in presets { - if ui.selectable_label(profile.preset == preset, tl!(name)).clicked() && profile.preset != preset { + 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); } } diff --git a/crates/ui-egui/src/options/tests.rs b/crates/ui-egui/src/options/tests.rs index 85ba583d51..a01ea93e4d 100644 --- a/crates/ui-egui/src/options/tests.rs +++ b/crates/ui-egui/src/options/tests.rs @@ -509,9 +509,10 @@ fn advanced_opendocument_presets_and_custom_cases() { 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() }; - assert_eq!(preset(&h).as_deref(), Some("WordCraft")); - pick(&mut h, "LibreOffice"); - assert_eq!(preset(&h).as_deref(), Some("LibreOffice")); + // 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; @@ -520,6 +521,7 @@ fn advanced_opendocument_presets_and_custom_cases() { 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, "WordCraft"); - assert_eq!(h.state().session.prefs.odf.effective(), OdfProfile::wordcraft()); + 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/odf-interpretation.md b/docs/odf-interpretation.md index da75e4782d..2a0ead068b 100644 --- a/docs/odf-interpretation.md +++ b/docs/odf-interpretation.md @@ -17,8 +17,10 @@ knows of, so none gets lost. 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`): - - **WordCraft**, the default: what WordCraft does for its own and Word documents. - - **LibreOffice**: what LibreOffice (and Collabora, which shares its engine) does. Measured + 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 @@ -39,13 +41,13 @@ Where to set it: | Interface | How | |---|---| | App | File › Options › Advanced › OpenDocument files | -| Command | `file.options` `{"odf": {"preset": "libreoffice"}}`, or one case's value: `{"odf": {"columnGap": 18}}` (this makes the profile custom) | +| 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=wordcraft\|libreoffice` | +| CLI | `WORDCRAFT_ODF=word` (the default) or `WORDCRAFT_ODF=libreoffice` | ## Cases the standard leaves open (the profile) -| Case | Standard | Setting (`file.options` `odf.*`) | WordCraft | LibreOffice | +| 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 | @@ -167,7 +169,7 @@ from public bug reports and ONLYOFFICE's converter test snapshots): Nothing in these files depends on the profile beyond what the LibreOffice preset covers. - **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 WordCraft's default. + (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. @@ -176,6 +178,7 @@ one more `OdfPreset` with its values, and the cases above stay the same. | Date | Change | |---|---| +| 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. | | 2026-11-03 | More written as read (tables, pages, sections, properties, declarations); files saved as ODF 1.3 with older markup brought to 1.3's meaning; drawings state `style:flow-with-text`. | | 2026-11-03 | Unedited content written as read (see How WordCraft writes ODF). |