diff --git a/ROADMAP.md b/ROADMAP.md index 80c099e58..090a57665 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-01 · **Change:** 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-01 · **Change:** 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-10-29 · **Change:** trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -140,6 +140,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-11-01: DOCX tables keep rows and cells wrapped in `mc:AlternateContent`, selecting one supported Choice or Fallback with the existing reader policy (#5219). Original synthetic checks cover namespace aliases, unsupported requirements, nested row/cell content controls and two save/reopen cycles; broader corpus and native Word qualification remain open. + - 2026-11-01: Shapes: a line's arrowheads are as long and as wide as Word draws them: 2, 3 or 5 times the line's width for small, medium and large (a line under 2 points as a 2-point one). Their kind, width and length are read from DOCX (DrawingML and VML) and saved back; before, every arrowhead was one size and saved as a medium triangle. - 2026-11-01: Columns with Line between, made for selected text (a section in the middle of a page), draw their line; the line runs as tall as the section's text on the page, not the whole page (#5178). Readiness unchanged. - 2026-10-31: `.doc` import: text that names no font takes the stylesheet's standard font, so a Word 365 `.doc` in Aptos opens in Aptos (or its substitute) instead of Times New Roman, the font table's first entry (#5066). Readiness unchanged. @@ -354,6 +356,8 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-17 | Non-overlapping body floating tables clear header tables. Original native controls distinguish never-overlap from allowed overlap. | | 2026-10-14 | Comment text being typed is no longer lost or misplaced (#1348): an edit or reply the document refuses (for example read-only protection) keeps the typed text in its field and says why on the status line; drafts belong to their document, so after New or Open a comment with the same number shows its own text; Save, Export, Print, Close, New and Open write pending comment text first; if the document refuses it, Save, Export and Print carry on with the stored comments and show why on the status line (a refused draft is dropped when Save is the only change), while Close, New and Open show the Save Changes prompt (Don't Save drops text the document refuses); Escape discards what was typed; in a document protected as read only or for forms the comment fields can't be typed in | |---|---| +| 2026-10-26 | Spelling & Grammar opens a right-side Proofing task pane observed in Word for Windows: sentence context, directly clickable suggestion cards and ignore/dictionary actions. Current-story traversal and stale-target checks are retained. Right-click keeps its compact menu. | +| 2026-10-22 | Documents that keep content in another format (`w:altChunk`: a web page, RTF, text or another Word document, as generators and mail-merge tools write them) show it where it stands, as Word does, instead of leaving it out; one such file in the docxide-pdf corpus opened as an empty page and now matches Word's 8 pages. `.mht` web archives open, and web pages open the way Word opens them (style sheets, paragraph margins, Times New Roman, single lines, automatic spacing). Insert › Text from File keeps the inserted file's pictures | | 2026-10-18 | Explicit New/Open keeps the requested document visible when startup recovery discovery finishes; older copies stay available. Recover uses the regular theme. See docs/recovery.md. | | 2026-10-15 | A floating picture or shape in a table cell set to align centre or bottom no longer lands half its height above its line (#1887); it starts at the anchor line | | 2026-10-18 | Picture Format › Size (#2573): width and height in the interface unit, and a size typed in any unit works, pixels too (`300 px` keeps the picture's proportions, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels) in one undo step. The group's launcher opens a Size dialog: height and width, scale of the original size (or of the size it opened with), Lock aspect ratio, the original size with its pixels, and Reset. `picture.size {"original": true, "scale"?}` sizes from the original; sizes past 4000 pt that keep the proportions shrink as a whole. | @@ -684,12 +688,17 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps ## Revision history +> 2026-10-30 · Technical synchronization: retain the Proofing pane and command/selection/undo contracts on main `3b7f7ed`; new-SHA validation pending, readiness unchanged. +> > 2026-10-28 · Markdown import bounds reference-label scans and normalization within the existing link-work allowance. Nested undefined labels preserve literal text instead of causing quadratic work (#4658). > +> 2026-10-27 · Technical synchronization: existing Proofing pane hooks and regression tests follow the extracted engine/UI modules on main `436d444`; compact context menus remain upstream. New-SHA validation is pending; readiness unchanged. +> > 2026-10-23 · cu20we review follow-up: Only paragraph-relative box floats use the pre-spacing origin. Line-relative floats and page/margin fallbacks in cells and text boxes keep the text-top origin. Synthetic controls compare zero and 24 pt space-before across all four box contexts. New cap conversion helpers and regression tests document their contracts; readiness estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open | | 2026-11-01 | trivial | Progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes | | 2026-11-01 | trivial | Progress log: columns for selected text draw their line between (#5178) | | 2026-10-31 | trivial | Progress log: a Word 365 .doc in Aptos opens in Aptos (#5066); readiness unchanged | @@ -704,6 +713,8 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-31 | trivial | Progress log: Backstage keytips, Alt, F, P prints (#4960) | | 2026-10-30 | trivial | Progress log: only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them (#4734) | | 2026-10-29 | trivial | Progress log: documents with links print (#4734) | +| 2026-10-31 | trivial | Synchronize the existing Proofing pane with main `eecbb8f`; preserve explicit-position lookup, implicit selection, navigation, undo and per-window state; fresh exact-SHA hosted validation pending, readiness unchanged | +| 2026-10-30 | trivial | Synchronize the existing Proofing pane and navigation/selection/undo regressions with main `3b7f7ed`; new-SHA validation pending, readiness unchanged | | 2026-10-28 | trivial | Aligned copies retain their locked document coordinate during middle-button scrolling and can start on secondary selected objects; existing Ctrl and Shift selection behavior stays (#4764) | | 2026-10-28 | trivial | Ctrl/Command+Shift drags a selected floating object into an axis-aligned copy (#4764); synthetic Linux interaction and persistence controls, native desktop and Word untested; gap/readiness estimates unchanged | | 2026-10-28 | trivial | Connector object lookups retain the typed column-selection inputs on current main (#3631); readiness estimates unchanged | @@ -719,6 +730,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-29 | trivial | Progress log: window fallback geometry and equation symbol tooltips | | 2026-10-29 | trivial | Progress log: window titles omit NUL characters (#3634) | | 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | +| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; exact selected spelling takes priority over overlapping grammar, with command/pane regressions and integration workflow paths; readiness estimates unchanged | | 2026-10-22 | trivial | Progress log: one native window per open document | | 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy | | 2026-10-22 | trivial | Progress log: several open documents in the desktop window | diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index 7fa947f02..580e28e6c 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -9,7 +9,8 @@ use wordcraft_engine::Session; use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome}; use wordcraft_ui_egui::{Inbox, Services, WordApp}; -const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"]; +const DOC_EXTS: &[&str] = + &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"]; const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"]; const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS; mod font_store; diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index aff28f8d9..6da6ec45f 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -9,7 +9,8 @@ description = "WordCraft document model: stories, paragraphs, runs, tables, sect workspace = true [features] -# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`); turned on only as a +# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`, +# `zip_guard::zip64_entry_count_sample`); turned on only as a # dev-dependency, so never built into the app. test-support = [] diff --git a/crates/doc/src/connector.rs b/crates/doc/src/connector.rs index 67791967b..8f69ee35a 100644 --- a/crates/doc/src/connector.rs +++ b/crates/doc/src/connector.rs @@ -213,6 +213,39 @@ pub struct ShapeExtra { /// main colour (the shape's `stroke`). #[serde(skip_serializing_if = "Option::is_none")] pub outline_fill: Option, + /// A horizontal rule (VML `o:hr`: a web page's `
` pasted into Word): it takes its whole + /// line, and is drawn across [`HorizontalRule::pct`] of it or at the shape's own width. + #[serde(skip_serializing_if = "Option::is_none")] + pub hr: Option, +} + +/// How a horizontal rule sits on its line (VML `o:hrpct`, `o:hralign`), as Word draws one: +/// across `pct` percent of the line's width (the paragraph's, inside its indents), or at the +/// shape's own width when `pct` is 0; `align` places it when it is narrower than the line. +#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct HorizontalRule { + pub pct: f32, + pub align: crate::props::Align, + /// Word's standard look (`o:hrstd`), kept for saving. + pub standard: bool, +} + +impl HorizontalRule { + /// The rule's width on a line `line` wide, for a shape `own` wide. + pub fn width(&self, line: f32, own: f32) -> f32 { + let line = line.max(0.0); + if self.pct.is_finite() && self.pct > 0.0 { line * (self.pct.min(100.0) / 100.0) } else { own.clamp(0.0, line) } + } + + /// Where a rule `w` wide starts on a line from `x`, `line` wide. + pub fn left(&self, x: f32, line: f32, w: f32) -> f32 { + match self.align { + crate::props::Align::Center => x + (line - w) / 2.0, + crate::props::Align::Right => x + line - w, + _ => x, + } + } } fn is_zero(v: &u32) -> bool { diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 66d483e81..0d426937e 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -205,11 +205,15 @@ struct BoxBudget { costs: BTreeMap, /// Boxes left without a story because the paste's budget couldn't hold theirs. skipped: usize, + /// A box the fragment has no story for may show this document's story of that id (a + /// fragment copied from this document without its parts). Off for another document's + /// fragment, whose ids mean nothing here. + local: bool, } impl BoxBudget { fn new() -> BoxBudget { - BoxBudget { stories: MAX_PASTED_BOXES, bytes: MAX_PASTED_BOX_BYTES, active: Vec::new(), costs: BTreeMap::new(), skipped: 0 } + BoxBudget { stories: MAX_PASTED_BOXES, bytes: MAX_PASTED_BOX_BYTES, active: Vec::new(), costs: BTreeMap::new(), skipped: 0, local: true } } } @@ -385,6 +389,167 @@ fn restyle_block(b: &mut Block, from: &str, to: &Option, depth: usize) - } } +/// What [`Document::adopt_with`] renumbers in another document's content: media keys, lists, +/// note and comment stories, comment ids, text boxes (one it doesn't bring is dropped), revisions +/// (after ours, at `rev`; an id past the `revs` it brings is dropped) and styles (those kept under new ids; unstyled paragraphs take +/// `default_style` when it's given). +struct Relink<'a> { + media: &'a BTreeMap, + nums: &'a BTreeMap, + notes: &'a BTreeMap, + comments: &'a BTreeMap, + boxes: &'a BTreeMap, + /// Added to permission range ids: ours come first. + perms: u32, + rev: u32, + revs: u32, + styles: &'a BTreeMap, + default_style: Option<&'a str>, +} + +impl Relink<'_> { + fn block(&self, b: &mut Block) { + // Its own stack, like `each_para_mut`: tables however deeply nested are relinked (#1339). + let mut todo = vec![b]; + while let Some(b) = todo.pop() { + match b { + Block::Para(p) => self.para(p), + Block::Table(t) => { + self.style(&mut t.props.style); + self.shift_change(&mut t.props.fmt_change); + if let Some(c) = t.props.fmt_change.as_mut() { + self.style(&mut c.old.style); + } + for row in t.rows.iter_mut() { + self.shift_change(&mut row.props.fmt_change); + for cell in row.cells.iter_mut() { + self.shift_change(&mut cell.props.fmt_change); + todo.extend(cell.blocks.iter_mut().map(Arc::make_mut)); + } + } + } + } + } + } + + fn style(&self, id: &mut Option) { + if let Some(new) = id.as_ref().and_then(|i| self.styles.get(i)) { + *id = Some(new.clone()); + } + } + + /// A paragraph's style: unstyled ones take `default_style` when it's given. + fn para_style(&self, id: &mut Option) { + match (&*id, self.default_style) { + (None, Some(d)) => *id = Some(d.to_string()), + _ => self.style(id), + } + } + + fn num(&self, n: &mut Option) { + if let Some(n) = n.as_mut() + && n.num != 0 + { + n.num = self.nums.get(&n.num).copied().unwrap_or(0); + } + } + + /// Our id for the other document's revision `r`, if it has one. + fn rev_id(&self, r: u32) -> Option { + if r < self.revs { self.rev.checked_add(r) } else { None } + } + + fn shift_change(&self, c: &mut Option>>) { + match (c.as_ref().and_then(|c| self.rev_id(c.rev)), c.as_mut()) { + (Some(v), Some(x)) => x.rev = v, + _ => *c = None, + } + } + + fn para(&self, p: &mut Paragraph) { + self.num(&mut p.props.numbering); + self.para_style(&mut p.props.style); + for r in &mut p.runs { + self.style(&mut r.props.style); + } + self.style(&mut p.mark.style); + for c in p.runs.iter_mut().map(|r| &mut r.props).chain([&mut p.mark]) { + c.ins = c.ins.and_then(|r| self.rev_id(r)); + c.del = c.del.and_then(|r| self.rev_id(r)); + self.shift_change(&mut c.fmt_change); + // A tracked change's old formatting names styles and lists as the live one does. + if let Some(f) = c.fmt_change.as_mut() { + self.style(&mut f.old.style); + } + } + self.shift_change(&mut p.props.fmt_change); + if let Some(f) = p.props.fmt_change.as_mut() { + self.para_style(&mut f.old.style); + self.num(&mut f.old.numbering); + } + if let Some(s) = p.section.as_deref_mut() { + self.shift_change(&mut s.fmt_change); + } + match (p.props.num_change.as_ref().and_then(|c| self.rev_id(c.rev)), p.props.num_change.as_mut()) { + (Some(v), Some(c)) => c.rev = v, + _ => p.props.num_change = None, + } + for o in &mut p.objects { + self.object(o, 0); + } + p.touch(); + } + + fn object(&self, o: &mut InlineObject, depth: usize) { + // An id that names nothing here becomes one that names nothing at all. + let map = |m: &BTreeMap, id: &mut u32| *id = m.get(id).copied().unwrap_or(u32::MAX); + match o { + InlineObject::Image { media, .. } => { + if let Some(k) = self.media.get(media.as_str()) { + *media = k.clone(); + } + } + InlineObject::Graphic { graphic, .. } => { + let mapped = |m: &str| self.media.get(m).filter(|k| k.as_str() != m); + if graphic.items.iter().any(|i| matches!(i, crate::graphic::GraphicItem::Image { media, .. } if mapped(media).is_some())) { + for i in &mut Arc::make_mut(graphic).items { + if let crate::graphic::GraphicItem::Image { media, .. } = i + && let Some(k) = self.media.get(media.as_str()) + { + *media = k.clone(); + } + } + } + } + InlineObject::Group { children, .. } if depth < MAX_BOX_DEPTH => children.iter_mut().for_each(|c| self.object(&mut c.obj, depth + 1)), + InlineObject::NoteRef { id, .. } => map(self.notes, id), + InlineObject::CommentStart { id } | InlineObject::CommentEnd { id } => map(self.comments, id), + InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } => *id = id.saturating_add(self.perms), + _ => {} + } + for story in o.text_box_slots() { + if story.is_some_and(|id| !self.boxes.contains_key(&id)) { + *story = None; + } + } + } +} + +/// One past the highest permission range id in `blocks`. +fn perm_end<'a>(blocks: impl Iterator>) -> u32 { + let mut end = 0u32; + for b in blocks { + each_para(b, 0, &mut |p| { + for o in &p.objects { + if let InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } = o { + end = end.max(id.saturating_add(1)); + } + } + }); + } + end +} + /// Text box story ids of a paragraph's shapes. fn box_ids(p: &Paragraph) -> impl Iterator + '_ { p.objects.iter().flat_map(InlineObject::text_boxes) @@ -862,6 +1027,186 @@ impl Document { Fragment { blocks, parts } } + /// Another document's body as a fragment of this one, to insert: the media, lists, styles, + /// notes, comments and tracked changes it refers to come along (lists, notes, comments and + /// revisions renumbered so they can't collide with ours; a style we already have by id stays + /// ours), and its text boxes ride in [`Fragment::parts`]. + pub fn adopt(&mut self, src: Document) -> Fragment { + self.adopt_with(src, false) + } + + /// [`Self::adopt`]; with `keep_styles` its content keeps its own formatting (Word's + /// `w:matchSrc`): a style whose id we already have comes along under a new id, and its + /// document defaults go into its base styles, which its unstyled paragraphs then use. + pub fn adopt_with(&mut self, src: Document, keep_styles: bool) -> Fragment { + self.adopt_in(src, keep_styles, &mut 0) + } + + /// [`Self::adopt_with`], its permission range ids from at least `perms` on (and past ours); + /// `perms` moves past them. + fn adopt_in(&mut self, mut src: Document, keep_styles: bool, perms: &mut u32) -> Fragment { + let mut renamed = BTreeMap::new(); + let mut default_style = None; + if keep_styles { + if src.styles.get("Normal").is_none() { + src.styles.upsert(crate::styles::Style { id: "Normal".into(), name: "Normal".into(), ..Default::default() }); + } + // Ids given out here are taken too: two styles never get the same new one. + let mut given = std::collections::HashSet::new(); + let room = src.styles.styles.len().saturating_mul(2).saturating_add(self.styles.styles.len()).saturating_add(1); + for st in &src.styles.styles { + if self.styles.get(&st.id).is_some() { + let fresh = (1..=room) + .map(|n| format!("{}{n}", st.id)) + .find(|id| self.styles.get(id).is_none() && src.styles.get(id).is_none() && !given.contains(id)); + if let Some(fresh) = fresh { + given.insert(fresh.clone()); + renamed.insert(st.id.clone(), fresh); + } + } + } + default_style = Some(renamed.get("Normal").cloned().unwrap_or_else(|| "Normal".into())); + } + let restyle = |id: &mut Option| { + if let Some(new) = id.as_ref().and_then(|i| renamed.get(i)) { + *id = Some(new.clone()); + } + }; + let mut media = BTreeMap::new(); + for (key, data) in &src.media { + let ext = key.rsplit_once('.').map(|(_, e)| e).unwrap_or("png"); + media.insert(key.clone(), self.add_media(data.to_vec(), ext)); + } + let next_abs = self.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0); + let mut abs_map = BTreeMap::new(); + for (i, a) in src.numbering.abstracts.iter().enumerate() { + let id = next_abs.saturating_add(i as u32); + abs_map.insert(a.id, id); + let mut a = crate::numbering::AbstractNum { id, ..a.clone() }; + a.levels.iter_mut().for_each(|l| restyle(&mut l.style)); + self.numbering.abstracts.push(a); + } + let next_num = self.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1); + let mut nums = BTreeMap::new(); + for (i, n) in src.numbering.nums.iter().enumerate() { + let id = next_num.saturating_add(i as u32); + nums.insert(n.id, id); + let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id); + let mut n = crate::numbering::Num { id, abstract_id, ..n.clone() }; + n.level_overrides.iter_mut().for_each(|(_, l)| restyle(&mut l.style)); + self.numbering.nums.push(n); + } + let renumber = |n: &mut Option| { + if let Some(n) = n.as_mut() + && n.num != 0 + { + n.num = nums.get(&n.num).copied().unwrap_or(0); + } + }; + // What a style in the other document doesn't set comes from its defaults there, and + // Word's own (no space, single lines) where those don't say. + let mut para_defaults = src.styles.default_para.clone(); + para_defaults.space_before.get_or_insert(0.0); + para_defaults.space_after.get_or_insert(0.0); + para_defaults.line_spacing.get_or_insert(crate::props::LineSpacing::Multiple(1.0)); + for st in &src.styles.styles { + let mut st = st.clone(); + // Our defaults stay ours. + st.default = false; + if keep_styles { + if let Some(new) = renamed.get(&st.id) { + st.name = format!("{} ({new})", st.name); + st.id = new.clone(); + } + restyle(&mut st.based_on); + restyle(&mut st.next); + restyle(&mut st.linked); + // A character style's runs get the defaults through their paragraph's style. + if st.based_on.is_none() && st.kind != crate::styles::StyleKind::Character { + let mut chr = src.styles.default_chr.clone(); + chr.overlay(&st.chr); + st.chr = chr; + let mut para = para_defaults.clone(); + para.overlay(&st.para); + st.para = para; + } + } + if self.styles.get(&st.id).is_none() { + renumber(&mut st.para.numbering); + self.styles.upsert(st); + } + } + let rev = u32::try_from(self.revisions.len()).unwrap_or(u32::MAX); + self.revisions.extend(src.revisions.iter().cloned()); + // Notes and comments get stories (and comments ids) of their own here; their text is + // relinked below, once every new id is known. + let mut notes = BTreeMap::new(); + for (id, part) in src.parts.iter().filter(|(_, p)| matches!(p.kind, PartKind::Footnote | PartKind::Endnote)) { + notes.insert(*id, self.add_part(part.kind, Vec::new())); + } + let mut comments = BTreeMap::new(); + let mut next_comment = self.comments.keys().next_back().map_or(0, |k| k.saturating_add(1)); + for (id, c) in &src.comments { + if src.parts.get(&c.part).is_some_and(|p| p.kind == PartKind::Comment) { + notes.insert(c.part, self.add_part(PartKind::Comment, Vec::new())); + comments.insert(*id, next_comment); + next_comment = next_comment.saturating_add(1); + } + } + let boxes: BTreeMap = src.parts.iter().filter(|(_, p)| p.kind == PartKind::TextBox).map(|(k, p)| (*k, p.clone())).collect(); + // Its permission ranges are numbered after ours, so no end of theirs closes one of ours. + let first_perm = perm_end(self.body.iter().chain(self.parts.values().flat_map(|p| p.blocks.iter()))).max(*perms); + *perms = first_perm.saturating_add(perm_end(src.body.iter().chain(src.parts.values().flat_map(|p| p.blocks.iter())))); + let link = Relink { + media: &media, + nums: &nums, + notes: ¬es, + comments: &comments, + boxes: &boxes, + perms: first_perm, + rev, + revs: u32::try_from(src.revisions.len()).unwrap_or(u32::MAX), + styles: &renamed, + default_style: default_style.as_deref(), + }; + let mut parts = boxes.clone(); + for part in parts.values_mut() { + part.blocks.iter_mut().for_each(|b| link.block(Arc::make_mut(b))); + } + // A note's or comment's text boxes get stories here now; the body's go with the fragment. + let mut budget = BoxBudget::new(); + for (old, new) in ¬es { + let Some(part) = src.parts.get(old) else { continue }; + let mut blocks: Vec = part.blocks.iter().map(|b| (**b).clone()).collect(); + blocks.iter_mut().for_each(|b| link.block(b)); + self.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget); + if let Some(p) = self.parts.get_mut(new) { + p.blocks = blocks.into_iter().map(Arc::new).collect(); + } + } + for (id, c) in &src.comments { + if let (Some(new), Some(part)) = (comments.get(id), notes.get(&c.part)) { + let parent = c.parent.and_then(|p| comments.get(&p).copied()); + // Its durable id is unique in its own document only: the writer gives the copy one. + self.comments.insert(*new, crate::Comment { parent, part: *part, durable_id: None, ..c.clone() }); + } + } + let mut blocks: Vec = src.body.iter().map(|b| (**b).clone()).collect(); + blocks.iter_mut().for_each(|b| link.block(b)); + Fragment { blocks, parts } + } + + /// [`Self::adopt_with`] as blocks to place in a story directly (a reader building one): its + /// text boxes already have stories of their own here. `perms` is the first permission range + /// id the reader hasn't given out (ranges it holds aren't in this document yet); it moves past + /// the adopted ones. + pub fn adopt_blocks(&mut self, src: Document, keep_styles: bool, perms: &mut u32) -> Vec { + let Fragment { mut blocks, parts } = self.adopt_in(src, keep_styles, perms); + let mut budget = BoxBudget::new(); + self.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget); + blocks + } + /// Copies of the text box stories shapes in `blocks` show, nested boxes included. fn text_box_parts(&self, blocks: &[Block]) -> BTreeMap { let mut out = BTreeMap::new(); @@ -971,7 +1316,7 @@ impl Document { budget.skipped = budget.skipped.saturating_add(1); return None; } - let src = parts.get(&old).or_else(|| self.parts.get(&old).filter(|p| p.kind == PartKind::TextBox))?; + let src = parts.get(&old).or_else(|| self.parts.get(&old).filter(|p| budget.local && p.kind == PartKind::TextBox))?; // Charge what the copy really allocates before making any of it: the block slots, and the // blocks holding text boxes (deep-copied below). The rest are shared `Arc`s, however many // boxes show them, so they cost nothing more. Worked out once per paste. @@ -1012,6 +1357,22 @@ impl Document { Some(id) } + /// The blocks of `frag`, a fragment of another document, with every text box given a new + /// story of its own here copied from the fragment's parts (as a paste does), to place them + /// in this document; a box the fragment has no story for keeps none. + pub fn adopt_fragment(&mut self, frag: &Fragment) -> Vec { + let mut blocks = frag.blocks.clone(); + let mut budget = BoxBudget { local: false, ..BoxBudget::new() }; + self.adopt_text_boxes(&mut blocks, &frag.parts, 0, &mut budget); + if budget.skipped > 0 { + log::warn!( + "{} text boxes are left empty; their stories are past what one copy brings ({MAX_PASTED_BOXES} stories, {MAX_PASTED_BOX_BYTES} bytes)", + budget.skipped + ); + } + blocks + } + /// Insert a fragment at `pos`; returns the position after the pasted content. pub fn insert_fragment(&mut self, pos: &Pos, frag: &Fragment) -> Result { // Tables that would nest deeper than the limit here become their paragraphs (#1596). diff --git a/crates/doc/src/edit/tests.rs b/crates/doc/src/edit/tests.rs index 68908775b..eaebae22a 100644 --- a/crates/doc/src/edit/tests.rs +++ b/crates/doc/src/edit/tests.rs @@ -664,3 +664,230 @@ fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() { d.delete_range(&Pos { story: StoryRef::Body, path: first, off: 0 }, &Pos::body(2, 0)).unwrap(); assert!(d.body.get(1).is_some_and(|b| b.as_table().is_some()), "a table with paragraphs the walk never saw was removed"); } +#[test] +fn adopting_a_document_renumbers_what_it_refers_to() { + use crate::para::NoteKind; + use crate::{Comment, Revision, RevisionKind}; + // Ours already has a footnote, a comment, a list and a revision with the ids the other's use. + let mut ours = doc(&["ours"]); + let our_note = ours.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("our note", CharProps::default()))]); + ours.comments.insert(0, Comment { part: our_note, ..Comment::default() }); + let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet); + ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() }); + + let mut theirs = doc(&["theirs"]); + let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("their note", CharProps::default()))]); + let said = theirs.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("their comment", CharProps::default()))]); + theirs.comments.insert(0, Comment { author: "Them".into(), part: said, durable_id: Some("0A0B0C0D".into()), ..Comment::default() }); + let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet); + assert_eq!(list, our_list); + theirs.revisions.push(Revision { kind: RevisionKind::Delete, ..Revision::default() }); + let mut style = crate::styles::Style { id: "TheirList".into(), name: "Their List".into(), ..Default::default() }; + style.para.numbering = Some(crate::props::NumRef { num: list, level: 0 }); + theirs.styles.upsert(style); + let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.props.numbering = Some(crate::props::NumRef { num: list, level: 0 }); + p.runs[0].props.del = Some(0); + // An insertion naming a revision it doesn't have. + p.runs[0].props.ins = Some(7); + let props = p.props_of_char(0).clone(); + p.insert_object(0, InlineObject::NoteRef { kind: NoteKind::Footnote, id: note, custom: String::new() }, &props).unwrap(); + p.insert_object(0, InlineObject::CommentStart { id: 0 }, &props).unwrap(); + + let frag = ours.adopt(theirs); + let Some(Block::Para(p)) = frag.blocks.first() else { panic!() }; + let new_list = p.props.numbering.unwrap().num; + assert_ne!(new_list, our_list); + assert!(ours.numbering.num(new_list).is_some()); + assert_eq!(ours.styles.get("TheirList").and_then(|s| s.para.numbering).map(|n| n.num), Some(new_list)); + // Their deletion is their revision, after ours. + let del = p.runs.iter().find_map(|r| r.props.del).unwrap(); + assert_eq!(ours.revisions.get(del as usize).map(|r| r.kind), Some(RevisionKind::Delete)); + assert!(p.runs.iter().all(|r| r.props.ins.is_none())); + // Their note and comment come along under ids of their own. + let (note_id, comment_id) = p.objects.iter().fold((None, None), |(n, c), o| match o { + InlineObject::NoteRef { id, .. } => (Some(*id), c), + InlineObject::CommentStart { id } => (n, Some(*id)), + _ => (n, c), + }); + let text = |id: u32| { + ours.parts + .get(&id) + .map(|p| p.blocks.iter().map(|b| if let Block::Para(p) = &**b { p.text.clone() } else { String::new() }).collect::()) + }; + assert_eq!(text(note_id.unwrap()).as_deref(), Some("their note")); + let comment = ours.comments.get(&comment_id.unwrap()).unwrap(); + assert_eq!((comment.author.as_str(), text(comment.part).as_deref()), ("Them", Some("their comment"))); + // Its durable id is the other document's: the writer gives the copy one of its own. + assert_eq!(comment.durable_id, None); + assert_eq!(text(our_note).as_deref(), Some("our note")); +} + +#[test] +fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() { + use crate::styles::{Style, StyleKind, StyleSheet}; + let mut ours = doc(&["ours"]); + let mut theirs = doc(&["theirs"]); + theirs.styles = StyleSheet::empty(); + theirs.styles.default_chr = CharProps { size: Some(9.0), ..Default::default() }; + let big = CharProps { size: Some(20.0), ..Default::default() }; + theirs.styles.upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() }); + theirs.styles.upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() }); + let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.props.style = Some("Big".into()); + p.runs[0].props.style = Some("Link".into()); + + let frag = ours.adopt_with(theirs, true); + let Some(Block::Para(p)) = frag.blocks.first() else { panic!() }; + assert_eq!(ours.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).size, 20.0); +} + +#[test] +fn kept_styles_renamed_on_adoption_are_renamed_in_list_levels_too() { + use crate::styles::Style; + let mut ours = doc(&["ours"]); + ours.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() }); + let mut theirs = doc(&["theirs"]); + theirs.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() }); + let list = theirs.numbering.add_list(crate::numbering::ListKind::Numbered); + let abs = theirs.numbering.nums.iter().find(|n| n.id == list).map(|n| n.abstract_id).unwrap(); + let a = theirs.numbering.abstracts.iter_mut().find(|a| a.id == abs).unwrap(); + a.levels[0].style = Some("Heading1".into()); + let level = a.levels[0].clone(); + theirs.numbering.nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level)); + + let before = ours.numbering.abstracts.len(); + ours.adopt_with(theirs, true); + let copied = &ours.numbering.abstracts[before..]; + let styles: Vec<_> = copied + .iter() + .flat_map(|a| a.levels.iter()) + .chain(ours.numbering.nums.iter().flat_map(|n| n.level_overrides.iter().map(|(_, l)| l))) + .filter_map(|l| l.style.clone()) + .collect(); + assert_eq!(styles.len(), 2, "{styles:?}"); + for st in &styles { + assert_ne!(st, "Heading1"); + assert!(ours.styles.get(st).is_some(), "{st}"); + } +} + +#[test] +fn an_adopted_section_breaks_tracked_change_names_its_revision() { + use crate::{Revision, RevisionKind, SectionProps}; + let mut ours = doc(&["ours"]); + ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() }); + let mut theirs = doc(&["theirs"]); + theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() }); + let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + p.section = Some(Box::new(SectionProps { fmt_change: crate::props::PropChange::boxed(0, SectionProps::default()), ..SectionProps::default() })); + + let frag = ours.adopt(theirs); + let Some(Block::Para(p)) = frag.blocks.first() else { panic!() }; + let rev = p.section.as_ref().and_then(|s| s.fmt_change.as_ref()).map(|c| c.rev); + assert_eq!(rev.and_then(|r| ours.revisions.get(r as usize)).map(|r| r.kind), Some(RevisionKind::Format)); +} + +#[test] +fn an_adopted_tracked_format_change_keeps_its_old_styles_and_list() { + use crate::props::{NumRef, ParaProps, PropChange}; + use crate::{Revision, RevisionKind}; + for keep in [false, true] { + let mut ours = doc(&["ours"]); + let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet); + let mut theirs = doc(&["theirs"]); + theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() }); + let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet); + assert_eq!(list, our_list); + let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap(); + // Live and old props name the same styles (ours has them too) and their list. + p.props.style = Some("Quote".into()); + p.props.numbering = Some(NumRef { num: list, level: 0 }); + let old = ParaProps { style: Some("Quote".into()), numbering: Some(NumRef { num: list, level: 0 }), ..ParaProps::default() }; + p.props.fmt_change = PropChange::boxed(0, old); + p.runs[0].props.style = Some("Strong".into()); + p.runs[0].props.fmt_change = PropChange::boxed(0, CharProps { style: Some("Strong".into()), ..CharProps::default() }); + + let frag = ours.adopt_with(theirs, keep); + let Some(Block::Para(p)) = frag.blocks.first() else { panic!() }; + let old = &p.props.fmt_change.as_ref().unwrap().old; + assert_eq!((&old.style, old.numbering), (&p.props.style, p.props.numbering), "keep_styles {keep}"); + assert_ne!(old.numbering.map(|n| n.num), Some(our_list)); + let run = &p.runs[0].props; + assert_eq!(run.fmt_change.as_ref().unwrap().old.style, run.style, "keep_styles {keep}"); + } +} + +#[test] +fn adopted_styles_never_become_our_defaults() { + use crate::styles::{Style, StyleKind}; + for keep in [false, true] { + let mut ours = doc(&["ours"]); + let mut theirs = doc(&["theirs"]); + // A localized default character style, and one whose id we have too. + theirs.styles.upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() }); + theirs.styles.upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() }); + ours.adopt_with(theirs, keep); + assert!(ours.styles.styles.iter().all(|s| !s.default), "keep_styles {keep}"); + } +} + +#[test] +fn adopted_permission_ranges_keep_ids_of_their_own() { + let perm = |id, editor: &str| InlineObject::PermStart { id, group: String::new(), editor: editor.into() }; + let mut ours = doc(&["ours"]); + ours.insert_object(&Pos::body(0, 4), InlineObject::PermEnd { id: 0 }, &CharProps::default()).unwrap(); + ours.insert_object(&Pos::body(0, 0), perm(0, "us"), &CharProps::default()).unwrap(); + // Theirs: a closed range and one left open, numbered from 0 like ours. + let mut theirs = doc(&["theirs"]); + theirs.insert_object(&Pos::body(0, 6), perm(1, "them"), &CharProps::default()).unwrap(); + theirs.insert_object(&Pos::body(0, 6), InlineObject::PermEnd { id: 0 }, &CharProps::default()).unwrap(); + theirs.insert_object(&Pos::body(0, 0), perm(0, "them"), &CharProps::default()).unwrap(); + + let frag = ours.adopt(theirs); + let Some(Block::Para(p)) = frag.blocks.first() else { panic!() }; + let ids: Vec = p + .objects + .iter() + .filter_map(|o| match o { + InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } => Some(*id), + _ => None, + }) + .collect(); + assert_eq!(ids.len(), 3); + assert!(!ids.contains(&0), "{ids:?}"); + assert_eq!(ids[0], ids[1], "a range's start and end still pair: {ids:?}"); + assert_ne!(ids[1], ids[2]); +} + +#[test] +fn an_adopted_notes_text_box_gets_a_story_of_its_own() { + use crate::para::NoteKind; + // Ours has a text box under the id their note's text box has. + let mut ours = doc(&["ours"]); + ours.add_part(PartKind::Footnote, Vec::new()); + let our_box = text_box(&mut ours, &Pos::body(0, 0), "our box"); + let mut theirs = doc(&["theirs"]); + let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("note", CharProps::default()))]); + let their_box = theirs.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("their box", CharProps::default()))]); + assert_eq!(their_box, our_box); + theirs + .insert_object(&Pos { story: StoryRef::Part(note), path: Path::top(0), off: 0 }, box_shape(Some(their_box)), &CharProps::default()) + .unwrap(); + theirs + .insert_object(&Pos::body(0, 0), InlineObject::NoteRef { kind: NoteKind::Footnote, id: note, custom: String::new() }, &CharProps::default()) + .unwrap(); + + let blocks = ours.adopt(theirs).blocks; + let Some(Block::Para(p)) = blocks.first() else { panic!() }; + let new_note = p.objects.iter().find_map(|o| if let InlineObject::NoteRef { id, .. } = o { Some(*id) } else { None }).unwrap(); + let story = ours.parts[&new_note] + .blocks + .iter() + .find_map(|b| if let Block::Para(p) = &**b { p.objects.iter().find_map(|o| o.text_boxes().first().copied()) } else { None }) + .unwrap(); + assert_ne!(story, our_box); + let part = &ours.parts[&story]; + assert_eq!(part.kind, PartKind::TextBox); + assert!(matches!(part.blocks.first().map(|b| &**b), Some(Block::Para(p)) if p.text == "their box")); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 9d0100eb0..bcdd02948 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -48,6 +48,7 @@ pub mod symbol_fonts; pub mod table; pub mod wordart; pub mod wrap; +pub mod zip_guard; use std::collections::BTreeMap; use std::sync::Arc; diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 4ae157deb..12189ac5a 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -448,6 +448,25 @@ fn box_para(story: u32, id: u32) -> Blocks { vec![para_block(p)] } +#[test] +fn adopting_relinks_lists_in_deeply_nested_tables() { + // As #1339: a list paragraph 20 tables down takes the list it is brought in with. + let mut ours = Document::from_text("ours"); + let our_list = ours.numbering.add_list(numbering::ListKind::Bullet); + let mut theirs = Document::from_text("theirs"); + let list = theirs.numbering.add_list(numbering::ListKind::Bullet); + assert_eq!(list, our_list); + let mut p = Paragraph::with_text("item", CharProps::default()); + p.props.numbering = Some(props::NumRef { num: list, level: 0 }); + theirs.body = nested_tables(20, vec![para_block(p)]); + let mut nums = Vec::new(); + for b in &ours.adopt(theirs).blocks { + edit::each_para(b, 0, &mut |p| nums.extend(p.props.numbering.map(|n| n.num))); + } + assert_eq!(nums.len(), 1); + assert_ne!(nums[0], our_list); +} + #[test] fn text_boxes_shown_from_deeply_nested_tables_are_kept() { // #1339: the paragraph walk stopped 16 tables down, so a box shown only from deeper was taken diff --git a/crates/doc/src/zip_guard.rs b/crates/doc/src/zip_guard.rs new file mode 100644 index 000000000..0610cac1e --- /dev/null +++ b/crates/doc/src/zip_guard.rs @@ -0,0 +1,191 @@ +//! A bytes-only check of a ZIP package's declared entry count before the `zip` crate opens it +//! (#3660). Every reader of `.docx`, `.odt` and spreadsheet packages calls [`check`] first. +//! +//! `zip::ZipArchive::new` reserves room for, and parses, as many central directory headers as the +//! end record declares before its caller can look at `len()`. A ZIP64 end record can declare +//! millions of entries for a file of a few hundred megabytes (only 46 bytes each have to fit), and +//! each parsed entry costs several times that in memory. Here the end records are found the way +//! `zip` finds them (APPNOTE.TXT §4.3.14 to §4.3.16), and a package declaring more than the +//! reader's limit is refused before `zip` parses anything. Anything else that is wrong is left for +//! `zip` to report. + +const EOCD: &[u8; 4] = b"PK\x05\x06"; +const EOCD64_LOCATOR: &[u8; 4] = b"PK\x06\x07"; +const EOCD64: &[u8; 4] = b"PK\x06\x06"; +const CENTRAL_HEADER: &[u8; 4] = b"PK\x01\x02"; + +/// End records looked at, at most. A package has one; a few more can turn up inside stored data. +const MAX_END_RECORDS: usize = 16; + +/// Refuse `bytes` when the end record `zip` would use declares more than `max_entries` entries. +/// +/// `zip` searches the file backwards for an end of central directory record. A ZIP64 one points +/// through its locator at the ZIP64 end record, which `zip` looks for from the locator's offset +/// on: every such record there is checked, and as `zip` may fall back to an earlier end record +/// when one doesn't parse, the search goes on. A plain one is used when it declares no entries, or +/// a central directory header lies between its directory offset and itself: it is the last one +/// looked at. +pub fn check(bytes: &[u8], max_entries: u64) -> Result<(), String> { + let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])); + let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + let u64_at = |at: usize| bytes.get(at..at.checked_add(8)?).and_then(|b| <[u8; 8]>::try_from(b).ok()).map(u64::from_le_bytes); + let refuse = |n: u64| -> Result<(), String> { + if n > max_entries { Err(format!("the package declares {n} entries, more than {max_entries}")) } else { Ok(()) } + }; + let mut end = bytes.len(); + let mut seen = 0; + while let Some(at) = bytes.get(..end).and_then(|b| b.windows(4).rposition(|w| w == EOCD)) { + end = at; + seen += 1; + if seen > MAX_END_RECORDS { + return Err(format!("more than {MAX_END_RECORDS} end of central directory records")); + } + // `zip` skips a record whose comment runs past the end of the file. + let Some(comment) = u16_at(at + 20) else { continue }; + if at + 22 + usize::from(comment) > bytes.len() { + continue; + } + let (Some(on_disk), Some(total), Some(cd_offset)) = (u16_at(at + 8), u16_at(at + 10), u32_at(at + 16)) else { continue }; + let zip64 = total == u16::MAX || cd_offset == u32::MAX; + if zip64 + && let Some(locator) = at.checked_sub(20) + && bytes.get(locator..locator + 4) == Some(EOCD64_LOCATOR.as_slice()) + { + let (Some(eocd64), Some(disks)) = (u64_at(locator + 8), u32_at(locator + 16)) else { continue }; + let Some(from) = usize::try_from(eocd64).ok().filter(|&f| f < locator) else { continue }; + if disks > 1 { + continue; + } + let records = bytes.get(from..locator).unwrap_or_default().windows(4).enumerate().filter(|(_, w)| *w == EOCD64); + for (i, _) in records { + let p = from + i; + refuse(u64_at(p + 24).unwrap_or(0).max(u64_at(p + 32).unwrap_or(0)))?; + } + continue; + } + if total == 0 { + return Ok(()); + } + let cd = cd_offset as usize; + if cd < at && bytes.get(cd..at).is_some_and(|b| b.windows(4).any(|w| w == CENTRAL_HEADER)) { + return refuse(u64::from(on_disk.max(total))); + } + } + Ok(()) +} + +/// A package of one empty stored file, `a`, whose central directory lists it `entries` times (at +/// most as many as fit in 64 MiB) under a ZIP64 end record that declares them all: `zip` 3.0.0 +/// parses and keeps every one of them before its caller can count them. Regression input for the +/// readers' tests: built only for tests, this crate's and (through the `test-support` feature, +/// which they turn on as a dev-dependency) the readers', never into the app. +#[cfg(any(test, feature = "test-support"))] +pub fn zip64_entry_count_sample(entries: u64) -> Vec { + // Local file header (APPNOTE.TXT §4.3.7): version 10, stored, no data, name "a". + let mut f = b"PK\x03\x04".to_vec(); + f.extend_from_slice(&[10, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]); // version, flags, method, time, date + f.extend_from_slice(&[0; 12]); // crc, sizes + f.extend_from_slice(&[1, 0, 0, 0]); // name and extra lengths + f.push(b'a'); + let cd_at = f.len() as u64; + // Its central header (§4.3.12), 47 bytes. + let mut header = CENTRAL_HEADER.to_vec(); + header.extend_from_slice(&[10, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]); // versions, flags, method, time, date + header.extend_from_slice(&[0; 12]); // crc, sizes + header.extend_from_slice(&[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); // name, extra, comment, disk, attributes + header.extend_from_slice(&[0; 4]); // local header offset + header.push(b'a'); + let count = entries.min((64 << 20) / header.len() as u64); + for _ in 0..count { + f.extend_from_slice(&header); + } + let cd_len = f.len() as u64 - cd_at; + let eocd64_at = f.len() as u64; + // ZIP64 end of central directory record (§4.3.14), 56 bytes. + f.extend_from_slice(EOCD64); + f.extend_from_slice(&44u64.to_le_bytes()); // size of the rest of the record + f.extend_from_slice(&[45, 0, 45, 0]); // versions + f.extend_from_slice(&[0; 8]); // this disk, disk with the central directory + f.extend_from_slice(&entries.to_le_bytes()); // entries on this disk + f.extend_from_slice(&entries.to_le_bytes()); // entries + f.extend_from_slice(&cd_len.to_le_bytes()); + f.extend_from_slice(&cd_at.to_le_bytes()); + // Its locator (§4.3.15), 20 bytes. + f.extend_from_slice(EOCD64_LOCATOR); + f.extend_from_slice(&0u32.to_le_bytes()); + f.extend_from_slice(&eocd64_at.to_le_bytes()); + f.extend_from_slice(&1u32.to_le_bytes()); + // End of central directory record (§4.3.16), 22 bytes, each count and offset "see ZIP64". + f.extend_from_slice(EOCD); + f.extend_from_slice(&[0; 4]); + f.extend_from_slice(&[0xFF; 12]); + f.extend_from_slice(&[0; 2]); + f +} + +#[cfg(test)] +mod tests { + use super::{CENTRAL_HEADER, EOCD, check, zip64_entry_count_sample}; + + /// A plain package's tail: one central header at 0, then an end record declaring `entries`. + fn plain(entries: u16, comment: &[u8]) -> Vec { + let mut f = CENTRAL_HEADER.to_vec(); + f.resize(46, 0); + f.extend_from_slice(EOCD); + f.extend_from_slice(&[0, 0, 0, 0]); + f.extend_from_slice(&entries.to_le_bytes()); + f.extend_from_slice(&entries.to_le_bytes()); + f.extend_from_slice(&46u32.to_le_bytes()); + f.extend_from_slice(&0u32.to_le_bytes()); + f.extend_from_slice(&(comment.len() as u16).to_le_bytes()); + f.extend_from_slice(comment); + f + } + + #[test] + fn a_zip64_end_record_declaring_too_many_entries_is_refused() { + // #3660: `zip` would reserve and parse them all before the reader's cap. + let sample = zip64_entry_count_sample(20_001); + assert!(check(&sample, 20_000).is_err_and(|e| e.contains("20001 entries"))); + assert!(check(&zip64_entry_count_sample(5), 20_000).is_ok()); + // Junk before the package (a self-extracting stub) doesn't hide it. + let mut prefixed = vec![0x55u8; 4096]; + prefixed.extend_from_slice(&sample); + assert!(check(&prefixed, 20_000).is_err()); + } + + #[test] + fn a_plain_end_record_is_checked_as_zip_reads_it() { + assert!(check(&plain(3, b""), 20_000).is_ok()); + assert!(check(&plain(30_000, b"a comment"), 20_000).is_err_and(|e| e.contains("30000 entries"))); + // A record whose comment runs past the end is not the one `zip` uses. + let mut cut = plain(30_000, b""); + cut.truncate(cut.len() - 2); + cut.extend_from_slice(&500u16.to_le_bytes()); + assert!(check(&cut, 20_000).is_ok()); + assert!(check(b"not a package", 20_000).is_ok()); + assert!(check(b"", 20_000).is_ok()); + } + + #[test] + fn a_fake_end_record_after_the_real_one_does_not_hide_it() { + // `zip` skips a last record with no central header before it and uses the one before. + let mut f = zip64_entry_count_sample(1_000_000); + f.extend_from_slice(EOCD); + f.extend_from_slice(&[0, 0, 0, 0]); + f.extend_from_slice(&7u16.to_le_bytes()); + f.extend_from_slice(&7u16.to_le_bytes()); + f.extend_from_slice(&0u32.to_le_bytes()); + f.extend_from_slice(&((f.len() - 12) as u32).to_le_bytes()); + f.extend_from_slice(&0u16.to_le_bytes()); + assert!(check(&f, 20_000).is_err()); + } + + #[test] + fn many_end_records_are_refused() { + // Each declares one entry and has no central header before it, so `zip` would try them all. + let record = [0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + let f: Vec = (0..20).flat_map(|_| EOCD.iter().copied().chain(record)).collect(); + assert!(check(&f, 20_000).is_err_and(|e| e.contains("end of central directory records"))); + } +} diff --git a/crates/docx/src/flat.rs b/crates/docx/src/flat.rs index a11b69f15..0e2b1dd77 100644 --- a/crates/docx/src/flat.rs +++ b/crates/docx/src/flat.rs @@ -76,7 +76,7 @@ pub fn sniff_xml(bytes: &[u8]) -> XmlKind { /// Read a Flat OPC file. pub fn read(bytes: &[u8]) -> Result { - crate::read::read_opened(&unpack(bytes)?) + crate::read::read_opened(&unpack(bytes)?, None) } /// The data element of the part being read. diff --git a/crates/docx/src/lib.rs b/crates/docx/src/lib.rs index 72cfdebfa..1667bc1c2 100644 --- a/crates/docx/src/lib.rs +++ b/crates/docx/src/lib.rs @@ -32,7 +32,7 @@ pub use crypt::{ }; pub use flat::{XmlKind, sniff_xml, write as write_flat}; pub use fonts::{EmbeddedFont, embedded_fonts}; -pub use read::read; +pub use read::{AltChunk, ChunkImporter, ReadOptions, read}; pub use smart_art::smart_art_items; pub use wordml2003_write::write as write_wordml2003; pub use write::{write, write_as}; @@ -56,11 +56,19 @@ pub fn read_xml(bytes: &[u8]) -> Result { /// The title (`dc:title`) in a package's core properties part (`docProps/core.xml`, ECMA-376 /// Part 2 §11), trimmed; `None` when the part can't be read or has no title. Only the package's -/// directory and at most `max_part` bytes of that part are read: for listing templates by name -/// without reading the whole package. -pub fn package_title(package: R, max_part: u64) -> Option { +/// directory and at most `max_part` bytes of that part are inflated: for listing templates by +/// name without unpacking the whole package. A package over 64 MiB, or one declaring more entries +/// than `MAX_ENTRIES` (20,000), has none (#3660). +pub fn package_title(package: R, max_part: u64) -> Option { use std::io::Read; - let mut zip = zip::ZipArchive::new(package).ok()?; + const MAX_PACKAGE: u64 = 64 << 20; + let mut bytes = Vec::new(); + package.take(MAX_PACKAGE + 1).read_to_end(&mut bytes).ok()?; + if bytes.len() as u64 > MAX_PACKAGE { + return None; + } + wordcraft_doc::zip_guard::check(&bytes, package::MAX_ENTRIES as u64).ok()?; + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).ok()?; let part = zip.by_name("docProps/core.xml").ok()?; let mut xml = Vec::new(); part.take(max_part).read_to_end(&mut xml).ok()?; @@ -84,11 +92,18 @@ pub(crate) const CANVAS_SPIN_EXT: &str = "urn:wordcraft:canvas-xfrm"; /// `password` first. An encrypted package without a password gives /// [`DocxError::PasswordRequired`]; a wrong one, [`DocxError::WrongPassword`]. pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result { + read_with(bytes, &ReadOptions { password, ..Default::default() }) +} + +/// Read a package with options: the password of an encrypted one ([`read_with_password`]) and +/// an importer for `w:altChunk` content in formats other than WordprocessingML (see +/// [`ChunkImporter`]). +pub fn read_with(bytes: &[u8], opts: &ReadOptions) -> Result { if is_encrypted(bytes) { - let package = zeroize::Zeroizing::new(decrypt(bytes, password)?); - return read::read_package(&package); + let package = zeroize::Zeroizing::new(decrypt(bytes, opts.password)?); + return read::read_package(&package, opts.chunks); } - read(bytes) + read::read_package(bytes, opts.chunks) } /// Which kind of WordprocessingML package to write. The main part's content type differs per diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index bdefc38fa..3835bdc0b 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -47,6 +47,7 @@ pub mod rt { pub const DIAGRAM_COLORS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramColors"; pub const CUSTOM_XML: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml"; pub const GLOSSARY: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/glossaryDocument"; + pub const AF_CHUNK: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk"; } /// Content type of a glossary document part (ECMA-376 Part 1 §11.3.8). @@ -212,6 +213,8 @@ impl Package { } pub fn open_limited(bytes: &[u8], max_part: u64, max_total: u64) -> Result { + // Before `zip` parses every entry the end record declares (#3660). + wordcraft_doc::zip_guard::check(bytes, MAX_ENTRIES as u64).map_err(DocxError::Limit)?; let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| DocxError::Zip(e.to_string()))?; if zip.len() > MAX_ENTRIES { return Err(DocxError::Limit(format!("{} zip entries", zip.len()))); @@ -252,6 +255,11 @@ impl Package { Ok(Package::new(files)) } + /// The bytes of all its parts. + pub fn size(&self) -> u64 { + self.files.values().map(|f| f.len() as u64).sum() + } + /// A package from parts already in memory (a Flat OPC file, a converted document), by part /// name without the leading `/`. pub fn from_parts(files: BTreeMap>) -> Package { @@ -524,6 +532,21 @@ mod tests { assert!(folded.iter().all(|w| w.contains("2 zip entries")), "{folded:?}"); } + #[test] + fn a_package_declaring_too_many_entries_is_refused_before_zip_reads_them() { + // #3660: a ZIP64 end record declaring more than MAX_ENTRIES. + let bomb = wordcraft_doc::zip_guard::zip64_entry_count_sample(MAX_ENTRIES as u64 + 1); + assert!(matches!(Package::open(&bomb), Err(DocxError::Limit(e)) if e.contains("declares"))); + assert_eq!(crate::package_title(std::io::Cursor::new(bomb), 1 << 20), None); + // A real package still opens, and its title still reads. + let z = zip_of(&[( + "docProps/core.xml", + br#"Plan"#.to_vec(), + )]); + assert!(Package::open(&z).is_ok()); + assert_eq!(crate::package_title(std::io::Cursor::new(z), 1 << 20).as_deref(), Some("Plan")); + } + #[test] fn decompression_bomb_is_rejected() { let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]); @@ -587,7 +610,7 @@ mod tests { ("word/media/pic.png", b"\x89PNG picture bytes".to_vec()), ]); let pkg = Package::open(&z).unwrap(); - let doc = crate::read::read_opened(&pkg).unwrap(); + let doc = crate::read::read_opened(&pkg, None).unwrap(); let held = doc.media.values().next().expect("the picture is read"); assert!(Arc::ptr_eq(held, pkg.get_shared("word/media/pic.png").unwrap()), "the same bytes, not a copy"); assert_eq!(pkg.get("/WORD/media/PIC.png"), Some(&b"\x89PNG picture bytes"[..]), "lookups are unchanged"); diff --git a/crates/docx/src/read.rs b/crates/docx/src/read.rs index 8f77ec63c..4c6b1cb01 100644 --- a/crates/docx/src/read.rs +++ b/crates/docx/src/read.rs @@ -1,6 +1,7 @@ //! DOCX → [`Document`]. pub(crate) mod chart; +mod chunk; pub(crate) mod diagram; mod drawing_color; mod embed; @@ -13,7 +14,7 @@ mod shape_style; mod story; mod wordart; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use wordcraft_doc::control::{ControlEvent, ControlKind, control_events, pair_events}; @@ -46,6 +47,9 @@ pub(crate) use meta::PROTECTION_HASH_ATTRS; pub(crate) use styles::LOWER_NAMES; use toc::{is_toc, toc_control_to_toc, toc_field_to_heading, unstyle_toc_links}; +use chunk::ChunkState; +pub use chunk::{AltChunk, ChunkImporter, ReadOptions}; + /// Most fonts read from a font table, and the longest name (hostile files). const MAX_TABLE_FONTS: usize = 2000; const MAX_FONT_NAME: usize = 256; @@ -87,12 +91,12 @@ pub(crate) struct Reader<'p> { pub bookmarks: HashMap, /// Restrict Editing exception ranges (`w:permStart`): the file's id → ours. pub perms: HashMap, + /// The next permission range id to give out (chunks' ranges take theirs from it too). + pub next_perm: u32, /// Chart and diagram parts by path, parsed once. graphic_parts: HashMap>>, /// Charts and diagrams by (kind, part path, width bits, height bits), built once. graphics: HashMap<(GraphicKind, String, u32, u32), Arc>, - /// Graphic work left before charts and diagrams are left empty (see [`MAX_GRAPHIC_WORK`]). - graphic_budget: usize, /// Drawing ids connectors are glued to (`a:stCxn`, `a:endCxn`), and whether any shape's id /// was kept while reading (those not glued to are dropped at the end). pub glued: HashSet, @@ -103,6 +107,8 @@ pub(crate) struct Reader<'p> { content_types: Option, /// Parts kept for charts, diagrams and OLE objects (see `embed`). embedded: EmbeddedManifest, + /// What alternative format chunks may still bring in, and how (see [`chunk`]). + chunks: ChunkState<'p>, /// Linked text boxes as read: (`wps:txbx/@id` or `wps:linkedTxbx/@id`, `seq` (0 for the box /// holding the text), text box part). See [`link_text_boxes`]. pub txbx_links: Vec<(u32, u32, u32)>, @@ -125,17 +131,24 @@ pub fn read(bytes: &[u8]) -> Result { if crate::is_encrypted(bytes) { return crate::read_with_password(bytes, None); } - read_package(bytes) + read_package(bytes, None) } -/// [`read`] for bytes known not to be an encrypted package. -pub(crate) fn read_package(bytes: &[u8]) -> Result { - read_opened(&Package::open(bytes)?) +/// [`read`] for bytes known not to be an encrypted package; `chunks` imports alternative format +/// chunks in formats other than WordprocessingML. +pub(crate) fn read_package(bytes: &[u8], chunks: Option) -> Result { + read_opened(&Package::open(bytes)?, chunks) } /// Read a package whose parts are already unpacked (from a zip, a Flat OPC file or a converted /// Word 2003 XML document). -pub(crate) fn read_opened(pkg: &Package) -> Result { +pub(crate) fn read_opened(pkg: &Package, chunks: Option) -> Result { + read_opened_in(pkg, ChunkState::new(chunks)).map(|(doc, ..)| doc) +} + +/// [`read_opened`] with what chunks may still bring in; also gives the chunks, chunk bytes and +/// graphic work left of that. +fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize, u64, usize), DocxError> { let root_rels = pkg.rels(""); let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string()); let Some(root) = pkg.xml_strict(&main)? else { @@ -164,9 +177,9 @@ pub(crate) fn read_opened(pkg: &Package) -> Result { comments_ended: HashSet::new(), bookmarks: HashMap::new(), perms: HashMap::new(), + next_perm: 0, graphic_parts: HashMap::new(), graphics: HashMap::new(), - graphic_budget: MAX_GRAPHIC_WORK, glued: HashSet::new(), shape_ids: false, main: main.clone(), @@ -175,6 +188,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result { txbx_links: Vec::new(), theme_fills: Default::default(), group_fill: None, + chunks, }; r.pc.major_font = r.doc.settings.major_font.clone(); r.pc.minor_font = r.doc.settings.minor_font.clone(); @@ -267,7 +281,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result { if let Some(body) = root.child("w:body") { let mut sc = StoryCtx::default(); let mut blocks = Blocks::new(); - r.read_blocks(&mut sc, body, &rels, &mut blocks, 0); + r.read_blocks(&mut sc, body, &rels, &mut blocks, r.chunks.depth); r.flush_pending(&mut sc, &mut blocks); r.doc.body = blocks; unstyle_toc_links(&mut r.doc); @@ -321,9 +335,10 @@ pub(crate) fn read_opened(pkg: &Package) -> Result { if r.shape_ids { keep_glued_ids(&mut r.doc, &r.glued); } + let (left, bytes, graphic) = (r.chunks.left, r.chunks.bytes, r.chunks.graphic); let mut doc = r.doc; doc.ensure_nonempty(); - Ok(doc) + Ok((doc, left, bytes, graphic)) } /// Most move ranges open at once, and most move names tracked, per file. diff --git a/crates/docx/src/read/chunk.rs b/crates/docx/src/read/chunk.rs new file mode 100644 index 000000000..b97aa4b47 --- /dev/null +++ b/crates/docx/src/read/chunk.rs @@ -0,0 +1,187 @@ +//! `w:altChunk` (ECMA-376 §17.17.2.1): content kept in another format (HTML, MHT, RTF, plain +//! text, another WordprocessingML document) that Word brings into the document where the chunk +//! stands when it opens the file. A nested WordprocessingML package is read here; other formats +//! go to the caller's [`ChunkImporter`] (the format crates sit beside this one), and without one +//! the chunk is left out. + +use std::sync::Arc; + +use wordcraft_doc::{Block, Blocks, Document}; + +use super::story::StoryCtx; +use super::{Reader, part_of}; +use crate::package::{ContentTypes, MAX_PART, MAX_TOTAL, Package, Rels, rt}; +use crate::xml::El; + +/// Most chunks brought in from one file (nested documents' chunks included). +pub(crate) const MAX_CHUNKS: usize = 256; +/// Deepest a WordprocessingML chunk may nest in another. +pub(crate) const MAX_CHUNK_NESTING: u8 = 4; + +/// An alternative format chunk's content, as a [`ChunkImporter`] gets it. +pub struct AltChunk<'a> { + /// The part's content type (`application/xhtml+xml`, `message/rfc822`, `application/rtf`, + /// `text/plain`…), empty when the package doesn't give one. + pub content_type: &'a str, + /// The part's name (`word/afchunk.mht`): its extension tells the format when the content type + /// doesn't. + pub part: &'a str, + pub bytes: &'a [u8], +} + +/// Turns a chunk the docx reader can't read itself into a document whose body takes the chunk's +/// place; `None` leaves the chunk out. +pub type ChunkImporter<'a> = &'a dyn Fn(&AltChunk) -> Option; + +/// How [`crate::read_with`] reads a package. +#[derive(Clone, Copy, Default)] +pub struct ReadOptions<'a> { + /// The password of an encrypted package. + pub password: Option<&'a str>, + /// Imports `w:altChunk` content in formats other than WordprocessingML. + pub chunks: Option>, +} + +impl Reader<'_> { + /// Bring the chunk `e` names into `out` where it stands (`depth`: of the XML around it). + pub(crate) fn read_alt_chunk(&mut self, sc: &mut StoryCtx, e: &El, rels: &Rels, out: &mut Blocks, depth: usize) { + let Some(part) = e.attr("r:id").and_then(|id| part_of(rels, id, rt::AF_CHUNK)) else { return }; + let pkg = self.pkg; + let Some(bytes) = pkg.get(&part) else { + log::warn!("docx: alternative format chunk {part} is missing"); + return; + }; + // Every chunk's bytes count against one budget for the whole file, however often the same + // part is named and however deep chunks nest. + if self.chunks.left == 0 || bytes.len() as u64 > self.chunks.bytes { + log::warn!("docx: more than {MAX_CHUNKS} alternative format chunks or {MAX_TOTAL} bytes of them; the rest are left out"); + return; + } + self.chunks.left -= 1; + self.chunks.bytes -= bytes.len() as u64; + let ct = self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&part).unwrap_or("").to_string(); + let chunk = AltChunk { content_type: &ct, part: &part, bytes }; + // A WordprocessingML chunk is a package; one that isn't Word's fails to read (logged). + let is_docx = bytes.starts_with(b"PK\x03\x04"); + let doc = if is_docx { self.read_nested(bytes, depth) } else { self.chunks.import.and_then(|f| f(&chunk)) }; + let Some(mut doc) = doc else { + log::warn!("docx: alternative format chunk {part} ({ct}) is in a format WordCraft can't bring in"); + return; + }; + // The chunk flows in the section it stands in; its own section breaks don't come along. + for b in &mut doc.body { + if let Block::Para(p) = &**b + && p.section.is_some() + && let Block::Para(p) = Arc::make_mut(b) + { + p.section = None; + } + } + // `w:matchSrc`: a Word document's content keeps its own styles (other formats' formatting + // is their own already). + let keep = is_docx && e.child("w:altChunkPr").and_then(|p| p.child("w:matchSrc")).is_some_and(crate::units::on_off); + let mut blocks = self.doc.adopt_blocks(doc, keep, &mut self.next_perm); + // Bookmarks and comment marks read before the chunk stay before it. + self.flush_pending_before(sc, out, &mut blocks); + out.extend(blocks.into_iter().map(Arc::new)); + } + + /// A WordprocessingML chunk: its document, read with the same importer and what is left of + /// the budgets, its XML depth counting on from `depth`. + fn read_nested(&mut self, bytes: &[u8], depth: usize) -> Option { + if self.chunks.nesting >= MAX_CHUNK_NESTING { + log::warn!("docx: alternative format chunks nest deeper than {MAX_CHUNK_NESTING}; the inner ones are left out"); + return None; + } + let pkg = + Package::open_limited(bytes, MAX_PART, self.chunks.bytes).map_err(|e| log::warn!("docx: unreadable nested document chunk: {e}")).ok()?; + self.chunks.bytes = self.chunks.bytes.saturating_sub(pkg.size()); + let state = ChunkState { nesting: self.chunks.nesting + 1, depth: depth + 1, ..self.chunks }; + let (doc, left, bytes, graphic) = + super::read_opened_in(&pkg, state).map_err(|e| log::warn!("docx: unreadable nested document chunk: {e}")).ok()?; + self.chunks.left = left; + self.chunks.bytes = bytes; + self.chunks.graphic = graphic; + Some(doc) + } +} + +/// What a reader may still bring in from chunks. +#[derive(Clone, Copy)] +pub(crate) struct ChunkState<'a> { + /// Chunks left in the file's budget ([`MAX_CHUNKS`]). + pub left: usize, + /// Chunk bytes (nested packages unpacked) left in the file's budget. + pub bytes: u64, + /// Graphic work left in the file's budget ([`super::MAX_GRAPHIC_WORK`]): nested documents' + /// charts and diagrams count against it too. + pub graphic: usize, + /// How deep in nested WordprocessingML chunks this reader is. + pub nesting: u8, + /// The XML depth its stories start at (a nested document's: that of the chunk naming it). + pub depth: usize, + pub import: Option>, +} + +impl<'a> ChunkState<'a> { + pub fn new(import: Option>) -> Self { + ChunkState { left: MAX_CHUNKS, bytes: MAX_TOTAL, graphic: super::MAX_GRAPHIC_WORK, nesting: 0, depth: 0, import } + } +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use wordcraft_doc::{Block, Document, InlineObject}; + + use super::ChunkState; + use crate::package::Package; + + /// A package whose body is `body`, with the document relationship `rel` (id, type, target) to + /// the part `(name, bytes)`. + fn package(body: &str, (id, ty, target): (&str, &str, &str), (name, bytes): (&str, &[u8])) -> Vec { + let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#; + let rels = + |inner: &str| format!(r#"{inner}"#); + let rel = |id: &str, ty: &str, target: &str| { + format!(r#""#) + }; + let files: [(&str, Vec); 4] = [ + ("_rels/.rels", rels(&rel("rId1", "officeDocument", "word/document.xml")).into_bytes()), + ("word/document.xml", format!(r#"{body}"#).into_bytes()), + ("word/_rels/document.xml.rels", rels(&rel(id, ty, target)).into_bytes()), + (name, bytes.to_vec()), + ]; + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in files { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(&bytes).unwrap(); + } + zw.finish().unwrap().into_inner() + } + + /// Whether the body's first paragraph's chart is drawn. + fn drawn(d: &Document) -> bool { + let Some(Block::Para(p)) = d.body.first().map(|b| &**b) else { panic!("no paragraph") }; + p.objects.iter().any(|o| matches!(o, InlineObject::Graphic { graphic, .. } if !graphic.items.is_empty())) + } + + #[test] + fn a_nested_documents_charts_count_against_the_files_graphic_budget() { + let chart = r#"12"#; + let drawing = r#""#; + let inner = package(drawing, ("rIdChart", "chart", "charts/chart1.xml"), ("word/charts/chart1.xml", chart.as_bytes())); + let outer = package(r#""#, ("rIdC", "aFChunk", "afchunk.docx"), ("word/afchunk.docx", &inner)); + let pkg = Package::open(&outer).unwrap(); + let read = |graphic| { + let (d, .., left) = super::super::read_opened_in(&pkg, ChunkState { graphic, ..ChunkState::new(None) }).unwrap(); + (drawn(&d), left) + }; + let (shown, left) = read(super::super::MAX_GRAPHIC_WORK); + assert!(shown); + assert!(left < super::super::MAX_GRAPHIC_WORK, "the nested chart's work is the file's"); + // With the file's budget spent, the nested document doesn't get one of its own. + assert_eq!(read(0), (false, 0)); + } +} diff --git a/crates/docx/src/read/meta.rs b/crates/docx/src/read/meta.rs index 9739f66dc..7f6e93fc3 100644 --- a/crates/docx/src/read/meta.rs +++ b/crates/docx/src/read/meta.rs @@ -1,6 +1,6 @@ use super::{ - Arc, BTreeMap, Blocks, Comment, DocxError, El, HashMap, HashSet, MAX_PARTS, MAX_SOURCES, MAX_TEMPLATE_PATH, NoteKind, NumFormat, Paragraph, - PartKind, Reader, Rels, Seen, StoryCtx, Zoom, ZoomFit, int, math, on_off, para_block, props, tw, u32_of, + Arc, Blocks, Comment, DocxError, El, HashMap, MAX_PARTS, MAX_SOURCES, MAX_TEMPLATE_PATH, NoteKind, NumFormat, Paragraph, PartKind, Reader, Rels, + Seen, StoryCtx, Zoom, ZoomFit, int, math, on_off, para_block, props, tw, u32_of, }; impl Reader<'_> { @@ -120,7 +120,7 @@ impl Reader<'_> { let mut sc = StoryCtx::default(); let mut blocks = Blocks::new(); self.current_note = Some((kind, id)); - self.read_blocks(&mut sc, n, &rels, &mut blocks, 0); + self.read_blocks(&mut sc, n, &rels, &mut blocks, self.chunks.depth); self.flush_pending(&mut sc, &mut blocks); self.current_note = None; if let Some(p) = self.doc.parts.get_mut(&id) @@ -152,7 +152,7 @@ impl Reader<'_> { let mut sc = StoryCtx::default(); let mut blocks = Blocks::new(); self.in_note_sep = true; - self.read_blocks(&mut sc, n, rels, &mut blocks, 0); + self.read_blocks(&mut sc, n, rels, &mut blocks, self.chunks.depth); self.flush_pending(&mut sc, &mut blocks); self.in_note_sep = false; if blocks.is_empty() { @@ -188,11 +188,7 @@ impl Reader<'_> { let rels = self.pkg.rels(path); let mut by_para: HashMap = HashMap::new(); let mut next_id = 0u32; - let mut entries: BTreeMap = BTreeMap::new(); - // Ids given before this part, looked up rather than scanned for every generated id: a - // scan per comment made many non-numeric ids quadratic (#3692). Ids given here are in - // `entries`. - let given: HashSet = self.comment_map.values().copied().collect(); + // Straight into the document, beside the comments chunks in notes brought in. for c in root.children("w:comment") { if self.doc.parts.len() >= MAX_PARTS { break; @@ -202,23 +198,26 @@ impl Reader<'_> { continue; } let id = match file_id.parse::() { - Ok(v) if !entries.contains_key(&v) => v, + Ok(v) if !self.doc.comments.contains_key(&v) => v, _ => { - while entries.contains_key(&next_id) || given.contains(&next_id) { + // Every id handed out so far is a key of `doc.comments` (inserted below). + while self.doc.comments.contains_key(&next_id) { next_id += 1; } next_id } }; + // Taken before its text is read: a chunk there brings comments of its own. + self.doc.comments.insert(id, Comment::default()); let mut sc = StoryCtx::default(); let mut blocks = Blocks::new(); - self.read_blocks(&mut sc, c, &rels, &mut blocks, 0); + self.read_blocks(&mut sc, c, &rels, &mut blocks, self.chunks.depth); self.flush_pending(&mut sc, &mut blocks); let part = self.doc.add_part(PartKind::Comment, blocks); if let Some(pid) = c.children("w:p").last().and_then(|p| p.attr("w14:paraId")) { by_para.insert(pid.to_string(), id); } - entries.insert( + self.doc.comments.insert( id, Comment { author: c.attr("w:author").unwrap_or("").to_string(), @@ -239,7 +238,7 @@ impl Reader<'_> { let Some(cid) = e.attr("w15:paraId").and_then(|p| by_para.get(p)).copied() else { continue }; let parent = e.attr("w15:paraIdParent").and_then(|p| by_para.get(p)).copied().filter(|p| *p != cid); let done = e.attr("w15:done").is_some_and(|v| v == "1" || v == "true"); - if let Some(c) = entries.get_mut(&cid) { + if let Some(c) = self.doc.comments.get_mut(&cid) { c.parent = parent; c.resolved = done; } @@ -258,7 +257,7 @@ impl Reader<'_> { for e in x.els().filter(|e| e.local() == "commentId") { let Some(cid) = local(e, "paraId").and_then(|p| by_para.get(&p).copied()) else { continue }; let Some(durable) = local(e, "durableId").filter(|d| crate::write::durable_id_ok(d)) else { continue }; - if let Some(c) = entries.get_mut(&cid) { + if let Some(c) = self.doc.comments.get_mut(&cid) { c.durable_id = Some(durable.to_ascii_uppercase()); by_durable.insert(durable.to_ascii_uppercase(), cid); } @@ -270,12 +269,11 @@ impl Reader<'_> { for e in x.els().filter(|e| e.local() == "commentExtensible") { let Some(cid) = local(e, "durableId").and_then(|d| by_durable.get(&d.to_ascii_uppercase()).copied()) else { continue }; let date = local(e, "dateUtc").filter(|d| d.len() <= 40 && d.chars().all(|c| c.is_ascii_alphanumeric() || ":-.+".contains(c))); - if let (Some(c), Some(date)) = (entries.get_mut(&cid), date) { + if let (Some(c), Some(date)) = (self.doc.comments.get_mut(&cid), date) { c.date_utc = Some(date); } } } - self.doc.comments = entries; Ok(()) } diff --git a/crates/docx/src/read/parts.rs b/crates/docx/src/read/parts.rs index f5ce13834..2e1f98e32 100644 --- a/crates/docx/src/read/parts.rs +++ b/crates/docx/src/read/parts.rs @@ -144,7 +144,7 @@ impl Reader<'_> { sc.in_hf = true; let mut blocks = Blocks::new(); self.hf_depth += 1; - self.read_blocks(&mut sc, &root, &rels, &mut blocks, 0); + self.read_blocks(&mut sc, &root, &rels, &mut blocks, self.chunks.depth); self.hf_depth = self.hf_depth.saturating_sub(1); self.flush_pending(&mut sc, &mut blocks); if let Some(p) = self.doc.parts.get_mut(&id) diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index ba62059dd..bce02dead 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -166,9 +166,9 @@ fn children_of_choice(ac: &El) -> Option<&El> { ac.children("mc:Choice").find(|c| ok(c)).or_else(|| ac.child("mc:Fallback")).or_else(|| ac.child("mc:Choice")) } -/// Gather `name` children (rows, cells), looking through `w:sdt`/`w:customXml` wrappers; each -/// with the content controls that open before it and how many close after it. `pending` holds -/// controls opened but not yet given to an element. +/// Gather `name` children (rows, cells), looking through content controls, custom XML and the +/// selected alternate-content branch; each with the content controls that open before it and how +/// many close after it. `pending` holds controls opened but not yet given to an element. fn collect<'a>(e: &'a El, name: &str, out: &mut Vec<(&'a El, ControlWrap)>, pending: &mut Vec, depth: usize) { if depth > 8 { return; @@ -194,6 +194,10 @@ fn collect<'a>(e: &'a El, name: &str, out: &mut Vec<(&'a El, ControlWrap)>, pend } } else if k.name == "w:customXml" { collect(k, name, out, pending, depth + 1); + } else if k.name == "mc:AlternateContent" + && let Some(branch) = children_of_choice(k) + { + collect(branch, name, out, pending, depth + 1); } } } diff --git a/crates/docx/src/read/story/blocks.rs b/crates/docx/src/read/story/blocks.rs index b1b26ca83..13f6f3e15 100644 --- a/crates/docx/src/read/story/blocks.rs +++ b/crates/docx/src/read/story/blocks.rs @@ -51,6 +51,7 @@ impl Reader<'_> { } } "w:sdt" => self.read_block_sdt(sc, e, rels, out, depth), + "w:altChunk" => self.read_alt_chunk(sc, e, rels, out, depth), "w:customXml" | "w:smartTag" | "w:ins" | "w:moveTo" => self.read_blocks(sc, e, rels, out, depth + 1), // Math is allowed straight in a story (EG_BlockLevelElts takes EG_RunLevelElements, // which takes EG_MathContent): it gets a paragraph of its own (#1273). @@ -135,6 +136,21 @@ impl Reader<'_> { sc.pending.clear(); } + /// Markers read before content placed whole (a chunk's `next`) stay before it: at the end of + /// the paragraph before, or after a table or with none, at the start of the content's first + /// paragraph, as a paragraph read there would take them (with a table first, the next + /// paragraph read takes them). + pub fn flush_pending_before(&mut self, sc: &mut StoryCtx, out: &mut Blocks, next: &mut [Block]) { + if out.last().is_some_and(|b| matches!(**b, Block::Para(_))) { + return self.flush_pending(sc, out); + } + if let Some(Block::Para(p)) = next.first_mut() { + for o in sc.pending.drain(..).rev() { + let _ = p.insert_object(0, o, &CharProps::default()); + } + } + } + pub(super) fn marker(&mut self, e: &El) -> Option { let id = e.attr("w:id").map(str::trim).unwrap_or("").to_string(); match e.name.as_str() { @@ -161,8 +177,15 @@ impl Reader<'_> { if self.perms.len() >= 100_000 { return None; } - let n = self.perms.len() as u32; - let id = *self.perms.entry(id).or_insert(n); + let id = match self.perms.get(&id) { + Some(n) => *n, + None => { + let n = self.next_perm; + self.next_perm = n.saturating_add(1); + self.perms.insert(id, n); + n + } + }; let text = |a: &str| e.attr(a).filter(|v| v.len() <= 256).unwrap_or("").to_string(); let (group, editor) = (text("w:edGrp"), text("w:ed")); if group.is_empty() && editor.is_empty() { diff --git a/crates/docx/src/read/story/drawing.rs b/crates/docx/src/read/story/drawing.rs index d6c05176b..22bcae2d5 100644 --- a/crates/docx/src/read/story/drawing.rs +++ b/crates/docx/src/read/story/drawing.rs @@ -218,8 +218,8 @@ impl Reader<'_> { if let Some(g) = self.graphics.get(&key) { return g.clone(); } - let items = if self.graphic_budget == 0 { Vec::new() } else { self.graphic_items(kind, &key.1, w, h) }; - self.graphic_budget = self.graphic_budget.saturating_sub(graphic_work(&items)); + let items = if self.chunks.graphic == 0 { Vec::new() } else { self.graphic_items(kind, &key.1, w, h) }; + self.chunks.graphic = self.chunks.graphic.saturating_sub(graphic_work(&items)); // A chart or SmartArt that is exactly what WordCraft writes for its model can be edited // (and is written from the model); any other stays as it was read. let chart = match kind { @@ -556,7 +556,7 @@ impl Reader<'_> { let mut blocks = Blocks::new(); // Shapes in the text aren't members of a group the box may be in. let group = self.group_fill.take(); - self.read_blocks(&mut inner, content, rels, &mut blocks, 0); + self.read_blocks(&mut inner, content, rels, &mut blocks, self.chunks.depth); self.group_fill = group; self.flush_pending(&mut inner, &mut blocks); self.doc.add_part(PartKind::TextBox, blocks) diff --git a/crates/docx/src/read/story/vml.rs b/crates/docx/src/read/story/vml.rs index 06fccdfa9..ebb2353b6 100644 --- a/crates/docx/src/read/story/vml.rs +++ b/crates/docx/src/read/story/vml.rs @@ -295,6 +295,22 @@ impl Reader<'_> { } let kind = kind?; let mut extra = ShapeExtra { cap, ..Default::default() }; + // A horizontal rule (`o:hr`, a pasted `
`): across `o:hrpct` tenths of a percent of its + // line, or its own width (`o:hrpct="0"`); a width of 0 with no percentage is the whole + // line. `o:hralign` places a shorter one (Word, measured: 50 % centred, left, right). + // Only a rule in the text, shown: a positioned or hidden one stays a plain shape, which + // keeps its placement and visibility when saved. + if attr("o:hr").is_some_and(|v| matches!(v.trim(), "t" | "true" | "1")) && float.wrap == Wrap::Inline && !float.hidden { + let tenths = attr("o:hrpct").and_then(|v| v.trim().parse::().ok()).filter(|p| p.is_finite() && *p > 0.0); + let pct = tenths.map_or(if w > 0.0 { 0.0 } else { 100.0 }, |t| (t / 10.0).min(100.0)); + let align = match attr("o:hralign").map(|a| a.trim().to_ascii_lowercase()).as_deref() { + Some("center") => wordcraft_doc::props::Align::Center, + Some("right") => wordcraft_doc::props::Align::Right, + _ => wordcraft_doc::props::Align::Left, + }; + let standard = attr("o:hrstd").is_some_and(|v| matches!(v.trim(), "t" | "true" | "1")); + extra.hr = Some(wordcraft_doc::connector::HorizontalRule { pct, align, standard }); + } if kind == ShapeKind::Line { // Arrowheads (`v:stroke` startarrow / endarrow, with their width and length): any but // `none` is drawn. diff --git a/crates/docx/src/wordml2003.rs b/crates/docx/src/wordml2003.rs index e876ef9f5..61798d810 100644 --- a/crates/docx/src/wordml2003.rs +++ b/crates/docx/src/wordml2003.rs @@ -33,7 +33,7 @@ const MAX_STORIES: usize = 50_000; /// Read a Word 2003 XML Document. pub fn read(bytes: &[u8]) -> Result { - crate::read::read_opened(&convert(bytes)?) + crate::read::read_opened(&convert(bytes)?, None) } /// Parts the converted package gets relationships for. diff --git a/crates/docx/src/write/story/object.rs b/crates/docx/src/write/story/object.rs index 340ca8fce..cee8a045c 100644 --- a/crates/docx/src/write/story/object.rs +++ b/crates/docx/src/write/story/object.rs @@ -115,6 +115,9 @@ impl Writer<'_> { // ink keep their original source and related parts. InlineObject::Graphic { .. } => self.write_graphic(w, o, props, rels, depth), InlineObject::Image { .. } => self.write_image(w, o, props, rels, depth), + InlineObject::Shape { extra, float, .. } if extra.hr.is_some() && float.wrap == Wrap::Inline && !float.hidden => { + self.write_hr(w, o, props) + } InlineObject::Shape { .. } => self.write_shape(w, o, props, rels, depth), InlineObject::Group { .. } => self.write_group(w, o, props, rels, depth), InlineObject::Ruby { base, ruby, align, size, raise, base_size, lang, props: ruby_props } => { @@ -277,6 +280,74 @@ impl Writer<'_> { self.rev_close(w, props); } + /// A horizontal rule goes back as the VML rule it was read from: DrawingML has none, and a + /// fixed-size shape would lose its line-wide width. + fn write_hr(&mut self, w: &mut W, o: &InlineObject, props: &CharProps) { + let InlineObject::Shape { w: sw, h: sh, fill, stroke, stroke_width, extra, float, .. } = o else { return }; + let Some(hr) = extra.hr else { return }; + let align = match hr.align { + wordcraft_doc::props::Align::Center => "center", + wordcraft_doc::props::Align::Right => "right", + _ => "left", + }; + let width = if hr.pct > 0.0 { "0".to_string() } else { format!("{:.2}pt", sw.max(0.0)) }; + let mut style = format!("width:{width};height:{:.2}pt", sh.max(0.0)); + let spin = float.spin(); + if spin.deg != 0.0 { + style.push_str(&format!(";rotation:{}", spin.deg)); + } + match (spin.flip_h, spin.flip_v) { + (true, true) => style.push_str(";flip:x y"), + (true, false) => style.push_str(";flip:x"), + (false, true) => style.push_str(";flip:y"), + (false, false) => {} + } + let pct = format!("{}", (hr.pct * 10.0).round().max(0.0) as u32); + let fill_hex = fill.as_ref().map(|f| format!("#{}", f.main_color().hex())); + let stroke_hex = stroke.map(|c| format!("#{}", c.hex())); + let weight = format!("{stroke_width:.2}pt"); + let mut attrs: Vec<(&str, &str)> = vec![ + ("xmlns:v", "urn:schemas-microsoft-com:vml"), + ("xmlns:o", "urn:schemas-microsoft-com:office:office"), + ("style", &style), + ("o:hrpct", &pct), + ("o:hralign", align), + ("o:hr", "t"), + ]; + if hr.standard { + attrs.push(("o:hrstd", "t")); + } + if let Some(f) = &fill_hex { + attrs.push(("fillcolor", f)); + } else { + attrs.push(("filled", "f")); + } + match &stroke_hex { + Some(s) => { + attrs.push(("strokecolor", s)); + attrs.push(("strokeweight", &weight)); + } + None => attrs.push(("stroked", "f")), + } + self.rev_open(w, props); + w.open("w:r", &[]); + self.rpr(w, props); + w.open("w:pict", &[]); + // A see-through fill keeps its opacity, which `fillcolor` can't say. + match fill.as_ref() { + Some(wordcraft_doc::fill::Fill::Solid { transparency, .. }) if *transparency > 0.0 => { + let opacity = format!("{:.4}", (1.0 - transparency / 100.0).clamp(0.0, 1.0)); + w.open("v:rect", &attrs); + w.empty("v:fill", &[("opacity", &opacity)]); + w.close("v:rect"); + } + _ => w.empty("v:rect", &attrs), + } + w.close("w:pict"); + w.close("w:r"); + self.rev_close(w, props); + } + fn write_shape(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) { let InlineObject::Shape { kind, w: sw, h: sh, float, effects, freeform, extra, .. } = o else { return }; self.rev_open(w, props); diff --git a/crates/docx/tests/it/alt_chunks.rs b/crates/docx/tests/it/alt_chunks.rs new file mode 100644 index 000000000..dd2dddbde --- /dev/null +++ b/crates/docx/tests/it/alt_chunks.rs @@ -0,0 +1,365 @@ +//! Alternative format chunks (`w:altChunk`, ECMA-376 §17.17.2.1): content in another format that +//! is brought in where the chunk stands. All markup here is synthetic. + +use std::io::Write; + +use wordcraft_doc::{Block, Document, InlineObject, StoryRef}; +use wordcraft_docx::{AltChunk, ReadOptions, read, read_with}; + +const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#; +const ROOT_RELS: &str = r#""#; +const DOCX_MAIN: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"; + +fn p(text: &str) -> String { + format!(r#"{text}"#) +} + +/// A package whose body is `body`, with the chunk `afchunk.{ext}` (content type `ct`) as `rIdC`. +fn package(body: &str, chunk: Option<(&str, &str, &[u8])>) -> Vec { + package_styled(body, chunk, None) +} + +/// [`package`] with a styles part holding `styles`. +fn package_styled(body: &str, chunk: Option<(&str, &str, &[u8])>, styles: Option<&str>) -> Vec { + let doc = format!(r#"{body}"#); + let mut files: Vec<(String, Vec)> = vec![("_rels/.rels".into(), ROOT_RELS.into()), ("word/document.xml".into(), doc.into_bytes())]; + let mut types = String::from( + r#""#, + ); + let mut rels = String::new(); + if let Some((ext, ct, bytes)) = chunk { + types.push_str(&format!(r#""#)); + rels.push_str(&format!(r#""#)); + files.push((format!("word/afchunk.{ext}"), bytes.to_vec())); + } + if let Some(styles) = styles { + rels.push_str( + r#""#, + ); + files.push(("word/styles.xml".into(), format!(r#"{styles}"#).into_bytes())); + } + if !rels.is_empty() { + files.push(( + "word/_rels/document.xml.rels".into(), + format!(r#"{rels}"#).into_bytes(), + )); + } + types.push_str(r#""#); + files.push(("[Content_Types].xml".into(), types.into_bytes())); + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in files { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(&bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +const CHUNK: &str = r#""#; + +fn body_lines(d: &Document) -> Vec { + d.plain_text(StoryRef::Body).split('\n').map(str::to_string).collect() +} + +#[test] +fn a_chunk_comes_in_through_the_importer_where_it_stands() { + let html = b"

Chunk one

Chunk two

"; + let bytes = package(&format!("{}{CHUNK}{}", p("Before"), p("After")), Some(("htm", "application/xhtml+xml", html))); + let seen = std::cell::RefCell::new(Vec::new()); + let import = |c: &AltChunk| { + seen.borrow_mut().push((c.content_type.to_string(), c.part.to_string(), c.bytes.to_vec())); + Some(Document::from_text("Chunk one\nChunk two")) + }; + let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap(); + assert_eq!(body_lines(&d), ["Before", "Chunk one", "Chunk two", "After"]); + assert_eq!(*seen.borrow(), [("application/xhtml+xml".to_string(), "word/afchunk.htm".to_string(), html.to_vec())]); + // Without an importer the chunk is left out. + assert_eq!(body_lines(&read(&bytes).unwrap()), ["Before", "After"]); +} + +#[test] +fn a_bookmark_opened_before_a_chunk_at_the_start_of_the_body_is_kept() { + let body = format!(r#"{CHUNK}{}"#, p("After")); + let bytes = package(&body, Some(("htm", "application/xhtml+xml", b"

Chunk

"))); + let import = |_: &AltChunk| Some(Document::from_text("Chunk")); + let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap(); + assert_eq!(body_lines(&d), ["Chunk", "After"]); + let marks = d.bookmarks(); + assert_eq!(marks.len(), 1); + assert_eq!((marks[0].0.as_str(), marks[0].1.path.last(), marks[0].1.off), ("Here", 0, 0)); +} + +#[test] +fn a_bookmark_opened_between_a_table_and_a_chunk_starts_the_chunk() { + let table = format!("{}", p("Cell")); + let body = format!(r#"{table}{CHUNK}{}"#, p("After")); + let bytes = package(&body, Some(("htm", "application/xhtml+xml", b"

Chunk

"))); + let import = |_: &AltChunk| Some(Document::from_text("Chunk")); + let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap(); + let marks = d.bookmarks(); + assert_eq!(marks.len(), 1); + assert_eq!((marks[0].1.path.clone(), marks[0].1.off), (wordcraft_doc::Path::top(1), 0), "not in the table's cell"); +} + +#[test] +fn permission_ranges_of_chunks_and_their_host_stay_apart() { + let inner = package(r#"Inner"#, None); + // The host's range 0 is open across two chunks whose own ranges are 0 too. + let body = format!( + r#"Host{CHUNK}{CHUNK}After"# + ); + let d = read(&package(&body, Some(("docx", DOCX_MAIN, &inner)))).unwrap(); + assert_eq!(body_lines(&d), ["Host", "Inner", "Inner", "After"]); + let check = |d: &Document| { + let ranges = d.perm_ranges(); + let mut ids: Vec = ranges.iter().map(|r| r.id).collect(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), 3, "{ranges:?}"); + let host = ranges.iter().find(|r| r.group == "everyone").unwrap(); + assert_eq!((host.start.path.last(), host.end.path.last()), (0, 3)); + assert!(ranges.iter().filter(|r| r.editor == "them").all(|r| r.start.path == r.end.path)); + }; + check(&d); + check(&read(&wordcraft_docx::write(&d).unwrap()).unwrap()); +} + +/// A package whose body is `body`, with story parts (relationship type, part name, XML) and the +/// Word document `chunk` as `rIdC` from the main part and from each story part. +fn package_with_stories(body: &str, stories: &[(&str, &str, String)], chunk: Option<&[u8]>) -> Vec { + let rel = |id: &str, ty: &str, target: &str| { + format!(r#""#) + }; + let rels = + |inner: String| format!(r#"{inner}"#); + let chunk_rel = if chunk.is_some() { rel("rIdC", "aFChunk", "/word/afchunk.docx") } else { String::new() }; + let doc = format!(r#"{body}"#); + let mut main_rels = chunk_rel.clone(); + let mut files: Vec<(String, Vec)> = vec![("_rels/.rels".into(), ROOT_RELS.into()), ("word/document.xml".into(), doc.into_bytes())]; + for (i, (ty, name, xml)) in stories.iter().enumerate() { + main_rels.push_str(&rel(&format!("rIdS{i}"), ty, name)); + files.push((format!("word/{name}"), xml.clone().into_bytes())); + files.push((format!("word/_rels/{name}.rels"), rels(chunk_rel.clone()).into_bytes())); + } + files.push(("word/_rels/document.xml.rels".into(), rels(main_rels).into_bytes())); + if let Some(c) = chunk { + files.push(("word/afchunk.docx".into(), c.to_vec())); + } + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in files { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(&bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +/// A paragraph `text` that comment `id` (in the file) is anchored around. +fn commented(id: u32, text: &str) -> String { + format!( + r#"{text}"# + ) +} + +/// A comments part: (file id, author, content). +fn comments_part(list: &[(u32, &str, String)]) -> (&'static str, &'static str, String) { + let body: String = list.iter().map(|(id, who, content)| format!(r#"{content}"#)).collect(); + ("comments", "comments.xml", format!("{body}")) +} + +/// The authors of the comments anchored in the stories whose text contains `text`, and whether +/// every comment has text of its own and a distinct id. +fn anchored(d: &Document, text: &str) -> Vec { + let mut stories: Vec<&[std::sync::Arc]> = vec![&d.body]; + stories.extend(d.parts.values().map(|p| p.blocks.as_slice())); + let mut out = Vec::new(); + for blocks in stories { + for b in blocks { + let Block::Para(p) = &**b else { continue }; + if !p.text.contains(text) { + continue; + } + for o in &p.objects { + if let InlineObject::CommentStart { id } = o { + out.push(d.comments.get(id).map_or("?".to_string(), |c| c.author.clone())); + } + } + } + } + out +} + +/// A Word document with one comment by "Them" around "Inner". +fn commented_chunk() -> Vec { + package_with_stories(&commented(0, "Inner"), &[comments_part(&[(0, "Them", p("Theirs"))])], None) +} + +#[test] +fn a_chunk_in_a_footnote_keeps_its_comments_beside_the_documents() { + let notes = format!(r#""#); + let body = format!(r#"{}"#, commented(0, "Host")); + let stories = [("footnotes", "footnotes.xml", notes), comments_part(&[(0, "Us", p("Ours"))])]; + let d = read(&package_with_stories(&body, &stories, Some(&commented_chunk()))).unwrap(); + assert_eq!(d.comments.len(), 2, "{:?}", d.comments); + assert_eq!(anchored(&d, "Host"), ["Us"]); + assert_eq!(anchored(&d, "Inner"), ["Them"]); +} + +#[test] +fn a_chunk_in_a_comment_keeps_its_comments_beside_the_documents() { + let inside = format!(r#"{}"#, p("Ours")); + let body = format!("{}{}", commented(0, "Host"), commented(1, "Second")); + let stories = [comments_part(&[(0, "Us", inside), (1, "Us too", p("Ours too"))])]; + let d = read(&package_with_stories(&body, &stories, Some(&commented_chunk()))).unwrap(); + assert_eq!(d.comments.len(), 3, "{:?}", d.comments); + assert_eq!(anchored(&d, "Host"), ["Us"]); + assert_eq!(anchored(&d, "Second"), ["Us too"]); + assert_eq!(anchored(&d, "Inner"), ["Them"]); +} + +#[test] +fn a_wordprocessing_chunk_is_read_without_an_importer_and_flows_in_its_section() { + let inner = package(&format!("{}{}", p("Inner one"), p("Inner two")), None); + let bytes = package(&format!("{}{CHUNK}{}", p("Before"), p("After")), Some(("docx", DOCX_MAIN, &inner))); + let d = read(&bytes).unwrap(); + assert_eq!(body_lines(&d), ["Before", "Inner one", "Inner two", "After"]); + // The inner document's own section doesn't come along. + assert_eq!(d.sections().len(), 1); +} + +/// Styles a matchSrc chunk brings under new ids get distinct ones: with `A` and `A1` ours and +/// `A` to `A10` the chunk's, its `A` and `A1` both renamed don't land on one id (the second then +/// lost, its paragraphs taking the first's formatting), and keep their formatting through a save. +#[test] +fn match_src_gives_each_renamed_style_its_own_id() { + let style = |id: &str, half_points: u32| { + format!(r#""#) + }; + let ours: String = ["A", "A1"].iter().map(|id| style(id, 22)).collect(); + let mut theirs = style("A", 20) + &style("A1", 28); + for n in 2..=10 { + theirs += &style(&format!("A{n}"), 16); + } + let para = |id: &str, text: &str| format!(r#"{text}"#); + let inner = package_styled(&(para("A", "ten") + ¶("A1", "fourteen")), None, Some(&theirs)); + let body = format!(r#"{}"#, p("Ours")); + let d = read(&package_styled(&body, Some(("docx", DOCX_MAIN, &inner)), Some(&ours))).unwrap(); + let check = |d: &Document| { + let look = |text: &str| { + d.body + .iter() + .find_map(|b| match &**b { + Block::Para(p) if p.text == text => { + Some((p.props.style.clone(), d.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0)).size)) + } + _ => None, + }) + .unwrap() + }; + let ((ten, ten_size), (fourteen, fourteen_size)) = (look("ten"), look("fourteen")); + assert_ne!(ten, fourteen); + assert_eq!((ten_size, fourteen_size), (10.0, 14.0)); + }; + check(&d); + check(&read(&wordcraft_docx::write(&d).unwrap()).unwrap()); +} + +#[test] +fn match_src_keeps_a_word_chunks_own_formatting() { + // Both define Normal and Subtitle differently, and the chunk's defaults differ from ours. + let ours = r#""#; + let theirs = r#""#; + let inner = package_styled( + &format!(r#"{}Sub"#, p("Plain")), + None, + Some(theirs), + ); + for match_src in [true, false] { + let pr = if match_src { "" } else { "" }; + let body = format!(r#"{}{pr}"#, p("Ours")); + let d = read(&package_styled(&body, Some(("docx", DOCX_MAIN, &inner)), Some(ours))).unwrap(); + let look = |i: usize| match &*d.body[i] { + Block::Para(p) => { + let para = d.styles.resolve_para(&p.props); + let chr = d.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0)); + (para.space_after, chr.size, chr.italic) + } + b => panic!("{b:?}"), + }; + // Ours stays ours either way. + assert_eq!(look(0), (12.4, 11.0, false)); + if match_src { + // The chunk's own Normal (its 12 pt, Word's no space after) and its Subtitle (not italic). + assert_eq!(look(1), (0.0, 12.0, false)); + assert_eq!(look(2), (0.0, 12.0, false)); + } else { + // Without it the chunk takes our styles (our Subtitle stands on our defaults). + assert_eq!(look(1), (12.4, 11.0, false)); + assert_eq!(look(2), (10.0, 11.0, true)); + } + } +} + +#[test] +fn nested_wordprocessing_chunks_stop_at_a_depth() { + let mut bytes = package(&p("Deepest"), None); + for level in 0..8 { + bytes = package(&format!("{}{CHUNK}", p(&format!("Level {level}"))), Some(("docx", DOCX_MAIN, &bytes))); + } + let lines = body_lines(&read(&bytes).unwrap()); + assert_eq!(lines.first().map(String::as_str), Some("Level 7")); + assert!(!lines.iter().any(|l| l == "Deepest"), "{lines:?}"); + assert!(lines.len() >= 4, "{lines:?}"); +} + +#[test] +fn a_chunks_pictures_come_with_it() { + let bytes = package(&format!("{}{CHUNK}", p("Before")), Some(("htm", "text/html", b"

x

"))); + let import = |_: &AltChunk| { + let mut d = Document::new(); + let key = d.add_media(vec![1, 2, 3], "png"); + let mut para = wordcraft_doc::Paragraph::new(); + let image = InlineObject::Image { media: key, w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None }; + para.insert_object(0, image, &Default::default()).unwrap(); + d.body = vec![std::sync::Arc::new(Block::Para(para))]; + Some(d) + }; + let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap(); + let keys: Vec = d + .body + .iter() + .filter_map(|b| match &**b { + Block::Para(p) => p.objects.iter().find_map(|o| match o { + InlineObject::Image { media, .. } => Some(media.clone()), + _ => None, + }), + _ => None, + }) + .collect(); + assert_eq!(keys.len(), 1); + assert_eq!(d.media.get(&keys[0]).map(|m| m.as_slice()), Some(&[1u8, 2, 3][..])); +} + +#[test] +fn a_file_brings_in_a_bounded_number_of_chunks() { + let bytes = package(&CHUNK.repeat(300), Some(("htm", "text/html", b"

x

"))); + let calls = std::cell::Cell::new(0); + let import = |_: &AltChunk| { + calls.set(calls.get() + 1); + Some(Document::from_text("x")) + }; + let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap(); + assert_eq!(calls.get(), 256); + assert_eq!(body_lines(&d).len(), 256); +} + +#[test] +fn hostile_chunks_never_panic() { + for (ext, ct, bytes) in [("docx", DOCX_MAIN, &b"PK\x03\x04 not a zip"[..]), ("htm", "text/html", b""), ("bin", "", b"\x00\xff")] { + let bytes = package(&format!("{CHUNK}{CHUNK}{}", p("x")), Some((ext, ct, bytes))); + let import = |_: &AltChunk| None; + let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap(); + assert_eq!(body_lines(&d), ["x"]); + } + // A chunk naming a missing part or another relationship type is left out. + let bytes = package(&format!(r#"{}"#, p("y")), None); + assert_eq!(body_lines(&read(&bytes).unwrap()), ["y"]); +} diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index 559291f63..82d506793 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -48,6 +48,76 @@ fn read_body(body: &str) -> Document { wordcraft_docx::read(&docx(body, &[], &[])).unwrap() } +/// Markup compatibility replaces an AlternateContent wrapper with one selected branch, +/// including where a table expects rows or cells (ECMA-376 Part 3). +#[test] +fn table_alternate_content_keeps_selected_rows_and_cells() { + let cell = |text: &str| format!("{text}"); + let row = |text: &str| format!("{}", cell(text)); + let table = + |rows: &str, columns: usize| format!(r#"{}{rows}"#, r#""#.repeat(columns)); + let alternate = |chosen: &str, fallback: &str, requires: &str| { + format!( + r#"{chosen}{fallback}"# + ) + }; + for (requires, use_choice) in [("wps", true), ("alias", true), ("unknown", false), ("wps unknown", false)] { + for cells in [false, true] { + let choice = if cells { cell("Chosen cell") } else { row("Chosen row") }; + let fallback = if cells { cell("Fallback cell") } else { row("Fallback row") }; + let selected = if use_choice { &choice } else { &fallback }; + let middle = alternate(&choice, &fallback, requires); + let (actual, expected) = if cells { + ( + format!("{}{middle}{}", cell("Before"), cell("After")), + format!("{}{selected}{}", cell("Before"), cell("After")), + ) + } else { + (format!("{}{middle}{}", row("Before"), row("After")), format!("{}{selected}{}", row("Before"), row("After"))) + }; + let columns = if cells { 3 } else { 1 }; + let expected = read_body(&table(&expected, columns)); + let wrapped = format!( + r#"{}"#, + table(&actual, columns) + ); + let mut actual = read_body(&wrapped); + for round in 0..=2 { + assert_eq!(actual.body, expected.body, "requires={requires}, cells={cells}, round={round}"); + if round < 2 { + actual = wordcraft_docx::read(&wordcraft_docx::write(&actual).unwrap()).unwrap(); + } + } + } + } +} + +#[test] +fn table_alternate_content_keeps_nested_row_and_cell_controls() { + let wrap = + |tag: &str, content: &str| format!(r#"{content}"#); + let alternate = + |content: &str| format!(r#"{content}"#); + let cell = "Controlled cell"; + let oracle_cell = wrap("cell", cell); + let actual_cell = alternate(&wrap("cell", &alternate(cell))); + let oracle_row = wrap("row", &format!("{oracle_cell}")); + let actual_row = wrap("row", &alternate(&format!("{actual_cell}"))); + let expected = read_body(&format!("{oracle_row}")); + let mut actual = read_body(&format!("{}", alternate(&actual_row))); + for round in 0..=2 { + assert_eq!(actual.body, expected.body, "round={round}"); + let table = actual.body.iter().find_map(|b| b.as_table()).unwrap(); + assert_eq!(table.rows[0].controls.open[0].tag, "row"); + assert_eq!(table.rows[0].controls.close, 1); + assert_eq!(table.rows[0].cells[0].controls.open[0].tag, "cell"); + assert_eq!(table.rows[0].cells[0].controls.close, 1); + if round < 2 { + actual = wordcraft_docx::read(&wordcraft_docx::write(&actual).unwrap()).unwrap(); + } + } +} + #[test] fn repeated_style_ids_use_last() { let styles = format!( @@ -4022,6 +4092,30 @@ fn equation_in_alternate_content_is_read() { assert!(!p.plain_text().contains("[picture]")); } +/// A fill's `a:alpha` is its opacity: a translucent white wash over a picture stayed opaque and +/// hid the picture. +#[test] +fn a_shape_fill_keeps_its_transparency() { + let fill = + r#""#; + let d = read_body(&format!("{}", anchored_wsp("Wash", 1270000, 635000, fill, "", ""))); + let transparency = |d: &Document| match ¶s(d)[0].objects[0] { + InlineObject::Shape { fill: Some(wordcraft_doc::fill::Fill::Solid { color, transparency }), .. } => (Some(*color), *transparency), + o => panic!("{o:?}"), + }; + const FILL_ALPHA: u32 = 30_980; + const ALPHA_SCALE: f32 = 100_000.0; + // Canonical Fill stores the percentage computed from opacity, including f32 rounding. + let expected = 100.0 - (FILL_ALPHA as f32 / ALPHA_SCALE) * 100.0; + assert_eq!(transparency(&d), (Some(Rgb(255, 255, 255)), expected)); + let bytes = wordcraft_docx::write(&d).unwrap(); + let parts = unzip(&bytes); + let xml = String::from_utf8_lossy(parts.get("word/document.xml").unwrap()); + assert!(xml.contains(&format!("")), "{xml}"); + let again = wordcraft_docx::read(&bytes).unwrap(); + assert_eq!(transparency(&again), (Some(Rgb(255, 255, 255)), expected)); +} + /// #1500: gradient fills and transparency on shapes (the cases in the issue), from the shape's /// own properties, the theme's fill styles (`a:fillRef` 3 and 1003, as Word's cover pages use), /// a group's fill (`a:grpFill`), an outline gradient and VML's `v:fill`. Each is read as the @@ -4218,3 +4312,62 @@ fn vml_textbox_style_anchor_and_fit() { assert_eq!(tb.body.anchor, wordcraft_doc::props::VAlign::Center); assert!(tb.body.auto_fit); } + +/// A VML horizontal rule (`o:hr`, a web page's `
` pasted into Word) is read as one: the whole +/// line when it has no width and no `o:hrpct`, a share of it (`o:hrpct`, tenths of a percent), or +/// its own width (`o:hrpct="0"`), placed by `o:hralign`. It saves back as the same VML rule. +#[test] +fn vml_horizontal_rules_are_read_and_kept() { + use wordcraft_doc::connector::HorizontalRule; + use wordcraft_doc::props::Align; + let hr = |attrs: &str, style: &str| format!(r#""#); + let body = [ + hr(r#"o:hralign="center" o:hrstd="t" o:hr="t""#, "width:0;height:1.5pt"), + hr(r#"o:hrpct="500" o:hralign="right" o:hr="t""#, "width:0;height:1.5pt"), + hr(r#"o:hrpct="0" o:hralign="center" o:hr="t""#, "width:100pt;height:3pt"), + hr("", "width:100pt;height:3pt"), + ] + .concat(); + let rules = |d: &Document| -> Vec<(Option, f32)> { + paras(d) + .iter() + .filter_map(|p| p.objects.iter().find_map(|o| if let InlineObject::Shape { extra, w, .. } = o { Some((extra.hr, *w)) } else { None })) + .collect() + }; + let d = read_body(&body); + let got = rules(&d); + assert_eq!( + got, + [ + (Some(HorizontalRule { pct: 100.0, align: Align::Center, standard: true }), 0.0), + (Some(HorizontalRule { pct: 50.0, align: Align::Right, standard: false }), 0.0), + (Some(HorizontalRule { pct: 0.0, align: Align::Center, standard: false }), 100.0), + (None, 100.0), + ] + ); + let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!(rules(&again)[..3], got[..3]); + // A rule's rotation and flips are kept. + let spin = |d: &Document| paras(d)[0].objects.iter().find_map(|o| o.frame()).map(|(_, _, f)| f.spin()); + let d = read_body(&hr(r#"o:hrpct="500" o:hr="t""#, "width:0;height:1.5pt;rotation:45;flip:x y")); + assert_eq!(spin(&d), Some(wordcraft_geom::Spin::new(45.0, true, true))); + let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!(spin(&again), spin(&d)); + // A see-through rule stays see-through. + let fill = |d: &Document| paras(d)[0].objects.iter().find_map(|o| if let InlineObject::Shape { fill, .. } = o { fill.clone() } else { None }); + let d = read_body( + r##""##, + ); + let Some(wordcraft_doc::fill::Fill::Solid { transparency, .. }) = fill(&d) else { panic!("{:?}", fill(&d)) }; + assert!((transparency - 75.0).abs() < 0.01, "{transparency}"); + let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!(fill(&again), fill(&d)); + // A positioned or hidden rule stays a plain shape, keeping its placement and visibility. + let body = [ + hr(r#"o:hralign="center" o:hr="t""#, "position:absolute;margin-left:10pt;margin-top:20pt;width:100pt;height:2pt"), + hr(r#"o:hralign="center" o:hr="t""#, "visibility:hidden;width:0;height:2pt"), + ] + .concat(); + let d = read_body(&body); + assert_eq!(rules(&d).iter().map(|r| r.0).collect::>(), [None, None]); +} diff --git a/crates/docx/tests/it/main.rs b/crates/docx/tests/it/main.rs index e7134e11d..dda0e4ffe 100644 --- a/crates/docx/tests/it/main.rs +++ b/crates/docx/tests/it/main.rs @@ -1,3 +1,4 @@ +mod alt_chunks; mod bidi; mod bidi_runs; mod cell_margin_inheritance; diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 57aaabdd5..c5c4a1240 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -134,7 +134,8 @@ pub fn specs() -> Vec { CommandSpec::new("insert.textFromFile", "Text from File", "Insert › Text › Object", |s, v| { let path = p::req_str(v, "path")?; let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?; - let mut frag = doc.body_fragment(); + // The file's pictures, lists and styles come along (its own keys and ids mean nothing here). + let mut frag = s.doc.adopt(doc); super::paste::unlink_sections(&mut frag.blocks); let at = delete_selection(s)?; let end = s.doc.insert_fragment(&at, &frag)?; diff --git a/crates/engine/src/cmd/paste.rs b/crates/engine/src/cmd/paste.rs index 5327e8e02..4a8aad3b0 100644 --- a/crates/engine/src/cmd/paste.rs +++ b/crates/engine/src/cmd/paste.rs @@ -8,7 +8,6 @@ //! program's HTML or RTF; Merge Formatting keeps only its emphasis (#687). //! Plain text has no formatting to keep, so Paste inserts it as Keep Text Only does. -use std::collections::BTreeMap; use std::sync::Arc; use serde_json::{Value, json}; @@ -401,35 +400,6 @@ pub(crate) fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Para } } -/// Point a tracked formatting change at its renumbered revision (dropped when it has none). -fn remap_change(c: &mut Option>>, rev: &dyn Fn(&mut Option)) { - let mut r = c.as_ref().map(|c| c.rev); - rev(&mut r); - match (r, c.as_mut()) { - (Some(v), Some(x)) => x.rev = v, - _ => *c = None, - } -} - -/// [`remap_change`] for the table, row and cell formatting changes in `b` (bounded depth). -fn remap_table_changes(b: &mut Block, depth: usize, rev: &dyn Fn(&mut Option)) { - if depth > MAX_DEPTH { - return; - } - if let Block::Table(t) = b { - remap_change(&mut t.props.fmt_change, rev); - for row in &mut t.rows { - remap_change(&mut row.props.fmt_change, rev); - for cell in &mut row.cells { - remap_change(&mut cell.props.fmt_change, rev); - for cb in &mut cell.blocks { - remap_table_changes(Arc::make_mut(cb), depth + 1, rev); - } - } - } - } -} - /// Section breaks brought in from another document keep their page, but their header and /// footer ids are that document's: clear them (and the different first page that went with /// them) so the sections link to the previous section's headers and footers, as in Word. @@ -448,81 +418,12 @@ pub(crate) fn unlink_sections(blocks: &mut [Block]) { /// Turn an imported document into a fragment of the session's document: bring over the media, /// lists and styles it refers to (renumbered so they can't collide with ours). fn adopt(s: &mut Session, src: Document) -> Fragment { - let mut media = BTreeMap::new(); - for (key, data) in &src.media { - let ext = key.rsplit_once('.').map(|(_, e)| e).unwrap_or("png"); - media.insert(key.clone(), s.doc.add_media(data.to_vec(), ext)); - } - let next_abs = s.doc.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0); - let mut abs_map = BTreeMap::new(); - for (i, a) in src.numbering.abstracts.iter().enumerate() { - let id = next_abs.saturating_add(i as u32); - abs_map.insert(a.id, id); - s.doc.numbering.abstracts.push(wordcraft_doc::numbering::AbstractNum { id, ..a.clone() }); - } - let next_num = s.doc.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1); - let mut num_map = BTreeMap::new(); - for (i, n) in src.numbering.nums.iter().enumerate() { - let id = next_num.saturating_add(i as u32); - num_map.insert(n.id, id); - let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id); - s.doc.numbering.nums.push(wordcraft_doc::numbering::Num { id, abstract_id, ..n.clone() }); - } - for st in &src.styles.styles { - if s.doc.styles.get(&st.id).is_none() { - s.doc.styles.upsert(st.clone()); - } - } - // Tracked changes the source carries (RTF revisions) point at its own revisions: add them - // after ours and renumber. - let rev_base = u32::try_from(s.doc.revisions.len()).unwrap_or(u32::MAX); - let src_revs = src.revisions.len(); - s.doc.revisions.extend(src.revisions.iter().cloned()); - let rev = |i: &mut Option| { - *i = i.filter(|v| (*v as usize) < src_revs).and_then(|v| rev_base.checked_add(v)); - }; - let mut blocks: Vec = src.body.iter().map(|b| (**b).clone()).collect(); + let mut frag = s.doc.adopt(src); // Pasted pictures fit our text width, like Insert › Pictures (layout keeps their size). let max_w = crate::io::text_width(&s.doc); - for b in &mut blocks { + for b in &mut frag.blocks { crate::io::fit_block_pictures(b, max_w); } - for b in &mut blocks { - each_para(b, 0, &mut |p| { - if let Some(n) = p.props.numbering.as_mut() - && n.num != 0 - { - n.num = num_map.get(&n.num).copied().unwrap_or(0); - } - for r in &mut p.runs { - rev(&mut r.props.ins); - rev(&mut r.props.del); - remap_change(&mut r.props.fmt_change, &rev); - } - rev(&mut p.mark.ins); - rev(&mut p.mark.del); - remap_change(&mut p.mark.fmt_change, &rev); - remap_change(&mut p.props.fmt_change, &rev); - let mut n = p.props.num_change.as_ref().map(|c| c.rev); - rev(&mut n); - match (n, p.props.num_change.as_mut()) { - (Some(v), Some(c)) => c.rev = v, - _ => p.props.num_change = None, - } - for o in &mut p.objects { - if let wordcraft_doc::InlineObject::Image { media: key, .. } = o - && let Some(k) = media.get(key.as_str()) - { - *key = k.clone(); - } - } - }); - } - for b in &mut blocks { - remap_table_changes(b, 0, &rev); - } - unlink_sections(&mut blocks); - // Text box stories the imported shapes show; pasting gives each a new story of its own. - let parts = src.parts.into_iter().filter(|(_, p)| p.kind == wordcraft_doc::PartKind::TextBox).collect(); - Fragment { blocks, parts } + unlink_sections(&mut frag.blocks); + frag } diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 78aca5176..da65f68e9 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -80,6 +80,7 @@ pub fn specs() -> Vec { CommandSpec::new("review.changes", "Reviewing Pane", "Review › Tracking", list_changes).pure(), CommandSpec::new("review.spelling", "Spelling & Grammar", "Review › Proofing", next_issue) .key(super::mac_or("F7 / Alt+F7 / Mod+Alt+L", "F7 / Alt+F7")) + .params(r#"{"wrap"?: bool (default true; false stops at the end of the current story)}"#) .pure(), CommandSpec::new("review.issues", "Proofing Issues", "Review › Proofing", all_issues).pure(), CommandSpec::new("review.suggestions", "Spelling Suggestions", "Review › Proofing", suggestions).params(r#"{"pos"?: Pos}"#).pure(), diff --git a/crates/engine/src/cmd/review/proofing.rs b/crates/engine/src/cmd/review/proofing.rs index 098c69879..2b3a2e4a9 100644 --- a/crates/engine/src/cmd/review/proofing.rs +++ b/crates/engine/src/cmd/review/proofing.rs @@ -55,7 +55,16 @@ fn para_issues(s: &Session, story: StoryRef, path: &wordcraft_doc::Path) -> Vec< /// The issue under a position. pub(super) fn issue_at(s: &Session, at: &Pos) -> Option<(Pos, Pos, wordcraft_proof::Issue)> { - para_issues(s, at.story, &at.path).into_iter().find(|(a, b, _)| a.off <= at.off && at.off <= b.off) + let issues = para_issues(s, at.story, &at.path); + let selected = s.sel.ordered(); + // F7 can select a spelling issue inside a larger grammar issue. Keep that + // exact target for either selection direction before falling back to a point. + if at == &s.sel.focus + && let Some(issue) = issues.iter().find(|(a, b, _)| a == &selected.0 && b == &selected.1) + { + return Some(issue.clone()); + } + issues.into_iter().find(|(a, b, _)| a.off <= at.off && at.off <= b.off) } fn issue_json(s: &Session, a: &Pos, b: &Pos, i: &wordcraft_proof::Issue) -> Value { @@ -69,15 +78,23 @@ fn issue_json(s: &Session, a: &Pos, b: &Pos, i: &wordcraft_proof::Issue) -> Valu } /// F7: select the next issue after the caret and return it with suggestions. -pub(super) fn next_issue(s: &mut Session, _: &Value) -> CmdResult { +pub(super) fn next_issue(s: &mut Session, v: &Value) -> CmdResult { let caret = s.sel.ordered().1; let story = caret.story; let paths = s.doc.para_paths(story); let start = paths.iter().position(|p| *p == caret.path).unwrap_or(0); - for k in 0..paths.len() { - let Some(path) = paths.get((start + k) % paths.len().max(1)) else { break }; + let wrap = p::bool(v, "wrap").unwrap_or(true); + // Scan forward first, including the rest of the caret's paragraph. A second pass + // optionally visits the earlier part; never select the first typo repeatedly in + // a single-paragraph story. Dialogs disable wrapping so Ignore Once can finish. + for (path, before) in paths + .iter() + .skip(start) + .map(|path| (path, false)) + .chain(paths.iter().take(if wrap { start.saturating_add(1) } else { 0 }).map(|path| (path, true))) + { for (a, b, i) in para_issues(s, story, path) { - if k == 0 && a.off < caret.off && start + k < paths.len() && paths.len() > 1 { + if *path == caret.path && (a.off < caret.off) != before { continue; } let r = issue_json(s, &a, &b, &i); @@ -103,8 +120,13 @@ pub(super) fn all_issues(s: &mut Session, _: &Value) -> CmdResult { } pub(super) fn suggestions(s: &mut Session, v: &Value) -> CmdResult { - let at = v.get("pos").and_then(crate::cmd::parse_pos).unwrap_or_else(|| s.sel.focus.clone()); - match issue_at(s, &at) { + // Explicit positions are point lookups, even when they equal the selection focus. + // Only an implicit or unparseable position keeps the exact selected issue. + let issue = match v.get("pos").and_then(crate::cmd::parse_pos) { + Some(at) => para_issues(s, at.story, &at.path).into_iter().find(|(a, b, _)| a.off <= at.off && at.off <= b.off), + None => issue_at(s, &s.sel.focus), + }; + match issue { Some((a, b, i)) => Ok(issue_json(s, &a, &b, &i)), None => Ok(Value::Null), } diff --git a/crates/engine/src/cmd/review/tests.rs b/crates/engine/src/cmd/review/tests.rs index 2f4abd16b..64dd98397 100644 --- a/crates/engine/src/cmd/review/tests.rs +++ b/crates/engine/src/cmd/review/tests.rs @@ -1058,3 +1058,66 @@ fn rtf_tracked_changes_accept_and_reject_after_opening_and_round_trip() { } } } + +#[cfg(test)] +mod spelling_navigation_tests { + use super::*; + use wordcraft_doc::{Document, Pos, StoryRef}; + + use crate::Selection; + + #[test] + fn spelling_review_dialog_navigation_moves_forward_and_wraps_only_when_requested() { + for text in ["The brwn fox and brwn dog.", "The brwn fox.\nThe brwn dog."] { + let mut s = Session::new(Document::from_text(text)); + let first = s.run("review.spelling", &json!({"wrap": false})).unwrap(); + assert_eq!(first["text"], "brwn"); + let first_selection = s.sel.clone(); + let second = s.run("review.spelling", &json!({"wrap": false})).unwrap(); + assert_eq!(second["text"], "brwn"); + assert_ne!(s.sel, first_selection); + assert_eq!(s.run("review.spelling", &json!({"wrap": false})).unwrap()["done"], true); + assert_eq!(s.run("review.spelling", &json!({})).unwrap()["start"], first["start"]); + } + } + #[test] + fn spelling_review_dialog_navigation_resolves_selected_spelling_before_overlapping_grammar() { + for reverse in [false, true] { + let mut s = Session::new(Document::from_text("A sentense sentense.")); + s.sel = Selection::caret(Pos::body(0, 11)); + let selected = s.run("review.spelling", &json!({"wrap": false})).unwrap(); + assert_eq!(selected["kind"], "spelling"); + assert_eq!(selected["start"]["off"], 11); + assert_eq!(selected["text"], "sentense"); + let issues = s.run("review.issues", &json!({})).unwrap(); + assert!(issues.as_array().unwrap().iter().any(|i| i["kind"] == "grammar" && i["text"] == "sentense sentense")); + if reverse { + std::mem::swap(&mut s.sel.anchor, &mut s.sel.focus); + } + assert_eq!(s.run("review.suggestions", &json!({})).unwrap(), selected); + let before_lookup = s.sel.clone(); + let focus = s.sel.focus.off; + let explicit = s.run("review.suggestions", &json!({"pos": {"block": 0, "off": focus}})).unwrap(); + assert_eq!(explicit["kind"], "grammar", "an explicit focus is a point lookup in either selection direction"); + assert_eq!(explicit["text"], "sentense sentense"); + assert_eq!(explicit["start"]["off"], 2); + assert_eq!(explicit["end"]["off"], 19); + assert_eq!(s.sel, before_lookup, "suggestion lookups do not move the selection"); + for pos in [serde_json::Value::Null, json!({"off": "invalid"})] { + assert_eq!( + s.run("review.suggestions", &json!({"pos": pos})).unwrap(), + selected, + "unparseable positions preserve implicit selection lookup" + ); + } + assert_eq!(s.run("review.suggestions", &json!({})).unwrap(), selected); + let elsewhere = s.run("review.suggestions", &json!({"pos": {"block": 0, "off": 5}})).unwrap(); + assert_eq!(elsewhere["kind"], "spelling"); + assert_eq!(elsewhere["start"]["off"], 2, "an explicit other position keeps point lookup"); + s.run("review.applySuggestion", &json!({"text": "sentence"})).unwrap(); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A sentense sentence."); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A sentense sentense."); + } + } +} diff --git a/crates/engine/src/cmd/tools/compare.rs b/crates/engine/src/cmd/tools/compare.rs index d7f1f8d28..d275d7365 100644 --- a/crates/engine/src/cmd/tools/compare.rs +++ b/crates/engine/src/cmd/tools/compare.rs @@ -1,6 +1,8 @@ use super::{ - Arc, Block, CharProps, CmdError, CmdResult, Document, Paragraph, RevisionKind, Selection, Session, StoryRef, Value, json, p, para_block, + Arc, Block, CharProps, CmdError, CmdResult, Document, InlineObject, Paragraph, RevisionKind, Selection, Session, StoryRef, Value, json, p, + para_block, }; +use wordcraft_doc::para::OBJ; /// Compare and Combine take the same settings. pub(super) const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character", "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#; @@ -64,7 +66,13 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult { let author = p::str(v, "author").map(str::trim).filter(|a| !a.is_empty()).map(str::to_string).unwrap_or_else(|| s.author.clone()); let date = crate::cmd::now_iso(); let old: Vec> = s.doc.body.clone(); - let new: Vec> = revised.body.clone(); + let new: Vec> = if revised.parts.values().any(|p| p.kind == wordcraft_doc::PartKind::TextBox) { + // The revised document's text boxes get stories of their own in the result, so its + // boxes never show a story of the original's (or none at all). + s.doc.adopt_fragment(&revised.body_fragment()).into_iter().map(Arc::new).collect() + } else { + revised.body.clone() + }; s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Insert, author: author.clone(), date: date.clone(), move_name: None }); let ins = (s.doc.revisions.len() - 1) as u32; s.doc.revisions.push(wordcraft_doc::Revision { kind: RevisionKind::Delete, author: author.clone(), date: date.clone(), move_name: None }); @@ -86,6 +94,16 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult { let (a, b) = (old.get(i), new.get(j)); // The revised block, with its formatting changes tracked. if let (Some(Block::Para(pa)), Some(Block::Para(pb))) = (a.map(|x| &**x), b.map(|x| &**x)) { + if !same_objects(&s.doc, pa, pb) { + // Same text, but objects (text boxes, pictures…) differ: diffed, so the + // original's objects aren't lost. + out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget))); + changes += 1; + i += 1; + j += 1; + k += 1; + continue; + } let mut p = pb.clone(); if opts.formatting && pa.text == pb.text { let before = s.doc.revisions.len(); @@ -111,7 +129,9 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult { for x in 0..dels.max(inss) { let (a, b) = (if x < dels { old.get(i + x) } else { None }, if x < inss { new.get(j + x) } else { None }); match (a.map(|x| &**x), b.map(|x| &**x)) { - (Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(pa, pb, ins, del, &opts, &mut budget))), + (Some(Block::Para(pa)), Some(Block::Para(pb))) => { + out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget))) + } _ => { if let Some(x) = a { out.push(Arc::new(mark_block(x, &|c| c.del = Some(del), 0))); @@ -308,30 +328,97 @@ fn flatten_region(out: &mut Vec, region: &[(Op, usize, Kind)]) { out.extend(std::iter::repeat_n(Op::Ins, inss)); } -/// Words with the spaces after them ("The ", "cat "), or single characters. -fn tokens(s: &str, characters: bool) -> Vec { - if characters { - return s.chars().map(String::from).collect(); +/// A token of a changed paragraph: a word (or character), or an inline object without text +/// of its own (a text box anchor, a picture, a bookmark…), which is compared and kept whole. +enum Tok<'a> { + Text(String), + Obj(&'a InlineObject), +} + +/// What a comparison sees of a token: the text as the options say, an object with the text of +/// the text boxes it shows instead of their story ids (each document numbers its own stories). +#[derive(PartialEq)] +enum Key { + Text(String), + Obj(Box, Vec), +} + +fn obj_key(doc: &Document, o: &InlineObject) -> Key { + let texts = o.text_boxes().into_iter().map(|id| doc.plain_text(StoryRef::Part(id))).collect(); + let mut o = o.clone(); + for story in o.text_box_slots() { + *story = None; + } + Key::Obj(Box::new(o), texts) +} + +/// Whether two paragraphs hold the same objects, as compared. +fn same_objects(doc: &Document, a: &Paragraph, b: &Paragraph) -> bool { + a.objects.len() == b.objects.len() && a.objects.iter().zip(&b.objects).all(|(x, y)| x == y || obj_key(doc, x) == obj_key(doc, y)) +} + +/// Words with the spaces after them ("The ", "cat "), or single characters, and the objects +/// between them. Objects with text (fields, equations…) count as their text. +fn tokens(p: &Paragraph, characters: bool) -> Vec> { + fn push(v: &mut Vec>, cur: &mut String, c: char, characters: bool) { + cur.push(c); + if characters || c == ' ' { + v.push(Tok::Text(std::mem::take(cur))); + } } let mut v = Vec::new(); let mut cur = String::new(); - for c in s.chars() { - cur.push(c); - if c == ' ' { - v.push(std::mem::take(&mut cur)); + let mut objects = p.objects.iter(); + for c in p.text.chars() { + if c != OBJ { + push(&mut v, &mut cur, c, characters); + continue; + } + match objects.next() { + Some(o) if o.plain_text().is_empty() => { + if !cur.is_empty() { + v.push(Tok::Text(std::mem::take(&mut cur))); + } + v.push(Tok::Obj(o)); + } + Some(o) => o.plain_text().chars().for_each(|c| push(&mut v, &mut cur, c, characters)), + None => { + // A marker without its object (a broken paragraph): kept as a visible + // placeholder, so the loss shows in the result instead of vanishing. + log::warn!("compare: an object marker without its object"); + push(&mut v, &mut cur, char::REPLACEMENT_CHARACTER, characters); + } } } if !cur.is_empty() { - v.push(cur); + v.push(Tok::Text(cur)); } v } -fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions, budget: &mut u64) -> Paragraph { +/// Add a token at the end of `p` with `props`. +fn put(p: &mut Paragraph, t: Option<&Tok<'_>>, props: &CharProps) { + let at = p.len(); + match t { + Some(Tok::Text(t)) => { + let _ = p.insert_text(at, t, props); + } + Some(Tok::Obj(o)) => { + let _ = p.insert_object(at, (*o).clone(), props); + } + None => {} + } +} + +fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions, budget: &mut u64) -> Paragraph { let (at, bt) = (a.plain_text(), b.plain_text()); let chars = opts.characters && at.chars().count().max(bt.chars().count()) <= MAX_CHAR_DIFF; - let (ta, tb) = (tokens(&at, chars), tokens(&bt, chars)); - let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::>(), &tb.iter().map(|t| opts.key(t)).collect::>(), budget); + let (ta, tb) = (tokens(a, chars), tokens(b, chars)); + let key = |t: &Tok<'_>| match t { + Tok::Text(t) => Key::Text(opts.key(t)), + Tok::Obj(o) => obj_key(doc, o), + }; + let ops = lcs_ops(&ta.iter().map(key).collect::>(), &tb.iter().map(key).collect::>(), budget); // Only at character granularity: a matched whole word almost never happens by coincidence, // but a matched single character very often does (digits, punctuation), so only there does // a short interior match need folding back into its surrounding edit. @@ -341,20 +428,19 @@ fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOpt let base = a.runs.first().map(|r| r.props.clone()).unwrap_or_default(); let (mut i, mut j) = (0, 0); for op in ops { - let at = p.len(); match op { Op::Same => { // Equal as compared (perhaps up to case or spacing): the revised text. - let _ = p.insert_text(at, tb.get(j).map(String::as_str).unwrap_or(""), &base); + put(&mut p, tb.get(j), &base); i += 1; j += 1; } Op::Del => { - let _ = p.insert_text(at, ta.get(i).map(String::as_str).unwrap_or(""), &CharProps { del: Some(del), ..base.clone() }); + put(&mut p, ta.get(i), &CharProps { del: Some(del), ..base.clone() }); i += 1; } Op::Ins => { - let _ = p.insert_text(at, tb.get(j).map(String::as_str).unwrap_or(""), &CharProps { ins: Some(ins), ..base.clone() }); + put(&mut p, tb.get(j), &CharProps { ins: Some(ins), ..base.clone() }); j += 1; } } diff --git a/crates/engine/src/cmd/tools/tests.rs b/crates/engine/src/cmd/tools/tests.rs index 5ac4df958..4683817e6 100644 --- a/crates/engine/src/cmd/tools/tests.rs +++ b/crates/engine/src/cmd/tools/tests.rs @@ -80,6 +80,86 @@ fn compare_at_word_level_still_matches_a_whole_unchanged_word() { ); } +#[test] +fn compare_keeps_text_boxes_of_the_original() { + // Text boxes are anchored in body paragraphs: one after changed text, one alone in an + // otherwise empty paragraph, one after text the revised document keeps. Their anchors must + // survive the compare (marked deleted, since the revised text has no boxes), so no text is lost. + for show_in in ["original", "revised", "new"] { + let mut s = Session::new(Document::from_text("Body one.\n\nKeep.")); + let mut boxes = Vec::new(); + for (para, off, text) in [(0usize, "Body one.".len(), "Alpha boxword gamma."), (1, 0, "Vml onlyword here."), (2, "Keep.".len(), "Kept box.")] + { + s.run("caret.set", &json!({"pos": Pos::body(para, off)})).unwrap(); + let id = s.run("insert.textBox", &json!({"text": text})).unwrap()["story"].as_u64().unwrap() as u32; + boxes.push((id, text)); + } + s.run("caret.set", &json!({"pos": Pos::body(0, 0)})).unwrap(); + s.run("review.compare", &json!({"text": "Totally new first.\nSecond new line.\nKeep.", "showIn": show_in})).unwrap(); + for (id, text) in &boxes { + let anchor = s.doc.text_box_anchor(*id).unwrap_or_else(|| panic!("{show_in}: the box \"{text}\" lost its anchor")); + assert_eq!(s.doc.plain_text(StoryRef::Part(*id)), *text); + let p = s.doc.para(anchor.story, &anchor.path).unwrap(); + assert!( + p.props_of_char(anchor.off - wordcraft_doc::para::OBJ.len_utf8()).del.is_some(), + "{show_in}: the box \"{text}\" is not marked deleted" + ); + } + s.run("review.rejectAll", &json!({})).unwrap(); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Body one.\n\nKeep."); + for (id, text) in &boxes { + assert!(s.doc.text_box_anchor(*id).is_some(), "{show_in}: rejecting lost the box \"{text}\""); + assert_eq!(s.doc.plain_text(StoryRef::Part(*id)), *text); + } + } +} + +#[test] +fn compare_with_a_revised_file_keeps_its_text_boxes_backed() { + // Text boxes in both documents, the revised one from a file: every anchor in the result + // shows a text box story of the result, and both documents' boxes are there, marked. + fn with_boxes(text: &str, boxes: &[(usize, usize, &str)]) -> Session { + let mut s = Session::new(Document::from_text(text)); + for (para, off, text) in boxes { + s.run("caret.set", &json!({"pos": Pos::body(*para, *off)})).unwrap(); + s.run("insert.textBox", &json!({"text": text})).unwrap(); + } + s + } + /// Each text box anchored in the body: its story's text, and whether its anchor is deleted + /// or inserted. + fn boxes(d: &Document) -> Vec<(String, bool, bool)> { + let mut out = Vec::new(); + for path in d.para_paths(StoryRef::Body) { + let p = d.para(StoryRef::Body, &path).unwrap(); + for (off, o) in p.objects_with_offsets() { + for id in o.text_boxes() { + let part = d.parts.get(&id).unwrap_or_else(|| panic!("an anchor shows story {id}, which the result lacks")); + assert_eq!(part.kind, wordcraft_doc::PartKind::TextBox, "story {id}"); + let c = p.props_of_char(off); + out.push((d.plain_text(StoryRef::Part(id)), c.del.is_some(), c.ins.is_some())); + } + } + } + out.sort(); + out + } + let rev = with_boxes("Body one.\nNew middle.\nKeep.", &[(1, "New middle.".len(), "Revised box."), (2, "Keep.".len(), "Kept box.")]); + let path = std::env::temp_dir().join(format!("wc-compare-boxes-{}.docx", std::process::id())); + crate::io::save_path(&path, &rev.doc).unwrap(); + let t = |t: &str, del, ins| (t.to_string(), del, ins); + for show_in in ["original", "revised", "new"] { + for (then, left) in [("review.acceptAll", "Revised box."), ("review.rejectAll", "Original box.")] { + let mut s = with_boxes("Body one.\nOld middle.\nKeep.", &[(1, "Old middle.".len(), "Original box."), (2, "Keep.".len(), "Kept box.")]); + s.run("review.compare", &json!({"path": path.to_string_lossy(), "showIn": show_in})).unwrap(); + assert_eq!(boxes(&s.doc), [t("Kept box.", false, false), t("Original box.", true, false), t("Revised box.", false, true)], "{show_in}"); + s.run(then, &json!({})).unwrap(); + assert_eq!(boxes(&s.doc), [t("Kept box.", false, false), t(left, false, false)], "{show_in} {then}"); + } + } + let _ = std::fs::remove_file(&path); +} + #[test] fn compare_bounds_total_diff_work() { // Many long, slightly different paragraphs: each pair alone fits one comparison table, diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 960255030..cbe8e4c03 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -18,6 +18,8 @@ pub const OPEN_EXTS: &[&str] = &[ "markdown", "html", "htm", + "mht", + "mhtml", "rtf", "odt", "tex", @@ -201,7 +203,7 @@ fn load_embedded_fonts(doc: &Document) { fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result<(Document, bool), String> { let ext = ext_of(name); if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) { - let mut doc = wordcraft_docx::read_with_password(bytes, password).map_err(|e| e.to_string())?; + let mut doc = read_docx(bytes, password).map_err(|e| e.to_string())?; opened(&mut doc); return Ok((doc, true)); } @@ -230,6 +232,42 @@ fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft Ok((doc, false)) } +/// Read a Word package (decrypted with `password` if it is encrypted), bringing in its +/// alternative format chunks (`w:altChunk`) as Word does when it opens one: see [`import_chunk`]. +pub(crate) fn read_docx(bytes: &[u8], password: Option<&str>) -> Result { + wordcraft_docx::read_with(bytes, &wordcraft_docx::ReadOptions { password, chunks: Some(&import_chunk) }) +} + +/// An alternative format chunk in a format the docx reader leaves to the format importers, as a +/// document: an MHT web archive, HTML, RTF or plain text, told by its content type, its part's +/// extension or what it holds. Its pictures fit the text width, as when the file is opened. +fn import_chunk(c: &wordcraft_docx::AltChunk) -> Option { + use wordcraft_formats::{html, mht, rtf, txt}; + let full = c.content_type.to_ascii_lowercase(); + let ct = full.split(';').next().unwrap_or("").trim(); + let charset = full.split(';').skip(1).find_map(|p| p.trim().strip_prefix("charset=")).map(|v| v.trim_matches('"')); + let ext = ext_of(c.part); + let b = c.bytes; + let mut doc = if matches!(ct, "message/rfc822" | "application/x-mimearchive") + || ct.starts_with("multipart/") + || matches!(ext.as_str(), "mht" | "mhtml") + || mht::is_mht(b) + { + mht::import(b) + } else if ct.contains("rtf") || ext == "rtf" || b.starts_with(b"{\\rtf") { + rtf::import(b).ok()? + } else if ct.contains("html") || matches!(ext.as_str(), "html" | "htm" | "xhtml") { + html::import_page(&html::decode_as(b, charset), &|_| None) + } else if ct == "text/plain" || ext == "txt" { + txt::import(b) + } else { + return None; + }; + // shortcut: pictures fit the chunk's default page, not the section it lands in; fit them there if wide pictures in chunks overflow narrow sections. + fit_pictures(&mut doc); + Some(doc) +} + /// The narrowest text width of the document's sections, points: what a picture must fit. pub(crate) fn text_width(doc: &Document) -> f32 { doc.sections().iter().map(|(_, sp)| sp.text_width()).fold(f32::INFINITY, f32::min).clamp(36.0, 4000.0) @@ -270,7 +308,7 @@ pub(crate) fn fit_pictures(doc: &mut Document) { /// Does the file hold a different document format than its Word or RTF extension says? An RTF /// file saved as `.doc` (as accounting systems and report generators often save their /// documents), a `.docx` package renamed `.doc`, a Word 97-2003 file renamed `.docx`, a Word XML -/// Document (2003 or Flat OPC) or a web page saved as `.doc` (#2853): Word opens +/// Document (2003 or Flat OPC) or a web page (HTML or MHT) saved as `.doc` (#2853): Word opens /// each by what it holds, so they go through [`open_by_content`] instead of failing. fn signature_contradicts(ext: &str, bytes: &[u8]) -> bool { let expected = match ext { @@ -289,6 +327,8 @@ fn signature_contradicts(ext: &str, bytes: &[u8]) -> bool { "xml" } else if looks_like_web_page(bytes) { "html" + } else if wordcraft_formats::mht::is_mht(bytes) { + "mht" } else { return false; }; @@ -346,9 +386,9 @@ fn open_by_content( page: &wordcraft_doc::SectionProps, ) -> Result<(Document, bool), String> { let (mut doc, encrypted) = if bytes.starts_with(OLE_SIG) && wordcraft_docx::is_encrypted(bytes) { - (wordcraft_docx::read_with_password(bytes, password).map_err(|e| e.to_string())?, true) + (read_docx(bytes, password).map_err(|e| e.to_string())?, true) } else if bytes.starts_with(ZIP_SIG) { - (wordcraft_docx::read(bytes).map_err(|e| format!("{name}: not a document WordCraft can open ({e})"))?, false) + (read_docx(bytes, None).map_err(|e| format!("{name}: not a document WordCraft can open ({e})"))?, false) } else if bytes.starts_with(OLE_SIG) { open_binary_doc(bytes, password) .map_err(|e| if needs_password(&e) || wrong_password(&e) { e } else { format!("{name}: not a document WordCraft can open ({e})") })? @@ -359,6 +399,9 @@ fn open_by_content( } else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && looks_like_web_page(bytes) { let opened = crate::io_ext::open("html", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web pages"))); (opened?, false) + } else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && wordcraft_formats::mht::is_mht(bytes) { + let opened = crate::io_ext::open("mht", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web archives"))); + (opened?, false) } else if wordcraft_formats::txt::is_text(bytes) { (wordcraft_formats::txt::import_on(bytes, page), false) } else if ext.is_empty() { diff --git a/crates/engine/src/io_ext.rs b/crates/engine/src/io_ext.rs index 762ac423a..6622ae664 100644 --- a/crates/engine/src/io_ext.rs +++ b/crates/engine/src/io_ext.rs @@ -6,7 +6,7 @@ use wordcraft_doc::Document; /// start on `page`. pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option> { match ext { - "docx" | "docm" | "dotx" | "dotm" => Some(wordcraft_docx::read(bytes).map_err(|e| e.to_string())), + "docx" | "docm" | "dotx" | "dotm" => Some(crate::io::read_docx(bytes, None).map_err(|e| e.to_string())), "doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())), // Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element. "xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d5033394e..24a3f9eec 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -11624,3 +11624,140 @@ fn export_png_rejects_a_page_number_that_does_not_fit() { assert!(!target.exists(), "no file is written for an invalid page"); let _ = std::fs::remove_dir_all(&dir); } + +/// A Word package whose body holds `body` and, as `rIdC`, the chunk part `word/afchunk.{ext}`. +#[cfg(not(target_arch = "wasm32"))] +fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> Vec { + use std::io::Write; + let files: [(String, Vec); 4] = [ + ( + "[Content_Types].xml".into(), + format!(r#""#).into_bytes(), + ), + ("_rels/.rels".into(), br#""#.to_vec()), + ( + "word/_rels/document.xml.rels".into(), + format!(r#""#).into_bytes(), + ), + ( + "word/document.xml".into(), + format!(r#"{body}"#).into_bytes(), + ), + ]; + let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (name, bytes) in files.iter().map(|(n, b)| (n.as_str(), b.as_slice())).chain([(format!("word/afchunk.{ext}").as_str(), chunk)]) { + zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); + zw.write_all(bytes).unwrap(); + } + zw.finish().unwrap().into_inner() +} + +/// A Single File Web Page under a Word or RTF name opens as one, like the web pages of #2853. +#[test] +fn a_single_file_web_page_saved_as_doc_opens_by_content() { + let mht = "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"B\"\r\n\r\n--B\r\nContent-Type: text/html; charset=\"utf-8\"\r\n\r\n

Izvje\u{161}taj

\r\n--B--\r\n"; + for name in ["report.doc", "report.docx", "report.rtf"] { + let doc = crate::io::open_bytes(name, mht.as_bytes()).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(doc.plain_text(StoryRef::Body), "Izvje\u{161}taj", "{name}"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn alt_chunks_open_in_place_as_word_brings_them_in() { + let png = wordcraft_formats::model::base64_encode(&tiny_png()); + let mht = format!( + "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"B\"\r\n\r\n--B\r\nContent-Location: file:///C:/doc.htm\r\n\ + Content-Transfer-Encoding: quoted-printable\r\nContent-Type: text/html; charset=\"utf-8\"\r\n\r\n\ +

=C4=8Clanak 1.

\r\n\ + --B\r\nContent-Location: file:///C:/doc_files/a.png\r\nContent-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n{png}\r\n--B--\r\n" + ); + let p = |t: &str| format!(r#"{t}"#); + let chunk = r#""#; + let bytes = docx_with_chunk(&format!("{}{chunk}{}", p("Before"), p("After")), "mht", "message/rfc822", mht.as_bytes()); + let doc = crate::io::open_bytes("chunk.docx", &bytes).unwrap(); + let text = doc.plain_text(StoryRef::Body); + assert!(text.starts_with("Before\nČlanak 1.\n") && text.ends_with("After"), "{text:?}"); + let para = doc.para(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap(); + assert_eq!(para.props.align, Some(wordcraft_doc::Align::Center)); + // Web page text: Times New Roman unless the page says otherwise. + assert_eq!(para.props_of_char(0).font.as_deref(), Some("Times New Roman")); + // The picture came along, its bytes under the key it shows. + let key = doc + .body + .iter() + .find_map(|b| match &**b { + wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o { + wordcraft_doc::InlineObject::Image { media, .. } => Some(media.clone()), + _ => None, + }), + _ => None, + }) + .expect("the chunk's picture"); + assert_eq!(doc.media.get(&key).map(|m| m.to_vec()), Some(tiny_png())); + + // HTML (in its content type's charset), RTF and plain-text chunks come in through their + // importers too, and an MHT chunk is known by what it holds. + for (ext, ct, data, want) in [ + ("htm", "text/html; charset=windows-1250", &b"

\xc8lanak chunk

"[..], "Članak chunk"), + ("rtf", "application/rtf", br"{\rtf1\ansi Rich chunk\par}", "Rich chunk"), + ("txt", "text/plain", b"Plain chunk", "Plain chunk"), + ("bin", "", mht.as_bytes(), "Članak 1."), + ] { + let bytes = docx_with_chunk(&format!("{chunk}{}", p("End")), ext, ct, data); + let text = crate::io::open_bytes("chunk.docx", &bytes).unwrap().plain_text(StoryRef::Body); + assert!(text.starts_with(want) && text.ends_with("End"), "{ext}: {text:?}"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn text_from_file_brings_the_files_pictures() { + let dir = scratch_dir("text-from-file-pictures"); + let mut src = Session::new(wordcraft_doc::Document::new()); + let list = src.doc.numbering.add_list(wordcraft_doc::numbering::ListKind::Bullet); + // A list item, then a paragraph (a single paragraph would take the caret's formatting). + src.doc.split_paragraph(&Pos::body(0, 0)).unwrap(); + src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering = + Some(wordcraft_doc::props::NumRef { num: list, level: 0 }); + let key = src.doc.add_media(tiny_png(), "png"); + let image = + wordcraft_doc::InlineObject::Image { media: key, w: 20.0, h: 20.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None }; + src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().insert_object(0, image, &Default::default()).unwrap(); + let path = dir.join("pictures.docx"); + std::fs::write(&path, wordcraft_docx::write(&src.doc).unwrap()).unwrap(); + + let mut s = s(); + // A picture and a list of our own first, so the file's key and list id would point at ours. + let ours = s.doc.add_media(vec![9, 9, 9], "png"); + let our_list = s.doc.numbering.add_list(wordcraft_doc::numbering::ListKind::Numbered); + run(&mut s, "insert.textFromFile", json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_dir_all(&dir); + let shown: Vec = s + .doc + .body + .iter() + .filter_map(|b| match &**b { + wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o { + wordcraft_doc::InlineObject::Image { media, .. } => Some(media.clone()), + _ => None, + }), + _ => None, + }) + .collect(); + assert_eq!(shown.len(), 1); + assert_ne!(shown[0], ours); + assert_eq!(s.doc.media.get(&shown[0]).map(|m| m.to_vec()), Some(tiny_png())); + let listed: Vec = s + .doc + .body + .iter() + .filter_map(|b| match &**b { + wordcraft_doc::Block::Para(p) => p.props.numbering.map(|n| n.num), + _ => None, + }) + .collect(); + assert_eq!(listed.len(), 1); + assert_ne!(listed[0], our_list); + assert!(s.doc.numbering.level(listed[0], 0).is_some_and(|l| l.format == wordcraft_doc::section::NumFormat::Bullet)); +} diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index 1c2c79538..a4f4bfd06 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -8,18 +8,22 @@ //! (`rb`/`rt`/`rp`: furigana and other phonetic guides, #1681), and the CSS //! properties `color`, `background-color`, `font-weight`, `font-style`, `font-size`, //! `font-family`, `text-decoration`, `vertical-align`, `text-align`, `white-space` and page -//! breaks. Scripts and styles are skipped; nesting is capped. +//! breaks, and on paragraphs `margin`, `text-indent` and `line-height`. CSS comes from `style` +//! attributes and from `style` elements' rules with simple selectors (`p`, `.note`, `p.note`), +//! applied in cascade order (specificity, then source order, then the `style` attribute). +//! Scripts are skipped; nesting is capped. //! //! Export writes clean semantic HTML with inline CSS, pictures as `data:` URIs and ruby as //! ``. use std::sync::Arc; +use wordcraft_doc::props::LineSpacing; use wordcraft_doc::props::VAlign; use wordcraft_doc::{Align, Document}; use crate::model::{ - self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, base64_encode, data_uri, make_img, mime_of, parse_color, + self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, Spacing, base64_encode, data_uri, make_img, mime_of, parse_color, }; mod builder; @@ -32,9 +36,11 @@ mod math_ruby; pub use builder::ImageLoader; pub use css::css_len_pt; -pub use entity::{decode, decode_entity, unescape}; +pub use entity::{decode, decode_as, decode_entity, unescape}; pub use export::{esc, export, export_flow, export_fragment, inlines_html}; -pub use import::{import, import_on, import_with, parse, parse_with}; +#[cfg(test)] +use import::parse_in; +pub use import::{PAGE_FONT, import, import_on, import_page, import_with, parse, parse_with}; #[cfg(test)] mod tests; diff --git a/crates/formats/src/html/builder.rs b/crates/formats/src/html/builder.rs index 5de71edd6..d487bd1ed 100644 --- a/crates/formats/src/html/builder.rs +++ b/crates/formats/src/html/builder.rs @@ -1,6 +1,9 @@ -use super::css::{apply_style, css_len_pt}; -use super::import::collapse_ws; -use super::{Align, Arc, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, VAlign, data_uri, make_img, model, parse_color}; +use super::css::{apply_style, css_len_pt, strip_important}; +use super::import::{PAGE_FONT, PAGE_HEADING_SIZES, collapse_ws}; +use super::{ + Align, Arc, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, LineSpacing, ListInfo, Meta, Para, Spacing, VAlign, data_uri, make_img, model, + parse_color, +}; /// Open elements kept at most (deeper start tags are ignored). const MAX_STACK: usize = 256; @@ -28,6 +31,12 @@ struct El { name: String, kind: ElKind, fmt: Fmt, + /// Margins, indents and line height its CSS states (blocks only; they don't inherit; on a + /// page's spans, their margins). + spacing: Spacing, + /// A page's `p` or heading: margins its CSS leaves out (or writes invalidly) are HTML's + /// automatic spacing. + auto_margins: bool, align: Option, preserve: bool, page_break: bool, @@ -77,20 +86,37 @@ fn apply_font_attributes(attrs: &[(String, String)], fmt: &mut Fmt) { } } -/// Whether an element's `border` attribute or `style` declares a visible border. -fn declares_border(attrs: &[(String, String)], is_table: bool) -> bool { +/// Whether an element's `border` attribute or its CSS declares a visible border. +fn declares_border(attrs: &[(String, String)], css: &str, is_table: bool) -> bool { if is_table && attr(attrs, "border").is_some_and(|b| b.trim().parse::().map(|n| n > 0).unwrap_or(b.trim().is_empty())) { return true; } - let Some(style) = attr(attrs, "style") else { return false }; - style.split(';').any(|decl| { - let Some((name, value)) = decl.split_once(':') else { return false }; - let name = name.trim().to_ascii_lowercase(); - if !matches!(name.as_str(), "border" | "border-top" | "border-right" | "border-bottom" | "border-left") { - return false; + // Each side's last declaration decides (the `style` attribute's come after the sheet's). + let mut sides = [false; 4]; + for (name, value) in decls(css) { + let set = match name.as_str() { + "border" => 0..4, + "border-top" => 0..1, + "border-right" => 1..2, + "border-bottom" => 2..3, + "border-left" => 3..4, + _ => continue, + }; + let value = value.to_ascii_lowercase(); + let on = !value.is_empty() && !value.split_whitespace().any(|tok| matches!(tok, "none" | "hidden" | "0" | "0px" | "0pt")); + if let Some(s) = sides.get_mut(set) { + s.fill(on); } - let value = value.trim().to_ascii_lowercase(); - !value.is_empty() && !value.split_whitespace().any(|tok| matches!(tok, "none" | "hidden" | "0" | "0px" | "0pt")) + } + sides.contains(&true) +} + +/// A declaration block's declarations, at most [`MAX_DECLS`]: (property in lower case, value +/// without `!important`). +pub(super) fn decls(css: &str) -> impl Iterator { + css.split(';').take(MAX_DECLS).filter_map(|d| { + let (k, v) = d.split_once(':')?; + Some((k.trim().to_ascii_lowercase(), strip_important(v))) }) } @@ -116,6 +142,12 @@ pub(super) struct Builder<'r> { pre_tail: bool, meta: Meta, title: Option, + /// Rules of the page's `style` elements. + pub(super) sheet: Sheet, + /// Read as Word opens a web page ([`import_page`]), not as a pasted fragment. + page: bool, + /// What is left of [`MAX_CSS_WORK`]. + css_work: usize, } fn is_void(n: &str) -> bool { @@ -196,12 +228,310 @@ fn closes_p(n: &str) -> bool { ) } +/// The paragraph spacing a block's CSS states: `margin` (and its sides) as space before / after +/// and left / right indents, `text-indent` as the first-line indent, `line-height` as line +/// spacing (a number or percentage: a multiple; a length: exactly). `auto` and values that don't +/// parse state nothing. +fn block_spacing(css: &str) -> Spacing { + let mut sp = Spacing::default(); + // A length with a unit, or 0: a bare number, a percentage or a keyword states nothing. + let len = |v: &str| match v.trim() { + "0" => Some(0.0), + v if v.ends_with('%') || v.parse::().is_ok() || !v.starts_with(|c: char| c.is_ascii_digit() || matches!(c, '.' | '-' | '+')) => None, + v => css_len_pt(v).filter(|x| x.abs() <= 1584.0), + }; + for (k, v) in decls(css) { + match k.as_str() { + "margin" => { + let parts: Vec> = v.split_whitespace().map(len).collect(); + let [top, right, bottom, left] = match parts.as_slice() { + [a] => [*a; 4], + [a, b] => [*a, *b, *a, *b], + [a, b, c] => [*a, *b, *c, *b], + [a, b, c, d] => [*a, *b, *c, *d], + _ => continue, + }; + sp.before = top.map(|x| x.max(0.0)).or(sp.before); + sp.after = bottom.map(|x| x.max(0.0)).or(sp.after); + sp.left = left.or(sp.left); + sp.right = right.or(sp.right); + } + "margin-top" => sp.before = len(v).map(|x| x.max(0.0)).or(sp.before), + "margin-bottom" => sp.after = len(v).map(|x| x.max(0.0)).or(sp.after), + "margin-left" => sp.left = len(v).or(sp.left), + "margin-right" => sp.right = len(v).or(sp.right), + "text-indent" => sp.first = len(v).or(sp.first), + "line-height" => { + let lv = v.to_ascii_lowercase(); + let multiple = |m: f32| (m.is_finite() && m > 0.0).then(|| LineSpacing::Multiple(m.clamp(0.25, 10.0))); + sp.line = if let Some(pc) = lv.strip_suffix('%') { + pc.trim().parse::().ok().and_then(|p| multiple(p / 100.0)) + } else if let Ok(m) = lv.parse::() { + multiple(m) + } else if lv == "normal" { + Some(LineSpacing::Multiple(1.0)) + } else { + len(&lv).filter(|x| *x > 0.0).map(LineSpacing::Exactly) + } + .or(sp.line); + } + _ => {} + } + } + sp +} + +/// A rule of a `style` element with a simple selector: an element name, classes or both. +#[derive(Clone, Debug)] +struct Rule { + tag: Option, + classes: Vec, + /// Classes count ten, an element name one. + specificity: usize, + /// Shared by the selectors of one block. + decls: Arc, + /// How many declarations `decls` holds. + count: usize, +} + +/// Most rules kept from a page's style elements. +const MAX_RULES: usize = 10_000; +/// Most declarations read for one element (its rules' and its `style` attribute's). +const MAX_DECLS: usize = 512; +/// Most declarations read from one `style` attribute. +const INLINE_DECLS: usize = 64; +/// Rule matching (rules looked at plus bytes gathered) one page may spend. +const MAX_CSS_WORK: usize = 64 << 20; +/// Most bytes of declarations gathered from the rules one element matches. +const MAX_RULE_CSS: usize = 64 * 1024; +/// Most of an element's classes looked up. +const MAX_CLASSES: usize = 32; + +/// A page's style rules, found by the element name or a class they need. +#[derive(Default)] +pub(super) struct Sheet { + rules: Vec, + /// Rules naming classes, by their first class. + by_class: std::collections::HashMap>, + /// Rules naming only an element, by its name. + by_tag: std::collections::HashMap>, + /// `*` rules. + any: Vec, +} + +impl Sheet { + fn push(&mut self, r: Rule) { + let i = self.rules.len(); + match (r.classes.first(), &r.tag) { + (Some(c), _) => self.by_class.entry(c.clone()).or_default().push(i), + (None, Some(t)) => self.by_tag.entry(t.clone()).or_default().push(i), + (None, None) => self.any.push(i), + } + self.rules.push(r); + } +} + +/// Read the rules of a style sheet into `out`. At-rules (`@media`, `@page`…) and rules whose +/// selectors aren't simple (descendants, pseudo-classes, attributes…) are skipped. +pub(super) fn parse_sheet(css: &str, out: &mut Sheet) { + // Comments go, as do the `` old pages hide their styles in. + let mut text = String::with_capacity(css.len()); + let mut rest = css; + while let Some(i) = rest.find("/*") { + text.push_str(rest.get(..i).unwrap_or("")); + rest = rest.get(i + 2..).and_then(|r| r.find("*/").and_then(|j| r.get(j + 2..))).unwrap_or(""); + } + text.push_str(rest); + let text = text.replace("", " "); + let mut rest = text.as_str(); + while let Some(open) = rest.find('{') { + // A statement at-rule before it (`@charset "x";`, `@import url(a);`) isn't a selector. + let selectors = rest.get(..open).unwrap_or("").rsplit(';').next().unwrap_or("").trim(); + let body = rest.get(open + 1..).unwrap_or(""); + // The block's end, counting nested braces (an at-rule's own rules). + let mut depth = 1usize; + let mut end = body.len(); + for (i, c) in body.char_indices() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + end = i; + break; + } + } + _ => {} + } + } + let decls = body.get(..end).unwrap_or(""); + rest = body.get(end + 1..).unwrap_or(""); + if selectors.starts_with('@') || decls.contains('{') || decls.len() > MAX_RULE_CSS { + continue; + } + let (count, decls) = (decls.split(';').count(), Arc::::from(decls)); + for sel in selectors.split(',') { + if out.rules.len() >= MAX_RULES { + return; + } + if let Some((tag, classes)) = simple_selector(sel.trim()) { + let specificity = classes.len() * 10 + usize::from(tag.is_some()); + out.push(Rule { tag, classes, specificity, decls: decls.clone(), count }); + } + } + } +} + +/// `p`, `.note`, `p.note.wide` or `*`: (element name, classes). Class names may start with a +/// digit (`.000003`): browsers drop such a rule, but Word's HTML import applies it, and +/// converters write them. +fn simple_selector(sel: &str) -> Option<(Option, Vec)> { + if sel == "*" { + return Some((None, Vec::new())); + } + let mut parts = sel.split('.'); + let tag = parts.next()?; + let tag = if tag.is_empty() { + None + } else if tag.chars().all(|c| c.is_ascii_alphanumeric()) && tag.starts_with(|c: char| c.is_ascii_alphabetic()) { + Some(tag.to_ascii_lowercase()) + } else { + return None; + }; + let ident = |c: &str| !c.is_empty() && c.chars().all(|ch| ch.is_alphanumeric() || ch == '_' || ch == '-'); + let classes: Vec = parts.map(str::to_string).collect(); + if classes.iter().any(|c| !ident(c)) || (tag.is_none() && classes.is_empty()) { + return None; + } + Some((tag, classes)) +} + +impl Builder<'_> { + /// An element's CSS: the declarations of the style sheet rules it matches, in cascade order, + /// then its `style` attribute (later declarations win). + fn css_of(&mut self, name: &str, attrs: &[(String, String)]) -> String { + // The `style` attribute's first declarations always fit in what is read of the result. + let inline: Vec<&str> = attr(attrs, "style").unwrap_or("").split(';').take(INLINE_DECLS).collect(); + let inline = inline.join(";"); + let sheet = &self.sheet; + if sheet.rules.is_empty() || self.css_work == 0 { + return important_last(&inline); + } + let mut classes: Vec<&str> = attr(attrs, "class").map(|c| c.split_whitespace().take(MAX_CLASSES).collect()).unwrap_or_default(); + classes.sort_unstable(); + classes.dedup(); + let mut candidates: Vec = sheet.any.clone(); + candidates.extend(sheet.by_tag.get(name).into_iter().flatten()); + for c in &classes { + candidates.extend(sheet.by_class.get(*c).into_iter().flatten()); + } + let mut hits: Vec<(usize, usize)> = candidates + .iter() + .filter_map(|&i| sheet.rules.get(i).map(|r| (i, r))) + .filter(|(_, r)| r.tag.as_deref().is_none_or(|t| t == name) && r.classes.iter().all(|c| classes.contains(&c.as_str()))) + .map(|(i, r)| (r.specificity, i)) + .collect(); + hits.sort_unstable(); + let mut css = String::new(); + let mut count = 0; + for (_, i) in hits { + let Some(r) = sheet.rules.get(i) else { continue }; + if css.len() + r.decls.len() > MAX_RULE_CSS || count + r.count > MAX_DECLS - INLINE_DECLS { + break; + } + css.push_str(&r.decls); + css.push(';'); + count += r.count; + } + // A page whose rules cost too much to apply reads on without them. + self.css_work = self.css_work.saturating_sub(candidates.len() + css.len()); + css.push_str(&inline); + important_last(&css) + } +} + +/// `css`'s declarations in order, the `!important` ones after the others: they win over normal +/// ones wherever they come from (CSS Cascade 5 §6.4), an inline one over the sheet's. +fn important_last(css: &str) -> String { + let (important, normal): (Vec<&str>, Vec<&str>) = css.split(';').filter(|d| !d.trim().is_empty()).partition(|d| strip_important(d) != d.trim()); + normal.into_iter().chain(important).collect::>().join(";") +} + +/// The borders a cell's CSS gives it (`border`, `border-top`…: style, width and colour in any +/// order; `windowtext` is the automatic colour). `None` when it states none. +fn cell_borders(css: &str) -> Option { + use wordcraft_doc::props::{Border, BorderStyle, Borders}; + let mut b = Borders::default(); + let mut any = false; + for (k, v) in decls(css) { + let sides: &[u8] = match k.as_str() { + "border" => b"tlbr", + "border-top" => b"t", + "border-left" => b"l", + "border-bottom" => b"b", + "border-right" => b"r", + _ => continue, + }; + // CSS's default width is `medium`. + let (mut style, mut width, mut color) = (None, 2.25_f32, None); + for tok in v.split_whitespace() { + let lt = tok.to_ascii_lowercase(); + match lt.as_str() { + "none" | "hidden" => style = Some(BorderStyle::None), + "solid" => style = Some(BorderStyle::Single), + "double" => style = Some(BorderStyle::Double), + "dotted" => style = Some(BorderStyle::Dotted), + "dashed" => style = Some(BorderStyle::Dashed), + "thin" => width = 0.75, + "medium" => width = 2.25, + "thick" => width = 3.75, + "windowtext" => {} + _ => match css_len_pt(tok).filter(|_| tok.starts_with(|c: char| c.is_ascii_digit() || c == '.')) { + Some(w) => width = w.clamp(0.0, 12.0), + None => color = parse_color(tok).or(color), + }, + } + } + // CSS draws no border without a style, or at no width. + let style = if width > 0.0 { style.unwrap_or(BorderStyle::None) } else { BorderStyle::None }; + let border = Border { style, width, color, space: 0.0 }; + for side in sides { + let slot = match side { + b't' => &mut b.top, + b'l' => &mut b.left, + b'b' => &mut b.bottom, + _ => &mut b.right, + }; + *slot = Some(border); + } + any = true; + } + any.then_some(b) +} + +/// Most columns given widths in one table. +const MAX_TABLE_COLS: usize = 63; + +/// A cell's width, points: its CSS `width`, else its `width` attribute (pixels); percentages +/// and widths that don't parse give none. +fn cell_width(css: &str, attrs: &[(String, String)]) -> Option { + let from_css = decls(css).filter(|(k, v)| k == "width" && !v.ends_with('%')).filter_map(|(_, v)| css_len_pt(v)); + let from_attr = || { + attr(attrs, "width") + .map(str::trim) + .filter(|v| !v.ends_with('%')) + .and_then(|v| v.trim_end_matches("px").trim().parse::().ok()) + .map(|px| px * 0.75) + }; + from_css.last().or_else(from_attr).filter(|w| w.is_finite() && *w > 1.0 && *w <= 1584.0) +} + fn attr<'a>(attrs: &'a [(String, String)], name: &str) -> Option<&'a str> { attrs.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str()) } impl<'r> Builder<'r> { - pub(super) fn new(images: ImageLoader<'r>) -> Builder<'r> { + pub(super) fn new(images: ImageLoader<'r>, page: bool) -> Builder<'r> { Builder { images, containers: vec![Vec::new()], @@ -215,11 +545,19 @@ impl<'r> Builder<'r> { pre_tail: false, meta: Meta::default(), title: None, + sheet: Sheet::default(), + page, + css_work: MAX_CSS_WORK, } } fn fmt(&self) -> Fmt { - self.stack.last().map(|e| e.fmt.clone()).unwrap_or_default() + match self.stack.last() { + Some(e) => e.fmt.clone(), + // A web page's text is Times New Roman 12 pt unless its CSS says otherwise. + None if self.page => Fmt { font: Some(PAGE_FONT.into()), size: Some(12.0), ..Fmt::default() }, + None => Fmt::default(), + } } fn preserve(&self) -> bool { @@ -237,6 +575,15 @@ impl<'r> Builder<'r> { let mut list_level: Option<(bool, usize)> = None; let mut li_at: Option = None; let mut lists = 0usize; + let mut spaced = false; + // A page's first span stating margins (see below). + let span = self + .stack + .iter() + .rev() + .take_while(|e| e.kind == ElKind::Inline) + .find(|e| e.spacing.before.is_some() || e.spacing.after.is_some()) + .map(|e| (e.spacing.before, e.spacing.after)); for (i, e) in self.stack.iter().enumerate().rev() { match e.kind { ElKind::Cell => { @@ -270,6 +617,27 @@ impl<'r> Builder<'r> { if p.align.is_none() && e.align.is_some() && e.kind != ElKind::Inline { p.align = e.align; } + // Margins belong to the paragraph's own block, not to those around it. + if !spaced && e.kind != ElKind::Inline { + p.spacing = e.spacing; + if self.page { + // Word opens a page's paragraphs with single lines; a margin a span + // states replaces the one its paragraph states, and one the paragraph + // leaves out is automatic spacing. + let (top, bottom) = span.unwrap_or_default(); + let sp = &mut p.spacing; + sp.before_auto = sp.before.is_none() && e.auto_margins; + sp.after_auto = sp.after.is_none() && e.auto_margins; + sp.before = sp.before.map(|own| top.unwrap_or(own)); + sp.after = sp.after.map(|own| bottom.unwrap_or(own)); + sp.line = sp.line.or(Some(LineSpacing::Multiple(1.0))); + } + spaced = true; + } + } + // Page text without a block of its own (straight in a cell) is single-spaced too. + if self.page && !spaced { + p.spacing.line = Some(LineSpacing::Multiple(1.0)); } if let (Some((o, _)), Some(li)) = (list_level, li_at) { p.list = Some(ListInfo { ordered: o, level: lists.saturating_sub(1).min(8) as u8, start: 1 }); @@ -310,7 +678,11 @@ impl<'r> Builder<'r> { } } if p.inlines.len() == 1 && p.text() == "\u{00A0}" { - // `

 

`: an empty paragraph on purpose. + // `

 

`: an empty paragraph on purpose, whose line is as high as the + // space's formatting makes it. + if let Some(Inline::Text(_, f)) = p.inlines.first() { + p.mark = Some(Box::new(f.clone())); + } p.inlines.clear(); self.para_cont = false; self.container().push(FBlock::Para(p)); @@ -482,6 +854,10 @@ impl<'r> Builder<'r> { let mut ft = FTable { rows, widths: Vec::new(), borderless }; if !ft.rows.is_empty() { ft.insert_covered(); + // Columns keep the widths their cells give when every column has one. + if ft.cols() <= MAX_TABLE_COLS { + ft.widths_from_cells(); + } self.container().push(FBlock::Table(ft)); } } @@ -625,13 +1001,15 @@ impl<'r> Builder<'r> { true } - fn element_style(&self, name: &str, attrs: &[(String, String)], kind: ElKind) -> ElementStyle { + /// `css`: the element's declarations in cascade order ([`Builder::css_of`]). + fn element_style(&self, name: &str, attrs: &[(String, String)], kind: ElKind, css: &str) -> ElementStyle { let mut fmt = self.fmt(); let mut align = None; let mut preserve = self.preserve(); let mut page_break = false; let mut page_break_after = false; match name { + // A header cell is bold, as in browsers and Word, unless its own CSS says otherwise. "b" | "strong" | "th" => fmt.bold = true, "i" | "em" | "cite" | "var" | "dfn" | "address" => fmt.italic = true, "u" | "ins" => fmt.underline = true, @@ -662,9 +1040,16 @@ impl<'r> Builder<'r> { if name == "center" { align = Some(Align::Center); } - if let Some(style) = attr(attrs, "style") { - apply_style(style, &mut fmt, &mut align, &mut preserve, &mut page_break, &mut page_break_after); + if self.page + && let ElKind::Heading(n) = kind + { + // A page's headings are bold, black (unless they inherit a colour) and sized as + // browsers show them. + fmt.bold = true; + fmt.color.get_or_insert(wordcraft_doc::Rgb(0, 0, 0)); + fmt.size = PAGE_HEADING_SIZES.get(usize::from(n.clamp(1, 6)) - 1).copied(); } + apply_style(css, &mut fmt, &mut align, &mut preserve, &mut page_break, &mut page_break_after); if kind == ElKind::Pre { preserve = true; } @@ -703,9 +1088,10 @@ impl<'r> Builder<'r> { if kind != ElKind::Inline { self.flush(); } - let ElementStyle { mut fmt, align, preserve, page_break, page_break_after } = self.element_style(name, attrs, kind); + let css = self.css_of(name, attrs); + let ElementStyle { mut fmt, align, preserve, page_break, page_break_after } = self.element_style(name, attrs, kind, &css); match kind { - ElKind::Table => self.tables.push(TableB { rows: Vec::new(), row: None, bordered: declares_border(attrs, true) }), + ElKind::Table => self.tables.push(TableB { rows: Vec::new(), row: None, bordered: declares_border(attrs, &css, true) }), ElKind::Tr => { if let Some(t) = self.tables.last_mut() && let Some(r) = t.row.take() @@ -716,7 +1102,7 @@ impl<'r> Builder<'r> { ElKind::Cell => { let tb = self.find_open(|e| e.kind == ElKind::Tr, |e| e.kind == ElKind::Table).is_none(); if let Some(t) = self.tables.last_mut() - && declares_border(attrs, false) + && declares_border(attrs, &css, false) { t.bordered = true; } @@ -728,6 +1114,7 @@ impl<'r> Builder<'r> { t.row = Some(Vec::new()); } let num = |k: &str| attr(attrs, k).and_then(|v| v.trim().parse::().ok()).map(|v| v.clamp(1, 63)); + let colspan = num("colspan").unwrap_or(1); let shading = attr(attrs, "bgcolor").and_then(parse_color).or(fmt.background); let mut valign = attr(attrs, "valign").and_then(|v| match v.trim().to_ascii_lowercase().as_str() { "top" => Some(VAlign::Top), @@ -735,36 +1122,35 @@ impl<'r> Builder<'r> { "bottom" => Some(VAlign::Bottom), _ => None, }); - if let Some(st) = attr(attrs, "style") { - for decl in st.split(';') { - if let Some((k, v)) = decl.split_once(':') - && k.trim().eq_ignore_ascii_case("vertical-align") - { - match v.trim().to_ascii_lowercase().as_str() { - "top" => valign = Some(VAlign::Top), - "middle" | "center" => valign = Some(VAlign::Center), - "bottom" => valign = Some(VAlign::Bottom), - _ => {} - } - } + for (_, v) in decls(&css).filter(|(k, _)| k == "vertical-align") { + match v.to_ascii_lowercase().as_str() { + "top" => valign = Some(VAlign::Top), + "middle" | "center" => valign = Some(VAlign::Center), + "bottom" => valign = Some(VAlign::Bottom), + _ => {} } } self.cells.push(Cell { blocks: Vec::new(), - colspan: num("colspan").unwrap_or(1), + colspan, rowspan: attr(attrs, "rowspan").and_then(|v| v.trim().parse::().ok()).map(|v| v.clamp(1, 1000)).unwrap_or(1), covered: false, header: name == "th", shading, - borders: None, + borders: cell_borders(&css), valign, + width: if colspan == 1 { cell_width(&css, attrs) } else { None }, }); fmt.background = None; self.containers.push(Vec::new()); } _ => {} } - self.stack.push(El { name: name.to_string(), kind, fmt, align, preserve, page_break, page_break_after, used: false }); + // The page's own margins (`body`, `html`) aren't its paragraphs'. + let spacing = + if (kind != ElKind::Inline || self.page) && !matches!(name, "body" | "html") { block_spacing(&css) } else { Spacing::default() }; + let auto_margins = self.page && (name == "p" || matches!(kind, ElKind::Heading(_))); + self.stack.push(El { name: name.to_string(), kind, fmt, spacing, auto_margins, align, preserve, page_break, page_break_after, used: false }); } pub(super) fn end(&mut self, name: &str) { diff --git a/crates/formats/src/html/css.rs b/crates/formats/src/html/css.rs index 6cb2a8c7b..0fcf54227 100644 --- a/crates/formats/src/html/css.rs +++ b/crates/formats/src/html/css.rs @@ -1,3 +1,5 @@ +use super::builder::decls; +use super::import::PAGE_FONT; use super::{Align, Fmt, model, parse_color}; /// A CSS length (or font-size keyword) in points; `None` when it is not one. @@ -33,7 +35,7 @@ pub fn css_len_pt(v: &str) -> Option { } } -fn strip_important(v: &str) -> &str { +pub(super) fn strip_important(v: &str) -> &str { let s = v.trim(); if let Some(pos) = s.rfind('!') { let after = s[pos + 1..].trim(); @@ -53,10 +55,7 @@ pub(super) fn apply_style( page_break: &mut bool, page_break_after: &mut bool, ) { - for decl in css.split(';').take(64) { - let Some((k, v)) = decl.split_once(':') else { continue }; - let k = k.trim().to_ascii_lowercase(); - let v = strip_important(v); + for (k, v) in decls(css) { let lv = v.to_ascii_lowercase(); match k.as_str() { "font-weight" => f.bold = lv.starts_with("bold") || lv.parse::().is_ok_and(|w| w >= 600), @@ -94,6 +93,14 @@ pub(super) fn apply_style( f.code = true; } else if !fam.is_empty() && !matches!(fam.to_ascii_lowercase().as_str(), "serif" | "sans-serif" | "inherit" | "initial") { f.font = model::kept_font(&fam).or(f.font.take()); + } else if f.font.is_some() { + // A generic family replaces a face the text has (a page's Times New Roman); + // text without one keeps the document's font. + match fam.to_ascii_lowercase().as_str() { + "serif" => f.font = Some(PAGE_FONT.into()), + "sans-serif" => f.font = Some("Arial".into()), + _ => {} + } } } "vertical-align" => { diff --git a/crates/formats/src/html/entity.rs b/crates/formats/src/html/entity.rs index 62b0fb103..09b017351 100644 --- a/crates/formats/src/html/entity.rs +++ b/crates/formats/src/html/entity.rs @@ -274,17 +274,39 @@ fn sniff_charset(head: &str) -> Option<&str> { /// Decode HTML bytes: BOM, then a `charset` in the first 2 KB, else UTF-8 with a legacy fallback. pub fn decode(b: &[u8]) -> String { + decode_as(b, None) +} + +/// [`decode`] in the `charset` its container states (a MIME part's, a content type's), when it +/// states one. Single-byte Windows code pages (`windows-1250`…, `iso-8859-1`, `us-ascii` as 1252) +/// are decoded as such, other labels by their WHATWG decoders. +pub fn decode_as(b: &[u8], charset: Option<&str>) -> String { if b.starts_with(&[0xFF, 0xFE]) || b.starts_with(&[0xFE, 0xFF]) || b.starts_with(&[0xEF, 0xBB, 0xBF]) { return crate::txt::decode(b); } let head = String::from_utf8_lossy(b.get(..b.len().min(2048)).unwrap_or(b)).to_ascii_lowercase(); - if let Some(label) = sniff_charset(&head) - && let Some(s) = crate::txt::decode_as(b, label) - { - return s; + let named = charset.map(str::to_ascii_lowercase).or_else(|| sniff_charset(&head).map(str::to_string)); + let code_page = match named.as_deref().map(str::trim) { + Some("iso-8859-1" | "latin1" | "us-ascii") => Some(1252), + Some(cs) => cs + .strip_prefix("windows-") + .or_else(|| cs.strip_prefix("cp")) + .and_then(|n| n.parse::().ok()) + .filter(|cp| wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(cp)), + None => None, + }; + // Other labels (ISO 8859, KOI8, Shift_JIS, GBK, Big5, EUC-KR…) by their WHATWG decoders; a + // UTF-16 one without a BOM means UTF-8, as in browsers. + let other = named + .as_deref() + .and_then(|l| encoding_rs::Encoding::for_label(l.trim().as_bytes())) + .filter(|e| ![encoding_rs::UTF_8, encoding_rs::UTF_16LE, encoding_rs::UTF_16BE].contains(e)); + match (code_page, other) { + (Some(cp), _) => b.iter().map(|&c| wordcraft_doc::encoding::windows_byte(cp, c)).collect(), + (None, Some(enc)) => enc.decode_without_bom_handling(b).0.into_owned(), + (None, None) => match std::str::from_utf8(b) { + Ok(s) => s.to_string(), + Err(_) => crate::txt::decode(b), + }, } - if let Ok(s) = std::str::from_utf8(b) { - return s.to_string(); - } - crate::txt::decode(b) } diff --git a/crates/formats/src/html/import.rs b/crates/formats/src/html/import.rs index 2d6233baf..9e726af30 100644 --- a/crates/formats/src/html/import.rs +++ b/crates/formats/src/html/import.rs @@ -1,4 +1,4 @@ -use super::builder::{Builder, ImageLoader}; +use super::builder::{Builder, ImageLoader, parse_sheet}; use super::lexer::{Lexer, Tok}; use super::math_ruby::{read_math, read_ruby}; use super::{Document, Flow, Inline, model}; @@ -10,6 +10,10 @@ pub fn parse(s: &str) -> Flow { /// [`parse`], loading pictures that aren't `data:` URIs through `images`. pub fn parse_with(s: &str, images: ImageLoader) -> Flow { + parse_in(s, images, false) +} + +pub(super) fn parse_in(s: &str, images: ImageLoader, page: bool) -> Flow { // HTML normalizes input newlines before tokenization, not after decoding character references. let normalized = if s.contains('\r') { let mut out = String::with_capacity(s.len()); @@ -23,8 +27,10 @@ pub fn parse_with(s: &str, images: ImageLoader) -> Flow { } else { std::borrow::Cow::Borrowed(s) }; - let mut lx = Lexer { s: &normalized, pos: 0 }; - let mut b = Builder::new(images); + // The lexer's offsets (a style element's text is cut out by them) are into the normalized text. + let s: &str = &normalized; + let mut lx = Lexer { s, pos: 0 }; + let mut b = Builder::new(images, page); let mut guard = 0usize; while let Some(t) = lx.next() { guard += 1; @@ -52,7 +58,11 @@ pub fn parse_with(s: &str, images: ImageLoader) -> Flow { continue; } if matches!(name.as_str(), "script" | "style" | "noscript" | "template" | "textarea") && !self_close { - lx.skip_past_end_tag(&name); + let from = lx.pos; + let to = lx.skip_past_end_tag(&name); + if name == "style" { + parse_sheet(s.get(from..to).unwrap_or(""), &mut b.sheet); + } continue; } b.start(&name, &attrs, self_close); @@ -91,9 +101,25 @@ pub fn import_with(s: &str, images: ImageLoader) -> Document { model::to_doc(&parse_with(s, images)) } -/// [`import_with`] onto `page` (its size and margins), which tables size to: HTML gives no page +/// A web page opened as a document, as Word opens one (a file, or a document's HTML +/// `w:altChunk`): its text is Times New Roman 12 pt on single lines, headings bold and black at +/// the sizes browsers give them, and a `p` or heading whose CSS leaves a margin out (or writes it +/// invalidly) gets HTML's automatic spacing there; a margin a span states replaces the one its +/// paragraph states (both as Word's import does). [`import`] reads a pasted fragment, which takes +/// the document's own formatting instead (but its CSS still applies). +pub fn import_page(s: &str, images: ImageLoader) -> Document { + model::begin_import(); + model::to_doc(&parse_in(s, images, true)) +} + +/// [`import_page`] onto `page` (its size and margins), which tables size to: HTML gives no page /// size, so the caller picks it (the region's paper, #1381). pub fn import_on(s: &str, images: ImageLoader, page: &wordcraft_doc::SectionProps) -> Document { model::begin_import(); - model::to_doc_in(&parse_with(s, images), page.clone()) + model::to_doc_in(&parse_in(s, images, true), page.clone()) } + +/// The font of a web page's text when its CSS names none. +pub const PAGE_FONT: &str = "Times New Roman"; +/// Heading sizes on a web page, `h1`–`h6`, points. +pub(super) const PAGE_HEADING_SIZES: [f32; 6] = [24.0, 18.0, 13.5, 12.0, 10.0, 7.5]; diff --git a/crates/formats/src/html/lexer.rs b/crates/formats/src/html/lexer.rs index 8b54bc055..a550f54ba 100644 --- a/crates/formats/src/html/lexer.rs +++ b/crates/formats/src/html/lexer.rs @@ -34,7 +34,9 @@ impl Lexer<'_> { } /// Skip past the next matching end tag `` (ASCII case-insensitive), or to the end. - pub(super) fn skip_past_end_tag(&mut self, tag: &str) { + /// Skip past the end tag of raw text element `tag`; returns where that end tag starts (the + /// end of the input when there is none). + pub(super) fn skip_past_end_tag(&mut self, tag: &str) -> usize { let prefix = format!(" { let found = hay.windows(p.len().max(1)).position(|w| w.eq_ignore_ascii_case(p)); let Some(k) = found else { self.pos = self.s.len(); - return; + return self.pos; }; let after_tag = k + p.len(); let next_byte = hay.get(after_tag); if next_byte.is_none_or(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/') { + let at = self.pos + k; self.pos += after_tag; self.skip_past(">"); - return; + return at; } self.pos += k + 2; } + self.s.len() } pub(super) fn next(&mut self) -> Option { diff --git a/crates/formats/src/html/tests.rs b/crates/formats/src/html/tests.rs index 2a111869e..179cf322f 100644 --- a/crates/formats/src/html/tests.rs +++ b/crates/formats/src/html/tests.rs @@ -7,6 +7,27 @@ fn numeric_entity_rejects_sign() { assert_eq!(unescape("aAAb"), "aAAb"); } +#[test] +fn important_declarations_win_the_cascade() { + let color = |html: &str| fmt_of(paras(&parse(html))[0]).color; + let red = Some(wordcraft_doc::Rgb(255, 0, 0)); + let blue = Some(wordcraft_doc::Rgb(0, 0, 255)); + assert_eq!(color("

x

"), blue); + assert_eq!(color("

x

"), red); + assert_eq!(color("

x

"), blue); + assert_eq!(color("

x

"), red); +} + +#[test] +fn a_declared_charset_decodes_the_page() { + // ISO 8859-2 (Central European): 0xA9 Š, 0xB9 š; named by the caller or the page. + assert_eq!(decode_as(&[0xA9, b' ', 0xB9], Some("iso-8859-2")), "Š š"); + assert!(decode_as(b"

\xA9

", None).contains("

Š

")); + assert_eq!(decode_as(&[0x8A], Some("windows-1252")), "Š"); + // A page that says UTF-16 without a BOM is read as UTF-8, as browsers do. + assert_eq!(decode_as("é".as_bytes(), Some("utf-16")), "é"); +} + fn texts(f: &Flow) -> Vec { f.blocks .iter() @@ -33,6 +54,9 @@ fn borderless_tables_get_no_grid_style() { assert_eq!(table_style("
a
").as_deref(), Some("TableGrid")); assert_eq!(table_style("
a
").as_deref(), Some("TableGrid")); assert_eq!(table_style("
a
").as_deref(), Some("TableGrid")); + // The last declaration decides: a `style` attribute's over the style sheet's. + assert_eq!(table_style("
a
").as_deref(), Some("TableGrid")); + assert_eq!(table_style("
a
").as_deref(), Some("")); } #[test] @@ -522,3 +546,171 @@ fn empty_comments_do_not_swallow_the_rest() { assert_eq!(doc.plain_text(Default::default()), "ab\nc", "{html}"); } } + +fn paras(f: &Flow) -> Vec<&Para> { + f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect() +} +fn fmt_of(p: &Para) -> Fmt { + p.inlines.iter().find_map(|i| if let Inline::Text(_, f) = i { Some(f.clone()) } else { None }).unwrap_or_default() +} + +#[test] +fn style_sheet_rules_apply_in_cascade_order() { + let f = parse( + "\ +

plain

big

inline wins

digits

", + ); + let ps = paras(&f); + let (plain, big, inline, digits) = (fmt_of(ps[0]), fmt_of(ps[1]), fmt_of(ps[2]), fmt_of(ps[3])); + assert_eq!((plain.color, plain.size, plain.bold, plain.italic), (Some(wordcraft_doc::Rgb(0, 255, 0)), Some(10.0), false, false)); + assert_eq!((big.size, big.bold), (Some(20.0), true)); + assert_eq!(inline.size, Some(8.0)); + assert_eq!(ps[3].align, Some(Align::Center)); + assert_eq!(digits.size, Some(10.0)); +} + +#[test] +fn paragraph_css_states_spacing_indents_and_line_height() { + let f = parse( + "\ +

a

b

c

", + ); + let ps = paras(&f); + assert_eq!( + ps[0].spacing, + Spacing { + before: Some(6.0), + after: Some(3.0), + left: Some(72.0), + right: Some(72.0), + first: Some(-18.0), + line: Some(LineSpacing::Multiple(1.5)), + ..Spacing::default() + } + ); + // Values that don't parse (a comma decimal, `auto`) state nothing. + assert_eq!(ps[1].spacing, Spacing { line: Some(LineSpacing::Exactly(20.0)), ..Spacing::default() }); + assert_eq!(ps[2].spacing, Spacing::default()); +} + +#[test] +fn a_page_opens_as_word_opens_one() { + let html = "\ +

first

Head

kept

own

bare

 

"; + let f = parse_in(html, &|_| None, true); + let ps = paras(&f); + // Times New Roman 12 pt on single lines; headings bold and black at a browser's sizes. + let first = fmt_of(ps[0]); + assert_eq!((first.font.as_deref(), first.size), (Some(PAGE_FONT), Some(12.0))); + assert_eq!(ps[0].spacing.line, Some(LineSpacing::Multiple(1.0))); + let cell = parse_in("
cell
", &|_| None, true); + let FBlock::Table(t) = &cell.blocks[0] else { panic!() }; + let FBlock::Para(cp) = &t.rows[0][0].blocks[0] else { panic!() }; + assert_eq!(cp.spacing.line, Some(LineSpacing::Multiple(1.0))); + let head = fmt_of(ps[1]); + assert_eq!((head.bold, head.size, head.color), (true, Some(24.0), Some(wordcraft_doc::Rgb(0, 0, 0)))); + // A colour the heading inherits stays. + // A generic family names a face on a page; pasted text keeps the document's font. + let generic = parse_in("

a

b

", &|_| None, true); + let fonts: Vec> = paras(&generic).iter().map(|p| fmt_of(p).font.map(|f| f.to_string())).collect(); + assert_eq!(fonts, [Some("Arial".to_string()), Some(PAGE_FONT.to_string())]); + assert_eq!(fmt_of(paras(&parse("

a

"))[0]).font, None); + let inherited = parse_in("

Head

", &|_| None, true); + assert_eq!(fmt_of(paras(&inherited)[0]).color, Some(wordcraft_doc::Rgb(0, 0, 0x80))); + // Automatic spacing where a p or heading leaves its margins out (layout drops it at the + // top of the page and of a cell). + let auto = |p: &Para| (p.spacing.before_auto, p.spacing.after_auto, p.spacing.before, p.spacing.after); + assert_eq!(auto(ps[0]), (true, true, None, None)); + assert_eq!(auto(ps[1]), (true, true, None, None)); + // A span's margin replaces the one its paragraph states; the one it leaves out stays automatic. + assert_eq!(auto(ps[2]), (true, false, None, Some(0.0))); + assert_eq!(auto(ps[3]), (true, false, None, Some(9.75))); + // Other blocks get no automatic spacing. + assert_eq!(auto(ps[4]), (false, false, None, None)); + // The document says so, and the empty paragraph's mark keeps its size. + let d = model::to_doc(&f); + let props = |i: usize| match &*d.body[i] { + wordcraft_doc::Block::Para(p) => p.clone(), + b => panic!("{b:?}"), + }; + assert_eq!((props(0).props.space_before_auto, props(0).props.space_after_auto), (Some(true), Some(true))); + assert_eq!((props(2).props.space_after_auto, props(2).props.space_after), (None, Some(0.0))); + assert_eq!(props(5).mark.size, Some(12.0)); + // `

 

` keeps the size of its space for its line. + assert!(ps[5].inlines.is_empty()); + assert_eq!(ps[5].mark.as_ref().and_then(|m| m.size), Some(12.0)); + // A fragment (a paste) keeps the document's own formatting. + let pasted = parse(html); + assert_eq!(fmt_of(paras(&pasted)[0]).font, None); + assert_eq!(paras(&pasted)[0].spacing, Spacing::default()); +} + +#[test] +fn cells_give_their_columns_widths() { + let f = parse( + "
t
1ab
", + ); + let FBlock::Table(t) = &f.blocks[0] else { panic!("{:?}", f.blocks) }; + assert_eq!(t.widths, [31.5, 150.0, 144.0]); + // A column without a width leaves them all to the layout. + let f = parse("
ab
"); + let FBlock::Table(t) = &f.blocks[0] else { panic!() }; + assert!(t.widths.is_empty()); + // A cell beside a cell spanning rows from above gives its own column its width. + let f = + parse("
ab
c
de
"); + let FBlock::Table(t) = &f.blocks[0] else { panic!() }; + assert_eq!(t.widths, [75.0, 150.0]); +} + +#[test] +fn cells_take_their_css_borders() { + use wordcraft_doc::props::{Border, BorderStyle}; + let f = parse( + "
ab
", + ); + let FBlock::Table(t) = &f.blocks[0] else { panic!() }; + // The style sheet's other cell properties apply too. + assert_eq!(t.rows[0][0].valign, Some(wordcraft_doc::props::VAlign::Bottom)); + let b = t.rows[0][0].borders.expect("borders"); + assert_eq!(b.top, Some(Border::single(0.75))); + assert_eq!(b.bottom, Some(Border { style: BorderStyle::Double, width: 2.0, color: Some(wordcraft_doc::Rgb(255, 0, 0)), space: 0.0 })); + assert_eq!(b.left.map(|l| l.style), Some(BorderStyle::None)); + // A border of no width is none (OOXML has no zero-width line). + assert_eq!(b.right.map(|r| r.style), Some(BorderStyle::None)); + assert_eq!(t.rows[0][1].borders, None); +} + +#[test] +fn style_sheet_edge_cases() { + let f = parse_in( + "\ +

a

bare

b

", + &|_| None, + true, + ); + let ps = paras(&f); + // A rule right after statement at-rules still applies. + assert_eq!(fmt_of(ps[0]).color, Some(wordcraft_doc::Rgb(0, 0, 255))); + // The body's margins aren't the paragraphs' indents. + assert_eq!((ps[1].spacing.left, ps[1].spacing.before), (None, None)); + // Bare numbers, percentages and keywords state no margin: automatic spacing stays. + assert_eq!((ps[2].spacing.before_auto, ps[2].spacing.left), (true, None)); + // The `style` attribute wins even over a sheet with more declarations than are read. + let many = (0..600).map(|i| format!("x-{i}: 1")).collect::>().join(";"); + let f = parse(&format!("

c

")); + assert_eq!(fmt_of(paras(&f)[0]).color, Some(wordcraft_doc::Rgb(0, 255, 0))); +} + +#[test] +fn hostile_style_sheets_never_panic() { + // One declaration block shared by many selectors isn't copied for each. + let selectors = vec!["p"; 20_000].join(","); + let huge = format!("

x

", "x: y;".repeat(200_000)); + let _ = parse_in(&huge, &|_| None, true); + let many = ".a { color: red } ".repeat(20_000); + for css in ["{", "}", "p {", "@media { p { x } ", "p { color: red", "/* open", ".{}", "p..a{}", "\u{0}.é{x:y}", many.as_str()] { + let _ = parse_in(&format!("

x

y"), &|_| None, true); + } +} diff --git a/crates/formats/src/lib.rs b/crates/formats/src/lib.rs index 8af0d2a50..1320c30bb 100644 --- a/crates/formats/src/lib.rs +++ b/crates/formats/src/lib.rs @@ -1,6 +1,7 @@ //! WordCraft import/export for the "other" text formats: plain text, Markdown (CommonMark subset -//! plus GFM tables and strikethrough), HTML, RTF, OpenDocument Text and LaTeX — and, read-only, -//! spreadsheet cells for mail-merge recipient lists ([`sheet`]: .xlsx/.xlsm/.xls/.ods). +//! plus GFM tables and strikethrough), HTML (and MHT web archives, read-only), RTF, OpenDocument +//! Text and LaTeX — and, read-only, spreadsheet cells for mail-merge recipient lists ([`sheet`]: +//! .xlsx/.xlsm/.xls/.ods). //! //! Every format maps to a small flow model ([`model::Flow`]: paragraphs with a kind, list //! membership, alignment and formatted spans; tables of cells), which [`model::to_doc`] and @@ -14,6 +15,7 @@ pub mod html; pub mod latex; pub mod markdown; pub mod mathml; +pub mod mht; pub mod model; pub mod odt; pub mod rtf; @@ -22,8 +24,12 @@ pub mod txt; use wordcraft_doc::Document; +/// Most entries an ODT or spreadsheet package may declare, as many as a Word package +/// (`wordcraft_docx::package::MAX_ENTRIES`); checked before `zip` parses them (#3660). +pub const MAX_ZIP_ENTRIES: u64 = 20_000; + /// Extensions this crate handles. -pub const EXTENSIONS: &[&str] = &["txt", "text", "md", "markdown", "html", "htm", "xhtml", "rtf", "odt", "tex", "latex", "ltx"]; +pub const EXTENSIONS: &[&str] = &["txt", "text", "md", "markdown", "html", "htm", "xhtml", "mht", "mhtml", "rtf", "odt", "tex", "latex", "ltx"]; /// Import `bytes` in the format named by `ext` (no dot, any case). `None` = not our format. pub fn import(ext: &str, bytes: &[u8]) -> Option> { @@ -31,7 +37,8 @@ pub fn import(ext: &str, bytes: &[u8]) -> Option> { let r = match ext.as_str() { "txt" | "text" => Ok(txt::import(bytes)), "md" | "markdown" => Ok(markdown::import(&txt::decode(bytes))), - "html" | "htm" | "xhtml" => Ok(html::import(&html::decode(bytes))), + "html" | "htm" | "xhtml" => Ok(html::import_page(&html::decode(bytes), &|_| None)), + "mht" | "mhtml" => Ok(mht::import(bytes)), "rtf" => rtf::import(bytes), "odt" => odt::import(bytes), "tex" | "latex" | "ltx" => Ok(latex::import(bytes)), @@ -44,7 +51,7 @@ pub fn import(ext: &str, bytes: &[u8]) -> Option> { } /// [`import`] onto `page` (its size and margins) for the formats whose files give no page size: -/// plain text, Markdown and HTML start on it, and their tables size to it (#1381). The other +/// plain text, Markdown, HTML and MHT start on it, and their tables size to it (#1381). The other /// formats keep the page their file gives. pub fn import_on(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option> { let ext = ext.trim_start_matches('.').to_ascii_lowercase(); @@ -52,6 +59,7 @@ pub fn import_on(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> "txt" | "text" => txt::import_on(bytes, page), "md" | "markdown" => markdown::import_on(&txt::decode(bytes), page), "html" | "htm" | "xhtml" => html::import_on(&html::decode(bytes), &|_| None, page), + "mht" | "mhtml" => mht::import_on(bytes, page), _ => return import(&ext, bytes), }; doc.ensure_nonempty(); diff --git a/crates/formats/src/mht.rs b/crates/formats/src/mht.rs new file mode 100644 index 000000000..c21c98d9a --- /dev/null +++ b/crates/formats/src/mht.rs @@ -0,0 +1,444 @@ +//! MHT / MHTML (Single File Web Page): a web page and its pictures in one MIME message (RFC 2557 +//! `multipart/related`, RFC 2045 transfer encodings). The page is the part the message's `start` +//! parameter names, else its first HTML part; it is read by [`crate::html`], with `img` sources +//! found among the other parts by `Content-Location` (also relative to the page's) or `cid:` +//! `Content-ID`, and the page opens as Word opens one ([`crate::html::import_page`]). Word keeps +//! HTML `w:altChunk`s in this form too. + +use std::collections::HashMap; +use std::sync::Arc; + +use wordcraft_doc::Document; + +/// Most parts read from one message (nested multiparts included). +const MAX_PARTS: usize = 10_000; +/// Deepest `multipart` nesting followed. +const MAX_NESTING: usize = 8; +/// Longest header block looked at for one part. +const MAX_HEADER: usize = 64 * 1024; +/// Longest boundary (RFC 2046 §5.1.1). +const MAX_BOUNDARY: usize = 70; +/// Picture bytes one page may load, counting every `img` that names a part. +const PICTURE_BUDGET: usize = 1 << 30; + +/// One leaf part of the message: its headers (names lower-cased) and decoded body. +struct Part { + content_type: String, + location: Option, + id: Option, + /// Shared by every `img` that names it. + body: Arc>, +} + +impl Part { + fn mime(&self) -> String { + self.content_type.split(';').next().unwrap_or("").trim().to_ascii_lowercase() + } +} + +/// Does `bytes` look like a MIME message (an `.mht` file)? +pub fn is_mht(bytes: &[u8]) -> bool { + let head = String::from_utf8_lossy(bytes.get(..bytes.len().min(4096)).unwrap_or(bytes)).to_ascii_lowercase(); + head.starts_with("mime-version:") || head.starts_with("content-type: multipart/") || head.contains("\ncontent-type: multipart/related") +} + +/// Import an MHT file. A message without an HTML part reads as its text (an empty document +/// when there's none). +pub fn import(bytes: &[u8]) -> Document { + import_on(bytes, &wordcraft_doc::SectionProps::default()) +} + +/// [`import`] onto `page`: like HTML, an MHT file gives no page size (#1381). +pub fn import_on(bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Document { + let mut parts = Vec::new(); + let (headers, body) = split_headers(bytes); + let start = param(header(&headers, "content-type").unwrap_or(""), "start").map(|s| s.trim_matches(['<', '>']).to_string()); + collect(&headers, body, &mut parts, 0); + let root = start + .as_deref() + .and_then(|s| parts.iter().position(|p| p.id.as_deref() == Some(s))) + .or_else(|| parts.iter().position(|p| matches!(p.mime().as_str(), "text/html" | "application/xhtml+xml"))); + let Some(root) = root.and_then(|i| parts.get(i)) else { + let text = parts.iter().find(|p| p.mime() == "text/plain").map(|p| p.body.as_slice()).unwrap_or(&[]); + return crate::txt::import_on(text, page); + }; + let html = crate::html::decode_as(&root.body, param(&root.content_type, "charset")); + let base = root.location.as_deref().unwrap_or(""); + // Parts by `Content-Location` (lower case) and `Content-ID`; the first of each wins. + let mut by_location = HashMap::new(); + let mut by_id = HashMap::new(); + for p in &parts { + if let Some(l) = &p.location { + by_location.entry(l.to_ascii_lowercase()).or_insert(p); + } + if let Some(i) = &p.id { + by_id.entry(i.as_str()).or_insert(p); + } + } + let left = std::cell::Cell::new(PICTURE_BUDGET); + let loader = |src: &str| -> Option>> { + let src = src.trim(); + let part = match src.get(..4).filter(|s| s.eq_ignore_ascii_case("cid:")).and(src.get(4..)) { + Some(cid) => by_id.get(cid), + // By its location as written, or resolved against the page's. + None => by_location.get(&src.to_ascii_lowercase()).or_else(|| by_location.get(&resolve(base, src).to_ascii_lowercase())), + }?; + left.set(left.get().checked_sub(part.body.len())?); + Some(part.body.clone()) + }; + crate::html::import_on(&html, &loader, page) +} + +/// `reference` resolved against the page's location `base`, as RFC 2557 §5 asks (RFC 3986 +/// §5.2.2): a reference with a scheme stays as it is, `//host/…` takes the base's scheme, `/…` +/// its scheme and host, an empty path its path and query, anything else is merged with its path +/// (§5.2.3); `.` and `..` segments go (§5.2.4) and fragments are dropped. Nothing is fetched. +fn resolve(base: &str, reference: &str) -> String { + let (b, r) = (UriParts::of(base), UriParts::of(reference)); + let t = if r.scheme.is_some() { + UriParts { path: remove_dots(&r.path), ..r } + } else if r.authority.is_some() { + UriParts { scheme: b.scheme, path: remove_dots(&r.path), ..r } + } else if r.path.is_empty() { + UriParts { query: r.query.or(b.query), ..b } + } else if r.path.starts_with('/') { + UriParts { path: remove_dots(&r.path), query: r.query, ..b } + } else { + let merged = if b.authority.is_some() && b.path.is_empty() { + format!("/{}", r.path) + } else { + format!("{}{}", b.path.rfind('/').and_then(|i| b.path.get(..=i)).unwrap_or(""), r.path) + }; + UriParts { path: remove_dots(&merged), query: r.query, ..b } + }; + t.to_string() +} + +/// A URI's parts (RFC 3986 §3): `scheme:`, `//authority`, path and `?query`; the fragment left out. +struct UriParts<'a> { + scheme: Option<&'a str>, + authority: Option<&'a str>, + path: String, + query: Option<&'a str>, +} + +impl<'a> UriParts<'a> { + fn of(uri: &'a str) -> Self { + let uri = uri.split('#').next().unwrap_or(""); + // A one-letter scheme is a Windows drive (`C:/…`), not a scheme. + let scheme = uri.split_once(':').map(|(k, _)| k).filter(|k| { + k.len() > 1 && k.starts_with(|c: char| c.is_ascii_alphabetic()) && k.chars().all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c)) + }); + let rest = scheme.and_then(|k| uri.get(k.len() + 1..)).unwrap_or(uri); + let (authority, rest) = match rest.strip_prefix("//") { + Some(r) => { + let end = r.find(['/', '?']).unwrap_or(r.len()); + (r.get(..end), r.get(end..).unwrap_or("")) + } + None => (None, rest), + }; + let (path, query) = match rest.split_once('?') { + Some((p, q)) => (p, Some(q)), + None => (rest, None), + }; + UriParts { scheme, authority, path: path.to_string(), query } + } +} + +impl std::fmt::Display for UriParts<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(s) = self.scheme { + write!(f, "{s}:")?; + } + if let Some(a) = self.authority { + write!(f, "//{a}")?; + } + f.write_str(&self.path)?; + if let Some(q) = self.query { + write!(f, "?{q}")?; + } + Ok(()) + } +} + +/// `path` without its `.` and `..` segments (RFC 3986 §5.2.4); `..` stops at the root. +fn remove_dots(path: &str) -> String { + let mut out: Vec<&str> = Vec::new(); + let segments: Vec<&str> = path.split('/').collect(); + for (i, seg) in segments.iter().enumerate() { + match *seg { + "." => {} + ".." => { + if out.len() > 1 || out.first().is_some_and(|f| !f.is_empty()) { + out.pop(); + } + } + s => out.push(s), + } + if i + 1 == segments.len() && matches!(*seg, "." | "..") { + out.push(""); + } + } + out.join("/") +} + +/// Split a part into its unfolded header lines and its body (after the first empty line). +fn split_headers(b: &[u8]) -> (Vec<(String, String)>, &[u8]) { + // Whichever blank line comes first ends the headers. + let blank = [find_bytes(b, b"\r\n\r\n").map(|i| (i, 4)), find_bytes(b, b"\n\n").map(|i| (i, 2))].into_iter().flatten().min(); + let (head, body) = match blank { + Some((i, n)) => (b.get(..i).unwrap_or(&[]), b.get(i + n..).unwrap_or(&[])), + // Headers only, or a body without them. + None if b.first().is_some_and(|c| c.is_ascii_alphabetic()) && find_bytes(b, b":").is_some() => (b, &[][..]), + None => (&[][..], b), + }; + let head = String::from_utf8_lossy(head.get(..head.len().min(MAX_HEADER)).unwrap_or(head)); + let mut headers: Vec<(String, String)> = Vec::new(); + for line in head.lines() { + if line.starts_with([' ', '\t']) { + if let Some((_, v)) = headers.last_mut() { + v.push(' '); + v.push_str(line.trim()); + } + } else if let Some((k, v)) = line.split_once(':') { + headers.push((k.trim().to_ascii_lowercase(), v.trim().to_string())); + } + } + (headers, body) +} + +fn header<'a>(h: &'a [(String, String)], name: &str) -> Option<&'a str> { + h.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str()) +} + +/// A `; name=value` parameter of a header value (quotes removed), name case-insensitive. +fn param<'a>(value: &'a str, name: &str) -> Option<&'a str> { + value.split(';').skip(1).find_map(|p| { + let (k, v) = p.split_once('=')?; + k.trim().eq_ignore_ascii_case(name).then(|| v.trim().trim_matches('"')) + }) +} + +/// Gather the leaf parts of a (possibly multipart) entity. +fn collect(headers: &[(String, String)], body: &[u8], out: &mut Vec, depth: usize) { + if out.len() >= MAX_PARTS { + return; + } + let ct = header(headers, "content-type").unwrap_or("text/plain").to_string(); + if ct.trim_start().to_ascii_lowercase().starts_with("multipart/") { + let Some(boundary) = param(&ct, "boundary").filter(|b| !b.is_empty() && b.len() <= MAX_BOUNDARY) else { return }; + if depth >= MAX_NESTING { + return; + } + for chunk in multipart_bodies(body, boundary.as_bytes()) { + let (h, b) = split_headers(chunk); + collect(&h, b, out, depth + 1); + if out.len() >= MAX_PARTS { + return; + } + } + return; + } + let enc = header(headers, "content-transfer-encoding").unwrap_or("").trim().to_ascii_lowercase(); + let body = match enc.as_str() { + "quoted-printable" => quoted_printable(body), + "base64" => crate::model::base64_decode(&String::from_utf8_lossy(body)).unwrap_or_default(), + _ => body.to_vec(), + }; + let clean = |s: &str| s.trim().trim_matches(['<', '>']).to_string(); + out.push(Part { + content_type: ct, + location: header(headers, "content-location").map(clean), + id: header(headers, "content-id").map(clean), + body: Arc::new(body), + }); +} + +/// The bodies between `--boundary` lines, up to the closing `--boundary--`. +fn multipart_bodies<'a>(body: &'a [u8], boundary: &[u8]) -> Vec<&'a [u8]> { + let mut delim = b"--".to_vec(); + delim.extend_from_slice(boundary); + let mut out = Vec::new(); + // Delimiters sit at line starts. + let mut starts = Vec::new(); + let mut i = 0; + while let Some(k) = body.get(i..).and_then(|rest| find_bytes(rest, &delim)) { + let at = i + k; + if at == 0 || body.get(at - 1) == Some(&b'\n') { + starts.push(at); + if starts.len() > MAX_PARTS { + break; + } + } + i = at + delim.len(); + } + for w in starts.windows(2) { + let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue }; + let Some(seg) = body.get(a + delim.len()..b) else { continue }; + if seg.starts_with(b"--") { + break; + } + // The rest of the delimiter line, then the part; the line break before the next + // delimiter belongs to it. + let seg = match find_bytes(seg, b"\n") { + Some(n) => seg.get(n + 1..).unwrap_or(&[]), + None => &[], + }; + let seg = seg.strip_suffix(b"\r\n").or_else(|| seg.strip_suffix(b"\n")).unwrap_or(seg); + out.push(seg); + if out.len() >= MAX_PARTS { + break; + } + } + out +} + +fn find_bytes(hay: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() { + return None; + } + hay.windows(needle.len()).position(|w| w == needle) +} + +/// Decode quoted-printable (RFC 2045 §6.7): `=XX` bytes and `=` soft line breaks; anything +/// malformed is kept as it stands. +fn quoted_printable(b: &[u8]) -> Vec { + let hex = |c: u8| (c as char).to_digit(16).and_then(|d| u8::try_from(d).ok()); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while let Some(&c) = b.get(i) { + if c != b'=' { + out.push(c); + i += 1; + continue; + } + match (b.get(i + 1).copied(), b.get(i + 2).copied()) { + (Some(b'\r'), Some(b'\n')) => i += 3, + (Some(b'\n'), _) => i += 2, + (Some(h), Some(l)) if let (Some(h), Some(l)) = (hex(h), hex(l)) => { + out.push((h << 4) | l); + i += 3; + } + _ => { + out.push(c); + i += 1; + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use wordcraft_doc::{Block, InlineObject}; + + const PNG_1X1: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + + fn message(html_part: &str) -> String { + format!( + "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"----=_B\"\r\n\r\n\ + ------=_B\r\n{html_part}\r\n\ + ------=_B\r\nContent-Location: file:///C:/page_files/dot.png\r\nContent-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n{PNG_1X1}\r\n\ + ------=_B--\r\n" + ) + } + + #[test] + fn reads_the_quoted_printable_page_and_its_pictures() { + let page = "Content-Location: file:///C:/page.htm\r\nContent-Transfer-Encoding: quoted-printable\r\nContent-Type: text/html; charset=\"utf-8\"\r\n\r\n\ +

Dobar dan, gospo=C4=91o, ovo je =\r\njedan red.

"; + let d = import(message(page).as_bytes()); + let text = d.plain_text(wordcraft_doc::StoryRef::Body); + assert!(text.contains("Dobar dan, gospođo, ovo je jedan red."), "{text:?}"); + let pictures = + d.body.iter().filter(|b| matches!(&***b, Block::Para(p) if p.objects.iter().any(|o| matches!(o, InlineObject::Image { .. })))).count(); + assert_eq!(pictures, 1); + assert_eq!(d.media.len(), 1); + } + + #[test] + fn pictures_named_relative_to_the_page_resolve_as_uris() { + // The page in `reports/`, its picture in `images/` (RFC 2557 §5: relative URIs resolve + // against the page's Content-Location). + let m = |src: &str| { + format!( + "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=b\r\n\r\n\ + --b\r\nContent-Location: https://example.test/reports/page.html\r\nContent-Type: text/html\r\n\r\n

x

\r\n\ + --b\r\nContent-Location: https://example.test/images/logo.png\r\nContent-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n{PNG_1X1}\r\n--b--\r\n" + ) + }; + for src in [ + "../images/logo.png", + "/images/logo.png", + "./../images/./logo.png", + "//example.test/images/logo.png", + "https://example.test/images/logo.png", + ] { + assert_eq!(import(m(src).as_bytes()).media.len(), 1, "{src}"); + } + // Nothing past the root: `..` there stays at the root. + assert_eq!(import(m("../../../images/logo.png").as_bytes()).media.len(), 1); + assert_eq!(import(m("images/logo.png").as_bytes()).media.len(), 0); + assert_eq!(resolve("https://h/a/b/c.html", "../d/./e.png#f"), "https://h/a/d/e.png"); + assert_eq!(resolve("file:///C:/x/page.htm", "page_files/dot.png"), "file:///C:/x/page_files/dot.png"); + assert_eq!(resolve("", "dot.png"), "dot.png"); + // A host with no path, and a base whose query holds slashes (RFC 3986 §5.2.3: the path + // merges, not the query; an authority with an empty path gets its `/`). + assert_eq!(resolve("https://example.test", "logo.png"), "https://example.test/logo.png"); + assert_eq!(resolve("https://example.test/page.html?redirect=/other/", "logo.png"), "https://example.test/logo.png"); + assert_eq!(resolve("https://h/a/p.html?q", "?r"), "https://h/a/p.html?r"); + assert_eq!(resolve("https://h/a/p.html?q", "x.png?v=1"), "https://h/a/x.png?v=1"); + assert_eq!(resolve("https://h/a/p.html", "mailto:x@y"), "mailto:x@y"); + for page in ["https://example.test", "https://example.test/page.html?redirect=/other/"] { + let m = format!( + "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=b\r\n\r\n\ + --b\r\nContent-Location: {page}\r\nContent-Type: text/html\r\n\r\n

x

\r\n\ + --b\r\nContent-Location: https://example.test/logo.png\r\nContent-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n{PNG_1X1}\r\n--b--\r\n" + ); + assert_eq!(import(m.as_bytes()).media.len(), 1, "{page}"); + } + } + + #[test] + fn the_part_charset_decides_the_text() { + let page = "Content-Type: text/html; charset=windows-1250\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n

=E8lanak

"; + let d = import(message(page).as_bytes()); + assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body).trim(), "članak"); + } + + #[test] + fn the_start_part_is_the_page_and_cid_names_pictures() { + let m = format!( + "MIME-Version: 1.0\nContent-Type: multipart/related; type=\"text/html\"; start=\"\"; boundary=b\n\n\ + --b\nContent-Type: text/html\n\n

Not this one

\n\ + --b\nContent-ID: \nContent-Type: text/html\n\n

This one

\n\ + --b\nContent-ID: \nContent-Transfer-Encoding: base64\nContent-Type: image/png\n\n{PNG_1X1}\n--b--\n" + ); + let d = import(m.as_bytes()); + assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body).trim(), "This one"); + assert_eq!(d.media.len(), 1); + // Without a page, a message reads as its text. + let d = import(b"MIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=b\n\n--b\nContent-Type: text/plain\n\nJust text\n--b--\n"); + assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Just text"); + assert!(is_mht(m.as_bytes()) && !is_mht(b"

x

")); + } + + #[test] + fn hostile_messages_never_panic() { + for m in [ + &b""[..], + b"MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"\"\r\n\r\n", + b"MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=x\r\n\r\n--x\r\n--x--", + b"MIME-Version: 1.0\nContent-Type: multipart/related; boundary=x\n\n--x\nContent-Transfer-Encoding: quoted-printable\nContent-Type: text/html\n\n=\n=Z=4", + b"Content-Type: text/html\r\nContent-Transfer-Encoding: base64\r\n\r\n!!!!", + ] { + let _ = import(m); + } + let mut deep = String::from("MIME-Version: 1.0\r\n"); + for i in 0..64 { + deep.push_str(&format!("Content-Type: multipart/related; boundary=b{i}\r\n\r\n--b{i}\r\n")); + } + let _ = import(deep.as_bytes()); + } +} diff --git a/crates/formats/src/model/from_doc.rs b/crates/formats/src/model/from_doc.rs index 120cf9d37..0f77785f1 100644 --- a/crates/formats/src/model/from_doc.rs +++ b/crates/formats/src/model/from_doc.rs @@ -148,6 +148,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec { mark_ins: None, mark_del: None, fmt_change: None, + mark: None, }; let in_code = kind == Kind::Code; let mut k = 0usize; @@ -179,6 +180,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec { mark_ins: None, mark_del: None, fmt_change: None, + mark: None, }; let prev = std::mem::replace(&mut out, next); if prev.inlines.is_empty() { @@ -320,6 +322,7 @@ fn flow_table(doc: &Document, t: &Table, depth: usize, tracked: bool) -> FTable shading: c.props.shading, borders: c.props.borders, valign: c.props.valign, + width: None, }); } rows.push(cells); diff --git a/crates/formats/src/model/to_doc.rs b/crates/formats/src/model/to_doc.rs index 2f4fa1361..d61243213 100644 --- a/crates/formats/src/model/to_doc.rs +++ b/crates/formats/src/model/to_doc.rs @@ -100,6 +100,12 @@ fn para_props(p: &Para, list_num: Option) -> ParaProps { out.indent_left = sp.left; out.indent_right = sp.right; out.indent_first = sp.first; + if sp.before_auto { + out.space_before_auto = Some(true); + } + if sp.after_auto { + out.space_after_auto = Some(true); + } if p.page_break { out.page_break_before = Some(true); } @@ -177,6 +183,9 @@ impl<'a> Builder<'a> { let old = para_props(&c.old, old_num); out.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, &c.mark), old); } + if let Some(f) = &p.mark { + out.mark = self.budget(char_props(f)); + } if p.keep_next { out.props.keep_next = Some(true); } diff --git a/crates/formats/src/model/types.rs b/crates/formats/src/model/types.rs index 1b52b2e68..0747af578 100644 --- a/crates/formats/src/model/types.rs +++ b/crates/formats/src/model/types.rs @@ -220,6 +220,9 @@ pub struct Spacing { pub right: Option, /// First-line indent; negative = hanging. pub first: Option, + /// HTML's automatic space before / after (see `ParaProps::space_before_auto`). + pub before_auto: bool, + pub after_auto: bool, } #[derive(Clone, Debug, Default, PartialEq)] @@ -244,6 +247,8 @@ pub struct Para { /// Tracked formatting change of the paragraph: the paragraph's formatting before it (kind, /// list, alignment, page break, spacing and borders of `old`; its inlines are unused). pub fmt_change: Option>>, + /// Formatting of an empty paragraph's mark (what sizes its line), when the source gives it. + pub mark: Option>, } impl Para { @@ -337,11 +342,14 @@ pub struct Cell { /// The cell's own borders (RTF `\clbrdr…`), drawn whatever the table's style. pub borders: Option, pub valign: Option, + /// The width, points, the source gives a cell spanning one column (HTML): see + /// [`FTable::widths_from_cells`]. + pub width: Option, } impl Default for Cell { fn default() -> Self { - Cell { blocks: Vec::new(), colspan: 1, rowspan: 1, covered: false, header: false, shading: None, borders: None, valign: None } + Cell { blocks: Vec::new(), colspan: 1, rowspan: 1, covered: false, header: false, shading: None, borders: None, valign: None, width: None } } } @@ -372,6 +380,22 @@ impl FTable { pub fn cols(&self) -> usize { self.rows.iter().map(|r| r.iter().map(|c| c.colspan.clamp(1, 63) as usize).sum::()).max().unwrap_or(0) } + /// Grid column widths from the cells that give one (after [`Self::insert_covered`], so a + /// cell's column counts those spanned from rows above), when every column has one. + pub fn widths_from_cells(&mut self) { + let mut widths = vec![None; self.cols()]; + for row in &self.rows { + let mut g = 0; + for c in row { + if let (Some(w), Some(slot)) = (c.width, widths.get_mut(g)) { + slot.get_or_insert(w); + } + g += c.colspan.clamp(1, 63) as usize; + } + } + self.widths = widths.into_iter().collect::>>().unwrap_or_default(); + } + /// After an importer that gives `rowspan` but no placeholder cells (HTML): insert covered /// cells into the rows below each spanning cell. pub fn insert_covered(&mut self) { diff --git a/crates/formats/src/odt/import.rs b/crates/formats/src/odt/import.rs index 61d446004..39742d3e8 100644 --- a/crates/formats/src/odt/import.rs +++ b/crates/formats/src/odt/import.rs @@ -115,6 +115,7 @@ fn parse_full(bytes: &[u8]) -> Result<(Flow, SectionProps), String> { /// [`parse_full`], plus whether change tracking is on (`None`: the document has no tracked changes). fn parse_tracked(bytes: &[u8]) -> Result<(Flow, SectionProps, Option, Option, page_parts::Parts), String> { + wordcraft_doc::zip_guard::check(bytes, crate::MAX_ZIP_ENTRIES).map_err(|e| format!("ODT: {e}"))?; let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("ODT: not a valid package ({e})"))?; if let Some(mt) = read_entry(&mut z, "mimetype") { let mt = String::from_utf8_lossy(&mt); diff --git a/crates/formats/src/rtf/styles.rs b/crates/formats/src/rtf/styles.rs index 94c6ea489..a78a99a43 100644 --- a/crates/formats/src/rtf/styles.rs +++ b/crates/formats/src/rtf/styles.rs @@ -261,5 +261,13 @@ fn spacing(st: &St) -> model::Spacing { } else { Some(LineSpacing::AtLeast(pt(sl))) }; - model::Spacing { before: gap(st.sb), after: gap(st.sa), line, left: st.li.map(pt), right: st.ri.map(pt), first: st.fi.map(pt) } + model::Spacing { + before: gap(st.sb), + after: gap(st.sa), + line, + left: st.li.map(pt), + right: st.ri.map(pt), + first: st.fi.map(pt), + ..Default::default() + } } diff --git a/crates/formats/src/sheet.rs b/crates/formats/src/sheet.rs index 8e352dd92..53c7fca84 100644 --- a/crates/formats/src/sheet.rs +++ b/crates/formats/src/sheet.rs @@ -201,6 +201,7 @@ struct Package<'a> { impl<'a> Package<'a> { fn open(bytes: &'a [u8]) -> Result { + wordcraft_doc::zip_guard::check(bytes, crate::MAX_ZIP_ENTRIES)?; let zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a valid spreadsheet package ({e})"))?; Ok(Package { zip, total: 0 }) } @@ -401,6 +402,14 @@ mod tests { use super::*; use std::io::Write; + #[test] + fn a_package_declaring_too_many_entries_is_refused() { + // #3660: refused before `zip` reserves and parses them. + let bomb = wordcraft_doc::zip_guard::zip64_entry_count_sample(crate::MAX_ZIP_ENTRIES + 1); + assert!(sheet_names(&bomb).is_err_and(|e| e.contains("declares"))); + assert!(crate::odt::parse(&bomb).is_err_and(|e| e.contains("declares"))); + } + #[test] fn negative_zero_formats_as_zero() { assert_eq!(number(-0.0), "0"); diff --git a/crates/layout/src/display/lines.rs b/crates/layout/src/display/lines.rs index 857a81aac..04e49bc41 100644 --- a/crates/layout/src/display/lines.rs +++ b/crates/layout/src/display/lines.rs @@ -583,7 +583,7 @@ fn inline_objects(ctx: &LineContext<'_, '_, '_>, out: &mut Vec) { } let cx = x + line.cl_left(k).unwrap_or(0.0); let obj = para.and_then(|p| p.objects.get(oi)); - let rect = inline_rect(obj, cx, base, c.adv, c.obj_h); + let rect = inline_rect(obj, cx, base, c.adv, c.obj_h, pl.rule_room(line, k)); let spin = obj.and_then(InlineObject::frame).map(|(_, _, f)| f.spin()).unwrap_or_default(); match obj { Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })) => { diff --git a/crates/layout/src/display/objects.rs b/crates/layout/src/display/objects.rs index c6119e525..7fe6d857f 100644 --- a/crates/layout/src/display/objects.rs +++ b/crates/layout/src/display/objects.rs @@ -479,10 +479,21 @@ pub(crate) fn effects_in(effects: wordcraft_doc::effects::ShapeEffects, spin: Sp /// Where inline object `obj` is drawn, given its cluster's box (`adv` × `obj_h` standing on the /// baseline at `cx`): inside the room kept for its effects. /// A rotated one's unrotated frame, centred in that room. -pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, obj_h: f32) -> Rect { +/// +/// `line` is the room a horizontal rule is placed in, from `cx`. +pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, obj_h: f32, line: f32) -> Rect { let frame = obj.and_then(InlineObject::frame); let [l, t, r, b] = frame.map_or([0.0; 4], |(_, _, float)| float.effect_extent()); let room = Rect::new(cx + l, base - obj_h + t, (adv - l - r).max(0.0), (obj_h - t - b).max(0.0)); + // A horizontal rule is as wide as its share of the paragraph's width, whatever narrows its + // line, and is placed by its alignment in what the line has left: it runs past the margin + // beside a float or a first-line indent, as in Word (see `ParaLayout::rule_room`). + if let Some(InlineObject::Shape { w, extra, .. }) = obj + && let Some(hr) = extra.hr + { + let rw = hr.width(room.w, *w); + return Rect::new(hr.left(room.x, line, rw), room.y, rw, room.h); + } let Some((w, h, float)) = frame.filter(|(_, _, f)| f.rot != 0.0) else { return room }; // The room is the rotated bounds of the frame: undo the rotation. let (w, h) = (wordcraft_geom::finite(w).clamp(1.0, 4000.0), wordcraft_geom::finite(h).clamp(1.0, 4000.0)); @@ -506,3 +517,6 @@ fn canvas_background(rect: Rect, c: &wordcraft_doc::para::CanvasStyle) -> Draw { cap: Default::default(), } } + +#[cfg(test)] +mod fill_alpha_tests; diff --git a/crates/layout/src/display/objects/fill_alpha_tests.rs b/crates/layout/src/display/objects/fill_alpha_tests.rs new file mode 100644 index 000000000..25f175c9e --- /dev/null +++ b/crates/layout/src/display/objects/fill_alpha_tests.rs @@ -0,0 +1,83 @@ +use super::*; +use wordcraft_doc::connector::ShapeExtra; +use wordcraft_doc::freeform::{FreePath, Freeform, InkTool}; + +#[test] +fn freeform_fill_has_own_alpha() { + let rect = Rect::new(4.0, 4.0, 24.0, 24.0); + for transparency in [0.0, 50.0, 100.0] { + for closed in [false, true] { + let freeform = Freeform { + w: 24.0, + h: 24.0, + paths: vec![FreePath { pts: vec![[0.0, 0.0], [24.0, 0.0], [24.0, 24.0]], closed }], + alpha: 0.25, + ..Default::default() + }; + let mut out = Vec::new(); + shape_draws( + rect, + &ShapeLook { + kind: ShapeKind::Freeform, + fill: Some(&wordcraft_doc::fill::Fill::Solid { color: Rgb::BLACK, transparency }), + stroke: Some(Rgb::WHITE), + stroke_width: 2.0, + effects: Default::default(), + freeform: Some(&freeform), + extra: &ShapeExtra::default(), + }, + &DisplayOptions::default(), + &mut out, + ); + let Draw::Path { fill, stroke, stroke_width, .. } = &out[0] else { panic!("expected freeform path") }; + match fill { + Some(Paint::Solid(color, alpha)) if closed => { + assert_eq!(*color, Rgb::BLACK); + assert_eq!(*alpha, 1.0 - transparency / 100.0); + } + None if !closed => {} + _ => panic!("unexpected freeform paint: {fill:?}"), + } + assert_eq!((*stroke, *stroke_width), (Some(Rgb::WHITE), 2.0)); + } + } + let ink = Freeform::ink(InkTool::Highlighter, 24.0, 24.0, vec![[0.0, 0.0], [24.0, 24.0]]); + let mut out = Vec::new(); + shape_draws( + rect, + &ShapeLook { + kind: ShapeKind::Freeform, + fill: Some(&wordcraft_doc::fill::Fill::Solid { color: Rgb::BLACK, transparency: 100.0 }), + stroke: Some(Rgb::WHITE), + stroke_width: 2.0, + effects: Default::default(), + freeform: Some(&ink), + extra: &ShapeExtra::default(), + }, + &DisplayOptions::default(), + &mut out, + ); + assert!(matches!(out.as_slice(), [Draw::Ink { alpha, .. }] if *alpha == 0.5)); +} + +#[test] +fn other_path_alpha_defaults() { + let rect = Rect::new(4.0, 4.0, 24.0, 24.0); + let graphic = Graphic { + items: vec![GraphicItem::Path { + segs: vec![PathSeg::Move(0.0, 0.0), PathSeg::Line(24.0, 24.0)], + fill: Some(Rgb::BLACK), + stroke: None, + stroke_width: 0.0, + }], + ..Default::default() + }; + let mut out = graphic_draws(&Document::new(), &graphic, rect, 1.0); + out.push(path_arrowhead(&[(4.0, 4.0), (28.0, 28.0)], Rgb::BLACK, 2.0, ArrowStyle::default()).unwrap()); + let extra = ShapeExtra { arrow_start: true, arrow_end: true, ..Default::default() }; + line_draws(rect, ShapeKind::ElbowConnector, Some(Rgb::BLACK), 2.0, Default::default(), &extra, &mut out); + assert_eq!(out.len(), 5); + assert!(out.iter().all( + |item| matches!(item, Draw::Path { fill, .. } if fill.as_ref().is_none_or(|paint| matches!(paint, Paint::Solid(_, alpha) if *alpha == 1.0))) + )); +} diff --git a/crates/layout/src/display/paint.rs b/crates/layout/src/display/paint.rs index b5cb62f7d..7509a5062 100644 --- a/crates/layout/src/display/paint.rs +++ b/crates/layout/src/display/paint.rs @@ -32,6 +32,16 @@ impl Paint { Paint::Radial { c, r, stops } => Paint::Radial { c: *c, r: *r, stops: fade(stops) }, } } + /// Inset soft-edge paints. Solid bands composite to the fill's target opacity. + pub fn soft_bands(&self, radius: f32) -> Vec<(f32, Paint)> { + match self { + Paint::Solid(color, alpha) => { + let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 1.0 }; + wordcraft_doc::effects::bands(-radius, 0.0, alpha).into_iter().map(|(grow, band)| (grow, Paint::Solid(*color, band))).collect() + } + _ => wordcraft_doc::effects::bands(-radius, 0.0, 1.0).into_iter().map(|(grow, alpha)| (grow, self.faded(alpha))).collect(), + } + } /// The colour and opacity this paint gives the point (`x`, `y`): stops blended, padded past /// the ends. pub fn at(&self, x: f32, y: f32) -> (Rgb, f32) { diff --git a/crates/layout/src/floats.rs b/crates/layout/src/floats.rs index 6a7d3551b..876b4c32a 100644 --- a/crates/layout/src/floats.rs +++ b/crates/layout/src/floats.rs @@ -258,7 +258,7 @@ fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryR effects: *effects, freeform: freeform.clone(), spin, - extra: extra.clone(), + extra: Box::new(extra.clone()), object: Some(object), alt: shape_figure_alt(o).unwrap_or_default().to_string(), }] @@ -491,7 +491,7 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec f32 { + let adv = self.clusters.get(k).map_or(0.0, |c| c.adv); + if line.rtl { + return adv; + } + let first = if line.c0 == 0 { self.rp.indent_first } else { 0.0 }; + (line.right + first - line.cl_left(k).unwrap_or(0.0)).max(0.0) + } + /// Returns whether `off` is a legal caret and edit boundary, that is, not the interior start of a /// joined grapheme cluster. fn is_edit_boundary(&self, off: usize) -> bool { diff --git a/crates/layout/src/para/paragraph.rs b/crates/layout/src/para/paragraph.rs index f5fdb3c67..1a67b2fe4 100644 --- a/crates/layout/src/para/paragraph.rs +++ b/crates/layout/src/para/paragraph.rs @@ -326,6 +326,17 @@ impl<'a> Shaped<'a> { // the margin and the page edge clips it (see `ParaEnv::fit_objects`). let maxw = (env.width - cx.rp.indent_left.max(0.0) - cx.rp.indent_right.max(0.0)).max(18.0); let h = env.fitted.iter().find(|(i, _)| *i == k).map_or(saved_h, |(_, h)| *h); + // A horizontal rule takes its whole line; display draws it at its share. Measured for + // a table column (an unbounded line) a share of its line asks for no width, following + // the column rather than widening it; one of its own width asks for that. + let w = match o { + InlineObject::Shape { extra, .. } => match extra.hr { + Some(_) if env.width < crate::table::MEASURE_WIDE => maxw, + Some(hr) if hr.pct > 0.0 => 1.0, + _ => w, + }, + _ => w, + }; let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0)); let s = if env.fit_objects && w > maxw { maxw / w } else { 1.0 }; // The line makes room for effects (shadows) around the picture too, and diff --git a/crates/layout/src/para/proof.rs b/crates/layout/src/para/proof.rs index 1cec32e4d..b9ffa911c 100644 --- a/crates/layout/src/para/proof.rs +++ b/crates/layout/src/para/proof.rs @@ -79,10 +79,15 @@ pub fn proof_text(p: &wordcraft_doc::Paragraph) -> String { let mut runs = p.runs.iter(); let mut run = runs.next(); let mut end = run.map_or(0, |r| r.len); + // Each run this walk looks at is a step ([`crate::work`]), so the linear-time test counts + // them instead of timing the walk: a caller that went back to the first run for every + // character would count the runs once per character and show up as a quadratic count (#5185). + crate::work::steps(usize::from(run.is_some())); for (i, c) in p.text.char_indices() { while run.is_some() && i >= end { run = runs.next(); end = end.saturating_add(run.map_or(0, |r| r.len)); + crate::work::steps(usize::from(run.is_some())); } let props = run.map_or(&p.mark, |r| &r.props); let deleted = c == wordcraft_doc::para::OBJ || props.del.is_some(); diff --git a/crates/layout/src/placed.rs b/crates/layout/src/placed.rs index cf55bccf9..e6ce2a2da 100644 --- a/crates/layout/src/placed.rs +++ b/crates/layout/src/placed.rs @@ -48,7 +48,7 @@ pub enum Placed { effects: wordcraft_doc::effects::ShapeEffects, spin: Spin, /// Arrowheads and text warp (see [`wordcraft_doc::connector::ShapeExtra`]). - extra: wordcraft_doc::connector::ShapeExtra, + extra: Box, /// The floating object it draws (a Drawing Canvas's members: the canvas's), for leaving /// exactly one object out of a page (see [`crate::display::DisplayOptions::hide_object`]). object: Option, diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 5e7904e39..d999f801d 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -145,7 +145,7 @@ pub fn cell_margins(doc: &wordcraft_doc::Document, t: &Table, row: usize, cell: /// rows only, so AutoFit stays quick. const MEASURE_BUDGET: usize = 4000; /// The line length paragraphs are measured at: wide enough that nothing real wraps. -const MEASURE_WIDE: f32 = 15_840.0; +pub(crate) const MEASURE_WIDE: f32 = 15_840.0; /// What an empty cell's text needs (about a paragraph mark), points. const EMPTY_TEXT: f32 = 12.0; diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 5caf63784..6879f5c37 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -5051,7 +5051,7 @@ fn inline_picture_keeps_room_for_its_effects() { assert_eq!((c.adv, c.obj_h), (124.0, 89.0), "picture plus effect extents"); // Drawn inside that room. let obj = picture(100.0, 50.0, shadow); - let r = display::inline_rect(Some(&obj), 10.0, 200.0, c.adv, c.obj_h); + let r = display::inline_rect(Some(&obj), 10.0, 200.0, c.adv, c.obj_h, c.adv); assert_eq!((r.x, r.y, r.w, r.h), (16.0, 123.0, 100.0, 50.0)); } @@ -12326,6 +12326,105 @@ fn a_row_leaves_room_for_the_border_below_it() { } } +/// A horizontal rule (VML `o:hr`) takes its whole line and is drawn across its share of it, +/// placed by its alignment (Word, measured: 50 % centred, left, right). In a table cell it +/// follows its column instead of widening the table. +#[test] +fn horizontal_rules_take_their_line() { + use wordcraft_doc::connector::HorizontalRule; + use wordcraft_doc::props::Align; + let rule = |pct: f32, align: Align| { + let mut o = rect_shape(0.0, 1.5, Default::default()); + if let InlineObject::Shape { extra, .. } = &mut o { + extra.hr = Some(HorizontalRule { pct, align, standard: true }); + } + o + }; + let drawn = |d: &Document| -> Vec { + let l = lay(d); + display::page_display(d, &l.pages[0], &display::DisplayOptions::default()) + .iter() + .filter_map( + |i| if let display::Draw::Shape { rect, kind: wordcraft_doc::para::ShapeKind::Rectangle, .. } = i { Some(*rect) } else { None }, + ) + .collect() + }; + let mut d = Document::from_text("a\nb"); + d.insert_object(&Pos::body(1, 0), rule(50.0, Align::Center), &Default::default()).unwrap(); + d.insert_object(&Pos::body(0, 0), rule(100.0, Align::Left), &Default::default()).unwrap(); + let s = drawn(&d); + let (full, half) = (s[0], s[1]); + assert!(full.w > 400.0, "the whole line: {full:?}"); + assert!((half.w - full.w / 2.0).abs() < 0.01 && (half.x - (full.x + full.w / 4.0)).abs() < 0.01, "{half:?} in {full:?}"); + let mut cell = Table::new(1, 1, 200.0); + let mut p = Paragraph::default(); + p.insert_object(0, rule(100.0, Align::Left), &Default::default()).unwrap(); + cell.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)]; + d.body = vec![Arc::new(Block::Table(cell))]; + let s = drawn(&d); + assert!(s.first().is_some_and(|r| r.w > 150.0 && r.w < 200.0), "the cell's width, not the page's: {s:?}"); + // Word's numbers (A4, 1 inch margins, a 100 pt float): beside a float a rule keeps its width + // and is placed in what the line has left, a first-line indent moving the end of that room + // along, so a whole-line rule runs past the margin as in Word. + let beside = |pct: f32, align: Align, side: Option, first: f32| { + use wordcraft_doc::para::{Anchor, Float, Wrap}; + let mut d = Document::from_text("a"); + if first != 0.0 { + let mut p = d.body[0].as_para().unwrap().clone(); + p.props.indent_first = Some(first); + d.body[0] = Arc::new(Block::Para(p)); + } + d.insert_object(&Pos::body(0, 0), rule(pct, align), &Default::default()).unwrap(); + if let Some(x) = side { + let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, dist: 9.0, ..Default::default() }; + d.insert_object(&Pos::body(0, 0), rect_shape(100.0, 100.0, f), &Default::default()).unwrap(); + } + let l = lay(&d); + display::page_display(&d, &l.pages[0], &display::DisplayOptions::default()) + .iter() + .find_map(|i| match i { + display::Draw::Shape { rect, kind: wordcraft_doc::para::ShapeKind::Rectangle, .. } if rect.h < 10.0 => { + Some((rect.x, rect.x + rect.w)) + } + _ => None, + }) + .unwrap() + }; + let (x0, x1) = beside(100.0, Align::Left, None, 0.0); + let (tw, half) = (x1 - x0, (x1 - x0) / 2.0); + let near = + |got: (f32, f32), want: (f32, f32)| assert!((got.0 - want.0).abs() < 0.5 && (got.1 - want.1).abs() < 0.5, "{got:?}, Word's rule {want:?}"); + // A float on the left: the room is from past it to the margin. + let lo = x0 + 109.0; + near(beside(50.0, Align::Left, Some(0.0), 0.0), (lo, lo + half)); + near(beside(50.0, Align::Center, Some(0.0), 0.0), ((lo + x1 - half) / 2.0, (lo + x1 + half) / 2.0)); + near(beside(50.0, Align::Right, Some(0.0), 0.0), (x1 - half, x1)); + near(beside(100.0, Align::Left, Some(0.0), 0.0), (lo, lo + tw)); + // A float on the right: from the margin to before it. + let hi = x1 - 109.0; + near(beside(50.0, Align::Center, Some(tw - 100.0), 0.0), ((x0 + hi - half) / 2.0, (x0 + hi + half) / 2.0)); + near(beside(50.0, Align::Right, Some(tw - 100.0), 0.0), (hi - half, hi)); + near(beside(100.0, Align::Left, Some(tw - 100.0), 0.0), (x0, x1)); + // A first-line indent moves the room along. + near(beside(100.0, Align::Left, None, 36.0), (x0 + 36.0, x1 + 36.0)); + near(beside(50.0, Align::Center, None, 36.0), (x0 + 36.0 + half / 2.0, x1 + 36.0 - half / 2.0)); + near(beside(50.0, Align::Right, None, 36.0), (x1 + 36.0 - half, x1 + 36.0)); + // Measuring a table column: a rule of its own width asks for it, a share of the line for none. + let measured = |pct: f32| { + let mut o = rect_shape(150.0, 1.5, Default::default()); + if let InlineObject::Shape { extra, .. } = &mut o { + extra.hr = Some(HorizontalRule { pct, align: Align::Left, standard: false }); + } + let mut t = Table::new(1, 1, 200.0); + let mut p = Paragraph::default(); + p.insert_object(0, o, &Default::default()).unwrap(); + t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)]; + crate::autofit_widths(&crate::measure_table_columns(&d, &t), 451.0)[0] + }; + assert!(measured(0.0) >= 150.0, "{}", measured(0.0)); + assert!(measured(100.0) < 150.0, "{}", measured(100.0)); +} + /// A kept-together paragraph starting a continuous section low on a page, with room there for /// one of its lines, moves whole to the next page, where it fits (#3907 review). #[test] @@ -12762,3 +12861,5 @@ fn header_margin_table_area() { } } } + +mod shape_alpha; diff --git a/crates/layout/src/tests/linear.rs b/crates/layout/src/tests/linear.rs index 30db6ec00..993dcbedd 100644 --- a/crates/layout/src/tests/linear.rs +++ b/crates/layout/src/tests/linear.rs @@ -2,10 +2,9 @@ //! looked up (#3692, #3706): an input 8x the size must cost about 8x as much, not 64x. //! //! The cost is the steps the lookups' walks count ([`crate::work`]), not a timing, so a busy -//! machine can't make a linear lookup look quadratic or the other way round. Only proofing text, -//! whose walk is the document model's, is timed. +//! machine can't make a linear lookup look quadratic or the other way round. -use super::{SCALE, cost_ratio}; +use super::SCALE; use crate::{ Block, CharProps, DocLayout, Document, HeaderSet, HeightRule, InlineObject, LayoutCache, LayoutOptions, Page, ParaLayout, Paragraph, Path, Placed, SectionProps, SectionStart, StoryRef, display, layout, para, work, @@ -56,22 +55,12 @@ fn proof_text_walks_the_runs_once() { p }; assert_eq!(para::proof_text(¶(5)), "a\u{1}a\u{1}a"); - // The runs are the document model's, whose walk counts no steps, so this one is timed: the - // CPU time this thread ran (`cost_ratio`). At 50,000 runs the old walk took 64x as long for - // 8x the runs, and the new one about 8x; a limit of 24x stays far from both, and a ratio over - // it is measured again, twice, before the test fails, so a busy spell can't fail it alone. - let (small, big) = (para(50_000), para(SCALE * 50_000)); - let proof_8 = |p: &Paragraph| (0..8).for_each(|_| drop(std::hint::black_box(para::proof_text(p)))); - let mut ratios = Vec::new(); - for _ in 0..3 { - let (t1, tk, ratio) = cost_ratio(SCALE, || proof_8(&small), || proof_8(&big)); - eprintln!("proof_text: t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}"); - if ratio < 24.0 { - return; - } - ratios.push(ratio); - } - panic!("proof_text: 8x the runs took {ratios:.1?}x the time, three times over: not linear"); + // The runs the walk looks at are counted now ([`crate::work`]), as every other walk here is, + // so this no longer depends on a timing: the ratio was creeping over its limit when the + // crate's tests ran in parallel, from cache and memory contention rather than from any + // regression (#5185). Going back to the first run for every character counts the runs once + // per character, which is the quadratic walk this watches for. + assert_linear_steps("proof_text", 50_000, para, |p| drop(std::hint::black_box(para::proof_text(p)))); } /// A right-to-left paragraph whose one line holds `n` hidden letters: caret offsets with no width. diff --git a/crates/layout/src/tests/shape_alpha.rs b/crates/layout/src/tests/shape_alpha.rs new file mode 100644 index 000000000..49bedc553 --- /dev/null +++ b/crates/layout/src/tests/shape_alpha.rs @@ -0,0 +1,24 @@ +use super::*; + +#[test] +fn a_translucent_shape_fill_is_drawn_translucent() { + let mut d = Document::from_text("Wash"); + let wash = InlineObject::Shape { + kind: wordcraft_doc::para::ShapeKind::Rectangle, + w: 100.0, + h: 50.0, + fill: Some(wordcraft_doc::fill::Fill::Solid { color: wordcraft_doc::props::Rgb::WHITE, transparency: 69.0 }), + stroke: None, + stroke_width: 0.0, + float: Default::default(), + story: None, + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + d.insert_object(&Pos::body(0, 0), wash, &Default::default()).unwrap(); + let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default())); + let alpha = + draws.iter().find_map(|x| if let display::Draw::Shape { fill: Some(display::Paint::Solid(_, alpha)), .. } = x { Some(*alpha) } else { None }); + assert!(alpha.is_some_and(|a| (a - 0.31).abs() < 0.001), "fill opacity {alpha:?}"); +} diff --git a/crates/pdf/src/exporter/draw.rs b/crates/pdf/src/exporter/draw.rs index 73b6d5e1a..f2e9d0ab5 100644 --- a/crates/pdf/src/exporter/draw.rs +++ b/crates/pdf/src/exporter/draw.rs @@ -104,9 +104,9 @@ impl Exporter<'_> { if let (Some(rad), Some(c), true) = (fx.soft_edge, f.as_ref(), can_fill) { let rad = rad.min(rect.w.min(rect.h) / 2.0).max(0.0); s.set_stroke(None); - for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) { + for (g, paint) in c.soft_bands(rad) { if let Some(p) = shape_path(*kind, &grown(rect, g, 0.0, 0.0), *corner) { - s.set_fill(Some(paint_fill(&c.faded(a)))); + s.set_fill(Some(paint_fill(&paint))); s.draw_path(&p); } } diff --git a/crates/pdf/src/fill_alpha_tests.rs b/crates/pdf/src/fill_alpha_tests.rs new file mode 100644 index 000000000..b90381f1c --- /dev/null +++ b/crates/pdf/src/fill_alpha_tests.rs @@ -0,0 +1,161 @@ +use super::*; +use hayro_interpret::font::Glyph; +use hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, interpret_page, +}; +use kurbo::Shape; +use wordcraft_doc::effects::ShapeEffects; +use wordcraft_doc::freeform::{FreePath, Freeform}; +use wordcraft_doc::para::{Anchor, Float, InlineObject, ShapeKind, Wrap}; +use wordcraft_doc::{CharProps, Document, Paragraph, para_block}; + +const ALPHA_TOLERANCE: f32 = 0.015; +const RED: wordcraft_doc::Rgb = wordcraft_doc::Rgb(255, 0, 0); +const GREEN: wordcraft_doc::Rgb = wordcraft_doc::Rgb(0, 255, 0); + +struct PaintedPath { + path: kurbo::BezPath, + color: [u8; 4], +} + +struct Paints { + fills: Vec, + strokes: Vec, + groups: Vec, +} + +impl Device<'_> for Paints { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, path: &kurbo::BezPath, transform: kurbo::Affine, paint: &Paint<'_>, mode: &PathDrawMode) { + let Paint::Color(color) = paint else { panic!("solid fixture paint") }; + let mut color = color.to_rgba().to_rgba8(); + color[3] = (f32::from(color[3]) * self.groups.last().copied().unwrap_or(1.0)).round() as u8; + let record = PaintedPath { path: transform * path.clone(), color }; + match mode { + PathDrawMode::Fill(_) => self.fills.push(record), + PathDrawMode::Stroke(_) => self.strokes.push(record), + } + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn pop_clip_path(&mut self) {} + fn push_transparency_group(&mut self, alpha: f32, _: Option>, _: BlendMode) { + self.groups.push(self.groups.last().copied().unwrap_or(1.0) * alpha); + } + fn pop_transparency_group(&mut self) { + assert!(self.groups.len() > 1); + let _ = self.groups.pop(); + } + fn draw_glyph(&mut self, _: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {} +} + +impl Paints { + fn red_coverage(&self, point: (f64, f64)) -> f32 { + 1.0 - self + .fills + .iter() + .filter(|r| r.color[..3] == [255, 0, 0] && r.path.contains(point.into())) + .map(|r| 1.0 - f32::from(r.color[3]) / 255.0) + .product::() + } +} + +fn paints(shape: InlineObject) -> Paints { + let mut doc = Document::new(); + let mut para = Paragraph::new(); + para.insert_object(0, shape, &CharProps::default()).unwrap(); + doc.body = vec![para_block(para)]; + let bytes = export(&doc, &PdfOptions { tagged: false, ..Default::default() }).unwrap(); + let pdf = hayro_syntax::Pdf::new(bytes).unwrap(); + let cache = InterpreterCache::new(); + let mut paints = Paints { fills: Vec::new(), strokes: Vec::new(), groups: vec![1.0] }; + for page in pdf.pages().iter() { + let bounds = page.media_box(); + let (width, height) = (bounds.width(), bounds.height()); + // PDF uses bottom-up coordinates; the fixture points use the layout's top-down frame. + let top_down = kurbo::Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, height]); + let mut ctx = Context::new(top_down, kurbo::Rect::new(0.0, 0.0, width, height), &cache, pdf.xref(), InterpreterSettings::default()); + interpret_page(page, &mut ctx, &mut paints); + } + assert_eq!(paints.groups, [1.0]); + paints +} + +fn shape(kind: ShapeKind, transparency: f32, effects: ShapeEffects) -> InlineObject { + InlineObject::Shape { + kind, + w: 24.0, + h: 24.0, + fill: Some(wordcraft_doc::fill::Fill::Solid { color: RED, transparency }), + stroke: None, + stroke_width: 0.0, + float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 100.0, y: 100.0, ..Default::default() }, + story: None, + freeform: None, + effects, + extra: Default::default(), + } +} + +fn close(actual: f32, want: f32) { + assert!((actual - want).abs() < ALPHA_TOLERANCE, "{actual} != {want}"); +} + +#[test] +fn freeform_fill_keeps_outline() { + for (transparency, alpha) in [(0.0, 1.0), (50.0, 0.5), (100.0, 0.0)] { + let mut item = shape(ShapeKind::Freeform, transparency, ShapeEffects::default()); + if let InlineObject::Shape { freeform, stroke, stroke_width, .. } = &mut item { + *freeform = Some(std::sync::Arc::new(Freeform { + w: 24.0, + h: 24.0, + paths: vec![FreePath { pts: vec![[0.0, 0.0], [24.0, 0.0], [24.0, 24.0], [0.0, 24.0]], closed: true }], + alpha: 0.25, + ..Default::default() + })); + *stroke = Some(GREEN); + *stroke_width = 2.0; + } + let paints = paints(item); + close(paints.red_coverage((112.0, 112.0)), alpha); + assert_eq!(paints.strokes.len(), 1); + assert_eq!(paints.strokes[0].color, [0, 255, 0, 255]); + } +} + +#[test] +fn soft_edge_target_alpha() { + for kind in [ShapeKind::Rectangle, ShapeKind::Ellipse] { + for (radius, count) in [(3.0, 3.0), (6.0, 4.0)] { + for (transparency, alpha) in [(0.0, 1.0), (50.0, 0.5), (100.0, 0.0), (f32::NAN, 1.0)] { + let effects = ShapeEffects { soft_edge: Some(radius), ..Default::default() }; + let paints = paints(shape(kind, transparency, effects)); + close(paints.red_coverage((112.0, 112.0)), alpha); + close(paints.red_coverage((101.125, 112.0)), alpha / count); + close(paints.red_coverage((99.0, 112.0)), 0.0); + assert!(paints.strokes.is_empty()); + } + } + } +} + +#[test] +fn soft_edge_keeps_effect_paint() { + let effects = ShapeEffects { + shadow: Some(wordcraft_doc::effects::Shadow::default()), + glow: Some(wordcraft_doc::effects::Glow::default()), + soft_edge: Some(6.0), + }; + let opaque = paints(shape(ShapeKind::Rectangle, 0.0, effects)); + let clear = paints(shape(ShapeKind::Rectangle, 100.0, effects)); + let other = |r: &&PaintedPath| r.color[..3] != [255, 0, 0]; + let before: Vec<_> = opaque.fills.iter().filter(other).map(|r| (&r.path, r.color)).collect(); + let after: Vec<_> = clear.fills.iter().filter(other).map(|r| (&r.path, r.color)).collect(); + assert!(!before.is_empty()); + assert_eq!(before, after); + assert!(opaque.strokes.is_empty() && clear.strokes.is_empty()); + for (transparency, alpha) in [(0.0, 1.0), (50.0, 0.5), (100.0, 0.0)] { + close(paints(shape(ShapeKind::Rectangle, transparency, ShapeEffects::default())).red_coverage((112.0, 112.0)), alpha); + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 78a27d35e..739108e1b 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -76,3 +76,6 @@ use picture::{load_image, load_image_within}; #[cfg(test)] mod tests; + +#[cfg(test)] +mod fill_alpha_tests; diff --git a/crates/render/src/fill_alpha_tests.rs b/crates/render/src/fill_alpha_tests.rs new file mode 100644 index 000000000..e27776e95 --- /dev/null +++ b/crates/render/src/fill_alpha_tests.rs @@ -0,0 +1,123 @@ +use super::*; +use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects}; +use wordcraft_doc::graphic::PathSeg; +use wordcraft_geom::Rect; + +const PIXEL_TOLERANCE: u8 = 3; +const BLUE: Rgb = Rgb(0, 0, 255); +const RED: Rgb = Rgb(255, 0, 0); +const GREEN: Rgb = Rgb(0, 255, 0); + +fn image(item: Draw) -> Rendered { + let items = [Draw::Fill { rect: Rect::new(0.0, 0.0, 40.0, 40.0), color: BLUE, alpha: 1.0, text_bg: false }, item]; + render_items(&Document::new(), &items, Rect::new(0.0, 0.0, 40.0, 40.0), 4.0, &RenderOptions::default()) +} + +fn close(pixel: [u8; 4], alpha: f32) { + let red = (255.0 * alpha).round() as u8; + let blue = (255.0 * (1.0 - alpha)).round() as u8; + let want = [red, 0, blue, 255]; + assert!(pixel.into_iter().zip(want).all(|(a, b)| a.abs_diff(b) <= PIXEL_TOLERANCE), "{pixel:?} != {want:?}"); +} + +fn soft_shape(kind: ShapeKind, alpha: f32, radius: f32, effects: ShapeEffects) -> Draw { + Draw::Shape { + rect: Rect::new(4.0, 4.0, 24.0, 24.0), + kind, + fill: Some(wordcraft_layout::display::Paint::Solid(RED, alpha)), + stroke: None, + stroke_width: 0.0, + dash: Vec::new(), + cap: Default::default(), + corner: None, + effects: ShapeEffects { soft_edge: Some(radius), ..effects }, + } +} + +#[test] +fn path_fill_keeps_stroke_alpha() { + for alpha in [0.0, 0.5, 1.0] { + let item = Draw::Path { + segs: vec![PathSeg::Move(4.0, 4.0), PathSeg::Line(28.0, 4.0), PathSeg::Line(28.0, 28.0), PathSeg::Line(4.0, 28.0), PathSeg::Close], + fill: Some(wordcraft_layout::display::Paint::Solid(RED, alpha)), + stroke: Some(GREEN), + stroke_width: 2.0, + dash: Vec::new(), + cap: Default::default(), + }; + let rendered = image(item); + close(rendered.pixel(64, 64), alpha); + assert_eq!(rendered.pixel(16, 64), [0, 255, 0, 255], "stroke at fill alpha {alpha}"); + } + let ink = image(Draw::Ink { pts: vec![(4.0, 16.0), (28.0, 16.0)], color: RED, width: 4.0, alpha: 0.5 }); + close(ink.pixel(64, 64), 0.5); +} + +#[test] +fn soft_edge_has_target_alpha() { + for kind in [ShapeKind::Rectangle, ShapeKind::Ellipse] { + for (radius, count) in [(3.0, 3.0), (6.0, 4.0)] { + for alpha in [0.0, 0.5, 1.0] { + let rendered = image(soft_shape(kind, alpha, radius, ShapeEffects::default())); + close(rendered.pixel(64, 64), alpha); + close(rendered.pixel(20, 64), alpha / count); + assert_eq!(rendered.pixel(0, 64), [0, 0, 255, 255]); + } + } + let opaque = image(soft_shape(kind, 1.0, 6.0, ShapeEffects::default())); + for alpha in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 2.0] { + assert_eq!(image(soft_shape(kind, alpha, 6.0, ShapeEffects::default())).pixels, opaque.pixels); + } + assert_eq!( + image(soft_shape(kind, -1.0, 6.0, ShapeEffects::default())).pixels, + image(soft_shape(kind, 0.0, 6.0, ShapeEffects::default())).pixels, + ); + } +} + +#[test] +fn soft_edge_keeps_effects() { + let effects = ShapeEffects { shadow: Some(Shadow::default()), glow: Some(Glow::default()), soft_edge: None }; + let opaque = image(soft_shape(ShapeKind::Rectangle, 1.0, 6.0, effects)); + let clear = image(soft_shape(ShapeKind::Rectangle, 0.0, 6.0, effects)); + let mut effect_pixels = 0; + for y in 0..160 { + for x in 0..160 { + if (16..112).contains(&x) && (16..112).contains(&y) { + continue; + } + assert_eq!(opaque.pixel(x, y), clear.pixel(x, y)); + effect_pixels += usize::from(opaque.pixel(x, y) != [0, 0, 255, 255]); + } + } + assert!(effect_pixels > 0); + let stroke = |alpha| { + let mut item = soft_shape(ShapeKind::Rectangle, alpha, 6.0, ShapeEffects::default()); + if let Draw::Shape { fill, stroke, stroke_width, .. } = &mut item { + *fill = None; + *stroke = Some(GREEN); + *stroke_width = 2.0; + } + image(item) + }; + assert_eq!(stroke(0.0).pixels, stroke(1.0).pixels); +} + +#[test] +fn caret_samples_soft_edge_bands() { + for kind in [ShapeKind::Rectangle, ShapeKind::Ellipse] { + for alpha in [0.0, 0.5, 1.0] { + let shape = soft_shape(kind, alpha, 6.0, ShapeEffects::default()); + let rendered = image(shape.clone()); + for (x, y) in [(18, 64), (22, 64), (30, 64), (64, 64)] { + let mut bg = [0.0, 0.0, 255.0]; + let point = wordcraft_geom::Point::new((x as f32 + 0.5) / 4.0, (y as f32 + 0.5) / 4.0); + blend_backgrounds(std::slice::from_ref(&shape), point, &RenderOptions::default(), &mut bg, 0); + let pixel = rendered.pixel(x, y); + for (sample, channel) in bg.into_iter().zip(pixel) { + assert!((sample - channel as f32).abs() <= PIXEL_TOLERANCE as f32, "{kind:?}, alpha={alpha}, ({x},{y}): {bg:?} vs {pixel:?}"); + } + } + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 853473508..3704b2f43 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -342,6 +342,28 @@ pub fn background_at(doc: &Document, page: &Page, opts: &RenderOptions, x: f32, Rgb(c(bg[0]), c(bg[1]), c(bg[2])) } +// Sample the same inset silhouettes and paints used by the raster and PDF. +fn soft_fill_alpha( + rect: wordcraft_geom::Rect, + kind: ShapeKind, + corner: Option, + radius: f32, + fill: &wordcraft_layout::display::Paint, + p: wordcraft_geom::Point, +) -> f32 { + let radius = radius.min(rect.w.min(rect.h) / 2.0).max(0.0); + let frame = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64); + let point = kurbo::Point::new(p.x as f64, p.y as f64); + let mut opacity = 0.0; + for (grow, paint) in fill.soft_bands(radius) { + let inset = frame.inflate(grow as f64, grow as f64); + if inset.width() > 0.0 && inset.height() > 0.0 && kurbo::Shape::contains(&shape_path(kind, inset, corner), point) { + opacity += (1.0 - opacity) * paint.at(p.x, p.y).1; + } + } + opacity +} + fn blend_backgrounds(items: &[Draw], p: wordcraft_geom::Point, opts: &RenderOptions, bg: &mut [f32; 3], depth: u32) { if depth > 16 { return; @@ -351,13 +373,15 @@ fn blend_backgrounds(items: &[Draw], p: wordcraft_geom::Point, opts: &RenderOpti Draw::Fill { rect, color, alpha, .. } if rect.contains(p) => (*color, *alpha), // The shape's own outline (an ellipse's corners are not inside it), in its fill's // colour and opacity there (#1500: gradients and transparency). - Draw::Shape { rect, kind, fill: Some(fill), corner, .. } + Draw::Shape { rect, kind, fill: Some(fill), corner, effects, .. } if *kind != ShapeKind::Line && rect.contains(p) && { let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64); kurbo::Shape::contains(&shape_path(*kind, r, *corner), kurbo::Point::new(p.x as f64, p.y as f64)) } => { - fill.at(p.x, p.y) + let (color, alpha) = fill.at(p.x, p.y); + let alpha = effects.sanitized().soft_edge.map_or(alpha, |radius| soft_fill_alpha(*rect, *kind, *corner, radius, fill, p)); + (color, alpha) } Draw::Clip { rect, items } if rect.contains(p) => { blend_backgrounds(items, p, opts, bg, depth + 1); @@ -913,10 +937,10 @@ fn shape_shadow_and_glow(ctx: &mut RenderContext, fr: &Frame, (geom, sw): (&Shap /// Soft edges: the fill fades out toward the outline (which fades with it). fn shape_soft_edges(ctx: &mut RenderContext, fr: &Frame, geom: &ShapeGeom, (rad, f): (f32, &Paint)) { - for (g, a) in wordcraft_doc::effects::bands(-rad, 0.0, 1.0) { + for (g, paint) in f.soft_bands(rad) { let rr = geom.r.inflate(g as f64, g as f64); if rr.width() > 0.0 && rr.height() > 0.0 { - set_paint(ctx, &f.faded(a), fr.opts); + set_paint(ctx, &paint, fr.opts); ctx.fill_path(&shape_path(geom.kind, rr, geom.corner)); } } @@ -1103,3 +1127,6 @@ mod tests; #[cfg(test)] mod cache_tests; + +#[cfg(test)] +mod fill_alpha_tests; diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 6223b5714..724c8f1f5 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -38,6 +38,7 @@ impl WordApp { dialog, colors, restrict, + spelling_review, status_msg, previews, change_picture_target, @@ -148,6 +149,7 @@ impl WordApp { dialog, colors, restrict, + spelling_review, status_msg, previews, change_picture_target, diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 70bb11782..c7719e381 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -85,15 +85,12 @@ impl WordApp { if matches!(id, "insert.picture" | "insert.icon") { self.select_inserted_picture(&r); } - // review.spelling only moves the selection to the next issue; without this, the ribbon's - // Spelling & Grammar and Editor buttons looked broken — just a selection with no way to - // see or apply a suggestion (#3340). Reuse the context menu's suggestions, as Shift+F10 - // already does for the word at the caret. - if id == "review.spelling" + // User proofing opens the persistent task pane; scripts keep structured results. + // Right-click and Shift+F10 retain their compact suggestions menu. + if matches!(id, "review.spelling" | "review.editor") && let Ok(v) = &r - && v.get("done").and_then(serde_json::Value::as_bool) != Some(true) { - self.canvas.context_menu_from_keys = true; + self.spelling_review = Some(crate::proofing_pane::SpellingForm::new(self, v.clone())); } let r = system_clipboard::explain(self, id, r); // Check Accessibility shows its findings in the Accessibility pane, kept up to date there. diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 6450f1292..604ff7b93 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2530,38 +2530,6 @@ fn control_click_on_macos_opens_the_context_menu() { assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brown fox"); } -/// #3340: review.spelling (the Review tab's Spelling & Grammar button and the Home tab's -/// Editor button both run it) only moved the selection to the next issue — with no way to see -/// or apply a suggestion from there, just the generic selection toolbar, it looked broken. -/// Running it now also opens the same suggestions menu Shift+F10 and a Control-click already -/// open for the word at the caret. -#[test] -fn spelling_and_grammar_opens_suggestions_for_the_issue_it_selects() { - use egui_kittest::kittest::Queryable; - let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); - a.run("text.insert", json!({"text": "The quick brwn fox"})).unwrap(); - a.session.sel = wordcraft_engine::Selection::caret(Pos::body(0, 0)); - a.run("review.spelling", json!({})).unwrap(); - assert!(a.canvas.context_menu_from_keys, "queues the open the context menu already uses for Shift+F10"); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( - |ui, app: &mut WordApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - a, - ); - for _ in 0..6 { - h.step(); - } - assert!(h.state().canvas.context_menu_open, "Spelling & Grammar opens the suggestions menu"); - h.get_by_label("brown").click(); - for _ in 0..3 { - h.step(); - } - assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brown fox"); -} - #[test] fn floating_picture_can_be_dragged_in_front_of_or_behind_text() { for wrap in ["inFrontOfText", "behindText"] { diff --git a/crates/ui-egui/src/control/handler.rs b/crates/ui-egui/src/control/handler.rs index 117d54c58..17dbd405c 100644 --- a/crates/ui-egui/src/control/handler.rs +++ b/crates/ui-egui/src/control/handler.rs @@ -20,6 +20,7 @@ pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value { json!({ "ui": app.prefs(), "view": app.session.view, + "proofing": app.spelling_review, "dialog": app.dialog.as_ref().map(|d| serde_json::to_value(d).unwrap_or_default()), "window": [r.width(), r.height()], "canvasRect": app.canvas.canvas_rect.map(|c| [c.left(), c.top(), c.width(), c.height()]), diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index c843e19d2..c7e4cd123 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -49,6 +49,7 @@ impl WordApp { self.canvas.show_other_document(seen); self.controls = Default::default(); self.dialog = None; + self.spelling_review = None; self.colors = None; self.restrict = crate::dialogs_review::RestrictPane { open: self.restrict.open, ..Default::default() }; self.status_msg = None; diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index 6bec025c8..c18a68a4a 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -39,7 +39,8 @@ pub type FileDialogAnswer = Result, String>; const MAX_FONTS_PER_PICK: usize = 2000; const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"]; -const DOCUMENTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"]; +const DOCUMENTS: &[&str] = + &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"]; /// The file types Open offers, as (label, extensions; `*` is any file): pictures for Insert/Change /// Picture, recipient lists for Select Recipients › Use an Existing List…, documents otherwise. diff --git a/crates/ui-egui/src/i18n/audit.rs b/crates/ui-egui/src/i18n/audit.rs index 8871d5564..bee244c36 100644 --- a/crates/ui-egui/src/i18n/audit.rs +++ b/crates/ui-egui/src/i18n/audit.rs @@ -172,6 +172,10 @@ impl<'a> Exempt<'a> { if let Some(sugg) = app.canvas.context_issue.as_ref().and_then(|i| i.get("suggestions")).and_then(Value::as_array) { names.extend(sugg.iter().filter_map(Value::as_str).map(str::to_string)); } + // The proofing pane displays document context and dictionary suggestions, not UI wording. + if let Some(form) = &app.spelling_review { + names.extend(form.content_text()); + } Exempt { body: app.session.doc.plain_text(wordcraft_doc::StoryRef::Body), names, math: math_placeholders } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0e6360273..beb71f624 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -61,6 +61,7 @@ pub mod paste_picture; pub mod previews; pub mod print_page; pub mod printing; +pub mod proofing_pane; pub mod read_aloud; pub mod ribbon; pub mod ribbon_fit; @@ -476,6 +477,8 @@ pub struct WordApp { pub colors: Option, /// The Restrict Editing pane (#412). pub restrict: dialogs_review::RestrictPane, + /// The language-aware spelling and grammar task pane. + pub spelling_review: Option, pub status_msg: Option<(String, f64)>, pub previews: previews::Previews, /// Media key of the picture a pending Change Picture replaces (#147). diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 8a21c5544..09e63f689 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -56,6 +56,7 @@ pub enum Pane { AltText, FormatShape, RestrictEditing, + Proofing, Comments, } @@ -71,6 +72,7 @@ impl Pane { Self::AltText => "Alt Text", Self::FormatShape => "Format Shape", Self::RestrictEditing => "Restrict Editing", + Self::Proofing => "Proofing", Self::Comments => "Comments", } } @@ -86,6 +88,7 @@ impl Pane { Self::AltText => (Edge::End, 260.0), Self::FormatShape => (Edge::End, 280.0), Self::RestrictEditing => (Edge::End, 270.0), + Self::Proofing => (Edge::End, 400.0), Self::Comments => (Edge::End, 290.0), }; PaneState::docked(edge, width) @@ -103,11 +106,12 @@ impl Pane { Self::AltText => "altText", Self::FormatShape => "formatShape", Self::RestrictEditing => "restrictEditing", + Self::Proofing => "proofing", Self::Comments => "comments", } } - const ALL: [Self; 10] = [ + const ALL: [Self; 11] = [ Self::SmartArtText, Self::Navigation, Self::Clipboard, @@ -117,6 +121,7 @@ impl Pane { Self::AltText, Self::FormatShape, Self::RestrictEditing, + Self::Proofing, Self::Comments, ]; @@ -136,7 +141,7 @@ impl Pane { Self::AltText => Some("view.altTextPane"), Self::FormatShape => Some("view.formatShapePane"), Self::Comments => Some("view.commentsPane"), - Self::RestrictEditing => None, + Self::RestrictEditing | Self::Proofing => None, } } } @@ -160,6 +165,7 @@ impl PaneContent for Content<'_> { egui::ScrollArea::vertical().show(ui, |ui| crate::format_shape::pane(app, ui)); } Pane::RestrictEditing => crate::dialogs_review::restrict_pane(app, ui), + Pane::Proofing => crate::proofing_pane::show(app, ui), Pane::Comments => comments(app, ui), } } diff --git a/crates/ui-egui/src/panes/host.rs b/crates/ui-egui/src/panes/host.rs index cdd7d9351..346e320de 100644 --- a/crates/ui-egui/src/panes/host.rs +++ b/crates/ui-egui/src/panes/host.rs @@ -151,6 +151,7 @@ fn close(app: &mut WordApp, pane: Pane) { Some(id) => { let _ = app.run(id, json!({"value": false})); } + None if pane == Pane::Proofing => app.spelling_review = None, None => app.restrict.open = false, } } diff --git a/crates/ui-egui/src/panes/state.rs b/crates/ui-egui/src/panes/state.rs index a738a67d4..2a806714e 100644 --- a/crates/ui-egui/src/panes/state.rs +++ b/crates/ui-egui/src/panes/state.rs @@ -48,6 +48,7 @@ pub fn open(app: &WordApp) -> Vec { (Pane::AltText, v.alt_text_pane), (Pane::FormatShape, v.format_shape_pane), (Pane::RestrictEditing, app.restrict.open), + (Pane::Proofing, app.spelling_review.is_some()), (Pane::Comments, v.comments_pane), ] .into_iter() diff --git a/crates/ui-egui/src/proofing_pane.rs b/crates/ui-egui/src/proofing_pane.rs new file mode 100644 index 000000000..91cf5dac0 --- /dev/null +++ b/crates/ui-egui/src/proofing_pane.rs @@ -0,0 +1,398 @@ +//! Language-aware proofing task pane, laid out from black-box observation of Word on Windows. + +use egui::{Align, Align2, Sense, Stroke, Ui, vec2}; +use serde::Serialize; +use serde_json::{Value, json}; +use wordcraft_engine::Selection; + +use crate::WordApp; +use crate::theme::{Tokens, regular, semibold}; + +#[derive(Clone, Debug, Serialize)] +pub struct SpellingForm { + issue: Value, + replacement: String, + message: String, + context_before: String, + context_after: String, + manual: bool, + #[serde(skip)] + document: u64, + #[serde(skip)] + epoch: u64, + #[serde(skip)] + selection: Selection, +} + +impl SpellingForm { + pub fn new(app: &WordApp, issue: Value) -> Self { + let replacement = issue.get("suggestions").and_then(Value::as_array).and_then(|a| a.first()).and_then(Value::as_str).unwrap_or("").into(); + let (a, b) = app.session.sel.ordered(); + let para = app.session.doc.para_at(&a); + let before = para.and_then(|p| p.text.get(..a.off)).unwrap_or("").rsplit(['.', '!', '?']).next().unwrap_or("").trim_start(); + let mut before: Vec = before.chars().rev().take(90).collect(); + before.reverse(); + let after = para.and_then(|p| p.text.get(b.off..)).unwrap_or("").split_inclusive(['.', '!', '?']).next().unwrap_or(""); + Self { + issue, + replacement, + message: String::new(), + context_before: before.into_iter().collect(), + context_after: after.chars().take(90).collect(), + manual: false, + document: app.session.document_id(), + epoch: app.session.ime_body_epoch(), + selection: app.session.sel.clone(), + } + } + + fn done(&self) -> bool { + self.issue.get("done").and_then(Value::as_bool) == Some(true) + } + + #[cfg(test)] + pub(crate) fn content_text(&self) -> Vec { + let mut text: Vec = + self.issue.get("suggestions").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + text.push(format!("{}{}{}", self.context_before, self.issue.get("text").and_then(Value::as_str).unwrap_or(""), self.context_after)); + text + } + + fn current(&self, app: &mut WordApp) -> bool { + self.document == app.session.document_id() + && self.epoch == app.session.ime_body_epoch() + && self.selection == app.session.sel + && app.session.run("review.suggestions", &json!({})).ok().as_ref() == Some(&self.issue) + } + + fn next(&mut self, app: &mut WordApp, wrap: bool) { + match app.execute("review.spelling", json!({"wrap": wrap})) { + Ok(issue) => *self = Self::new(app, issue), + Err(e) => self.message = e, + } + } + + fn act(&mut self, app: &mut WordApp, command: Option<&str>) { + if !self.current(app) { + self.message = tl!("The document or selection changed. Check again to continue.").into(); + return; + } + if let Some(command) = command { + let params = if command == "review.applySuggestion" { json!({"text": self.replacement}) } else { json!({}) }; + if let Err(e) = app.run(command, params) { + self.message = e; + return; + } + } + self.next(app, false); + } +} + +/// The shared task-pane host supplies the title, menu, Close button, docking and keyboard routing. +pub fn show(app: &mut WordApp, ui: &mut Ui) { + let Some(mut form) = app.spelling_review.take() else { return }; + egui::Frame::NONE.inner_margin(egui::Margin { left: 4, right: 16, top: 0, bottom: 0 }).show(ui, |ui| { + egui::ScrollArea::vertical().show(ui, |ui| body(app, ui, &mut form)); + }); + app.spelling_review = Some(form); +} + +fn action_link(ui: &mut Ui, text: &str) -> bool { + ui.add(egui::Button::new(egui::RichText::new(tl!(text)).color(Tokens::get(ui.ctx()).text).underline()).frame(false)).clicked() +} + +fn context(ui: &mut Ui, app: &mut WordApp, form: &mut SpellingForm, spelling: bool) { + let t = Tokens::get(ui.ctx()); + let width = ui.available_width(); + egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.input_border)).corner_radius(2.0).inner_margin(8).show(ui, |ui| { + ui.set_min_width((width - 18.0).max(0.0)); + ui.horizontal(|ui| { + let mut job = egui::text::LayoutJob::default(); + let format = egui::TextFormat { font_id: regular(12.0), color: t.text, ..Default::default() }; + job.append(&form.context_before, 0.0, format.clone()); + let marked = egui::TextFormat { underline: Stroke::new(1.0, if spelling { t.red } else { t.blue }), ..format.clone() }; + job.append(form.issue.get("text").and_then(Value::as_str).unwrap_or(""), 0.0, marked); + job.append(&form.context_after, 0.0, format); + let text_width = (ui.available_width() - 36.0).max(20.0); + ui.allocate_ui_with_layout(vec2(text_width, 30.0), egui::Layout::top_down(Align::Min), |ui| { + ui.set_min_width(text_width); + ui.add(egui::Label::new(job).wrap()); + }); + let (rect, response) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Read Aloud"))); + crate::icons::paint(ui.painter(), rect.shrink(4.0), "readAloud", t.icon, t.accent); + if response.on_hover_text(tl!("Read Aloud")).clicked() { + if form.current(app) { + if let Err(e) = app.run("review.readAloud", json!({})) { + form.message = e; + } + } else { + form.message = tl!("The document or selection changed. Check again to continue.").into(); + } + } + }); + }); +} + +fn body(app: &mut WordApp, ui: &mut Ui, form: &mut SpellingForm) { + let t = Tokens::get(ui.ctx()); + if form.done() { + ui.label(tl!("The spelling and grammar check is complete.")); + ui.add_space(8.0); + if action_link(ui, "Check Again") { + form.next(app, true); + } + return; + } + let spelling = form.issue.get("kind").and_then(Value::as_str) == Some("spelling"); + ui.label(egui::RichText::new(tl!(if spelling { "Spelling" } else { "Grammar" })).font(regular(14.0))) + .on_hover_text(form.issue.get("lang").and_then(Value::as_str).unwrap_or("")); + let message = if spelling { tl!("Not in Dictionary") } else { tl!(form.issue.get("message").and_then(Value::as_str).unwrap_or("")) }; + ui.label(egui::RichText::new(message).font(regular(11.0)).color(t.text_dim)); + ui.add_space(8.0); + context(ui, app, form, spelling); + ui.add_space(12.0); + ui.label(egui::RichText::new(tl!("Suggestions")).font(regular(11.0)).color(t.text_dim)); + let suggestions: Vec = + form.issue.get("suggestions").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect(); + let mut change = None; + if suggestions.is_empty() { + ui.label(egui::RichText::new(tl!("(no suggestions)")).italics()); + } else { + egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.input_border)).corner_radius(2.0).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + egui::ScrollArea::vertical().id_salt("proofing_suggestions").max_height(210.0).show(ui, |ui| { + for (index, suggestion) in suggestions.iter().enumerate() { + ui.push_id(index, |ui| { + if index > 0 { + ui.separator(); + } + ui.horizontal(|ui| { + let width = (ui.available_width() - 34.0).max(20.0); + let (rect, response) = ui.allocate_exact_size(vec2(width, 64.0), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, suggestion)); + if response.hovered() { + ui.painter().rect_filled(rect, 0.0, t.hover); + } + ui.painter().with_clip_rect(rect.shrink2(vec2(10.0, 0.0))).text( + rect.left_center() + vec2(12.0, 0.0), + Align2::LEFT_CENTER, + suggestion, + semibold(12.5), + t.text, + ); + if response.clicked() { + change = Some(suggestion.clone()); + } + ui.menu_button("⌄", |ui| { + if ui.button(tl!("Change…")).clicked() { + form.replacement.clone_from(suggestion); + form.manual = true; + ui.close(); + } + if spelling && ui.button(tl!("Add to AutoCorrect")).clicked() { + let word = form.issue.get("text").and_then(Value::as_str).unwrap_or(""); + let params = json!({"add": {"from": word, "to": suggestion}}); + if form.current(app) { + if let Err(e) = app.run("tools.autocorrect", params) { + form.message = e; + } + } else { + form.message = tl!("The document or selection changed. Check again to continue.").into(); + } + ui.close(); + } + if ui.button(tl!("AutoCorrect Options…")).clicked() { + let _ = app.run("tools.autocorrect", json!({})); + ui.close(); + } + }) + .response + .on_hover_text(tl!("More options")); + }); + }); + } + }); + }); + } + if let Some(replacement) = change { + form.replacement = replacement; + form.act(app, Some("review.applySuggestion")); + return; + } + ui.add_space(6.0); + if action_link(ui, "Ignore Once") { + form.act(app, None); + } else if spelling && action_link(ui, "Ignore All") { + form.act(app, Some("review.ignoreAll")); + } else if spelling && action_link(ui, "Add to Dictionary") { + form.act(app, Some("review.addToDictionary")); + } + if suggestions.is_empty() && action_link(ui, "Change…") { + form.manual = true; + } + if form.manual { + ui.add_space(8.0); + let label = ui.label(tl!("Change to:")); + ui.add(egui::TextEdit::singleline(&mut form.replacement).char_limit(1024)).labelled_by(label.id); + if ui.button(tl!("Change")).clicked() { + form.act(app, Some("review.applySuggestion")); + } + } + if !form.message.is_empty() { + ui.colored_label(ui.visuals().error_fg_color, &form.message); + if action_link(ui, "Check Again") { + form.next(app, true); + } + } +} + +#[cfg(test)] +mod tests { + use egui_kittest::kittest::Queryable; + use wordcraft_doc::{Document, Pos, StoryRef}; + use wordcraft_engine::Session; + + use super::*; + use crate::Services; + + fn app(text: &str) -> WordApp { + WordApp::new(Session::new(Document::from_text(text)), Services::default()) + } + + fn frame(ui: &mut Ui, app: &mut WordApp) { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + } + + fn harness(text: &str) -> egui_kittest::Harness<'static, WordApp> { + let mut app = app(text); + app.run("review.spelling", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(frame, app); + h.run_steps(6); + h + } + + fn form(app: &WordApp) -> &SpellingForm { + app.spelling_review.as_ref().expect("proofing pane expected") + } + + #[test] + fn spelling_review_dialog_changes_a_suggestion_and_undo_restores_the_word() { + let mut h = harness("The quick brwn fox."); + assert!(!h.state().canvas.context_menu_open); + let replacement = form(h.state()).issue["suggestions"][0].as_str().unwrap().to_string(); + h.get_by_label(&replacement).click(); + h.run_steps(3); + assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), format!("The quick {replacement} fox.")); + assert!(form(h.state()).done()); + h.get_by_label("Close").click(); + h.run_steps(2); + assert!(h.state().spelling_review.is_none()); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), "The quick brwn fox."); + } + + #[test] + fn spelling_review_dialog_ignore_once_advances_and_finishes_without_changing_text() { + let text = "The brwn fox and brwn dog."; + let mut h = harness(text); + let first = h.state().session.sel.clone(); + h.get_by_label("Ignore Once").click(); + h.run_steps(3); + assert_ne!(h.state().session.sel, first); + assert_eq!(form(h.state()).issue["text"], "brwn"); + h.get_by_label("Ignore Once").click(); + h.run_steps(3); + assert!(form(h.state()).done()); + assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), text); + h.get_by_label("Check Again").click(); + h.run_steps(3); + assert_eq!(h.state().session.sel, first, "Ignore Once never adds a word to the dictionary"); + } + + #[test] + fn spelling_review_dialog_ignore_all_and_add_to_dictionary_advance() { + for (word, button) in [("zqproofingdialogignore", "Ignore All"), ("zqproofingdialogcustom", "Add to Dictionary")] { + let text = format!("The {word} fox and {word} dog."); + let mut h = harness(&text); + h.get_by_label(button).click(); + h.run_steps(3); + assert!(form(h.state()).done()); + assert_eq!(h.state().session.doc.plain_text(StoryRef::Body), text); + assert!(h.state_mut().session.run("review.issues", &json!({})).unwrap().as_array().unwrap().is_empty()); + } + } + + #[test] + fn spelling_review_dialog_selected_spelling_stays_actionable_when_grammar_overlaps() { + let mut a = app("A sentense sentense."); + a.session.sel = Selection::caret(Pos::body(0, 11)); + a.run("review.spelling", json!({"wrap": false})).unwrap(); + let mut f = a.spelling_review.take().unwrap(); + assert_eq!(f.issue["kind"], "spelling"); + assert!(f.current(&mut a), "the selected typo is current despite overlapping grammar"); + f.replacement = "sentence".into(); + f.act(&mut a, Some("review.applySuggestion")); + assert!(f.message.is_empty()); + assert_eq!(a.session.doc.plain_text(StoryRef::Body), "A sentense sentence."); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(a.session.doc.plain_text(StoryRef::Body), "A sentense sentense."); + } + + #[test] + fn spelling_review_dialog_refuses_a_stale_selection_or_replaced_body() { + for replace in [false, true] { + let mut a = app("The brwn fox and brwn dog."); + let issue = a.execute("review.spelling", json!({})).unwrap(); + assert!(a.dialog.is_none() && a.spelling_review.is_none(), "programmatic calls never open a pane or window"); + let mut f = SpellingForm::new(&a, issue); + f.replacement = "brown".into(); + if replace { + a.execute("document.setText", json!({"text": "The brwn fox and brwn dog."})).unwrap(); + } else { + a.session.sel = Selection::caret(Pos::body(0, 0)); + } + let before = a.session.doc.plain_text(StoryRef::Body); + f.act(&mut a, Some("review.applySuggestion")); + assert!(!f.message.is_empty()); + assert_eq!(a.session.doc.plain_text(StoryRef::Body), before); + } + } + + #[test] + fn spelling_review_dialog_shows_completion_without_issues_and_close_returns_to_document() { + let mut h = harness("The quick brown fox."); + assert!(form(h.state()).done()); + h.get_by_label("Close").click(); + h.run_steps(2); + assert!(h.state().spelling_review.is_none()); + } + + #[test] + #[ignore = "offscreen visual review; requires a software or hardware graphics adapter"] + fn spelling_review_dialog_preview() { + let mut a = app("The quick brwn fox."); + a.run("review.spelling", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).wgpu().build_ui_state(frame, a); + for (name, visuals) in [("light", egui::Visuals::light()), ("dark", egui::Visuals::dark())] { + h.ctx.set_visuals(visuals); + h.run_steps(6); + h.render().unwrap().save(format!("spelling-review-{name}.png")).unwrap(); + } + } + + #[test] + fn spelling_review_dialog_pane_is_local_to_each_window_and_clears_on_document_switch() { + let mut a = app("The brwn fox."); + a.run("review.spelling", json!({})).unwrap(); + let mut other = crate::window_ui::WindowUi::new(a.session.document_id()); + a.swap_window(&mut other); + assert!(a.spelling_review.is_none()); + a.swap_window(&mut other); + assert_eq!(form(&a).issue["text"], "brwn"); + a.run("file.new", json!({})).unwrap(); + assert!(a.spelling_review.is_none()); + } +} diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index 29464e87a..d53542697 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -22,6 +22,7 @@ pub(crate) struct WindowUi { pub(crate) dialog: Option, pub(crate) colors: Option, pub(crate) restrict: dialogs_review::RestrictPane, + pub(crate) spelling_review: Option, pub(crate) status_msg: Option<(String, f64)>, pub(crate) previews: previews::Previews, pub(crate) change_picture_target: Option, @@ -91,6 +92,7 @@ impl WindowUi { dialog: None, colors: None, restrict: Default::default(), + spelling_review: None, status_msg: None, previews: Default::default(), change_picture_target: None, @@ -213,6 +215,7 @@ impl WordApp { dialog, colors, restrict, + spelling_review, status_msg, previews, change_picture_target, @@ -309,6 +312,7 @@ impl WordApp { dialog, colors, restrict, + spelling_review, status_msg, previews, change_picture_target, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 63fa04f58..3be026eaf 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously trivial (a VML horizontal rule (`o:hr`) is drawn across its line at Word's measured width and kept on save, with rotation and flips, #3956); previously trivial (soft edges on a see-through solid fill fade from the fill's own opacity, #1600); previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -58,9 +58,9 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154; Windows and Enhanced Metafiles, `\wmetafile` and `\emfblip`, read and written, #4259), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL | | `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/it/odt_sections.rs` | Styles, lists (#87), tables, pictures, page breaks (#86), headers and footers of each master page (every, left and first page) read on open, not yet written (#863), keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099; export still drops equations, as before), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL | -| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (``, ``) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As | -| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154); the empty comments `` and `` end at once instead of swallowing the rest of the page (#5040). Header cells (``) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `
` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `basereading` in the base text's formatting and read back from ``/``/`` (`` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). | -| `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | | +| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (``, ``) or a Single File Web Page (MHT) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`, `tests::a_single_file_web_page_saved_as_doc_opens_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As | +| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~65% | 5–9 | `crates/formats/src/tests.rs`, `html.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154); the empty comments `` and `` end at once instead of swallowing the rest of the page (#5040). `style` element rules with simple selectors (`p`, `.x`, `p.x`; class names may start with a digit, as Word accepts) in cascade order; paragraph `margin`, `text-indent`, `line-height`; cell `width` and borders. Header cells (``) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). An opened page looks as Word opens one: Times New Roman 12 pt, single lines, browser-sized black headings, automatic 14 pt spacing where a `p` or heading states no margin (none before the first), a span's margin replacing its paragraph's. HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do (#1381). No `@page`, descendant selectors or `div` margins. No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `
` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `basereading` in the base text's formatting and read back from ``/``/`` (`` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). | +| `.mht/.mhtml` Single File Web Page | <1% | R/W | 🟡 | ❌ | ~60% | 2–4 | `crates/formats/src/mht.rs` | Opens: the MIME message's page (its `start` part, else the first HTML part) in its part's charset, quoted-printable or base64, pictures found by `Content-Location` (also relative to the page) or `cid:`; then read as an HTML page, on the region's paper. Not saved | | `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/it/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element | | `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/it/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/it/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, rotation and floating position (pictures become inline), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) | | `.wps` Works 6–9, WordPerfect `.wpd` (Windows) | <1% | R | ❌ | — | 0% | 10–20 | — | Windows-only converters; low value | @@ -121,10 +121,11 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Section vertical alignment (`w:vAlign` in `w:sectPr`, §17.6.23) | ✅ | ✅ | `top`, `center`, `bottom` and `both` (Justified, read as Center before #1320); unknown values read as top, top writes nothing (`crates/docx/tests/it/section_valign.rs`). ODT/RTF don't carry it | | Ruby (`w:ruby`: `w:rubyPr`, `w:rt`, `w:rubyBase`) | ✅ | ✅ | #288; base and ruby text with their run formatting, alignment, sizes, raise, language | | Document grid (`w:docGrid`: type, line pitch, `w:charSpace`), `w:snapToGrid` on paragraphs, runs and styles | ✅ | ✅ | #391; run and character-style flags keep absent/true/false across repeated saves; inherited true remains explicit false when overridden. `w:webHidden` remains distinct from `w:vanish` and preserves absent/true/false through runs, styles and repeated saves; Print Layout still shows web-hidden text | +| Alternative format chunks (`w:altChunk`) | ✅ | — | Brought into the document where they stand when it opens, as Word does: MHT, HTML and XHTML as web pages, RTF and plain text through their readers, a nested WordprocessingML package by the docx reader (at most 4 deep, 256 chunks and 512 MB of chunk data a file). The chunk's pictures, lists, styles, notes, comments and tracked changes come along (the document's own styles win by id, unless the chunk asks to keep its source formatting, `w:matchSrc`: then its styles come along under new ids, carrying its own defaults); its section breaks don't. Saving writes the content as ordinary paragraphs, as Word does. In a Word XML (Flat OPC) file only WordprocessingML chunks come in | | Two lines in one and horizontal in vertical (`w:eastAsianLayout`, §17.3.2.10) | ✅ | ✅ | Every attribute (`w:id`, `w:combine`, `w:combineBrackets`, `w:vert`, `w:vertCompress`) read into the run's formatting and written back after `w:lang`, in runs and styles; values outside the schema are dropped (`crates/docx/tests/it/east_asian_layout.rs`). Not laid out yet: the text shows as ordinary text | | `w:fitText` | ❌ | ❌ | East Asian layout | | Glossary document (building blocks) | ✅ | ✅ | `w:docPart` entries (name, style, gallery, category, types, behaviours, description, guid, body) are read into `Document::glossary` and offered by Insert › Quick Parts and AutoText; `save` with `"in": "document"` stores one in the file. An untouched glossary is written back byte for byte with its own parts (styles, settings, fonts…); an edited one, or one whose parts weren't kept, is written from the model as `word/glossary1.xml` with copies of the document's styles and numbering. The glossary's own styles, settings and fonts, text boxes inside entries, and the notes and comments of a selection saved into the document don't survive an edit. Tests: `crates/docx/tests/it/glossary.rs` | -| `w:altChunk`, sub-documents | ❌ | ❌ | | +| Sub-documents | ❌ | ❌ | | | Hostile input | ✅ | — | `malformed.rs`, capped allocations, never-crash standard. Failed zip entries count against the size limit, case variants of a part name are one part, embedded fonts and charts are written within budgets, and Flat OPC inlines kept XML only when it is one well-formed element (2026-10-22) | | Document paragraph defaults (`w:pPrDefault`) | ✅ | ✅ | Omission and explicit empty defaults remain distinct across repeated saves; nonempty defaults and later default-spacing edits are written | @@ -269,6 +270,8 @@ Repeated DOCX style IDs use the last complete definition. The original regressio Custom document properties retain their bookmark link targets through DOCX saves, native JSON reopens, value edits and dialog renames (#4767). `file.properties` accepts a text string or a linked text object with `value` and optional `linkTarget`. Strings keep the existing association. Objects replace it, with an omitted or null target meaning unlinked. Values and bookmark targets are limited to 1 MiB each. Linked values are not evaluated; the dialog layout and field-update behavior are unchanged. Original fixtures cover ordinary and raw property values; broader corpus qualification and readiness estimates remain unchanged. +Table rows and cells inside `mc:AlternateContent` use the same selected-branch policy as paragraphs and runs (#5219), rather than disappearing on open and save. Original synthetic fixtures cover Choice/Fallback selection, namespace aliases, mixed requirements, row/cell order and nested content controls through two DOCX saves. This fixes a focused preservation case; native Word and broader real-world corpus qualification remain open. + ## Revision history > 2026-10-28 · Charts and SmartArt: text the font lacks (Chinese axis labels and legends) is drawn in a fallback face instead of boxes, one face per run as in body text (#4720, `layout::tests::chart_text_the_font_lacks_is_drawn_in_a_fallback_face`). @@ -279,6 +282,10 @@ Custom document properties retain their bookmark link targets through DOCX saves | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | `w:altChunk` content (MHT, HTML, RTF, text, nested documents) brought in on open, where it stands; `.mht`/`.mhtml` open; HTML reads `style` element rules, paragraph margins, indents and line height, cell widths and borders, and an opened page takes Word's web-page defaults. Insert › Text from File keeps the file's pictures and lists | +| 2026-11-02 | trivial | DOCX: a VML horizontal rule (`v:rect o:hr`, a web page's `
` pasted into Word) is drawn across its line as Word draws it — the whole width inside the paragraph's indents, or `o:hrpct` of it, or its own width, placed by `o:hralign` — instead of as a 1 pt dot; in a table cell it follows the column; it saves back as the same VML rule | +| 2026-11-02 | trivial | Soft edges on a see-through solid fill fade from the fill's own opacity, not from fully opaque, in the raster, PDF and caret-background paths; opaque fills draw as before (#1600). Synthetic raster/PDF tests; no new Word comparison | +| 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. | | 2026-11-01 | trivial | Arrowheads keep their kind, width and length (DrawingML `a:headEnd`/`a:tailEnd` `type`/`w`/`len`, VML `startarrow`/`endarrow` with `…arrowwidth`/`…arrowlength`), read and written back, and are drawn 2, 3 or 5 times the line's width long and wide (small, medium, large), a line under 2 points as a 2-point one, as measured in Word for Windows; every kind is still drawn as a triangle | | 2026-10-31 | trivial | .doc import: text naming no font takes the stylesheet's standard fonts (rgftcStandardChpStsh, ftcBi), not the font table's first entry; runs' East Asian and complex-script fonts are read (#5066) | | 2026-10-31 | trivial | DOCX: a paragraph naming a style the file doesn't define is formatted as Normal, as in Word (#5077) | diff --git a/docs/gaps.md b/docs/gaps.md index 6a4880eb6..97446b0f8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,8 +1,8 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** trivial (gap 8 points at craft-proof in craft-libs, its data in craft-i18n, #5202; no ranked gap changed); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** 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 (gap 8 points at craft-proof in craft-libs, its data in craft-i18n, #5202; no ranked gap changed); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** trivial (#26: MHT opens and `w:altChunk` is brought in; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -70,7 +70,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 2 | **Word's native ink is not drawn from its strokes**; OLE objects show only their picture | B | Every native ink form is preserved: drawing-level ink with its optional picture fallback, direct run-level content parts, and ink members of groups and drawing canvases (kept in their place among the members, drawn from their fallback picture). Drawing canvases are read and written (#344); group shapes (`wpg`) read and written (#267). Charts and SmartArt are drawn (#292) and, with OLE objects, written back with all their parts on save (#319) | Pen notes from Word show only as their picture and can't be edited; OLE objects can't be opened | 45–70 to render and edit charts and SmartArt (#13) | [file-format-parity.md](file-format-parity.md) | | 5 | **Content controls** landed (#381): `w:sdt` kept, shown and edited (Developer tab, Design Mode, Properties). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; Track Changes Options has the Moves rows (Moved from / Moved to mark and colour) and `w:doNotTrackMoves` (Track moves off: a cut and paste is a deletion and an insertion) round-trips; still missing: drag-and-drop moves (no text drag yet). Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes (table, row, cell and section property changes now record in WordCraft too, #41 follow-up). Opaque custom XML backing stores now survive saves; binding evaluation remains absent. Replace All now checks content-control locks before editing any match; a refused batch preserves the document and undo history. | B | List-label pitch corrected in synthetic metric controls; Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) | | 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) | -| 7 | **Dialog depth**: 61 modal dialogs vs Word's ~100 (Custom Document Properties from File › Info, #2982; Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | +| 7 | **Dialog depth**: Word-observed spelling/grammar task pane added (shared review commands, current-story traversal; see [spelling-review.md](spelling-review.md)); 61 modal dialogs vs Word's ~100 (Custom Document Properties from File › Info, #2982; Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) | | 9 | **Password-protected `.doc` files with an AES header in the Word 97-2003 format** can't be opened: [MS-OFFCRYPTO] defines AES only for encrypted packages, so these are refused with a clear message. Encrypted packages open under any Word extension (`.docx`, `.doc`, `.dot`…) with either agile (Word 2010+) or Standard (Word 2007, AES-ECB) encryption; protected `.docx` saves with agile encryption (#55, #281, gap 9) | | `crates/docx/src/crypt.rs` (agile and Standard); `crates/docbin/src/crypt.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079). All synthetic fixtures; untested on Word-written files | Corporate and legal users with old files | 0 (refused by design; nothing left to build) | [file-format-parity.md](file-format-parity.md) | | 8 | **Proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; no dictionaries or grammar for the other ~41 languages Word proofs (text in them is left unmarked), and no hyphenation for them except Bulgarian and German | | craft-proof (storytold/craft-libs `crates/proof`, data in craft-i18n `language-data/`) `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each flagging the other's spellings, #1281), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian (#826) and German (TeX patterns, 2026-10-14) have hyphenation only; open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #40, #100 open | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) | | 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; live rotation preview while dragging fixed for pictures and shapes without text stories, #2277; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | @@ -90,7 +90,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 24 | **Default shortcuts**: 140 (macOS) / 158 (Linux/Windows) of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and Quick Access Toolbar #379; inspector, macros, strikethrough, file/edit keys, the Spike) | | [ui-parity.md](ui-parity.md) | Power users | 1–3 | [ui-parity.md](ui-parity.md) | | 25 | **In-app AI assistant** (Copilot-style draft, rewrite, summarize) | | Chat PR #178 and AI assistant PR #815 open | Users expecting Copilot | 15–30 + owner (provider) | [target-app-parity.md](target-app-parity.md) | | 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All; a window per open document (the engine holds several open documents and Switch Windows lists and switches them) | | [parity-checklist.md](parity-checklist.md) (View 26/29); PR #331 open | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) | -| 26 | **Minor formats**: MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word) | Rare | 14–26 | [file-format-parity.md](file-format-parity.md) | +| 26 | **Minor formats**: saving MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word); MHT opens and documents' `w:altChunk` content is brought in | Rare | 12–23 | [file-format-parity.md](file-format-parity.md) | | 28 | **Dictate** | | `tools.dictate` missing | Dictation users | 8–15 + owner (speech model) | [hardware-parity.md](hardware-parity.md) | | 29 | **Equations**: ink equations; a copied equation pasting into Word as an editable equation, still to be confirmed (#816, #1297) | | #191 notes; long display equations wrap at operators (#326); a copy holding an equation puts MathML in HTML on the clipboard (#816), and a copy of one equation alone also its MathML in the "MathML" clipboard formats Word reads first (Windows, macOS; #1297; Word/LibreOffice paste not yet verified) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) | @@ -142,8 +142,12 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): Recipient label generation (#4880) now includes all selected recipients across as many sheets as needed, retaining filtered/sorted order and the chosen product geometry. This fixes the one-sheet omission; Update Labels and Finish & Merge template behavior remain separate, and gap 21 remains open. Readiness estimates are unchanged. +Table rows and cells inside `mc:AlternateContent` use the same selected-branch policy as paragraphs and runs (#5219), rather than disappearing on open and save. Original synthetic fixtures cover Choice/Fallback selection, namespace aliases, mixed requirements, row/cell order and nested content controls through two DOCX saves. This fixes a focused preservation case; native Word and broader real-world corpus qualification remain open. + ## Revision history +> 2026-10-27 · Technical synchronization: existing Proofing pane hooks and regression tests follow the extracted engine/UI modules on main `436d444`; compact context menus remain upstream. New-SHA validation is pending; readiness unchanged. +> > 2026-10-23 · cu20we review follow-up: Only paragraph-relative box floats use the pre-spacing origin. Line-relative floats and page/margin fallbacks in cells and text boxes keep the text-top origin. Synthetic controls compare zero and 24 pt space-before across all four box contexts. New cap conversion helpers and regression tests document their contracts; readiness estimates unchanged. > 2026-10-23 · cu20we: DrawingML flat, square and round line caps are imported, saved, displayed and exported to PDF; absent or unknown caps default to flat. VML end caps (including shapetype inheritance) are preserved through DOCX save. Ink and metafile stroke handling remain separate. Synthetic DOCX, raster and PDF regressions cover the change; no new native Word comparison. @@ -152,6 +156,8 @@ Recipient label generation (#4880) now includes all selected recipients across a | Date | Change | Summary | |---|---|---| +| 2026-11-02 | trivial | #26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains | +| 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. | | 2026-11-01 | trivial | Gap 8's evidence names craft-proof (storytold/craft-libs `crates/proof`) and craft-i18n `language-data/` instead of WordCraft's removed `crates/proof` (#5202). No ranked gap changed. | | 2026-11-01 | trivial | Arrowheads keep their kind, width and length (DrawingML `a:headEnd`/`a:tailEnd` `type`/`w`/`len`, VML `startarrow`/`endarrow` with `…arrowwidth`/`…arrowlength`), read and written back, and are drawn 2, 3 or 5 times the line's width long and wide (small, medium, large), a line under 2 points as a 2-point one, as measured in Word for Windows; every kind is still drawn as a triangle; no ranked gap changed; readiness estimates unchanged | | 2026-10-31 | trivial | The .doc reader's default font is the stylesheet's standard font, not the font table's first entry (#5066); no ranked gap changed; readiness estimates unchanged | @@ -160,6 +166,8 @@ Recipient label generation (#4880) now includes all selected recipients across a | 2026-10-31 | trivial | A list level above that hasn't counted yet takes its first number at the first deeper item, as measured in Word (10.1–10.4, then 11); not a ranked gap; readiness estimates unchanged | | 2026-10-30 | trivial | Only the PDF sent to the printer omits link annotations; File › Print › Save these pages as PDF (`file.printPdf`) keeps them, like Export (#4734); no ranked gap changed; readiness estimates unchanged | | 2026-10-29 | trivial | The print job's PDF omits link annotations, which the spooler refused to fit to the paper, so documents with links reach the printer (#4734); physical printer output remains to be validated; no ranked gap changed; readiness estimates unchanged | +| 2026-10-31 | trivial | Synchronize the existing Proofing pane with main `eecbb8f`; preserve explicit-position lookup, implicit selection, navigation, undo and per-window state; fresh exact-SHA hosted validation pending, readiness unchanged | +| 2026-10-30 | trivial | Synchronize the existing Proofing pane and navigation/selection/undo regressions with main `3b7f7ed`; new-SHA validation pending, readiness unchanged | | 2026-10-28 | trivial | Aligned copies retain their locked document coordinate during middle-button scrolling and can start on secondary selected objects; existing Ctrl and Shift selection behavior stays (#4764) | | 2026-10-28 | trivial | Ctrl/Command+Shift drags a selected floating object into an axis-aligned copy (#4764); synthetic Linux interaction and persistence controls, native desktop and Word untested; gap/readiness estimates unchanged | | 2026-10-28 | trivial | Connector object lookups retain the typed column-selection inputs on current main (#3631); readiness estimates unchanged | @@ -172,6 +180,7 @@ Recipient label generation (#4880) now includes all selected recipients across a | 2026-10-28 | trivial | #4658: bound Markdown reference-label rejection work, preserving literal source and ordinary link matching | | 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | | 2026-10-29 | trivial | HTML import keeps preformatted lines with CR, CRLF or LF input newlines (#4810); numeric character references retain their separate meaning; readiness estimates unchanged. | +| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; exact selected spelling takes priority over overlapping grammar, with command/pane regressions and integration workflow paths; readiness estimates unchanged | | 2026-10-22 | trivial | View windows: several open documents and Switch Windows in the engine | | 2026-10-25 | trivial | egui 0.37 migration: interface catalogs remain pinned by `craft-i18n.rev` (`9205108…`; 27 languages, English fallback); IME surrounding deletions and composition resets are implemented with regressions, but managed CI passed; native IME acceptance and native-speaker catalog review remain open; no gap or readiness change | | 2026-10-17 | trivial | Gap 9 narrowed: Standard (Word 2007, AES-ECB) encrypted packages open with a password under any Word extension; AES headers in Word 97-2003 files are outside the public spec and refused with a clear message; estimate 3–5 → 0 h, since the remaining AES-in-`.doc` case is refused by design | diff --git a/docs/spelling-review.md b/docs/spelling-review.md new file mode 100644 index 000000000..385f66018 --- /dev/null +++ b/docs/spelling-review.md @@ -0,0 +1,67 @@ +# Spelling and grammar task pane + +> **Last updated:** 2026-11-01 · **Status:** the feature-specific preview workflow is removed to follow the shared-CI policy. Rust implementation and tests are unchanged from `3abe452`; validation of this documentation/CI-only follow-up SHA is pending. Historical parent `3abe452` passed its exact-head hosted gate, three-platform Printing and proofing preview; those results remain attributed to that parent. + +## Word reference + +Observed the actual Word for Windows interface on the preparation PC on 2026-10-26, using a newly created document with the synthetic sentence "Le chat mange une souri. Ce documant contient une faute." F7 selected "documant" and opened a right-side pane titled Vérification. Existing documents were left unchanged. The exact Word build was not recorded. + +At a 1920 × 1152 window, the pane occupied approximately 400 logical pixels on the right. Its body had a category heading and dictionary explanation, a framed sentence context approximately 48 px high, a Suggestions heading, and three bordered suggestion rows approximately 68 px high. Ignore Once, Ignore All and Add to Dictionary were text actions below the list. A suggestion's chevron opened Read Aloud, Spell Out, Change All and AutoCorrect actions. These are observations, not a copied screenshot or asset. + +WordCraft uses this arrangement and its shared pane host, icons, typography and theme palette. Clicking a suggestion applies the correction directly and advances; the central modal proposal is superseded. The pane supports sentence context, Read Aloud through the existing command, manual replacement and AutoCorrect actions. Word's dictionary definitions/synonyms, Spell Out and Change All are not implemented here. Visual similarity must be assessed from the generated light/dark previews; implementation alone is not evidence of pixel identity. + +## Scope and behavior + +Review › Spelling & Grammar, Home › Editor and F7 open the task pane. The pass begins at the next issue from the current selection and continues to the end of the current story (body, header, note or other active text), not all document stories or only a selection. Each correction uses the existing review command and remains undoable. Ignore Once advances without changing the document or dictionaries. Ignore All lasts for the session; Add to Dictionary uses the personal dictionary. Those two actions are offered only for spelling issues. Completion remains visible; Check Again starts a wrapping search. The pane host supplies Close and docking. + +If another command changes the document body or selection, a cached suggestion is refused; Check Again obtains a fresh target. Read-only/protected document commands retain their checks. The pane is local to each window, clears on a document switch and does not alter the compact right-click menu. Programmatic review commands return structured results without opening UI. + +This work is independent of French contribution #3185. It consumes language-aware engine results and adds neither dictionaries nor grammar rules. Labels use the external shared translation lookup with English fallback for missing keys. No release date or readiness increase is implied. + +## Technical synchronization (2026-10-31) + +The existing pane is merged with main `e6a5cd05f045e4caf25a28660bfeb622f25b5129`, retaining its Macros dialog, keytips and print-pipeline changes. The proofing-specific source and regressions are unchanged from `7c20316`: a valid explicit position remains a point lookup at the focus, while an absent/unparseable position preserves exact-selection priority. Navigation, correction/undo, compact menus and per-window state are retained. Fresh exact-SHA hosted checks and rendered previews must validate this synchronization; no new feature or readiness increase is claimed. + +## Technical synchronization and API follow-up (2026-10-30) + +The existing pane is merged with main `3b7f7ed10b2eb0ae3c39b71551d36ed2df1d1d8b`, retaining the upstream menus, pane host and per-window state. For `review.suggestions`, a successfully parsed explicit `pos` is a pure point lookup even when it equals the selection focus; an absent or unparseable `pos` keeps exact-selection priority. The follow-up preserves correction and undo; fresh hosted checks must validate its new SHA. Results for its parent `c077c155` do not validate the follow-up. + +## Technical synchronization (2026-10-27) + +The language-independent pane is rebased by a three-way merge with main `436d444ef0ee35c2e2b9fb44f87aa7d08d72641f`. Engine navigation and exact selected-range lookup live in `crates/engine/src/cmd/review/proofing.rs`; the existing regressions follow into `review/tests.rs`. The UI command hook follows into `app/commands.rs`, construction into `app.rs`, inspection into `control/handler.rs`, and pane state/closure into `panes/state.rs` and `panes/host.rs`. The upstream context-menu and dropdown implementations stay intact. Workflow path filters cover the extracted modules while retaining historical paths. No dictionary, grammar rule, new feature or readiness increase is introduced by this synchronization. + +## Verification + +- Engine: one-/two-paragraph navigation, forward-only completion, explicit wrapping, exact spelling selection within overlapping grammar in either selection direction, explicit point lookup at the focus, absent/unparseable-position selection fallback, unchanged selection, correction and undo. +- UI: direct suggestion correction and undo, Ignore Once progress/completion, Ignore All, personal dictionary addition, stale selection/body refusal, completion and Close, per-window isolation and document switching. +- Offscreen: the ignored spelling_review_dialog_preview test writes light/dark PNGs for comparison with the recorded Word arrangement. +- Required gate: `cargo xtask ci`, through the repository's normal shared CI. The feature adds no workflow or shared-CI change. Local attempt outcomes are recorded separately; an environment-limited attempt does not replace hosted checks. Each follow-up SHA requires its own results. + +### Manual regression and visual review + +From the repository root, keep `Cargo.lock` and the existing dependency pins, use the normal native UI build prerequisites, and run: + +```sh +cargo test --locked -p wordcraft-engine --lib spelling_review_dialog_navigation +cargo test --locked -p wordcraft-ui-egui --lib spelling_review_dialog -- --skip preview +cargo test --locked -p wordcraft-ui-egui --lib spelling_review_dialog_preview -- --ignored --nocapture +``` + +The filters currently select two engine regressions, seven UI regressions and one explicitly ignored preview test. Check the reported counts; a successful command with zero selected tests is not verification. The last command needs a software or hardware graphics adapter supported by the headless renderer (for example Mesa Vulkan on Linux) and writes `crates/ui-egui/spelling-review-light.png` and `crates/ui-egui/spelling-review-dark.png`. Inspect both outputs and attach the resulting images manually to the review with their source SHA; the ignored test is not a new CI job. These synthetic English fixtures check pane geometry and actions, not an interactive Windows session or pixel-perfect Word identity. + +Historical images for `3abe452b7c26f624c09e0143dbee275776821dff` were generated by [run 41220215503](https://github.com/totoz64/wordcraft/actions/runs/41220215503), artifact `13049424116`, and inspected in both themes. They remain evidence for that exact parent SHA; removing the workflow does not relabel them as a render of this follow-up. + +The first light/dark artifacts at `20205a5` were inspected: the sentence frame shrank to its text and the unbounded suggestion list pushed actions below the viewport. The revised layout fills the pane width and scrolls the suggestion list after approximately three cards, keeping the actions below it visible. At `f1264f3`, the revised light/dark previews and full hosted gate passed; Windows and Linux UI checks passed, while macOS remained unvalidated after pre-run cancellations. Both PNGs were inspected, including the right-aligned Read Aloud control. The following review revision gives the exact selected spelling range priority over an overlapping grammar issue and requires new hosted checks. Exact pixel parity is not claimed. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-11-01 | trivial | Remove the feature-specific preview workflow to follow shared-CI policy; preserve all Rust and tests, document three direct commands and manually attached exact-SHA images; fresh follow-up validation pending | +| 2026-10-31 | trivial | Synchronize the existing Proofing pane with main `e6a5cd0` after the earlier `eecbb8f` synchronization; preserve explicit-position lookup, implicit selection, navigation, undo and per-window state; parent `7c20316` passed hosted checks, fresh evening-SHA validation pending, readiness unchanged | +| 2026-10-30 | trivial | Merge the existing pane with main `3b7f7ed`; preserve explicit-position point lookup at the selection focus, implicit/parse-fallback selection priority and correction/undo; new follow-up SHA requires fresh hosted validation | +| 2026-10-27 | trivial | Synchronize the existing pane with extracted engine/UI modules on main `436d444`; preserve selection/navigation/undo tests and compact menus, expand workflow path filters; new-SHA validation pending | +| 2026-10-26 | minor | Resolve selected spelling before overlapping grammar, add command/pane regressions, include pane-integration workflow paths and distinguish validated history from pending review corrections | +| 2026-10-26 | minor | Inspect first successful light/dark artifacts; fill the sentence frame and bound suggestion-list height, retaining all suggestions through scrolling | +| 2026-10-26 | minor | Actual Word for Windows observation replaces the modal proposal with a right-side Proofing pane, sentence context and directly clickable suggestion cards | +| 2026-10-26 | minor | Separate language-independent correction-form proposal and verification scope | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 18ea32347..1bbe43048 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -82,12 +82,20 @@ Aligned copy drags keep their locked document coordinate when middle-button scro Labels → New Document with “One label per recipient” includes all filtered/sorted recipients across product-sized sheets (#4880). The last sheet keeps unused labels blank. Fixed text and Single label still produce one sheet. Native Windows interaction and comparison with Microsoft Word remain unverified; this change does not add Update Labels or change Finish & Merge templates. +## Spelling and grammar correction + +Review › Spelling & Grammar, Home › Editor and F7 open a right-side Proofing task pane. Black-box observation of Word for Windows on 2026-10-26 established the arrangement: issue category, dictionary explanation, sentence context with the issue underlined, a bordered stack of suggestion cards, then Ignore Once, Ignore All and Add to Dictionary links. Clicking a suggestion applies it and advances. The pane uses WordCraft's existing title/docking/close host and its own icons and palette; no Microsoft assets are copied. The suggestion menu exposes supported manual replacement and AutoCorrect actions; Word's synonyms, spelling-out and Change All are not implemented here. No pixel-identical claim is made until the rendered comparison is inspected. + +Right-click and Shift+F10 keep the compact suggestions menu. The pane is shared by all proofing languages; it adds no dictionary or grammar rule. See [spelling-review.md](spelling-review.md) for scope, observation and verification. + ## Revision history | Date | Change | Summary | |---|---|---| | 2026-10-31 | trivial | Keytips: Alt, F badges the Backstage pages with Word's letters and the letter opens the page; Alt, F, P and Alt, F, V open Print; the badges end with the Backstage (#4960) | | 2026-10-31 | trivial | View › Macros opens a Macros dialog (also Alt+F8 and command search): the macros of this session listed with Run, Delete and Record, which reads Stop Recording while a recording runs; the button did nothing before, as `tools.macros` without a macro to run, define or delete only returned the list (#4327) | +| 2026-10-31 | trivial | Synchronize the existing Proofing pane with main `eecbb8f`; preserve explicit-position lookup, implicit selection, navigation, undo and per-window state; fresh exact-SHA hosted validation pending, readiness unchanged | +| 2026-10-30 | trivial | Synchronize the existing Proofing pane and navigation/selection/undo regressions with main `3b7f7ed`; new-SHA validation pending, readiness unchanged | | 2026-10-28 | trivial | Aligned copies retain their locked document coordinate during middle-button scrolling and can start on secondary selected objects; existing Ctrl and Shift selection behavior stays (#4764) | | 2026-10-28 | trivial | Ctrl/Command+Shift drags a selected floating object into an axis-aligned copy (#4764); synthetic Linux interaction and persistence controls, native desktop and Word untested; gap/readiness estimates unchanged | | 2026-10-29 | trivial | Insert › Shapes lists the basic shapes as a grid of code-drawn icon tiles with accessible names and tooltips, as Word's gallery does, instead of a text list (#4349) | @@ -97,6 +105,8 @@ Labels → New Document with “One label per recipient” includes all filtered | 2026-10-29 | trivial | Displayed document titles and suggested file names omit NUL characters (X11 panicked on them) and use the localized untitled name when nothing remains (#3634) | | 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | | 2026-10-26 | trivial | Layout › Arrange › Group stays on while a group is selected, so Ungroup in its menu can be reached; it was greyed out because one object has nothing to group (#4229) | +| 2026-10-27 | trivial | Port the existing Proofing pane and navigation/selection regressions to extracted engine/UI modules on main `436d444`; preserve compact context menus; new-SHA validation pending, readiness unchanged | +| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; exact selected spelling takes priority over overlapping grammar, with command/pane regressions and integration workflow paths; readiness estimates unchanged | | 2026-10-22 | trivial | View › Window › New Window opens another window on the document, focused and cascaded; two windows on one document are titled `Name:1` and `Name:2` (one window: the plain name). Each window keeps its own selection, scroll position, zoom, view mode, ruler and panes; the text, undo history, unsaved state and file are the document's, so an edit or Undo in either window shows in both, and a caret in text the other window deleted moves to the nearest place left. Closing one of a document's windows asks nothing; closing its last asks about unsaved changes, and quitting asks once per document. Switch Windows lists every window. The web app keeps one window | | 2026-10-22 | trivial | One native window per open document on the desktop (the web app keeps one window): New and Open open the document in a window of its own, focused and cascaded from the last; Open replaces an untouched blank document in its window and brings an already-open file's window forward; each window keeps its own ribbon tab, File page, dialogs, panes and composition; View › Window › Switch Windows lists the windows and brings the chosen one forward; File › Close and the window's close button close the window (asking about unsaved changes) and the window focused before comes forward, while the last window stays open with no document; quitting asks Save / Don't Save / Cancel about each document in its own window | | 2026-10-22 | trivial | Several open documents in one window (one window per document comes next): View › Window › Switch Windows lists the open documents, the shown one marked and unsaved ones dotted, and shows the one chosen, with its own scroll position, zoom, comment drafts and AutoSave file; File › New, Open, Envelopes, Labels, Finish & Merge and personal templates open beside the open documents without asking about unsaved changes; File › Close shows the next open document; the window's close button asks Save / Don't Save / Cancel about each document with unsaved changes, showing it, and Cancel keeps the app open. Untitled documents are numbered (Document2) in the interface language |