diff --git a/ROADMAP.md b/ROADMAP.md index 330a474a6..34ed16eea 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; 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 minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; 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; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: egui 0.37 migration records the `craft-i18n.rev` catalog pin (9205108…; 27 languages) and the input-method handling; managed CI passed; native acceptance and native-speaker review remain open; previously trivial: progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; 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 minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; 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; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** 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; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -138,10 +138,14 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-25: Interface catalogs are still the shared storytold/craft-i18n ones embedded from `craft-i18n.rev` (`9205108c…`): 27 WordCraft languages with English fallback for missing strings, and the equation “Delete Around Caret in Equation” translation preserved. Native-speaker review remains human. +- 2026-10-25: Input methods (egui 0.37): character- and byte-counted surrounding deletions, a fresh composition after body/equation replacement, rejection of queued stale replacements before mutation, transactional equation deletes and one undo group, with tracked and placeholder length mapping. Synthetic regressions document these paths; managed CI passed, and native IME acceptance (#155–#164) plus browser IME behavior remain open. - 2026-10-25: Text Highlight Color shows the last colour as the button's bar in the ribbon and mini toolbar; after No Color it shows the pen without a misleading yellow bar, and the button removes highlighting (#4087). - 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. - 2026-10-23: cu20we: paragraph-relative box floats start above effective space before; header, footer, cell and text-box layout regressions pass. Framed paragraph positioning is unchanged; no new native Word comparison. +- 2026-10-23: AutoFormat As You Type: `*Bold* and _italic_ as real formatting`. With the option on, `*word*` typed and finished becomes bold and `_word_` italic, without the marks. It is off by default (`tools.autocorrect {"boldItalic"}`). +- 2026-10-24: Parity catalog: Read Mode's View menu has its own rows. Column Width (`view.readColumnWidth`) and Page Color (`view.readPageColor`) count as live; Layout (`view.readLayout`) is listed as missing. Catalog 504/520 (96.9%). - 2026-10-23: DOCX: a document whose default font size is unset keeps its size when saved and reopened. The size was left out of the file, and the reader read the missing size as 10 pt while the document resolved it as 11 pt; the file now says 11 pt (`DEFAULT_FONT_SIZE`). Files from other programs without a size are read as before. - 2026-10-23: Insert › Table: hovering the grid shows the table on the page where it would go, as Word's Live Preview does, and the preview follows the pointer from square to square. It is laid out on a copy of the document, so nothing changes, and Undo has nothing new, until a square is clicked (#3931). - 2026-10-24: Exact line spacing fixes the floating-table contact boundary independently of paragraph-mark metrics (#3977). Both row selection and word skipping use it; original regressions cover oversized and undersized marks, contact and one-twip offsets in modes 11/12/14/15. Shape behavior is unchanged. @@ -149,6 +153,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-22: Mail merge (#3028): Preview Results has First Record and Last Record buttons and a box with the previewed record number; type a number and press Enter to go there (past either end stops at the first or last record). Finish & Merge is now a menu. Edit Individual Documents… and Print Documents… ask for all records, the current record, or a From and To range by record number, and Print Documents opens Print for the merged document. Scripts get `mailings.first`, `mailings.last`, `mailings.goToRecord`, `mailings.printDocuments` and a `records` choice on `mailings.finish`; a range with no records in it is refused instead of leaving an empty document. - 2026-10-24: AutoFormat As You Type has "Built-in Heading styles" (off by default): pressing Enter twice after a line of five or fewer words that ends in a letter or digit makes it Heading 1, or Heading 2 or 3 when the line starts with one or two tabs. - 2026-10-22: Re-routing a group connector keeps its free end at its displayed rotated and flipped position, including after nonuniform group resizing, while the glued end follows its existing connection-site path. Missing target shapes and invalid connection sites keep their transformed endpoints too. Fallback routes that cannot fit the renderer's 4000 pt child-size cap leave the original connector unchanged. Model and engine regressions cover starts, ends, unit/uniform/nonuniform scale, settled reroutes and safe handling of degenerate dimensions and display-size limits; no new native Word comparison. +- 2026-10-24: AutoFormat: a paragraph numbered through its style (a heading in a heading-linked Multilevel List) keeps `---` and `+---+` as text on Enter, as a paragraph numbered directly does. At the top of the document the border-line AutoFormat removed the characters and switched off the heading's number, and left no border. - 2026-10-23: Lists: an empty paragraph whose mark is hidden takes no room and draws no list number, in the body and in table cells, as in Word; the number is still counted, so the next item keeps its place (#3982). - 2026-10-24: Spelling: with Ignore web and file addresses on, a web or e-mail address after a no-break space or an ideographic space is skipped, as it is after a plain space. Its words were marked as misspelled. - 2026-10-23: Font dialog: "NaN" or "inf" typed in the Size box previews the text at 12 pt. "NaN" reads as a number, and a font size that is not a number stopped the app while drawing the preview (#3663). @@ -312,6 +317,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-16 | Create Source: the automatic Tag is three letters of the first author and the last two digits of the year (`Smi20`), with 1, 2… added when taken, as Word for Mac 16.113.4 does (user-checked). Editing Author or Year keeps the Tag, as in Word, and the citations in the text show the new author and year (#2094; the reporter expected the Tag to follow). | | 2026-10-18 | Shape fills keep and draw gradients and transparency (#1500): Word 365 cover pages show their theme gradient panel and translucent wave; DrawingML and VML shapes, text boxes, group members (`a:grpFill`) and canvases are read, drawn (screen, PNG, PDF) and saved with their gradient stops, angles, focus and transparency; theme fill styles (`a:fillRef`) resolve to the fill they name. `.json` documents keep opaque fills as `[r,g,b]`. Edited as in Word: Shape Fill › Gradient (Light and Dark Variations) and the Format Shape pane (solid colour and transparency; gradient presets, type, direction, angle and stops) | | 2026-10-18 | Split Cells and Convert Text to Table dialogs (#2592), matched to Word: Split Cells starts from the selection (twice its columns, its rows; Merge cells before split offered for several cells or one cell merged across columns); `table.split` gains `rows` and `mergeFirst`, a merged cell splits into 1 to its height rows (its height cuts it into rows, 1 × 1 changes nothing, a number that doesn't divide the height or more than it is refused with Word's messages) and column splits make equal parts without moving other rows' grid lines. Convert Text to Table guesses columns live, shows the row count, and offers Initial column width (Auto or up to 22 in, kept as each column's preferred width), AutoFit to contents/window and Paragraphs/Commas/Tabs/Other separators; `table.fromText` gains `paragraph`/`other` + `char`, `columns`, `autofit` and `width`. | +| 2026-10-24 | Table Layout › Convert to Text opens its dialog: Separate text with Paragraph marks, Tabs (the default), Commas or Other. With rows selected only those rows become text, and the rows above and below stay tables; the caret alone converts the whole table. The text keeps its formatting and objects, and a table nested in a cell stays a table; `table.toText` gains `other` + `char`. | | 2026-10-16 | Font list entries of fonts with a very tall ascent (Amiri, Amiri Quran) sit in their row like the others instead of low or cut in half (#2570) | | 2026-10-16 | Read Mode draws documents cleanly (#1772): the arrows, Page Up/Down and Escape work as soon as it opens (the canvas kept a stable widget id, so it no longer lost the keyboard), only whole columns show, centred, at any column width, text size or window size, and the columns end above the page-turn bar instead of under it. | | 2026-10-16 | Comment and revision balloons are readable in the dark theme (#2045): they were drawn on a fixed white card under light text; the card, markup area and author colours are now theme tokens with a contrast test. | @@ -453,6 +459,9 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-12 | Paste from other programs keeps their formatting (#687): Ctrl+V and Home › Paste read the system clipboard's HTML (and RTF on Windows and Wayland); Merge Formatting takes the destination's font and paragraph and keeps bold, italic, underline and strikethrough; Paste Special lists what the system clipboard holds (HTML, RTF, picture, text). Linux: pictures copied in a browser or Spectacle paste on KDE and other Wayland desktops (data-control clipboard), and a paste that finds nothing usable names what the clipboard holds instead of "empty" (#833) | | 2026-10-12 | Table style editor (#714): New/Modify Table Style edits each of the 13 table style parts (header row, total row, first/last column, odd/even bands, corner cells) with font, colour, fill, borders and cell alignment; every table using the style follows; all parts round-trip in DOCX; `table.modifyStyle` takes `part`/`parts` and per-edge borders | | 2026-10-12 | STYLEREF fields (#775): running headers and footers show each page's chapter title (page's first, or `\l` last, paragraph of the style, else the nearest before), style names, ids, heading levels and character styles, number switches; body STYLEREF updates with the other fields; STYLEREF in the Field dialog; PDF export follows the layout | +| 2026-10-24 | Document information fields: SUBJECT, KEYWORDS, COMMENTS, LASTSAVEDBY, REVNUM and NUMCHARS update with the other fields and are listed in the Field dialog; a quoted text after TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS replaces that document property (at most 255 characters), and NUMWORDS, NUMCHARS and REVNUM take a `\*` number format | +| 2026-10-24 | Field capitalization switches: `\* Upper`, `\* Lower`, `\* Caps` and `\* FirstCap` apply to the document information, DOCPROPERTY, FILENAME, date, REF, PAGEREF, SEQ, STYLEREF and table formula results that Update Field computes and to the page-number fields, and `\* CardText` and `\* OrdText` results are lowercase unless one of them follows (`\* CardText \* Caps` shows "Seven Hundred Ninety"); a table formula takes a `\*` number format (`=SUM(A1:B2) \* CardText`), and other `\*` switches such as `\* MERGEFORMAT` no longer make it a syntax error | +| 2026-10-24 | Field `\* DollarText` and `\* Hex` switches: `{ = 9.20 + 5.35 \* DollarText \* Upper }` shows "FOURTEEN AND 55/100" and `\* Hex` writes uppercase hexadecimal digits (458 shows "1CA"), in table formulas and in the page-number, SEQ, PAGEREF and count fields; `{ PAGE \* ArabicDash }` shows "- 31 -"; cardinal, ordinal and dollar text past 999,999 keep their digits | | 2026-10-12 | Undo history list (#701): a ▾ beside the Quick Access Toolbar's Undo lists recent steps newest first; hovering highlights what a click undoes ("Undo 3 Actions"), and a click undoes them in one go. `edit.undoTo {count}` and `edit.undoHistory` (newest first, up to 100) for CLI and MCP | | 2026-10-12 | Start screen (#766): launched with nothing to open, WordCraft starts on File › Home (Blank document, templates, Recent) like Word, desktop and web; Esc leaves a blank document; File › Options › General › Start-up turns it off | | 2026-10-13 | No document behind the Start screen (#1129): Esc or Back from it shows an empty window (no page, ribbon greyed out but for Help) until Blank document, a template or a file is opened, as in Word; closing it asks nothing | @@ -619,6 +628,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | Progress log: egui 0.37 migration records the `craft-i18n.rev` catalog pin (9205108…; 27 languages) and the input-method handling; managed CI passed; native acceptance and native-speaker review remain open | | 2026-10-25 | trivial | Progress log: Text Highlight Color buttons show the remembered colour, or no colour bar after No Color (#4087) | | 2026-10-23 | minor | A command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it. | | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | @@ -629,6 +639,8 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | 2026-10-25 | trivial | Highlight decisions include fully selected paragraph marks in whole cells and ordinary ranges, including empty paragraphs; partial text columns exclude unselected marks (#4085). | | 2026-10-25 | trivial | Highlight toggle checks the full target beyond 10,000 runs without collecting resolved formatting; mixed and inherited tails and Undo/Redo covered (#4085). | | 2026-10-24 | trivial | Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). | +| 2026-10-24 | trivial | Progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting | +| 2026-10-24 | trivial | Progress log: the parity catalog counts Read Mode's View menu (Column Width, Page Color, Layout) | | 2026-10-23 | trivial | Progress log: interface translations from storytold/craft-i18n through the `craft-i18n` crate | | 2026-10-23 | trivial | Progress log: comment times in local time (#2322, #2337) | | 2026-10-22 | trivial | Progress log: mail merge record navigation and record ranges for Finish & Merge and Print Documents (#3028) | diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 3031f7420..d3c028067 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -165,6 +165,54 @@ impl Table { self.rows.remove(r); } + /// Split the table before row `at`: the rows from `at` on move to the returned table, which + /// has this table's properties and grid. A vertical merge that the split cuts ends in this + /// table's last row and starts again in the new table's first row. A row content control + /// around rows on both sides closes after this table's last row and opens again (without its + /// `w:id`) before the new table's first row. Content controls that close after this table + /// close after the new one. + pub fn split_off(&mut self, at: usize) -> Table { + let at = at.min(self.rows.len()); + let mut open = Vec::new(); + for row in self.rows.iter().take(at) { + open.extend(row.controls.open.iter().cloned()); + let close = usize::try_from(row.controls.close).unwrap_or(usize::MAX).min(open.len()); + open.truncate(open.len() - close); + } + let mut tail = Table { + props: self.props.clone(), + grid: self.grid.clone(), + rows: self.rows.split_off(at), + controls: ControlWrap { open: Vec::new(), close: std::mem::take(&mut self.controls.close) }, + }; + if let Some(last) = self.rows.last_mut() { + last.controls.close = last.controls.close.saturating_add(u32::try_from(open.len()).unwrap_or(u32::MAX)); + for c in &mut last.cells { + if c.props.vmerge == VMerge::Restart { + c.props.vmerge = VMerge::None; + } + } + } + for c in &mut open { + c.id = None; + } + if let Some(first) = tail.rows.first_mut() { + let own = std::mem::replace(&mut first.controls.open, open); + first.controls.open.extend(own); + } + let starts: Vec<(usize, usize)> = (0..tail.rows.first().map_or(0, |row| row.cells.len())) + .filter(|&c| tail.rows.first().and_then(|row| row.cells.get(c)).is_some_and(|x| x.props.vmerge == VMerge::Continue)) + .map(|c| (c, tail.grid_col(0, c))) + .collect(); + for (c, g) in starts { + let more = tail.cell_at_grid(1, g).and_then(|i| tail.rows.get(1)?.cells.get(i)).is_some_and(|x| x.props.vmerge == VMerge::Continue); + if let Some(x) = tail.rows.first_mut().and_then(|r| r.cells.get_mut(c)) { + x.props.vmerge = if more { VMerge::Restart } else { VMerge::None }; + } + } + tail + } + /// Insert a grid column at grid index `g` (cells spanning it widen instead). pub fn insert_col(&mut self, g: usize) { if self.grid.len() >= MAX_COLS { diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 896fa9e28..81ca0fd0e 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -346,6 +346,9 @@ View|Window|Switch Windows|view.switchWindows View|Macros|Macros|tools.macros View|Macros|Record Macro|tools.recordMacro View|SharePoint|Properties|file.properties +Read Mode|View|Column Width|view.readColumnWidth +Read Mode|View|Page Color|view.readPageColor +Read Mode|View|Layout|view.readLayout Developer|Controls|Rich Text Content Control|developer.richText Developer|Controls|Plain Text Content Control|developer.plainText Developer|Controls|Picture Content Control|developer.picture @@ -591,4 +594,17 @@ mod tests { // The floor only ever rises. assert!(pct >= 90.4, "parity {pct}%"); } + + #[test] + fn read_mode_view_menu_is_catalogued() { + let e = entries(); + let read_mode: Vec<_> = e.iter().filter(|x| x.0 == "Read Mode").map(|x| (x.1, x.2, x.3)).collect(); + assert_eq!( + read_mode, + [("View", "Column Width", "view.readColumnWidth"), ("View", "Page Color", "view.readPageColor"), ("View", "Layout", "view.readLayout")] + ); + let reg = crate::cmd::registry(); + assert!(reg.get("view.readColumnWidth").is_some(), "Column Width counts as live"); + assert!(reg.get("view.readPageColor").is_some(), "Page Color counts as live"); + } } diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index dca3cffb0..e8d66695e 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -286,7 +286,7 @@ fn content_base(s: &Session, r: &ControlRange) -> CharProps { /// and stops showing it. Returns the formatting the text should get, when it changes. pub fn prepare_edit(s: &mut Session) -> Result, CmdError> { if s.sel.is_collapsed() { - let at = s.doc.typing_pos(&s.sel.focus); + let at = super::protect::typing_pos(s, &s.doc.typing_pos(&s.sel.focus)); if at != s.sel.focus { s.sel = Selection::caret(at); } diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index a1824a863..f40c042c3 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -637,6 +637,35 @@ pub(crate) fn protection_lifted(before: &Document, after: &Document) -> bool { b.protection.is_some() && (a.protection != b.protection || a.protect_formatting != b.protect_formatting || a.protect_hash != b.protect_hash) } +/// Where typing at the caret `p` goes in a document that is read only or open to comments only: a +/// caret right before the start of an exception this author may edit goes inside it, and one right +/// after its end goes back inside it. The markers take no room, so the start of a line that an +/// exception starts, and the end of a line that one ends, are those carets. +pub(crate) fn typing_pos(s: &Session, p: &Pos) -> Pos { + if !matches!(s.doc.settings.protection.as_deref(), Some("readOnly" | "comments")) { + return p.clone(); + } + let Some(para) = s.doc.para_at(p) else { return p.clone() }; + if para.objects.is_empty() { + return p.clone(); + } + let editable: Vec = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect(); + let is_start = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id)); + let is_end = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id)); + let w = OBJ.len_utf8(); + let mut off = p.off; + if is_start(off) { + while is_start(off) { + off += w; + } + return Pos { off, ..p.clone() }; + } + while off >= w && is_end(off - w) { + off -= w; + } + Pos { off, ..p.clone() } +} + /// The first position in an editable range after `from` (wrapping round), for Find Next Region /// I Can Edit; with it, the range's end. Filling in forms: the next fillable control's content. pub fn next_region(s: &Session, from: &Pos) -> Option<(Pos, Pos)> { @@ -689,6 +718,49 @@ mod tests { assert_eq!(s.doc.settings.protection.as_deref(), Some("readOnly")); } + /// An exception that starts a line: Home, Left and Down put the caret before its start + /// marker, and Enter and typing there go inside the exception. A caret after the end marker at + /// the end of the line goes back inside it too. + #[test] + fn typing_at_the_start_or_end_of_an_exception_line_goes_inside_it() { + let visible = |s: &Session| s.doc.plain_text(StoryRef::Body).replace(OBJ, ""); + let mut s = Session::new(Document::from_text("Title\nBody text\nEnd.")); + select(&mut s, "Body text"); + s.run("review.restrict", &json!({"exception": "add"})).unwrap(); + s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); + s.run("caret.home", &json!({})).unwrap(); + assert_eq!(s.sel.focus, Pos::body(1, 0), "the caret is before the start marker"); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "New "})).unwrap(); + assert_eq!(visible(&s), "Title\n\nNew Body text\nEnd."); + s.run("caret.end", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "!"})).unwrap(); + s.run("text.newParagraph", &json!({})).unwrap(); + assert_eq!(visible(&s), "Title\n\nNew Body text!\n\nEnd."); + // Outside the exception stays refused: the start of "Title" and the end of "End.". + for at in [Pos::body(0, 0), s.doc.end_of(StoryRef::Body)] { + s.sel = Selection::caret(at); + assert!(s.run("text.newParagraph", &json!({})).is_err()); + assert!(s.run("text.insert", &json!({"text": "x"})).is_err()); + } + + // An empty editable line right after a protected short line, with Built-in Heading styles + // on: two Enters break the paragraph, and the protected line stays Normal text. + let mut s = Session::new(Document::from_text("Title\n\nEnd.")); + s.run("tools.autocorrect", &json!({"headingStyles": true})).unwrap(); + s.sel = Selection { anchor: Pos::body(1, 0), focus: Pos::body(2, 0) }; + s.run("review.restrict", &json!({"exception": "add"})).unwrap(); + s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap(); + s.sel = Selection::caret(Pos::body(0, 2)); + s.run("caret.down", &json!({})).unwrap(); + assert_eq!(s.sel.focus, Pos::body(1, 0), "the caret is before the start marker"); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run("text.newParagraph", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "Notes"})).unwrap(); + assert_eq!(visible(&s), "Title\n\n\nNotes\nEnd."); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.style.is_none())); + } + /// Table, row and cell formatting reaches protected text, so an exception inside one cell /// doesn't let table commands run there change the table, the row or the cell (#1327). #[test] diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index b9b448fc0..c8a4fa183 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -940,12 +940,12 @@ pub fn update_seq(s: &mut Session) -> Result<(), CmdError> { n.0 = n.0.saturating_add(1); } let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default(); - updates.push((k, if f.has('h') { String::new() } else { fmt.format(n.0) })); + updates.push((k, if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) })); } "STYLEREF" => { let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(&s.doc)); if let Some(i) = ix.index_of(&path) { - updates.push((k, ix.eval(instr, wordcraft_layout::styleref::At::Body(i)))); + updates.push((k, text_result(instr, ix.eval(instr, wordcraft_layout::styleref::At::Body(i))))); } } _ => {} @@ -975,10 +975,11 @@ fn has_switch(instr: &str, sw: char) -> bool { }) } -/// Update fields of the story being edited, as Word's F9 does: dates, REF/PAGEREF, TITLE, AUTHOR -/// and NUMWORDS in the body, or in the header, footer, note or text box the caret is in (the body's -/// fields stay as they are then). SEQ numbering, citations and TOC are body-only, so they run only -/// when the caret is in the body. +/// Update fields of the story being edited, as Word's F9 does: dates, REF/PAGEREF, the document +/// information fields (TITLE, SUBJECT, AUTHOR, KEYWORDS, COMMENTS, LASTSAVEDBY, REVNUM, NUMWORDS, +/// NUMCHARS, FILENAME, DOCPROPERTY) in the body, or in the header, footer, note or text box the +/// caret is in (the body's fields stay as they are then). SEQ numbering, citations and TOC are +/// body-only, so they run only when the caret is in the body. pub fn update_fields(s: &mut Session) -> Result<(), CmdError> { update_fields_in(s, false) } @@ -1008,6 +1009,9 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> { } let stories: Vec = if all { std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect() } else { vec![caret_story] }; + // The document properties as the fields read and set them (`SUBJECT "New subject"`): a field + // after one that sets a property shows the new value. + let mut core = s.doc.core.clone(); for (story, path) in stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect::>() { let Some(p) = s.doc.para(story, &path) else { continue }; if !p.objects.iter().any(|o| matches!(o, InlineObject::Field { .. })) { @@ -1034,7 +1038,7 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> { match name.as_str() { "DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => { let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() }); - updates.push((k, super::insert::format_date(&pic))); + updates.push((k, text_result(instr, super::insert::format_date(&pic)))); } "REF" | "PAGEREF" => { let f = FieldCode::parse(instr); @@ -1044,34 +1048,48 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> { } else if !has_switch(instr, 'p') { pages.get(target).map(|(num, page_fmt)| { let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or(*page_fmt); - fmt.format(*num) + wordcraft_layout::fields::field_number(instr, fmt, *num) }) } else { None }; if let Some(r) = r { - updates.push((k, r)); + updates.push((k, if name == "REF" { text_result(instr, r) } else { r })); } } - "TITLE" => updates.push((k, s.doc.core.title.clone())), - "AUTHOR" => updates.push((k, s.doc.core.creator.clone())), - "NUMWORDS" => updates.push((k, s.doc.word_count().to_string())), + // The summary properties; a quoted text after the name replaces the property. + "TITLE" | "SUBJECT" | "AUTHOR" | "KEYWORDS" | "COMMENTS" => { + if let Some(v) = new_property_value(instr) { + set_summary_property(&mut core, &name, v); + } + updates.push((k, text_result(instr, summary_property(&core, &name).to_string()))); + } + "LASTSAVEDBY" => updates.push((k, text_result(instr, core.last_modified_by.clone()))), + "NUMWORDS" => updates.push((k, count_text(instr, s.doc.word_count()))), + "NUMCHARS" => { + let chars = s.doc.plain_text(StoryRef::Body).chars().filter(|c| !c.is_whitespace()).count(); + updates.push((k, count_text(instr, chars))); + } + "REVNUM" => updates.push((k, count_text(instr, core.revision as usize))), // DOCPROPERTY : a custom property first, then a built-in one (#1798). "DOCPROPERTY" => { if let Some(name) = doc_property_arg(instr) - && let Some(v) = doc_property(&s.doc, &name) + && let Some(v) = doc_property(&s.doc, &core, &name) { - updates.push((k, v)); + updates.push((k, text_result(instr, v))); } } "FILENAME" => { let (fn_name, fn_path) = s.resolved_filenames(); let text = if has_switch(instr, 'p') { fn_path } else { fn_name }; - updates.push((k, text)); + updates.push((k, text_result(instr, text))); } _ => {} } } + if core != s.doc.core { + s.doc.core.clone_from(&core); + } if updates.is_empty() { continue; } @@ -1114,7 +1132,7 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) { } // Word's default picture when the code carries no `\@` switch. let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() }); - updates.push((k, super::insert::format_date(&pic))); + updates.push((k, text_result(instr, super::insert::format_date(&pic)))); } if updates.is_empty() { continue; @@ -1130,6 +1148,52 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) { } } +/// The summary property a TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS field shows. +fn summary_property<'a>(core: &'a wordcraft_doc::CoreProps, field: &str) -> &'a str { + match field { + "TITLE" => &core.title, + "SUBJECT" => &core.subject, + "AUTHOR" => &core.creator, + "KEYWORDS" => &core.keywords, + "COMMENTS" => &core.description, + _ => "", + } +} + +fn set_summary_property(core: &mut wordcraft_doc::CoreProps, field: &str, value: String) { + match field { + "TITLE" => core.title = value, + "SUBJECT" => core.subject = value, + "AUTHOR" => core.creator = value, + "KEYWORDS" => core.keywords = value, + "COMMENTS" => core.description = value, + _ => {} + } +} + +/// The new value a summary-property field gives its property: the argument after the field's +/// name (`SUBJECT "Quarterly report"`, or one word without quotes: `SUBJECT Draft`), at most 255 +/// characters as Word allows. +fn new_property_value(instr: &str) -> Option { + FieldCode::parse(instr).args.first().map(|a| a.chars().take(255).collect()) +} + +/// A count (NUMWORDS, NUMCHARS, REVNUM) in the field's `\*` number format, digits without one or +/// when the format does not cover the count (see [`super::table_formula::number_format_covers`]), +/// with the field's capitalization switches. +fn count_text(instr: &str, n: usize) -> String { + let fmt = wordcraft_layout::fields::format_switch(instr) + .filter(|f| super::table_formula::number_format_covers(*f, n as f64)) + .unwrap_or(NumFormat::Decimal); + wordcraft_layout::fields::field_number(instr, fmt, u32::try_from(n).unwrap_or(u32::MAX)) +} + +/// A field's computed text with its capitalization switches (`\* Upper`, `\* Lower`, `\* Caps`, +/// `\* FirstCap`) applied in order. +fn text_result(instr: &str, text: String) -> String { + wordcraft_layout::fields::apply_case_switch(instr, text) +} + /// The property name of a `DOCPROPERTY` field: the word (or quoted text) after the field name, /// before any switch (`DOCPROPERTY "Project Name" \* MERGEFORMAT`). fn doc_property_arg(instr: &str) -> Option { @@ -1143,12 +1207,12 @@ fn doc_property_arg(instr: &str) -> Option { /// The value of a document property under the name a `DOCPROPERTY` field uses: a custom property /// when one has this name, else the built-in property (English names, case-insensitively, as -/// Word's field accepts). `None` for a name this application doesn't know. -fn doc_property(doc: &wordcraft_doc::Document, name: &str) -> Option { +/// Word's field accepts) from `core`. `None` for a name this application doesn't know. +fn doc_property(doc: &wordcraft_doc::Document, core: &wordcraft_doc::CoreProps, name: &str) -> Option { if let Some(v) = doc.custom_prop(name) { return Some(v.to_string()); } - let c = &doc.core; + let c = core; Some(match name.trim().to_ascii_lowercase().as_str() { "title" => c.title.clone(), "subject" => c.subject.clone(), @@ -1482,6 +1546,158 @@ mod tests { assert!(story_fields(&s, StoryRef::Body).iter().all(|(_, r)| r == "stale"), "the body is not touched"); } + /// A session whose body reads "one two three" and whose header holds stale fields with + /// these codes, the caret in the header. + fn header_fields_session(doc: wordcraft_doc::Document, instrs: &[&str]) -> (Session, u32) { + use wordcraft_doc::{CharProps, Paragraph, PartKind}; + let mut p = Paragraph::new(); + for instr in instrs { + let off = p.len(); + let field = + InlineObject::Field { instr: (*instr).into(), result: "stale".into(), locked: false, dirty: false, code: false, form: String::new() }; + p.insert_object(off, field, &CharProps::default()).unwrap(); + } + let mut doc = doc; + let hdr = doc.add_part(PartKind::Header, vec![std::sync::Arc::new(wordcraft_doc::Block::Para(p))]); + let mut s = Session::new(doc); + let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0); + s.sel = crate::session::Selection::caret(Pos::new(StoryRef::Part(hdr), path, 0)); + (s, hdr) + } + + /// SUBJECT, KEYWORDS, COMMENTS, LASTSAVEDBY and REVNUM show the document properties, and + /// NUMCHARS counts the body's characters without spaces; the counts take a `\*` format. + #[test] + fn document_information_fields_show_the_properties() { + let mut doc = wordcraft_doc::Document::from_text("one two three"); + doc.core.subject = "Budget".into(); + doc.core.keywords = "plan; costs".into(); + doc.core.description = "Draft for review".into(); + doc.core.last_modified_by = "Grace".into(); + doc.core.revision = 7; + let codes = ["SUBJECT", "KEYWORDS", "COMMENTS", "LASTSAVEDBY", "REVNUM", r"REVNUM \* ROMAN", "NUMCHARS", r"NUMWORDS \* roman"]; + let (mut s, hdr) = header_fields_session(doc, &codes); + s.run("references.updateFields", &json!({})).unwrap(); + let got: Vec = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect(); + assert_eq!(got, ["Budget", "plan; costs", "Draft for review", "Grace", "7", "VII", "11", "iii"]); + } + + /// A quoted text after TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS replaces the property + /// (at most 255 characters); the field and later ones, DOCPROPERTY too, show the new value. + #[test] + fn summary_fields_with_a_new_value_replace_the_property() { + let mut doc = wordcraft_doc::Document::from_text("one two three"); + doc.core.subject = "Old".into(); + let long = "x".repeat(300); + let long_code = format!("COMMENTS \"{long}\""); + let codes = [ + r#"SUBJECT "Quarterly report""#, + "SUBJECT", + r#"TITLE "Plan" \* MERGEFORMAT"#, + r#"AUTHOR "Ada""#, + r#"KEYWORDS "a, b""#, + &long_code, + "DOCPROPERTY Subject", + ]; + let (mut s, hdr) = header_fields_session(doc, &codes); + s.run("references.updateFields", &json!({})).unwrap(); + let got: Vec = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect(); + assert_eq!(got[..5], ["Quarterly report", "Quarterly report", "Plan", "Ada", "a, b"]); + assert_eq!(got[5].chars().count(), 255); + assert_eq!(got[6], "Quarterly report", "DOCPROPERTY reads the subject the paragraph set"); + let c = &s.doc.core; + assert_eq!((c.subject.as_str(), c.title.as_str(), c.creator.as_str(), c.keywords.as_str()), ("Quarterly report", "Plan", "Ada", "a, b")); + assert_eq!(c.description, got[5]); + } + + /// One word after the name sets the property without quotes; a switch alone sets nothing. + #[test] + fn summary_fields_take_one_word_without_quotes() { + let mut doc = wordcraft_doc::Document::from_text("one two three"); + doc.core.subject = "Old".into(); + doc.core.creator = "Grace".into(); + doc.core.title = "Plan".into(); + let (mut s, hdr) = header_fields_session(doc, &["SUBJECT Draft", "AUTHOR Ada", r"TITLE \* Upper"]); + s.run("references.updateFields", &json!({})).unwrap(); + let got: Vec = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect(); + assert_eq!(got, ["Draft", "Ada", "PLAN"]); + let c = &s.doc.core; + assert_eq!((c.subject.as_str(), c.creator.as_str(), c.title.as_str()), ("Draft", "Ada", "Plan")); + } + + /// `\* Upper`, `\* Lower`, `\* Caps` and `\* FirstCap` apply to the text Update Field computes, + /// and `\* CardText` and `\* OrdText` are lowercase unless one of them follows. + #[test] + fn update_fields_apply_the_capitalization_switches() { + let mut doc = wordcraft_doc::Document::from_text("one two three"); + doc.core.subject = "budget".into(); + doc.core.creator = "Ada LOVELACE".into(); + doc.core.title = "weekly report on sales".into(); + doc.core.description = "weekly report on sales".into(); + doc.core.last_modified_by = "grace".into(); + doc.core.revision = 7; + let codes = [ + r"SUBJECT \* Upper", + r"AUTHOR \* Lower", + r"TITLE \* Caps \* MERGEFORMAT", + r"COMMENTS \*FirstCap", + r"LASTSAVEDBY \* upper", + r"NUMWORDS \* CardText", + r"NUMWORDS \* CardText \* Caps", + r"REVNUM \* OrdText", + r"REVNUM \* OrdText \* FirstCap", + r"DOCPROPERTY Subject \* Upper", + r#"SUBJECT "new \* Upper""#, + ]; + let (mut s, hdr) = header_fields_session(doc, &codes); + s.run("references.updateFields", &json!({})).unwrap(); + let got: Vec = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect(); + assert_eq!( + got, + [ + "BUDGET", + "ada lovelace", + "Weekly Report On Sales", + "Weekly report on sales", + "GRACE", + "three", + "Three", + "seventh", + "Seventh", + "BUDGET", + "new \\* Upper" + ] + ); + } + + /// The capitalization switches also apply to REF, PAGEREF, SEQ and FILENAME results in the body. + #[test] + fn body_fields_apply_the_capitalization_switches() { + let mut s = Session::new(wordcraft_doc::Document::from_text("Bookmark target text here.\nSecond paragraph.")); + s.run("select.text", &json!({"text": "Bookmark target text"})).unwrap(); + s.run("insert.bookmark", &json!({"name": "TargetMark"})).unwrap(); + s.run("caret.docEnd", &json!({})).unwrap(); + for instr in [r"REF TargetMark \* Upper", r"PAGEREF TargetMark \* CardText", r"SEQ Figure \* OrdText \* Caps", r"FILENAME \* Lower"] { + s.run("insert.field", &json!({"instr": instr, "result": "old"})).unwrap(); + } + update_fields(&mut s).unwrap(); + let got: Vec = fields(&s).into_iter().map(|(_, r)| r).collect(); + let name = s.resolved_filenames().0.to_lowercase(); + assert_eq!(got, ["BOOKMARK TARGET TEXT".to_string(), "one".into(), "First".into(), name]); + } + + /// A count the field's number format does not cover keeps its digits. + #[test] + fn counts_past_the_number_format_keep_their_digits() { + let mut doc = wordcraft_doc::Document::from_text("one two three"); + doc.core.revision = 1_000_000; + let codes = [r"REVNUM \* ROMAN", r"REVNUM \* CardText", r"REVNUM \* alphabetic", r"NUMWORDS \* ROMAN"]; + let (mut s, hdr) = header_fields_session(doc, &codes); + s.run("references.updateFields", &json!({})).unwrap(); + let got: Vec = story_fields(&s, StoryRef::Part(hdr)).into_iter().map(|(_, r)| r).collect(); + assert_eq!(got, ["1000000", "1000000", "1000000", "III"]); + } + /// #1706: `all: true` (printing) updates the whole document even with the caret in a header. #[test] fn update_fields_all_covers_every_story() { diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 743212b99..9fcaa98a4 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -36,10 +36,14 @@ pub fn specs() -> Vec { }) .params(r#"{"value"?: bool (omit to toggle)}"#) .key("Mod+Shift+E"), - CommandSpec::new("review.acceptAll", "Accept All Changes", "Review › Changes", |s, _| resolve_all(s, true)), - CommandSpec::new("review.rejectAll", "Reject All Changes", "Review › Changes", |s, _| resolve_all(s, false)), - CommandSpec::new("review.accept", "Accept", "Review › Changes", |s, _| resolve_sel(s, true)), - CommandSpec::new("review.reject", "Reject", "Review › Changes", |s, _| resolve_sel(s, false)), + CommandSpec::new("review.acceptAll", "Accept All Changes", "Review › Changes", |s, v| resolve_all_then(s, v, true)) + .params(r#"{"stopTracking"?: bool (also turn Track Changes off)}"#), + CommandSpec::new("review.rejectAll", "Reject All Changes", "Review › Changes", |s, v| resolve_all_then(s, v, false)) + .params(r#"{"stopTracking"?: bool (also turn Track Changes off)}"#), + CommandSpec::new("review.accept", "Accept", "Review › Changes", |s, v| resolve_sel_then(s, v, true)) + .params(r#"{"next"?: bool (then select the next change)}"#), + CommandSpec::new("review.reject", "Reject", "Review › Changes", |s, v| resolve_sel_then(s, v, false)) + .params(r#"{"next"?: bool (then select the next change)}"#), CommandSpec::new("review.nextChange", "Next Change", "Review › Changes", |s, _| nav_change(s, 1)).pure(), CommandSpec::new("review.previousChange", "Previous Change", "Review › Changes", |s, _| nav_change(s, -1)).pure(), CommandSpec::new("review.markup", "Display for Review", "Review › Tracking", |s, v| { @@ -588,7 +592,21 @@ fn resolve_all(s: &mut Session, accept: bool) -> CmdResult { sel_result(s) } -fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult { +/// Accept or Reject All; with `stopTracking`, Track Changes is turned off as well. +fn resolve_all_then(s: &mut Session, v: &Value, accept: bool) -> CmdResult { + let out = resolve_all(s, accept)?; + if p::bool(v, "stopTracking") == Some(true) { + s.doc.settings.track_changes = false; + } + Ok(out) +} + +/// Accept or Reject at the selection; with `next`, the next change is selected afterwards. +fn resolve_sel_then(s: &mut Session, v: &Value, accept: bool) -> CmdResult { + resolve_sel(s, accept, p::bool(v, "next") == Some(true)) +} + +fn resolve_sel(s: &mut Session, accept: bool, next: bool) -> CmdResult { let (a, b) = s.sel.ordered(); // A caret right before a tracked paragraph mark (and no tracked text around it): that mark. let mut caret_mark = None; @@ -650,7 +668,7 @@ fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult { } None => { if resolve_cell(s, a.story, &a.path, accept)? { - return sel_result(s); + return if next { select_next_unresolved(s) } else { sel_result(s) }; } return nav_change(s, 1); } @@ -675,7 +693,7 @@ fn resolve_sel(s: &mut Session, accept: bool) -> CmdResult { super::moves::resolve(s, &moves, accept)?; s.sel = Selection::caret(a); s.clamp_selection(); - sel_result(s) + if next { select_next_unresolved(s) } else { sel_result(s) } } fn changes(s: &Session) -> Vec { @@ -864,6 +882,18 @@ fn nav_change(s: &mut Session, dir: i32) -> CmdResult { sel_result(s) } +/// After a change at the caret is resolved: select the first change from the caret on, including +/// one that starts and ends there (an empty paragraph's, a paragraph mark's or a table's +/// formatting), else the first change in the document. +fn select_next_unresolved(s: &mut Session) -> CmdResult { + let list = changes(s); + let caret = s.sel.ordered().0; + if let Some(c) = list.iter().find(|c| c.a >= caret).or(list.first()) { + s.sel = Selection { anchor: c.a.clone(), focus: c.b.clone() }; + } + sel_result(s) +} + fn list_changes(s: &mut Session, _: &Value) -> CmdResult { let list = changes(s); Ok(Value::Array( @@ -1675,6 +1705,119 @@ mod tests { } } + /// "one two three" with "new " typed at the start, "two " deleted and " end" typed at the end, + /// under Track Changes: three changes. + fn three_changes() -> Session { + let mut s = Session::new(Document::new()); + s.view.proofing = false; + s.author = "Ana".into(); + s.run("document.setText", &json!({"text": "one two three"})).unwrap(); + s.run("review.trackChanges", &json!({"value": true})).unwrap(); + s.run("caret.docStart", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "new "})).unwrap(); + s.run("select.range", &json!({"anchor": {"block": 0, "off": 8}, "focus": {"block": 0, "off": 12}})).unwrap(); + s.run("text.delete", &json!({})).unwrap(); + s.run("caret.docEnd", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": " end"})).unwrap(); + s + } + + fn change_spans(s: &mut Session) -> Vec<(String, u64, u64)> { + let list = s.run("review.changes", &json!({})).unwrap(); + list.as_array() + .unwrap() + .iter() + .map(|c| (c["kind"].as_str().unwrap().to_string(), c["start"]["off"].as_u64().unwrap(), c["end"]["off"].as_u64().unwrap())) + .collect() + } + + fn selected(s: &Session) -> (usize, usize) { + let (a, b) = s.sel.ordered(); + (a.off, b.off) + } + + /// Accept and Move to Next, Reject and Move to Next: the change at the caret is resolved and + /// the next one is selected; without `next` the caret stays where the change was. + #[test] + fn accept_and_reject_can_move_to_the_next_change() { + for (cmd, accept) in [("review.accept", true), ("review.reject", false)] { + let mut s = three_changes(); + let before = change_spans(&mut s); + assert_eq!(before.iter().map(|c| c.0.as_str()).collect::>(), ["insert", "delete", "insert"], "{cmd}"); + s.run("caret.docStart", &json!({})).unwrap(); + s.run(cmd, &json!({"next": true})).unwrap(); + let after = change_spans(&mut s); + assert_eq!(after.len(), 2, "{cmd}: the first insertion is resolved"); + assert_eq!(after[0].0, "delete", "{cmd}"); + let deletion = (after[0].1 as usize, after[0].2 as usize); + assert_eq!(selected(&s), deletion, "{cmd}: the deletion is selected next"); + assert_eq!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).starts_with("new "), accept, "{cmd}"); + + // Without `next`, the caret stays at the resolved change. + let mut s = three_changes(); + s.run("caret.docStart", &json!({})).unwrap(); + s.run(cmd, &json!({})).unwrap(); + assert_eq!(selected(&s), (0, 0), "{cmd}"); + } + } + + /// With the caret on no change, Accept and Move to Next selects the next change without + /// resolving it, as Accept and Next Change do. + #[test] + fn accept_and_move_to_next_off_a_change_selects_it_unresolved() { + let mut s = three_changes(); + s.run("select.range", &json!({"anchor": {"block": 0, "off": 5}, "focus": {"block": 0, "off": 5}})).unwrap(); + s.run("review.accept", &json!({"next": true})).unwrap(); + let after = change_spans(&mut s); + assert_eq!(after.len(), 3, "nothing is resolved"); + assert_eq!(selected(&s), (after[1].1 as usize, after[1].2 as usize), "the deletion after the caret is selected"); + } + + /// Reject and Move to Next on the only text of a centered paragraph: the paragraph's formatting + /// change, now empty at the caret, is selected next, not the insertion after it. + #[test] + fn move_to_next_selects_a_change_left_at_the_caret() { + let mut s = Session::new(Document::new()); + s.view.proofing = false; + s.author = "Ana".into(); + s.run("document.setText", &json!({"text": "one\n\nthree"})).unwrap(); + s.run("review.trackChanges", &json!({"value": true})).unwrap(); + s.run("select.range", &json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 1, "off": 0}})).unwrap(); + s.run("para.align", &json!({"value": "center"})).unwrap(); + s.run("text.insert", &json!({"text": "x"})).unwrap(); + s.run("caret.docEnd", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "!"})).unwrap(); + let before = change_spans(&mut s); + assert_eq!(before.iter().map(|c| c.0.as_str()).collect::>(), ["format", "insert", "insert"], "{before:?}"); + s.run("select.range", &json!({"anchor": {"block": 1, "off": 1}, "focus": {"block": 1, "off": 1}})).unwrap(); + s.run("review.reject", &json!({"next": true})).unwrap(); + let after = change_spans(&mut s); + assert_eq!(after.iter().map(|c| c.0.as_str()).collect::>(), ["format", "insert"], "{after:?}"); + let (a, b) = s.sel.ordered(); + let at = (wordcraft_doc::Path::top(1), 0); + assert_eq!(((a.path.clone(), a.off), (b.path.clone(), b.off)), (at.clone(), at), "the centering is selected"); + } + + /// Accept All Changes and Stop Tracking, Reject All Changes and Stop Tracking: every change is + /// resolved and Track Changes is off; plain Accept All and Reject All leave it on. + #[test] + fn accept_and_reject_all_can_stop_tracking() { + for cmd in ["review.acceptAll", "review.rejectAll"] { + let mut s = three_changes(); + s.run(cmd, &json!({"stopTracking": true})).unwrap(); + assert!(change_spans(&mut s).is_empty(), "{cmd}"); + assert!(!s.doc.settings.track_changes, "{cmd}: Track Changes is off"); + s.run("edit.undo", &json!({})).unwrap(); + assert!(s.doc.settings.track_changes, "{cmd}: one undo step brings tracking and the changes back"); + assert_eq!(change_spans(&mut s).len(), 3, "{cmd}"); + + let mut s = three_changes(); + s.run(cmd, &json!({})).unwrap(); + assert!(change_spans(&mut s).is_empty(), "{cmd}"); + assert!(s.doc.settings.track_changes, "{cmd}: Track Changes stays on"); + } + } + /// "plain words here" with "words" made bold, then 14 pt, and the paragraph centred, under /// Track Changes by Ana. fn formatted_while_tracking() -> Session { diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 3c53ab85b..b1e6942f0 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -4,7 +4,7 @@ use super::table_formula::parse_number; use serde_json::{Value, json}; use wordcraft_doc::collate::{SortKey, sort_key}; use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, HeightRule, Rgb, TextDirection, VAlign}; -use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, RevisionKind, StoryRef, Table, para_block}; +use wordcraft_doc::{Block, Document, InlineObject, Paragraph, Path, Pos, RevisionKind, StoryRef, Table, para_block}; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; @@ -171,7 +171,8 @@ pub fn specs() -> Vec { .params(r#"{"text"?: string, "title"?: string} (empty clears)"#)), t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table) .params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)), - t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text).params(r#"{"separator"?: "tab|comma|paragraph"}"#)), + t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text) + .params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other)} (the selected rows, or the whole table)"#)), t(CommandSpec::new("table.formula", "Formula", "Table Layout › Data", formula).params(r#"{"formula"?: "=SUM(ABOVE)"}"#)), t(CommandSpec::new("table.selectTable", "Select Table", "Table Layout › Table › Select", |s, _| { let (tp, _, _) = cell(s)?; @@ -844,11 +845,7 @@ fn split_table(s: &mut Session, _: &Value) -> CmdResult { } let story = s.sel.focus.story; let t = s.doc.table_mut(story, &tp)?; - let tail_rows = t.rows.split_off(r); - // Content controls closing after the table now close after its second part. - let close = std::mem::take(&mut t.controls.close); - let controls = wordcraft_doc::ControlWrap { open: Vec::new(), close }; - let tail = Table { props: t.props.clone(), grid: t.grid.clone(), rows: tail_rows, controls }; + let tail = t.split_off(r); let after = tp.with_last(tp.last() + 1); s.doc.insert_block(story, &after, Block::Para(Paragraph::new()))?; s.doc.insert_block(story, &after.with_last(after.last() + 1), Block::Table(tail))?; @@ -948,26 +945,153 @@ fn sort_table(s: &mut Session, v: &Value) -> CmdResult { }) } +/// Table Layout › Data › Convert to Text: the selected rows, or the whole table with the caret +/// in one cell, become paragraphs. Each row is one paragraph with its cells set apart by the +/// separator (a tab, a comma or another character); with `paragraph` each cell paragraph +/// stays a separate paragraph. The text keeps its formatting and objects, a table nested in a cell stays +/// a table, and rows above and below the selected ones stay a table each. fn to_text(s: &mut Session, v: &Value) -> CmdResult { let (tp, _, _) = cell(s)?; - let sep = match p::str(v, "separator").unwrap_or("tab") { - "comma" => ",", - "paragraph" => "\n", - _ => "\t", + let sep = match p::str(v, "separator") { + None | Some("tab") => Some('\t'), + Some("comma") => Some(','), + Some("paragraph") => None, + Some("other") => match p::str(v, "char").and_then(|c| c.chars().next()) { + // U+FFFC marks an object in the text, so it cannot set the cells apart. + Some(c) if c != '\n' && c != '\r' && c != wordcraft_doc::para::OBJ => Some(c), + _ => return Err(CmdError::Params("`char` (the separating character) is required with `other`".into())), + }, + Some(x) => return Err(CmdError::Params(format!("unknown separator `{x}`"))), }; let story = s.sel.focus.story; - let t = s.doc.table(story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?; + let mut t = s.doc.table(story, &tp).cloned().ok_or_else(|| CmdError::Failed("no table".into()))?; + // Several cells selected: their rows. A caret (or a selection inside one cell): every row. + let cells = selected_cells(s); + let (r0, r1) = match (cells.iter().map(|x| x.0).min(), cells.iter().map(|x| x.0).max()) { + (Some(a), Some(b)) if cells.len() > 1 || s.cell_range().is_some() => (a, b.min(t.rows.len().saturating_sub(1))), + _ => (0, t.rows.len().saturating_sub(1)), + }; + // `split_off` ends the vertical merges and row content controls that a cut crosses, and opens + // them again (without their ids) below it. + let mut tail = t.split_off(r1 + 1); + let rows = t.split_off(r0).rows; + t.controls.close = std::mem::take(&mut tail.controls.close); + let mut blocks = Vec::new(); + if !t.rows.is_empty() { + let mut head = Table { props: t.props.clone(), grid: t.grid.clone(), rows: std::mem::take(&mut t.rows), controls: Default::default() }; + head.controls.open = std::mem::take(&mut t.controls.open); + blocks.push(Block::Table(head)); + } + let text_from = blocks.len(); + for row in rows { + row_to_text(row, sep, &mut blocks); + } + // Rows without cells (only from a damaged file) still leave a paragraph in their place. + if blocks.len() == text_from { + blocks.push(Block::Para(Paragraph::new())); + } + // The caret goes to the first converted paragraph (into a nested table when there is none). + let caret = match blocks.iter().enumerate().skip(text_from).find(|(_, b)| matches!(b, Block::Para(_))) { + Some((k, _)) => tp.with_last(tp.last() + k), + None => Path(tp.with_last(tp.last() + text_from).0.into_iter().chain([0, 0, 0]).collect()), + }; + if !tail.rows.is_empty() { + tail.controls.close = std::mem::take(&mut t.controls.close); + // A floating table keeps its position in the part above; the part below goes in the text + // flow. + if text_from > 0 { + tail.props.float = None; + } + blocks.push(Block::Table(tail)); + } + // A content control around the table keeps both ends: on the text where no table part is + // left to carry them. + let (open, close) = (std::mem::take(&mut t.controls.open), std::mem::take(&mut t.controls.close)); + match blocks.get_mut(text_from) { + Some(Block::Para(p)) => { + for (k, mut control) in open.into_iter().enumerate() { + control.block = true; + let start = InlineObject::ControlStart { control: Box::new(control) }; + p.insert_object(k * wordcraft_doc::para::OBJ.len_utf8(), start, &Default::default())?; + } + } + Some(Block::Table(x)) => { + let inner = std::mem::replace(&mut x.controls.open, open); + x.controls.open.extend(inner); + } + None => {} + } + match blocks.last_mut() { + Some(Block::Para(p)) => { + // The count is read from the file; at most 1,024 ends go onto the text. + for _ in 0..close.min(1024) { + let end = p.len(); + p.insert_object(end, InlineObject::ControlEnd, &Default::default())?; + } + } + Some(Block::Table(x)) => x.controls.close = x.controls.close.saturating_add(close), + None => {} + } s.doc.remove_block(story, &tp)?; - let mut i = tp.last(); - for row in &t.rows { - let text: Vec = row.cells.iter().map(cell_text).collect(); - for line in text.join(sep).split('\n') { - s.doc.insert_block(story, &tp.with_last(i), Block::Para(Paragraph::with_text(line, Default::default())))?; - i += 1; + for (k, b) in blocks.into_iter().enumerate() { + s.doc.insert_block(story, &tp.with_last(tp.last() + k), b)?; + } + s.sel = Selection::caret(s.doc.clamp(&Pos { story, path: caret, off: 0 })); + sel_result(s) +} + +/// One row of Convert to Text as blocks: a paragraph per row (and per further paragraph in a +/// cell) with the cells set apart by `sep`, or each cell's blocks as they are without one. +fn row_to_text(row: wordcraft_doc::Row, sep: Option, out: &mut Vec) { + let Some(sep) = sep else { + for b in row.cells.into_iter().flat_map(|c| c.blocks) { + out.push(match std::sync::Arc::unwrap_or_clone(b) { + Block::Para(mut para) => { + para.section = None; + para.touch(); + Block::Para(para) + } + other => other, + }); + } + return; + }; + let mut line: Option = None; + for (ci, cell) in row.cells.into_iter().enumerate() { + if ci > 0 { + // The separator takes no formatting from the text before it. + line.get_or_insert_with(Paragraph::new).append(Paragraph::with_text(sep.encode_utf8(&mut [0; 4]), Default::default())); + } + let mut first = true; + for b in cell.blocks { + match std::sync::Arc::unwrap_or_clone(b) { + Block::Para(mut para) => { + para.section = None; + match line.as_mut() { + Some(p) if first => p.append(para), + _ => { + if let Some(done) = line.take() { + out.push(Block::Para(done)); + } + para.touch(); + line = Some(para); + } + } + } + other => { + // A nested table stays a table between the row's paragraphs. + if let Some(done) = line.take() { + out.push(Block::Para(done)); + } + out.push(other); + } + } + first = false; } } - s.sel = Selection::caret(Pos { story, path: tp, off: 0 }); - sel_result(s) + if let Some(done) = line { + out.push(Block::Para(done)); + } } /// Table Layout › Data › Formula: inserts a formula field at the caret, evaluated now and again @@ -998,8 +1122,8 @@ fn formula(s: &mut Session, v: &Value) -> CmdResult { Ok(json!({"result": text, "formula": instr})) } -/// A formula as a field stores it: upper case, with one leading `=`. A `\#` numeric picture is -/// kept as typed. +/// A formula as a field stores it: upper case, with one leading `=`. The `\#` numeric picture and +/// the `\*` switches are kept as typed. pub(crate) fn formula_instr(raw: &str) -> String { let body = raw.trim(); let body = body.strip_prefix('=').unwrap_or(body); diff --git a/crates/engine/src/cmd/table_formula.rs b/crates/engine/src/cmd/table_formula.rs index 542a4e3d2..6e6342d7b 100644 --- a/crates/engine/src/cmd/table_formula.rs +++ b/crates/engine/src/cmd/table_formula.rs @@ -59,7 +59,8 @@ pub fn format_result(v: f64) -> String { } /// The text a formula field shows: its result under its `\#` numeric picture when it has one (see -/// [`format_picture`]), otherwise as [`format_result`] shows it. +/// [`format_picture`]), otherwise as [`format_result`] shows it, and then under its `\*` switches: +/// a number format such as `\* CardText`, then a capitalization switch such as `\* Upper`. pub fn evaluate_field(t: &Table, r: usize, c: usize, instr: &str) -> Result { evaluate_field_with(t, None, None, r, c, instr) } @@ -74,7 +75,7 @@ pub fn evaluate_field_with( ) -> Result { let (formula, switch) = split_switch(instr); let value = evaluate_with(t, doc, default_lang, r, c, formula)?; - Ok(match switch.and_then(picture_of) { + let text = match switch.and_then(picture_of) { Some(picture) => format_picture(value, &picture), None => { let out_lang = doc @@ -97,21 +98,63 @@ pub fn evaluate_field_with( .or_else(|| default_lang.map(str::to_string)); wordcraft_doc::locale_number::format_number(value, out_lang.as_deref()) } + }; + Ok(match switch { + Some(switch) => format_switches(switch, value, text), + None => text, }) } -/// Splits a field's `\#` switch off its formula: `=SUM(ABOVE) \# "0.00"` gives the formula and the -/// switch text (from the `\#` on). +/// The text of a formula result under the field's `\*` switches, where `text` is `value` formatted +/// with the `\#` picture, or in the cell's language when there is no picture. A number format such +/// as `\* CardText`, `\* OrdText` or `\* roman` writes a whole number the format covers (up to +/// 999,999 for CardText and OrdText, 1 to 50,000 for roman, 1 to 780 for alphabetic), in lowercase +/// for CardText and OrdText. `\* Hex` writes a whole number from 0 to 4,294,967,295 in uppercase +/// hexadecimal digits. `\* DollarText` writes a result from 0 to 999,999.99 in lowercase cardinal +/// text with its first two decimals (rounded) over 100 (see +/// [`wordcraft_layout::fields::dollar_text`]). Any other result keeps its digits. The capitalization +/// switches (`\* Upper`, `\* Lower`, `\* Caps`, `\* FirstCap`) then apply to the text in order. Other +/// `\*` switches leave the text as it is. Quoted text, such as a `\#` picture, is not a switch. +fn format_switches(switches: &str, value: f64, text: String) -> String { + use wordcraft_doc::section::NumFormat; + let unquoted = switches.split('"').step_by(2).collect::>().join(" "); + let fmt = wordcraft_layout::fields::format_switch(&unquoted).filter(|f| *f != NumFormat::Decimal); + match fmt { + Some(NumFormat::DollarText) => { + let text = wordcraft_layout::fields::dollar_text(value).unwrap_or(text); + wordcraft_layout::fields::apply_case_switch(switches, text) + } + Some(fmt) if number_format_covers(fmt, value) => wordcraft_layout::fields::field_number(switches, fmt, value as u32), + _ => wordcraft_layout::fields::apply_case_switch(switches, text), + } +} + +/// Whether the `\*` number format `fmt` writes `value`: a whole number up to 999,999 for CardText +/// and OrdText, 1 to 50,000 for roman, 1 to 780 for alphabetic, and 0 to `u32::MAX` otherwise. +pub(crate) fn number_format_covers(fmt: wordcraft_doc::section::NumFormat, value: f64) -> bool { + use wordcraft_doc::section::NumFormat; + let range = match fmt { + NumFormat::CardinalText | NumFormat::OrdinalText => 0.0..=999_999.0, + NumFormat::LowerRoman | NumFormat::UpperRoman => 1.0..=50_000.0, + NumFormat::LowerLetter | NumFormat::UpperLetter => 1.0..=780.0, + _ => 0.0..=f64::from(u32::MAX), + }; + value.fract() == 0.0 && range.contains(&value) +} + +/// Splits a field's switches (`\#` and `\*`) off its formula: `=SUM(ABOVE) \# "0.00"` gives the +/// formula and the switch text (from the first switch on). pub fn split_switch(instr: &str) -> (&str, Option<&str>) { - match instr.find("\\#") { + match [instr.find("\\#"), instr.find("\\*")].into_iter().flatten().min() { Some(at) => (instr[..at].trim_end(), Some(&instr[at..])), None => (instr, None), } } -/// The picture a `\#` switch names: a quoted one (`"#,##0.00"`) or a single word. +/// The picture of the `\#` switch in a field's switch text: a quoted one (`"#,##0.00"`) or a +/// single word. fn picture_of(switch: &str) -> Option { - let rest = switch.strip_prefix("\\#")?.trim_start(); + let rest = switch[switch.find("\\#")? + 2..].trim_start(); let picture = match rest.strip_prefix('"') { Some(quoted) => quoted.split('"').next().unwrap_or_default(), None => rest.split_whitespace().next().unwrap_or_default(), @@ -954,6 +997,46 @@ mod tests { assert_eq!(shown("=A1 \\# \"\""), "1234.50", "an empty picture is none"); } + #[test] + fn a_format_switch_writes_the_result_as_text_and_sets_its_capitals() { + let t = table(&[&["790"], &["1.5"], &[""]]); + assert_eq!(split_switch("=A1 \\* Caps \\# 0"), ("=A1", Some("\\* Caps \\# 0"))); + let shown = |f: &str| evaluate_field(&t, 2, 0, f).unwrap_or_else(|e| panic!("{f}: {e}")); + assert_eq!(shown("=A1 \\* CardText"), "seven hundred ninety"); + assert_eq!(shown("=A1 \\* CardText \\* Caps"), "Seven Hundred Ninety"); + assert_eq!(shown("=A1 \\* OrdText \\* FirstCap"), "Seven hundred ninetieth"); + assert_eq!(shown("=A1 \\* roman"), "dccxc"); + assert_eq!(shown("=A1 \\* MERGEFORMAT"), "790", "a switch that formats nothing"); + assert_eq!(shown("=A1 \\# \"#,##0.00\" \\* MERGEFORMAT"), "790.00"); + assert_eq!(shown("=A1 \\* MERGEFORMAT \\# 0.0"), "790.0", "a picture after a format switch"); + assert_eq!(shown("=A1 \\# \"0 \\* CardText items\""), "790 \\* CardText items", "a switch inside the picture is text"); + assert_eq!(shown("=A2 \\* CardText"), "1.50", "a fraction keeps its digits"); + assert_eq!(shown("=A1-800 \\* CardText"), "-10", "a negative number keeps its digits"); + assert_eq!(shown("=A1*1266 \\* CardText"), "1000140", "a number past CardText keeps its digits"); + assert_eq!(shown("=A1*1266 \\* OrdText \\* Upper"), "1000140"); + assert_eq!(shown("=A1*64 \\* roman"), "50560", "a number past roman keeps its digits"); + assert_eq!(shown("=A1-790 \\* roman"), "0", "roman has no zero"); + assert_eq!(shown("=A1 \\* alphabetic"), "790", "a number past alphabetic keeps its digits"); + } + + #[test] + fn dollar_text_and_hex_switches_write_the_result() { + let t = table(&[&["9.20", "5.35"], &["458", "-1"], &["", ""]]); + let shown = |f: &str| evaluate_field(&t, 2, 0, f).unwrap_or_else(|e| panic!("{f}: {e}")); + assert_eq!(shown("= 9.20 + 5.35 \\* DollarText \\* Upper"), "FOURTEEN AND 55/100"); + assert_eq!(shown("=A1+B1 \\* DollarText"), "fourteen and 55/100"); + assert_eq!(shown("=A2 \\* DollarText \\* FirstCap"), "Four hundred fifty-eight and 00/100"); + assert_eq!(shown("=0.999 \\* DollarText"), "one and 00/100"); + assert_eq!(shown("=A2 \\* Hex"), "1CA"); + assert_eq!(shown("=A2 \\* Hex \\* Lower"), "1ca"); + assert_eq!(shown("=A2*0 \\* Hex"), "0"); + assert_eq!(shown("=B2 \\* DollarText"), shown("=B2"), "a negative number keeps its digits"); + assert_eq!(shown("=B2 \\* Hex"), shown("=B2")); + assert_eq!(shown("=A2/4 \\* Hex"), shown("=A2/4"), "a fraction keeps its digits"); + assert_eq!(shown("=A2*2200 \\* DollarText"), shown("=A2*2200"), "a number past DollarText keeps its digits"); + assert_eq!(shown("=A2 \\* ArabicDash"), "- 458 -"); + } + #[test] fn a_huge_range_over_a_small_table_reads_only_the_table() { let t = table(&[&["1", "2", "3"], &["4", "5", "6"], &["", "", ""]]); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index ed72d5cf7..c8910372f 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -269,7 +269,7 @@ fn border_line(s: &Session, at: &Pos) -> Option { || at.off != text.len() || text.len() < 3 || !text.chars().all(|x| x == c) - || para.props.numbering.is_some_and(|n| n.num != 0) + || s.doc.styles.resolve_para(¶.props).numbering.is_some_and(|n| n.num != 0) || para.run_ranges().any(|(_, r)| r.del.is_some()) { return None; @@ -296,7 +296,7 @@ fn table_line(s: &Session, at: &Pos) -> Option> { || at.story != StoryRef::Body || at.path.0.len() != 1 || at.off != text.len() - || para.props.numbering.is_some_and(|n| n.num != 0) + || s.doc.styles.resolve_para(¶.props).numbering.is_some_and(|n| n.num != 0) || para.run_ranges().any(|(_, c)| c.del.is_some()) { return None; @@ -722,6 +722,31 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult { } // Enter finishes a word like a space does ("teh" → "the", sentence capitals, links). if s.sel.is_collapsed() && at.off > 0 { + // A `*bold*` or `_italic_` word: like the border lines above, Undo right after brings + // the marks back and the Enter stays. + let plain = super::tools::has_emphasis(s, true).then(|| (s.doc.clone(), s.sel.clone())); + if let Some((doc, sel)) = plain { + // Removes the marks, then AutoCorrects the word as without them. + super::tools::autocorrect_word(s, true)?; + s.autocap.take_if(|c| c.depth == u64::MAX); + let (done_doc, done_sel) = (std::mem::replace(&mut s.doc, doc), std::mem::replace(&mut s.sel, sel)); + let new = split_para(s, &at)?; + let c = s.doc.para_at(&new).map(|p| p.mark.clone()).unwrap_or_default(); + s.sel = Selection::caret(new); + let plain_doc = std::mem::replace(&mut s.doc, done_doc); + let plain_sel = std::mem::replace(&mut s.sel, done_sel); + let new = split_para(s, &s.sel.focus.clone())?; + let p = s.doc.para_mut(new.story, &new.path)?; + // The new paragraph is formatted as after a plain Enter (the closing mark's + // formatting, or none when the next style starts afresh); its mark keeps its revisions. + let (ins, del, fmt_change) = (p.mark.ins, p.mark.del, p.mark.fmt_change.take()); + p.mark = wordcraft_doc::props::CharProps { ins, del, fmt_change, ..c }; + p.touch(); + s.sel = Selection::caret(new); + s.goal_x = None; + s.push_undo("AutoFormat", plain_doc, plain_sel); + return sel_result(s); + } super::tools::autocorrect_word(s, true)?; s.autocap.take_if(|c| c.depth == u64::MAX); at = s.sel.focus.clone(); diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 75828c956..1a00aa70e 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -93,6 +93,9 @@ pub struct AutoCorrectPrefs { pub fractions: bool, /// Ordinals (1st) with superscript. pub ordinals: bool, + /// *Bold* and _italic_ with real formatting: the marks are removed and the text between them + /// is made bold or italic. Off unless the user turns it on. + pub bold_italic: bool, /// Hyphens (--) with dashes. pub dashes: bool, /// Internet addresses with hyperlinks. @@ -141,6 +144,7 @@ impl Default for AutoCorrectPrefs { smart_quotes: true, fractions: true, ordinals: true, + bold_italic: false, dashes: true, links: true, bullets: true, @@ -332,6 +336,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult { ("smartQuotes", &mut ac.smart_quotes), ("fractions", &mut ac.fractions), ("ordinals", &mut ac.ordinals), + ("boldItalic", &mut ac.bold_italic), ("dashes", &mut ac.dashes), ("links", &mut ac.links), ("bullets", &mut ac.bullets), @@ -407,6 +412,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult { "smartQuotes": ac.smart_quotes, "fractions": ac.fractions, "ordinals": ac.ordinals, + "boldItalic": ac.bold_italic, "dashes": ac.dashes, "links": ac.links, "bullets": ac.bullets, @@ -451,7 +457,7 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options) .params( - r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#, + r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#, ) .pure(), CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard) @@ -891,6 +897,8 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> if !s.prefs.autocorrect.enabled { return Ok(()); } + // The word without its marks goes on through the steps below ("*teh* " → "The "). + let outside = emphasis_autoformat(s, on_enter)?; let ac = s.prefs.autocorrect.clone(); let mut f = s.sel.focus.clone(); let Some(text) = s.doc.para_at(&f).map(|p| p.text.clone()) else { return Ok(()) }; @@ -903,11 +911,20 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> let Some(body) = before.get(..before.len() - trigger) else { return Ok(()) }; // The word starts after the last white-space character, whatever its width (a no-break // space is two bytes and an ideographic space is three). - let start = body.char_indices().rev().find(|(_, c)| c.is_whitespace()).map(|(i, c)| i + c.len_utf8()).unwrap_or(0); + let mut start = body.char_indices().rev().find(|(_, c)| c.is_whitespace()).map(|(i, c)| i + c.len_utf8()).unwrap_or(0); let Some(mut word) = body.get(start..).map(str::to_string) else { return Ok(()) }; if word.is_empty() { return Ok(()); } + // The opening mark of a `*bold*` or `_italic_` span is not part of the word, so the first + // word of a span of several words is corrected as it is typed ("*teh end*" → "*the end*"). + let mark = word.chars().next().filter(|c| ac.bold_italic && (*c == '*' || *c == '_')); + let mut opened = false; + if let Some(rest) = mark.and_then(|m| word.get(1..).filter(|r| !r.is_empty() && !r.contains(m))) { + start += 1; + word = rest.to_string(); + opened = true; + } // The word's language (run, paragraph style, document default) picks the replacement list // (#835) and the case mapping (#1923). Text with no language at all is in the editing // language (a Polish interface writes Polish), else English. @@ -992,14 +1009,16 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> c.link = Some(href.clone()); c.style = Some("Hyperlink".into()); })?; - // Typing after the link isn't part of it (but keeps the text's other formatting). - let mut after = s.doc.para_at(&f).map(|p| p.props_of_char(start).clone()).unwrap_or_default(); + // Typing after the link isn't part of it (but keeps the text's other formatting, or + // the formatting outside a `*bold*` or `_italic_` span). + let mut after = outside.unwrap_or_else(|| s.doc.para_at(&f).map(|p| p.props_of_char(start).clone()).unwrap_or_default()); after.link = None; after.style = None; s.pending = Some(after); return Ok(()); } - let before_word = text.get(..start).unwrap_or(""); + // A sentence starts before the opening mark, as for the word without it. + let before_word = text.get(..start - usize::from(opened)).unwrap_or(""); // The first word in a table cell follows its own option; other sentences the sentence one. let sentence_on = if before_word.trim().is_empty() && f.path.cell().is_some() { ac.cap_cells } else { ac.cap_sentences }; let exceptions = ac.exception_list(); @@ -1129,6 +1148,87 @@ fn case_fix(caps: &AutoCorrectPrefs, word: &str, turkic: bool) -> Option<(usize, None } +/// *Bold* and _italic_ with real formatting, for the word ending at the caret (`on_enter` as for +/// [`autocorrect_word`]): the two marks are removed and the text between them is made bold or +/// italic, for plain and complex-script text. Returns the closing mark's formatting, which is +/// the formatting outside the span, when anything changed. +pub fn emphasis_autoformat(s: &mut Session, on_enter: bool) -> Result, CmdError> { + let Some((open, close, bold)) = emphasis_at_caret(s, on_enter) else { return Ok(None) }; + let f = s.sel.focus.clone(); + let para = s.doc.para_mut(f.story, &f.path)?; + let outside = para.props_of_char(close).clone(); + para.delete(close, close + 1)?; + para.delete(open, open + 1)?; + para.format(open, close - 1, &|c| { + if bold { + c.bold = Some(true); + c.bold_cs = Some(true); + } else { + c.italic = Some(true); + c.italic_cs = Some(true); + } + })?; + s.sel = Selection::caret(Pos { off: f.off.saturating_sub(2), ..f }); + Ok(Some(outside)) +} + +/// Would [`emphasis_autoformat`] change the word ending at the caret? +pub fn has_emphasis(s: &Session, on_enter: bool) -> bool { + emphasis_at_caret(s, on_enter).is_some() +} + +/// The `*bold*` or `_italic_` span ending at the caret (see [`emphasis_span`]) when the option is +/// on. With Track Changes on, only the current author's tracked insertions are converted, since +/// removing the marks from other text would not be tracked. Nothing is converted while the +/// document limits formatting to its styles. +fn emphasis_at_caret(s: &Session, on_enter: bool) -> Option<(usize, usize, bool)> { + // Formatting limited to the document's styles allows no direct bold or italic. + let styles_only = super::protect::enforced(&s.doc) && s.doc.settings.protect_formatting; + if !(s.prefs.autocorrect.enabled && s.prefs.autocorrect.bold_italic) || styles_only { + return None; + } + let f = &s.sel.focus; + let para = s.doc.para_at(f)?; + let before = para.text.get(..f.off)?; + let trigger = match before.chars().next_back() { + _ if on_enter => 0, + Some(c) if c == ' ' || ",.;:!?".contains(c) => c.len_utf8(), + _ => return None, + }; + let span = before.get(..before.len() - trigger).and_then(emphasis_span)?; + let tracked = |(open, close, _): &(usize, usize, bool)| { + !s.doc.settings.track_changes + || para + .run_ranges() + .filter(|(r, _)| r.start <= *close && r.end > *open) + .all(|(_, p)| p.del.is_none() && p.ins.and_then(|i| s.doc.revisions.get(i as usize)).is_some_and(|rv| rv.author == s.author)) + }; + Some(span).filter(tracked) +} + +/// The marks of a `*bold*` or `_italic_` span that `text` ends with: the byte offsets of the +/// opening and closing mark, and whether it is bold. The opening mark starts a word (it follows +/// the paragraph start, white space or an opening bracket or quote), the text between the marks +/// holds a letter or digit and neither starts nor ends with white space, so `2*3*`, `file_name_` +/// and `*?!*` stay as typed. Text holding an object (a picture, field or content control) is left +/// alone. +fn emphasis_span(text: &str) -> Option<(usize, usize, bool)> { + let mark = text.chars().next_back().filter(|c| *c == '*' || *c == '_')?; + let close = text.len() - 1; + let open = text.get(..close)?.rfind(mark)?; + let inner = text.get(open + 1..close)?; + let edge_ok = |c: Option| c.is_some_and(|c| !c.is_whitespace()); + if !edge_ok(inner.chars().next()) + || !edge_ok(inner.chars().next_back()) + || !inner.chars().any(char::is_alphanumeric) + || inner.contains(wordcraft_doc::para::OBJ) + { + return None; + } + let starts_word = text.get(..open)?.chars().next_back().is_none_or(|c| c.is_whitespace() || "([{\"'“‘".contains(c)); + starts_word.then_some((open, close, mark == '*')) +} + /// Byte length of an ordinal suffix to superscript ("1st" → 2), if `word` is a correct ordinal. fn ordinal_suffix(word: &str) -> Option { let digits = word.find(|c: char| !c.is_ascii_digit())?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2e8116de6..bd095a6f3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1969,6 +1969,65 @@ fn delete_row_on_a_merge_start_promotes_the_cell_below() { assert_eq!(vm(&s, 1), VMerge::Continue); } +/// Split Table under a vertically merged cell: the merge ends in the first table and starts +/// again in the second. +#[test] +fn split_table_ends_and_restarts_a_merge_it_cuts() { + use wordcraft_doc::props::VMerge::{self, Continue, None as No, Restart}; + let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0}); + let tables = |s: &Session| -> Vec>> { + let rows = |t: &wordcraft_doc::Table| t.rows.iter().map(|r| r.cells.iter().map(|c| c.props.vmerge).collect()).collect(); + s.doc.body.iter().filter_map(|b| b.as_table()).map(rows).collect() + }; + let merged = |last: u32| { + let mut s = table_from("A\tB\nC\tD\nE\tF\nG\tH"); + run(&mut s, "select.range", json!({"anchor": cell(0, 0), "focus": cell(last, 0)})); + run(&mut s, "table.merge", json!({})); + s + }; + // Rows 0 to 2 merged, split at row 1: the second table's merge covers rows 1 and 2. + let mut s = merged(2); + let before = tables(&s); + run(&mut s, "caret.set", json!({"pos": cell(1, 1)})); + run(&mut s, "table.splitTable", json!({})); + assert_eq!(tables(&s), [vec![vec![No, No]], vec![vec![Restart, No], vec![Continue, No], vec![No, No]]]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(tables(&s), before); + // Rows 0 and 1 merged, split at row 1: neither part keeps a merge. + let mut s = merged(1); + run(&mut s, "caret.set", json!({"pos": cell(1, 1)})); + run(&mut s, "table.splitTable", json!({})); + assert_eq!(tables(&s), [vec![vec![No, No]], vec![vec![No, No], vec![No, No], vec![No, No]]]); +} + +/// Split Table inside a row content control (a repeating section) around several rows: the +/// control closes after the first table and opens again before the second. +#[test] +fn split_table_keeps_a_row_control_on_both_parts() { + let cell = |r: u32, c: u32| json!({"story": "body", "path": [0, r, c, 0], "off": 0}); + let control = |tag: &str| wordcraft_doc::ContentControl { id: Some(7), tag: tag.into(), ..Default::default() }; + let mut s = table_from("A\tB\nC\tD\nE\tF\nG\tH"); + if let Some(b) = s.doc.body.iter_mut().find(|b| b.as_table().is_some()) + && let wordcraft_doc::Block::Table(t) = std::sync::Arc::make_mut(b) + { + // "outer" goes around every row, and "inner" goes around row 1 alone. + t.rows[0].controls.open = vec![control("outer")]; + t.rows[1].controls.open = vec![control("inner")]; + t.rows[1].controls.close = 1; + t.rows[3].controls.close = 1; + } + run(&mut s, "caret.set", json!({"pos": cell(2, 0)})); + run(&mut s, "table.splitTable", json!({})); + let wraps = |t: &wordcraft_doc::Table| -> Vec<(Vec, u32)> { + t.rows.iter().map(|r| (r.controls.open.iter().map(|c| c.tag.clone()).collect(), r.controls.close)).collect() + }; + let parts: Vec<&wordcraft_doc::Table> = s.doc.body.iter().filter_map(|b| b.as_table()).collect(); + assert_eq!(parts.len(), 2); + assert_eq!(wraps(parts[0]), [(vec!["outer".to_string()], 0), (vec!["inner".to_string()], 2)], "the first table closes the control"); + assert_eq!(wraps(parts[1]), [(vec!["outer".to_string()], 0), (vec![], 1)], "the second table opens it again"); + assert_eq!(parts[1].rows[0].controls.open[0].id, None, "the copy doesn't repeat the control's id"); +} + fn cells(s: &Session) -> Option<((usize, usize), (usize, usize))> { s.cell_range().map(|x| (x.rows, x.cols)) } @@ -6971,6 +7030,210 @@ fn convert_text_to_table_keeps_inline_content() { assert_eq!(cell(3).props_of_char(0).del, Some(1), "cell 4 lost its tracked deletion"); } +/// A table `cols` wide holding `cells` row by row, typed in with Tab; the caret ends in the +/// last cell. +fn typed_table(cols: usize, cells: &[&str]) -> Session { + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": cells.len() / cols, "cols": cols})); + for (k, c) in cells.iter().enumerate() { + if k > 0 { + run(&mut s, "text.tab", json!({})); + } + if !c.is_empty() { + run(&mut s, "text.insert", json!({"text": c})); + } + } + s +} + +/// The body as `T` for a table and the text of each paragraph. +fn body_outline(s: &Session) -> Vec { + s.doc.body.iter().map(|b| b.as_para().map_or_else(|| "T".to_string(), |p| p.text.clone())).collect() +} + +fn cell_at(table: u32, r: u32, c: u32) -> serde_json::Value { + json!({"story": "body", "path": [table, r, c, 0], "off": 0}) +} + +/// Convert to Text sets a row's cells apart with a tab, a comma, another character or +/// paragraph marks; rows are paragraphs. +#[test] +fn convert_to_text_takes_each_separator() { + let cases = [ + (json!({}), vec!["a\tb", "c\td"]), + (json!({"separator": "tab"}), vec!["a\tb", "c\td"]), + (json!({"separator": "comma"}), vec!["a,b", "c,d"]), + (json!({"separator": "other", "char": ";"}), vec!["a;b", "c;d"]), + (json!({"separator": "paragraph"}), vec!["a", "b", "c", "d"]), + ]; + for (params, want) in cases { + let mut s = typed_table(2, &["a", "b", "c", "d"]); + run(&mut s, "table.toText", params.clone()); + let got = body_outline(&s); + assert_eq!(got.get(..want.len()), Some(&want.iter().map(|x| x.to_string()).collect::>()[..]), "{params}: {got:?}"); + assert!(s.doc.body.iter().all(|b| b.as_table().is_none()), "{params}"); + } + // U+FFFC marks an object in a paragraph's text, so it cannot be the separator. + let obj = wordcraft_doc::para::OBJ.to_string(); + for bad in [ + json!({"separator": "other"}), + json!({"separator": "other", "char": "\n"}), + json!({"separator": "other", "char": obj}), + json!({"separator": "pipe"}), + ] { + let mut s = typed_table(2, &["a", "b", "c", "d"]); + let depth = s.undo_depth(); + assert!(s.run("table.toText", &bad).is_err(), "{bad}"); + assert!(s.doc.body.iter().any(|b| b.as_table().is_some()), "{bad}: the table stays"); + assert_eq!(s.undo_depth(), depth); + } +} + +/// With rows selected, only those become text; the rows above and below stay tables, and a +/// vertical merge cut by the selection starts again below it. +#[test] +fn convert_to_text_converts_the_selected_rows() { + let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f", "g", "h"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + // Column 1 merged down rows 1 to 3. + run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 3, 1)})); + run(&mut s, "table.merge", json!({})); + run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)})); + run(&mut s, "table.toText", json!({})); + let at = t as usize; + // The merged cell holds d, f and h, one paragraph each. + assert_eq!(body_outline(&s).get(at..at + 5), Some(&["T", "c\td", "f", "h", "T"].map(String::from)[..]), "{:?}", body_outline(&s)); + let head = s.doc.body[at].as_table().unwrap(); + let tail = s.doc.body[at + 4].as_table().unwrap(); + assert_eq!((head.rows.len(), tail.rows.len()), (1, 2)); + use wordcraft_doc::props::VMerge; + let merges: Vec = tail.rows.iter().map(|r| r.cells.last().unwrap().props.vmerge).collect(); + assert_eq!(merges, [VMerge::Restart, VMerge::Continue], "the merge starts again in the first row left"); + assert_eq!(s.sel.focus.path.0, [t + 1], "the caret goes to the converted text"); + + // A merge that went on into the converted rows ends in the last row above them. + let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + run(&mut s, "select.range", json!({"anchor": cell_at(t, 0, 0), "focus": cell_at(t, 1, 0)})); + run(&mut s, "table.merge", json!({})); + run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 2, 1)})); + run(&mut s, "table.toText", json!({})); + let head = s.doc.body[t as usize].as_table().unwrap(); + assert_eq!(head.rows.len(), 1); + assert_eq!(head.rows[0].cells[0].props.vmerge, VMerge::None, "no merge goes on below the table"); + + // A floating table keeps its place in the part above; the part below goes in the text flow. + let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + let float = wordcraft_doc::props::TableFloat { x: 100.0, y: 200.0, overlap: true, ..Default::default() }; + s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap().props.float = Some(float); + run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)})); + run(&mut s, "table.toText", json!({})); + let at = t as usize; + assert_eq!(s.doc.body[at].as_table().unwrap().props.float, Some(float)); + assert_eq!(s.doc.body[at + 2].as_table().unwrap().props.float, None, "the part below doesn't sit on the part above"); + + // The caret alone in a cell converts every row, as before. + let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); + run(&mut s, "table.toText", json!({})); + assert!(s.doc.body.iter().all(|b| b.as_table().is_none())); +} + +/// A content control around the table keeps both its ends when the first rows, the last rows +/// or every row become text. +#[test] +fn convert_to_text_keeps_a_content_control_around_the_table() { + use wordcraft_doc::{ContentControl, ControlWrap, InlineObject}; + let has = |p: &wordcraft_doc::Paragraph, start: bool| { + p.objects.iter().any(|o| if start { matches!(o, InlineObject::ControlStart { .. }) } else { matches!(o, InlineObject::ControlEnd) }) + }; + for rows in [Some((0, 0)), Some((2, 2)), None] { + let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap().controls = + ControlWrap { open: vec![ContentControl { tag: "wrap".into(), ..Default::default() }], close: 1 }; + assert!(!s.doc.has_unbalanced_controls()); + match rows { + Some((r0, r1)) => run(&mut s, "select.range", json!({"anchor": cell_at(t, r0, 0), "focus": cell_at(t, r1, 1)})), + None => run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 0)})), + }; + run(&mut s, "table.toText", json!({})); + assert!(!s.doc.has_unbalanced_controls(), "{rows:?}: {:?}", body_outline(&s)); + let at = t as usize; + if rows != Some((2, 2)) { + assert!(s.doc.body[at].as_para().is_some_and(|p| has(p, true)), "{rows:?}: the control starts on the text"); + } + if rows != Some((0, 0)) { + let last = s.doc.body.iter().skip(at).rfind(|b| b.as_para().is_some_and(|p| !p.text.is_empty()) || b.as_table().is_some()).unwrap(); + assert!(last.as_para().is_some_and(|p| has(p, false)), "{rows:?}: the control ends on the text"); + } + } + + // A control around rows 0 to 3, row 1 converted: the table above closes it, the table below + // opens it again. + let mut s = typed_table(2, &["a", "b", "c", "d", "e", "f", "g", "h"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + { + let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![t])).unwrap(); + table.rows[0].controls.open = vec![ContentControl { id: Some(7), tag: "rows".into(), ..Default::default() }]; + table.rows[3].controls.close = 1; + } + run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 0), "focus": cell_at(t, 1, 1)})); + run(&mut s, "table.toText", json!({})); + let at = t as usize; + let head = s.doc.body[at].as_table().unwrap(); + let tail = s.doc.body[at + 2].as_table().unwrap(); + assert_eq!((head.rows.len(), head.rows[0].controls.close), (1, 1), "the table above closes the control"); + assert_eq!(tail.rows[0].controls.open.iter().map(|c| c.tag.as_str()).collect::>(), ["rows"], "the table below opens it"); + assert_eq!((head.rows[0].controls.open[0].id, tail.rows[0].controls.open[0].id), (Some(7), None), "one w:id in the file"); + assert_eq!(tail.rows[1].controls.close, 1); + assert!(!s.doc.has_unbalanced_controls()); +} + +/// The text keeps its formatting, and a table nested in a cell stays a table. +#[test] +fn convert_to_text_keeps_formatting_and_nested_tables() { + let mut s = typed_table(2, &["Name", "X"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + let first = json!({"story": "body", "path": [t, 0, 0, 0], "off": 0}); + let end = json!({"story": "body", "path": [t, 0, 0, 0], "off": 4}); + run(&mut s, "select.range", json!({"anchor": first, "focus": end})); + run(&mut s, "format.bold", json!({})); + // A table nested in the second cell, after its text. + s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 0, 1, 0]), off: 1 }); + run(&mut s, "text.newParagraph", json!({})); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + run(&mut s, "text.insert", json!({"text": "Inner"})); + s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 0, 0, 0]), off: 0 }); + run(&mut s, "table.toText", json!({})); + let at = t as usize; + let line = s.doc.body[at].as_para().unwrap(); + assert_eq!(line.text, "Name\tX"); + assert_eq!(line.props_of_char(0).bold, Some(true), "the text lost its bold"); + assert_ne!(line.props_of_char(4).bold, Some(true), "the tab takes no formatting"); + let inner = s.doc.body.iter().skip(at + 1).find_map(|b| b.as_table()).expect("the nested table stays a table"); + assert_eq!(table_cell_text(inner), "Inner"); + + // A selected row whose first cell starts with a nested table: the caret goes to the row's + // text, not back into the table above. + let mut s = typed_table(2, &["A", "B", "", "D"]); + let t = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32; + s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 1, 0, 0]), off: 0 }); + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + let row = &s.doc.body[t as usize].as_table().unwrap().rows[1]; + assert!(row.cells[0].blocks.first().is_some_and(|b| b.as_table().is_some()), "the cell starts with the nested table"); + run(&mut s, "select.range", json!({"anchor": cell_at(t, 1, 1), "focus": cell_at(t, 1, 1)})); + s.sel.anchor = wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path(vec![t, 1, 0, 1]), off: 0 }; + run(&mut s, "table.toText", json!({})); + let at = t as usize; + assert_eq!(body_outline(&s).get(at..at + 3), Some(&["T", "T", "\tD"].map(String::from)[..]), "{:?}", body_outline(&s)); + assert_eq!(s.sel.focus.path.0, [t + 2], "the caret is on the row's text"); +} + +fn table_cell_text(t: &wordcraft_doc::Table) -> String { + t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect() +} + #[test] fn delete_columns_deletes_every_selected_column() { // Issue #431: only the selection's focus column went. @@ -8512,6 +8775,51 @@ fn update_fields_recalculates_a_table_formula() { assert_eq!(formula_result(&s, 4, 1).as_deref(), Some("21")); } +/// A formula's `\*` switches format its result when the formula is inserted and when Update Field +/// works it out again. +#[test] +fn table_formula_results_take_the_format_switches() { + for (formula, inserted, updated) in [ + (r"=SUM(ABOVE) \* CardText", "twelve", "twenty-one"), + (r"=SUM(ABOVE) \* CardText \* Caps", "Twelve", "Twenty-one"), + (r"=SUM(ABOVE) \* OrdText \* Upper", "TWELFTH", "TWENTY-FIRST"), + (r"=SUM(ABOVE) \* MERGEFORMAT", "12", "21"), + ] { + let mut s = formula_table(); + let r = run(&mut s, "table.formula", json!({"formula": formula})); + assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some(formula), Some(inserted))); + run( + &mut s, + "select.range", + json!({"anchor": {"story": "body", "path": [1, 1, 1, 0], "off": 0}, "focus": {"story": "body", "path": [1, 1, 1, 0], "off": 1}}), + ); + run(&mut s, "text.insert", json!({"text": "10"})); + run(&mut s, "references.updateFields", json!({})); + assert_eq!(formula_result(&s, 4, 1).as_deref(), Some(updated), "{formula}"); + } +} + +#[test] +fn table_formula_results_take_dollar_text_and_hex() { + for (formula, inserted, updated) in [ + (r"=SUM(ABOVE) \* DollarText", "twelve and 00/100", "twenty-one and 00/100"), + (r"=SUM(ABOVE) \* DollarText \* Upper", "TWELVE AND 00/100", "TWENTY-ONE AND 00/100"), + (r"=SUM(ABOVE) \* Hex", "C", "15"), + ] { + let mut s = formula_table(); + let r = run(&mut s, "table.formula", json!({"formula": formula})); + assert_eq!((r["formula"].as_str(), r["result"].as_str()), (Some(formula), Some(inserted))); + run( + &mut s, + "select.range", + json!({"anchor": {"story": "body", "path": [1, 1, 1, 0], "off": 0}, "focus": {"story": "body", "path": [1, 1, 1, 0], "off": 1}}), + ); + run(&mut s, "text.insert", json!({"text": "10"})); + run(&mut s, "references.updateFields", json!({})); + assert_eq!(formula_result(&s, 4, 1).as_deref(), Some(updated), "{formula}"); + } +} + /// A one-column table (no header row) holding `cells`, in a document whose default language /// is `lang`; the caret ends in the last cell. fn column_table(lang: &str, cells: &[&str]) -> Session { diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 7c8f4bb72..c9a2c434d 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -319,6 +319,29 @@ fn list_autoformat_does_not_override_a_style_linked_heading_number() { assert_eq!(paras(&s)[1].0, "- Two", "the trigger stays as typed text, not stripped into a bullet"); } +/// A heading numbered only through its style's linked Multilevel List keeps "---" and "+--+--+" +/// as text on Enter, as a paragraph numbered directly does: no border, no table, and at the top +/// of the document the heading keeps its number. +#[test] +fn border_and_table_lines_stay_text_in_a_style_numbered_heading() { + for (above, keys) in [("", "---"), ("Above\n", "---"), ("", "+--+--+"), ("Above\n", "+--+--+")] { + let mut s = s(); + typ(&mut s, &format!("{above}{keys}")); + run(&mut s, "para.heading1", json!({})); + run(&mut s, "list.multilevelPreset", json!({"preset": "headings"})); + let at = Pos::body(s.doc.body.len() - 1, 0); + assert_eq!(s.doc.para_at(&at).unwrap().props.numbering, None, "numbered through the style, not directly"); + typ(&mut s, "\nText"); + assert_eq!(s.doc.plain_text(StoryRef::Body), format!("{above}{keys}\nText"), "{above:?}{keys}"); + assert!(s.doc.body.iter().all(|b| b.as_para().is_some_and(|p| p.props.borders.is_none())), "{above:?}{keys}"); + let mut want = vec![num(Some("1"), "Heading1"), num(None, "Normal")]; + if !above.is_empty() { + want.insert(0, num(None, "Normal")); + } + assert_eq!(numbers(&mut s), want, "{above:?}{keys}"); + } +} + #[test] fn list_autoformat_triggers() { let s = typed("- Item\nNext"); @@ -524,6 +547,174 @@ fn ordinals_become_superscript() { assert!(!sup("1th")); } +#[test] +fn bold_and_italic_marks_become_formatting() { + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "capSentences": false})); + typ(&mut s, "say *very* nice, _quite_ good *two words*. ok\n*end*\nnext"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "say very nice, quite good two words. ok\nend\nnext"); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()); + assert_eq!((at("very").bold, at("very").italic), (Some(true), None)); + assert_eq!((at("quite").italic, at("quite").bold), (Some(true), None)); + // Complex-script text (Arabic, Hebrew) takes the formatting too. + assert_eq!((at("very").bold_cs, at("quite").italic_cs), (Some(true), Some(true))); + assert_eq!(at("two").bold, Some(true)); + assert_eq!(at("words").bold, Some(true)); + for plain in ["say", " nice", ", ", " good", ". ok"] { + assert_eq!((at(plain).bold, at(plain).italic), (None, None), "{plain:?}"); + } + // Enter finishes the marked word too, and the next paragraph is not bold. + let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); + assert_eq!((p.props_of_char(0).bold, p.mark.bold), (Some(true), None)); + let p = s.doc.para_at(&Pos::body(2, 0)).unwrap(); + assert_eq!((p.props_of_char(0).bold, p.mark.bold), (None, None)); + // With Track Changes on, the paragraph marks carry the same revisions as for unmarked text. + let marks = |keys: &str| { + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, keys); + s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.mark.ins.is_some(), p.mark.del.is_some())).collect::>() + }; + assert_eq!(marks("x *end*\n"), marks("x end\n")); + // After a no-break space too. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "a\u{a0}*word* "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{a0}word "); + // A red word between plain marks: the next paragraph takes the closing mark's formatting. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "*"); + run(&mut s, "format.color", json!({"color": "FF0000"})); + typ(&mut s, "red"); + run(&mut s, "format.color", json!({"color": "auto"})); + typ(&mut s, "*\nnext"); + let red = s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).color; + let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); + assert!(red.is_some()); + assert_ne!(p.mark.color, red); + assert_ne!(p.props_of_char(0).color, red); + // A heading's next style starts afresh after Enter, as without the marks. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + run(&mut s, "para.heading1", json!({})); + run(&mut s, "format.bold", json!({})); + typ(&mut s, "*title*\n"); + let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); + assert_eq!((p.props.style.as_deref(), p.mark.bold), (Some("Normal"), None)); + // Marks typed before Track Changes was turned on stay: removing them would not be tracked. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "x *end*"); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, " "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X *end* "); + // Marks the same author typed with Track Changes on are converted. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + run(&mut s, "review.trackChanges", json!({"value": true})); + typ(&mut s, "x *end* "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X end "); + // Marks another author inserted stay. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + run(&mut s, "review.trackChanges", json!({"value": true})); + run(&mut s, "file.setAuthor", json!({"name": "Ana"})); + typ(&mut s, "x *end*"); + run(&mut s, "file.setAuthor", json!({"name": "Ben"})); + typ(&mut s, " "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X *end* "); + // Undo right after brings the marks back. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "a *b* "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A b "); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A *b* "); + // After Enter, Undo brings the marks back and the Enter stays. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "a *b*\n"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A b\n"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "A *b*\n"); +} + +#[test] +fn autocorrect_goes_on_after_bold_and_italic_marks() { + // After a space or punctuation, the word without its marks is replaced and capitalized. + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "add": {"from": "wc", "to": "WordCraft"}})); + typ(&mut s, "*teh* end. _wc_."); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The end. WordCraft."); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone(); + assert_eq!((at("The").bold, at("WordCraft").italic, at(" end").bold), (Some(true), Some(true), None)); + // One Undo brings the marks back as typed. + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The end. _wc_."); + // A marked web address becomes a link, and the text typed after it is neither bold nor italic. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "see *www.example.org* and _www.example.com_ next "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone(); + assert_eq!((at("www.example.org").bold, at("www.example.org").link.is_some()), (Some(true), true)); + assert_eq!((at("www.example.com").italic, at("www.example.com").link.is_some()), (Some(true), true)); + for plain in [" and", " next"] { + assert_eq!((at(plain).bold, at(plain).italic, at(plain).link.is_some()), (None, None, false), "{plain:?}"); + } + // After Enter too; a longer replacement moves the Enter with it, and Undo keeps the Enter. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "add": {"from": "wc", "to": "WordCraft"}})); + typ(&mut s, "*teh*\nok. *wc*\nnext"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The\nOk. WordCraft\nnext"); + let p = s.doc.para_at(&Pos::body(1, 0)).unwrap(); + assert_eq!(p.props_of_char(p.text.find("WordCraft").unwrap()).bold, Some(true)); + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "*teh*\n"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "*teh*\n"); + // In a span of several words, the first word is replaced and capitalized as it is typed. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "*teh end* and _teh rest_ "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "The end and the rest "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + let at = |t: &str| p.props_of_char(p.text.find(t).unwrap()).clone(); + assert_eq!((at("The end").bold, at("the rest").italic, at(" and").bold), (Some(true), Some(true), None)); +} + +#[test] +fn bold_and_italic_marks_stay_as_typed_unless_they_enclose_words() { + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true, "capSentences": false})); + let keys = "2*3* file_name_ * x* *x * ** __ *?!* _-_ "; + typ(&mut s, keys); + assert_eq!(s.doc.plain_text(StoryRef::Body), keys); + // Marks stay while formatting is limited to the document's styles. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true})); + typ(&mut s, "*word* "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "*word* "); + // Marks around an object (here a check box content control) stay. + let mut s = self::s(); + run(&mut s, "tools.autocorrect", json!({"boldItalic": true})); + typ(&mut s, "x *"); + run(&mut s, "developer.checkBox", json!({})); + run(&mut s, "caret.docEnd", json!({})); + typ(&mut s, "* "); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text.matches('*').count(), 2, "{:?}", p.text); + // Off by default. + let s = typed("*bold* _italic_ "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "*bold* _italic_ "); +} + #[test] fn web_addresses_become_links() { let mut s = typed("see https://example.org/log, then "); diff --git a/crates/layout/src/fields.rs b/crates/layout/src/fields.rs index 9c3c358f6..016548f55 100644 --- a/crates/layout/src/fields.rs +++ b/crates/layout/src/fields.rs @@ -125,7 +125,8 @@ pub(crate) fn reference_link(instr: &str) -> Option { None } -/// `\* FORMAT` switch value (Arabic, roman, ROMAN, alphabetic, ALPHABETIC, CardText, Ordinal…). +/// `\* FORMAT` switch value (Arabic, roman, ROMAN, alphabetic, ALPHABETIC, CardText, Ordinal, +/// ArabicDash, DollarText, Hex…). `{ PAGE \* ArabicDash }` shows "- 31 -". pub fn format_switch(instr: &str) -> Option { let mut it = instr.split_whitespace(); while let Some(w) = it.next() { @@ -141,6 +142,9 @@ pub fn format_switch(instr: &str) -> Option { "OrdText" | "ordtext" => NumFormat::OrdinalText, "Ordinal" | "ordinal" => NumFormat::Ordinal, "Arabic" | "arabic" => NumFormat::Decimal, + "ArabicDash" | "arabicdash" | "ARABICDASH" => NumFormat::NumberInDash, + "DollarText" | "dollartext" | "DOLLARTEXT" => NumFormat::DollarText, + "Hex" | "hex" | "HEX" => NumFormat::Hex, // Chinese numerals: counting (一百零一), legal (壹佰零壹), digit by digit (一〇一). v if v.eq_ignore_ascii_case("ChineseNum1") => NumFormat::ChineseCountingThousand, v if v.eq_ignore_ascii_case("ChineseNum2") => NumFormat::ChineseLegalSimplified, @@ -153,6 +157,120 @@ pub fn format_switch(instr: &str) -> Option { None } +/// A field's capitalization switch (`\* Upper`, `\* Lower`, `\* Caps`, `\* FirstCap`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CaseSwitch { + Upper, + Lower, + Caps, + FirstCap, +} + +/// The capitalization switches in a field code, in the order they appear. Quoted text in the code +/// is not a switch. +fn case_switches(instr: &str) -> Vec { + let mut chars = instr.chars().peekable(); + let mut quoted = false; + let mut cases = Vec::new(); + while let Some(c) = chars.next() { + match c { + '\\' if quoted => { + chars.next(); + } + '"' => quoted = !quoted, + '\\' if chars.peek() == Some(&'*') => { + chars.next(); + while chars.next_if(|c| c.is_whitespace()).is_some() {} + let mut word = String::new(); + while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '\\' && *c != '"') { + word.push(c); + } + let case = match word.to_ascii_lowercase().as_str() { + "upper" => CaseSwitch::Upper, + "lower" => CaseSwitch::Lower, + "caps" => CaseSwitch::Caps, + "firstcap" => CaseSwitch::FirstCap, + _ => continue, + }; + cases.push(case); + } + _ => {} + } + } + cases +} + +/// `text` with the field's capitalization switches applied in the order they appear, so +/// `\* Lower \* Caps` shows "WEEKLY REPORT" as "Weekly Report". `\* Upper` capitalizes every +/// letter, `\* Lower` makes every letter lowercase, `\* Caps` capitalizes the first letter of each +/// word, and `\* FirstCap` capitalizes the first letter of the first word. A word's first letter is +/// its first letter or digit, after any leading quote, bracket or other punctuation. Without a +/// switch, the function returns `text` unchanged. +pub fn apply_case_switch(instr: &str, text: String) -> String { + case_switches(instr).into_iter().fold(text, apply_case) +} + +/// `text` with one capitalization switch applied (see [`apply_case_switch`]). +fn apply_case(text: String, case: CaseSwitch) -> String { + match case { + CaseSwitch::Upper => text.to_uppercase(), + CaseSwitch::Lower => text.to_lowercase(), + CaseSwitch::Caps | CaseSwitch::FirstCap => { + let mut out = String::with_capacity(text.len()); + let mut word_start = true; + let mut done = false; + for c in text.chars() { + if c.is_whitespace() { + word_start = true; + out.push(c); + } else if word_start && !done && c.is_alphanumeric() { + out.extend(c.to_uppercase()); + word_start = false; + done = case == CaseSwitch::FirstCap; + } else { + if c.is_alphanumeric() { + word_start = false; + } + out.push(c); + } + } + out + } + } +} + +/// Formats `n` in `fmt` as a field shows it. Cardinal, ordinal and dollar text is lowercase +/// (`\* CardText` shows "twenty-one", `\* DollarText` shows "twenty-one and 00/100") and writes +/// numbers up to 999,999; a larger number keeps its digits. `\* Hex` writes uppercase hexadecimal +/// digits (`1CA`). The field's capitalization switches apply after that (`\* CardText \* Caps` +/// shows "Twenty-one"). +pub fn field_number(instr: &str, fmt: NumFormat, n: u32) -> String { + let text = match fmt { + NumFormat::CardinalText | NumFormat::OrdinalText | NumFormat::DollarText if n > MAX_TEXT_NUMBER => n.to_string(), + NumFormat::CardinalText | NumFormat::OrdinalText => fmt.format(n).to_lowercase(), + NumFormat::DollarText => dollar_text(f64::from(n)).unwrap_or_else(|| n.to_string()), + NumFormat::Hex => format!("{n:X}"), + _ => fmt.format(n), + }; + apply_case_switch(instr, text) +} + +/// The largest number cardinal, ordinal and dollar text write in words. +const MAX_TEXT_NUMBER: u32 = 999_999; + +/// `value` as `\* DollarText` writes it, before any capitalization switch: the whole part in +/// lowercase cardinal text, "and", and the first two decimals (rounded) over 100 +/// (14.55 gives "fourteen and 55/100"). `None` for NaN, or for a value that rounds below 0 or past +/// 999,999.99. +pub fn dollar_text(value: f64) -> Option { + let cents = (value * 100.0).round(); + if !(0.0..=f64::from(MAX_TEXT_NUMBER) * 100.0 + 99.0).contains(¢s) { + return None; + } + let cents = cents as u32; + Some(format!("{} and {:02}/100", NumFormat::CardinalText.format(cents / 100).to_lowercase(), cents % 100)) +} + /// The light grey behind a field shown as its code. pub const FIELD_SHADING: wordcraft_doc::props::Rgb = wordcraft_doc::props::Rgb(0xD9, 0xD9, 0xD9); @@ -171,16 +289,16 @@ pub fn field_text_on(instr: &str, result: &str, ctx: &FieldCtx, page: u32) -> (S let name = field_name(instr); let fmt = format_switch(instr); match name.as_str() { - "PAGE" => (fmt.unwrap_or(ctx.page_format).format(page), true), - "NUMPAGES" => (fmt.unwrap_or(NumFormat::Decimal).format(ctx.pages), true), - "SECTIONPAGES" => (fmt.unwrap_or(NumFormat::Decimal).format(ctx.section_pages), true), - "SECTION" => (fmt.unwrap_or(NumFormat::Decimal).format(ctx.section), true), + "PAGE" => (field_number(instr, format_switch(instr).unwrap_or(ctx.page_format), page), true), + "NUMPAGES" => (field_number(instr, fmt.unwrap_or(NumFormat::Decimal), ctx.pages), true), + "SECTIONPAGES" => (field_number(instr, fmt.unwrap_or(NumFormat::Decimal), ctx.section_pages), true), + "SECTION" => (field_number(instr, fmt.unwrap_or(NumFormat::Decimal), ctx.section), true), "PAGEREF" => match pageref_page(instr, ctx) { - Some((n, page_format)) => (fmt.unwrap_or(page_format).format(n), true), + Some((n, page_format)) => (field_number(instr, fmt.unwrap_or(page_format), n), true), None => (result.to_string(), false), }, - "TITLE" if result.is_empty() => (ctx.title.to_string(), false), - "AUTHOR" if result.is_empty() => (ctx.author.to_string(), false), + "TITLE" if result.is_empty() => (apply_case_switch(instr, ctx.title.to_string()), false), + "AUTHOR" if result.is_empty() => (apply_case_switch(instr, ctx.author.to_string()), false), "FILENAME" if result.is_empty() => { let text = if has_switch(instr, 'p') && !ctx.filepath.is_empty() { ctx.filepath.to_string() @@ -189,12 +307,12 @@ pub fn field_text_on(instr: &str, result: &str, ctx: &FieldCtx, page: u32) -> (S } else { "Document1".into() }; - (text, false) + (apply_case_switch(instr, text), false) } // ASK and SET only assign text to a bookmark: they never show a result (ECMA-376 §17.16.5). "ASK" | "SET" => (String::new(), false), "STYLEREF" => match &ctx.styleref { - Some((ix, at)) => (ix.eval(instr, *at), true), + Some((ix, at)) => (apply_case_switch(instr, ix.eval(instr, *at)), true), None => (result.to_string(), false), }, _ => (result.to_string(), false), @@ -273,7 +391,7 @@ mod tests { assert_eq!(field_text(" PAGE \\* ROMAN ", "", &ctx).0, "IV"); assert_eq!(field_text("PAGE \\*roman", "", &ctx).0, "iv"); assert_eq!(field_text("PAGE \\*roman\\*MERGEFORMAT", "", &ctx).0, "iv"); - assert_eq!(field_text("NUMPAGES \\* CardText", "", &ctx).0, "Twelve"); + assert_eq!(field_text("NUMPAGES \\* CardText", "", &ctx).0, "twelve"); assert_eq!(field_text("NUMPAGES \\* CHINESENUM1", "", &ctx).0, "十二"); assert_eq!(field_text("PAGE \\* ChineseNum2", "", &ctx).0, "肆"); assert_eq!(field_text("DATE \\@ \"M/d/yyyy\"", "1/2/2026", &ctx), ("1/2/2026".into(), false)); @@ -282,6 +400,83 @@ mod tests { assert_eq!(field_code_text(""), "{ }"); } + /// Cardinal and ordinal text is lowercase unless a capitalization switch follows; `\* Upper`, + /// `\* Lower`, `\* Caps` and `\* FirstCap` apply to the page fields and the TITLE, AUTHOR and + /// FILENAME fallbacks. A switch inside quotes is text. + #[test] + fn capitalization_switches() { + let ctx = FieldCtx { + page: 21, + pages: 790, + title: "weekly report on sales".into(), + filename: "Weekly Sales Report.doc".into(), + ..Default::default() + }; + let text = |instr: &str| field_text(instr, "", &ctx).0; + assert_eq!(text(r"NUMPAGES \* CardText"), "seven hundred ninety"); + assert_eq!(text(r"NUMPAGES \* CardText \* Caps"), "Seven Hundred Ninety"); + assert_eq!(text(r"PAGE \* OrdText"), "twenty-first"); + assert_eq!(text(r"PAGE \* OrdText \* FirstCap"), "Twenty-first"); + assert_eq!(text(r"PAGE \* CardText \* Upper"), "TWENTY-ONE"); + assert_eq!(text(r"PAGE \* roman \* upper"), "XXI"); + assert_eq!(text(r"TITLE \* FirstCap \* MERGEFORMAT"), "Weekly report on sales"); + assert_eq!(text(r"TITLE \*Caps"), "Weekly Report On Sales"); + assert_eq!(text(r"FILENAME \* Lower"), "weekly sales report.doc"); + assert_eq!(text(r"AUTHOR \* Upper"), ""); + for (title, caps, first_cap) in [ + ("\"weekly report\"", "\"Weekly Report\"", "\"Weekly report\""), + ("(weekly) report", "(Weekly) Report", "(Weekly) report"), + (" q3 - final", " Q3 - Final", " Q3 - final"), + ] { + let ctx = FieldCtx { title: title.into(), ..Default::default() }; + assert_eq!(field_text(r"TITLE \* Caps", "", &ctx).0, caps); + assert_eq!(field_text(r"TITLE \* FirstCap", "", &ctx).0, first_cap); + } + assert_eq!(field_text(r#"TITLE "x \* Upper""#, "", &ctx).0, "weekly report on sales"); + assert_eq!(field_text(r"REF mark \* Upper", "stored", &ctx).0, "stored", "a stored result shows as it is"); + } + + /// `\* DollarText` writes lowercase cardinal text with "and 00/100", `\* Hex` uppercase + /// hexadecimal digits, and `\* ArabicDash` the number between hyphens. Words + /// stop at 999,999; a larger number keeps its digits. + #[test] + fn dollar_text_hex_and_arabic_dash_switches() { + let ctx = FieldCtx { page: 31, pages: 458, section: 790, ..Default::default() }; + let text = |instr: &str| field_text(instr, "", &ctx).0; + assert_eq!(text(r"PAGE \* ArabicDash"), "- 31 -"); + assert_eq!(text(r"PAGE \*arabicdash \* MERGEFORMAT"), "- 31 -"); + assert_eq!(text(r"NUMPAGES \* ArabicDash"), "- 458 -", "any number field reads ArabicDash"); + assert_eq!(text(r"NUMPAGES \* ArabicDash \* roman"), "- 458 -", "the first format switch counts"); + assert_eq!(text(r"NUMPAGES \* Hex"), "1CA"); + assert_eq!(text(r"NUMPAGES \* hex \* Lower"), "1ca"); + assert_eq!(text(r"PAGE \* Hex"), "1F"); + assert_eq!(text(r"SECTION \* DollarText"), "seven hundred ninety and 00/100"); + assert_eq!(text(r"SECTION \* DollarText \* Upper"), "SEVEN HUNDRED NINETY AND 00/100"); + assert_eq!(text(r"PAGE \* DollarText \* FirstCap"), "Thirty-one and 00/100"); + assert_eq!(dollar_text(9.20 + 5.35).as_deref(), Some("fourteen and 55/100")); + assert_eq!(dollar_text(0.999).as_deref(), Some("one and 00/100"), "the decimals round"); + assert_eq!(dollar_text(999_999.99).as_deref(), Some("nine hundred ninety-nine thousand nine hundred ninety-nine and 99/100")); + assert_eq!(dollar_text(999_999.995), None); + assert_eq!(dollar_text(-1.0), None); + assert_eq!(dollar_text(-0.004).as_deref(), Some("zero and 00/100"), "a value that rounds to 0 is not below 0"); + assert_eq!(dollar_text(f64::NAN), None); + for fmt in [NumFormat::CardinalText, NumFormat::OrdinalText, NumFormat::DollarText] { + assert_eq!(field_number(r"SEQ x \* Upper", fmt, 1_000_140), "1000140", "{fmt:?}"); + } + assert_eq!(field_number("SEQ x", NumFormat::CardinalText, 999_999), "nine hundred ninety-nine thousand nine hundred ninety-nine"); + } + + /// Several capitalization switches apply in the order they appear. + #[test] + fn capitalization_switches_apply_in_order() { + let ctx = FieldCtx { title: "WEEKLY REPORT".into(), ..Default::default() }; + let text = |instr: &str| field_text(instr, "", &ctx).0; + assert_eq!(text(r"TITLE \* Lower \* Caps"), "Weekly Report"); + assert_eq!(text(r"TITLE \* Lower \* FirstCap"), "Weekly report"); + assert_eq!(text(r"TITLE \* Caps \* Lower"), "weekly report"); + assert_eq!(text(r"TITLE \* Lower \* MERGEFORMAT \* Upper"), "WEEKLY REPORT"); + } + /// #1765: ASK and SET only assign text to a bookmark; they show nothing, whatever result is stored. #[test] fn ask_and_set_show_nothing() { diff --git a/crates/ui-egui/src/dialogs_insert.rs b/crates/ui-egui/src/dialogs_insert.rs index e467a9141..bb555b003 100644 --- a/crates/ui-egui/src/dialogs_insert.rs +++ b/crates/ui-egui/src/dialogs_insert.rs @@ -1039,9 +1039,15 @@ pub const FIELDS: &[(&str, &str, &str)] = &[ ("SAVEDATE", "Date and Time", "When this document was most recently saved"), ("TIME", "Date and Time", "The time right now"), ("AUTHOR", "Document Information", "Who the document properties name as author"), + ("COMMENTS", "Document Information", "Comments from the document properties"), ("FILENAME", "Document Information", "Name of the file this document is saved as"), + ("KEYWORDS", "Document Information", "Keywords from the document properties"), + ("LASTSAVEDBY", "Document Information", "Who last saved the document"), + ("NUMCHARS", "Document Information", "How many characters the document holds"), ("NUMPAGES", "Document Information", "How many pages the whole document has"), ("NUMWORDS", "Document Information", "How many words the document holds"), + ("REVNUM", "Document Information", "How many times the document has been saved"), + ("SUBJECT", "Document Information", "Subject from the document properties"), ("TITLE", "Document Information", "Title from the document properties"), ("PAGE", "Numbering", "Number of the page the field sits on"), ("SECTION", "Numbering", "Number of the section the field sits in"), @@ -1104,7 +1110,7 @@ pub const NUMBER_FORMATS: &[(&str, &str)] = &[ pub fn field_kind(name: &str) -> FieldKind { match name { "DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => FieldKind::Date, - "PAGE" | "NUMPAGES" | "SECTION" | "SECTIONPAGES" | "NUMWORDS" => FieldKind::Number, + "PAGE" | "NUMPAGES" | "SECTION" | "SECTIONPAGES" | "NUMWORDS" | "NUMCHARS" | "REVNUM" => FieldKind::Number, "REF" | "PAGEREF" => FieldKind::Bookmark, "SEQ" => FieldKind::Sequence, "MERGEFIELD" => FieldKind::Merge, @@ -1574,4 +1580,22 @@ mod tests { f.code = " ".into(); assert!(f.params().is_none()); } + + /// The Field dialog lists Word's document information fields, and the counts take a number + /// format. + #[test] + fn field_dialog_lists_the_document_information_fields() { + let listed: Vec<&str> = FIELDS.iter().filter(|(_, cat, _)| *cat == "Document Information").map(|(n, _, _)| *n).collect(); + for name in ["COMMENTS", "KEYWORDS", "LASTSAVEDBY", "NUMCHARS", "REVNUM", "SUBJECT"] { + assert!(listed.contains(&name), "{name} in {listed:?}"); + } + let a = app(); + let mut f = FieldForm::new(&a); + f.select("REVNUM"); + f.number_format = "ROMAN".into(); + f.preserve = false; + assert_eq!(f.instruction(), r"REVNUM \* ROMAN"); + f.select("SUBJECT"); + assert_eq!(f.instruction(), "SUBJECT"); + } } diff --git a/crates/ui-egui/src/dialogs_layout.rs b/crates/ui-egui/src/dialogs_layout.rs index 877ad276f..e0e0f5222 100644 --- a/crates/ui-egui/src/dialogs_layout.rs +++ b/crates/ui-egui/src/dialogs_layout.rs @@ -35,6 +35,7 @@ pub enum LayoutDialog { Sort(SortForm), SplitCells(SplitCellsForm), TextToTable(TextToTableForm), + TableToText(TableToTextForm), Size(SizeForm), PageNumbers(PageNumbersForm), } @@ -52,6 +53,7 @@ impl LayoutDialog { LayoutDialog::Sort(_) => "tableSort", LayoutDialog::SplitCells(_) => "splitCells", LayoutDialog::TextToTable(_) => "textToTable", + LayoutDialog::TableToText(_) => "tableToText", LayoutDialog::Size(_) => "pictureSize", LayoutDialog::PageNumbers(_) => "pageNumbers", } @@ -70,6 +72,7 @@ impl LayoutDialog { LayoutDialog::Sort(_) => "Sort", LayoutDialog::SplitCells(_) => "Split Cells", LayoutDialog::TextToTable(_) => "Convert Text to Table", + LayoutDialog::TableToText(_) => "Convert to Text", LayoutDialog::Size(_) => "Size", LayoutDialog::PageNumbers(_) => "Page Number Format", } @@ -89,6 +92,7 @@ pub fn open(name: &str, app: &mut WordApp) -> Option { "tableSort" => LayoutDialog::Sort(SortForm::read(app)?), "splitCells" => LayoutDialog::SplitCells(SplitCellsForm::read(app)?), "textToTable" => LayoutDialog::TextToTable(TextToTableForm::read(app)?), + "tableToText" => LayoutDialog::TableToText(TableToTextForm::read(app)?), "pictureSize" => LayoutDialog::Size(SizeForm::read(app)?), "pageNumbers" => LayoutDialog::PageNumbers(PageNumbersForm::read(app)), _ => return None, @@ -108,6 +112,7 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut LayoutDialog) -> bool { LayoutDialog::Sort(f) => sort_ui(app, ui, f), LayoutDialog::SplitCells(f) => split_cells_ui(app, ui, f), LayoutDialog::TextToTable(f) => text_to_table_ui(app, ui, f), + LayoutDialog::TableToText(f) => table_to_text_ui(app, ui, f), LayoutDialog::Size(f) => size_ui(app, ui, f), LayoutDialog::PageNumbers(f) => page_numbers_ui(app, ui, f), } @@ -1120,6 +1125,55 @@ fn text_to_table_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TextToTableForm) -> cancel } +// --------------------------------------------------------------------------------------------- +// Convert to Text + +/// The Convert to Text dialog (Table Layout › Data): what sets the cells of a row apart in the +/// text (`paragraph`, `tab`, `comma` or `other` with `other_char`). It opens on Tabs. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TableToTextForm { + pub separator: String, + pub other_char: String, +} + +impl TableToTextForm { + /// None when the caret isn't in a table. + pub fn read(app: &WordApp) -> Option { + app.session.sel.focus.path.cell()?; + Some(TableToTextForm { separator: "tab".into(), other_char: "-".into() }) + } + + /// `table.toText` parameters for OK. + pub fn params(&self) -> Value { + let mut v = json!({"separator": self.separator}); + if self.separator == "other" { + v["char"] = json!(self.other_char); + } + v + } +} + +fn table_to_text_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TableToTextForm) -> bool { + heading(ui, "Separate text with"); + ui.radio_value(&mut f.separator, "paragraph".to_string(), tl!("Paragraph marks")); + ui.radio_value(&mut f.separator, "tab".to_string(), tl!("Tabs")); + ui.radio_value(&mut f.separator, "comma".to_string(), tl!("Commas")); + ui.horizontal(|ui| { + ui.radio_value(&mut f.separator, "other".to_string(), tl!("Other:")); + let r = ui.add(egui::TextEdit::singleline(&mut f.other_char).char_limit(1).desired_width(24.0)); + if r.changed() { + f.separator = "other".into(); + } + }); + let ready = f.separator != "other" || !f.other_char.is_empty(); + let (ok, cancel) = buttons(ui, "OK", ready); + if ok { + return run(app, "table.toText", f.params()); + } + cancel +} + // --------------------------------------------------------------------------------------------- // Size @@ -1907,6 +1961,29 @@ mod tests { assert_eq!(t.rows.iter().map(|r| r.cells.len()).collect::>(), [3, 3, 3, 3]); } + /// Convert to Text opens its dialog on Tabs; OK converts with the separator chosen. + #[test] + fn table_to_text_opens_its_dialog() { + let mut a = table_app(2, &["a", "b", "c", "d"]); + assert_eq!(a.run("table.toText", json!({})).unwrap(), json!({"pending": "tableToText"})); + assert!(a.session.doc.body.iter().any(|b| b.as_table().is_some()), "nothing converted yet"); + let LayoutDialog::TableToText(mut f) = form(&mut a) else { panic!("table to text") }; + assert_eq!((f.separator.as_str(), f.other_char.as_str()), ("tab", "-")); + assert_eq!(f.params(), json!({"separator": "tab"})); + f.separator = "other".into(); + f.other_char = "/".into(); + assert_eq!(f.params(), json!({"separator": "other", "char": "/"})); + a.run("table.toText", f.params()).unwrap(); + assert!(a.dialog.is_none()); + let texts: Vec<&str> = a.session.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.text.as_str()).collect(); + assert!(texts.contains(&"a/b") && texts.contains(&"c/d"), "{texts:?}"); + assert!(a.session.doc.body.iter().all(|b| b.as_table().is_none())); + + // Outside a table there is nothing to convert. + let mut a = app(); + assert_eq!(a.run("ui.dialog", json!({"name": "tableToText"})).unwrap(), json!({"dialog": null})); + } + /// The envelope sizes go through the interface language's catalog and use the region's /// decimal separator (#1128; the untranslated-text audit found `#10 4.13 × 9.5 in` drawn in /// English). diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index 085f86a61..2285bab08 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -348,6 +348,7 @@ impl AutoCorrectForm { "smartQuotes": o.smart_quotes, "fractions": o.fractions, "ordinals": o.ordinals, + "boldItalic": o.bold_italic, "dashes": o.dashes, "links": o.links, "bullets": o.bullets, @@ -397,6 +398,7 @@ fn autocorrect_ui(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) -> bo ui.checkbox(&mut o.smart_quotes, tl!("Smart quotes instead of straight quotes")); ui.checkbox(&mut o.fractions, tl!("Fractions (1/2) as fraction characters (½)")); ui.checkbox(&mut o.ordinals, tl!("Ordinals (1st) as superscript")); + ui.checkbox(&mut o.bold_italic, tl!("*Bold* and _italic_ as real formatting")); ui.checkbox(&mut o.dashes, tl!("Hyphens (--) as dashes (—)")); ui.checkbox(&mut o.links, tl!("Internet addresses as links")); heading(ui, "Apply as you type"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e7411412b..02aef90c2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2893,6 +2893,8 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> { // Split Cells and Convert Text to Table without settings ask for them (#2592). "table.split" if params.as_object().is_none_or(|m| m.is_empty()) => Some("splitCells"), "table.fromText" if params.as_object().is_none_or(|m| m.is_empty()) => Some("textToTable"), + // Convert to Text without a separator asks for one. + "table.toText" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tableToText"), // Tabs, Borders and Shading, and Page Borders without settings show their dialogs (#320). "para.tabs" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tabs"), "para.borders" if params.as_object().is_none_or(|m| m.is_empty()) => Some("borders"), diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 51b9fa486..51b69514a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2212,12 +2212,16 @@ fn review(app: &mut WordApp, ui: &mut Ui) { }); group(ui, "Changes", None, app, |ui, app| { menu_button(ui, app, "accept", Some("Accept"), "Accept", true, "review.accept", |ui, app| { + mi(ui, app, "Accept and Move to Next", "review.accept", json!({"next": true})); mi(ui, app, "Accept This Change", "review.accept", json!({})); mi(ui, app, "Accept All Changes", "review.acceptAll", json!({})); + mi(ui, app, "Accept All Changes and Stop Tracking", "review.acceptAll", json!({"stopTracking": true})); }); menu_button(ui, app, "reject", Some("Reject"), "Reject", true, "review.reject", |ui, app| { + mi(ui, app, "Reject and Move to Next", "review.reject", json!({"next": true})); mi(ui, app, "Reject Change", "review.reject", json!({})); mi(ui, app, "Reject All Changes", "review.rejectAll", json!({})); + mi(ui, app, "Reject All Changes and Stop Tracking", "review.rejectAll", json!({"stopTracking": true})); }); stack(ui, |ui| { small(ui, app, "prevChange", Some("Previous"), "Previous Change", "review.previousChange", json!({}), false); diff --git a/docs/gaps.md b/docs/gaps.md index e0cf7a6ae..8b660994e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,7 +1,7 @@ # Where WordCraft falls short of Word > **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-25 · **Change:** 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-25 · **Change:** 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: 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). @@ -14,6 +14,8 @@ The work list. One entry per shortfall, ranked by how much it stops a Word user wall-clock estimate, and which parity document tracks it. Agents: pick from the top, check open PRs first (`gh pr list`), and update this file and the parity doc when a gap closes. +egui 0.37 migration record: the interface catalogs remain the shared storytold/craft-i18n ones embedded from `craft-i18n.rev` (`9205108cfdced5177706264038848b68b2422d2b`; 27 WordCraft languages, English fallback), and the equation “Delete Around Caret in Equation” translation is preserved. The input-method path handles character- and byte-counted surrounding deletions, starts a new composition after a body or equation replacement, rejects queued stale replacements before mutation, keeps equation deletion transactional in one undo group, and maps tracked and placeholder length changes. The new regressions are documented, but managed CI passed; native IME acceptance (#155–#164), browser IME behavior and native-speaker catalog review remain open. No ranked gap or readiness estimate changes. + Native save reliability: synthetic cases on macOS reproduced loss through the predictable temporary-file name. Saves now use owned, exclusively created siblings, synchronize before replacement and clean up on failure; originals survive pre-replacement errors. This narrows save-path reliability under gap #3; the listed field failures and broader stability work remain open. See [save-path guarantees](file-format-parity.md#native-save-path-guarantees). Row table border exceptions (`w:tblPrEx/w:tblBorders`) are read, drawn and saved. Other row table exceptions, including widths and cell margins, remain unsupported. @@ -123,6 +125,14 @@ The Language dialog reads effective character formatting, including document def Footer page-table height and body wrapping are corrected. Other pagination gaps remain open. +Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): + +- Convert Table to Text has no "Convert nested tables" check box; nested tables always stay tables, which is Word's default (#4155). +- PAGE with a section page-number format of cardinal or ordinal text (`w:pgNumType`) shows the words in lowercase; only a `\* CardText` or `\* OrdText` switch should lowercase them (#4224). +- A number outside a text format's range (`\* CardText` or `\* OrdText` past 999,999, `\* roman` past 50,000) keeps its digits; Word shows "Error! Number cannot be represented in specified format." (#4224). +- AutoFormat's `*bold*` and `_italic_` don't convert when a closing bracket or quote follows the closing mark (`(*see note*)`): the trigger checks only space and `,.;:!?` (#4098). +- The ribbon's Accept and Reject buttons only open their menus; Word's main half runs Accept (Reject) and Move to Next. The ribbon needs a big split button (Paste needs one too) (#4205). + ## Revision history > 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. @@ -133,6 +143,8 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 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-25 | trivial | Small follow-ups from the field, table and AutoFormat batch (#4232) | | 2026-10-25 | trivial | Highlight decisions include fully selected paragraph marks in whole cells and ordinary ranges, including empty paragraphs; partial text columns exclude unselected marks (#4085). | | 2026-10-25 | trivial | Highlight toggle checks the full target beyond 10,000 runs without collecting resolved formatting; mixed and inherited tails and Undo/Redo covered (#4085). | | 2026-10-24 | trivial | Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index de35548a4..8c46325ec 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**502 of 517 catalog features have commands (97.1%).** +**504 of 520 catalog features have commands (96.9%).** | Tab | Live | Total | |---|---|---| @@ -18,6 +18,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Mailings | 23 | 23 | | Review | 29 | 31 | | View | 25 | 29 | +| Read Mode | 2 | 3 | | Developer | 11 | 11 | | Table Design | 8 | 8 | | Table Layout | 33 | 33 | @@ -62,6 +63,10 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- - Window › Synchronous Scrolling (`view.syncScroll`) - Window › Switch Windows (`view.switchWindows`) +### Read Mode + +- View › Layout (`view.readLayout`) + ### Equation - Tools › Ink Equation (`insert.inkEquation`) diff --git a/docs/roadmap.md b/docs/roadmap.md index b622b4764..586736e25 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # WordCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (M6: Manage Sources imports Word's `Sources.xml`, #2640; previously minor: Czech proofing review fixes; previously minor: Portuguese proofing; previously minor: Polish interface and Polish proofing; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…), and the input-method handling has synthetic regressions while managed CI passed and native acceptance (#155–#164) remains open; previously trivial: M6: Manage Sources imports Word's `Sources.xml`, #2640; previously minor: Czech proofing review fixes; previously minor: Portuguese proofing; previously minor: Polish interface and Polish proofing; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Forward-looking plan. The summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); the numbers are in [`target-app-parity.md`](target-app-parity.md). Hours are @@ -62,7 +62,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay | M12 | Formats breadth (PDF, ODT, RTF, HTML, MD, TXT, LaTeX, `.doc` import) | done (first versions); `.doc` write, encryption, minor formats missing | 60–100 h | | M13 | Performance budgets | on track (1.4 ms relayout); large real-world documents unmeasured | 10–20 h | | M14 | 1.0 polish, packaging, signing | releases v0.1.0–v0.4.0 published (signed macOS, Windows MSI x64/x86/arm64, Linux, FreeBSD, web) | — | -| M15 | Localization (12 key languages, RTL interface, proofing languages) | 15 UI languages; English, Polish and Portuguese proofing plus Czech spelling and generic checks (system Hunspell; English hyphenation fallback), a proofing language per run | 70–115 h | +| M15 | Localization (12 key languages, RTL interface, proofing languages) | 27 UI languages from the shared catalogs pinned by `craft-i18n.rev` (`9205108c…`); proofing: English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish, plus Bulgarian and German hyphenation only; native-speaker review and native IME acceptance (#155–#164) remain open | 70–115 h | | M16 | Right-to-left and East Asian typography | RTL paragraphs done (#207) | 30–45 h | ## Next after beta @@ -74,6 +74,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i | Date | Change | Summary | |---|---|---| +| 2026-10-25 | trivial | egui 0.37 migration: 27 interface languages remain pinned by `craft-i18n.rev` (9205108…); input-method surrounding-delete and commit handling has synthetic regressions only, with managed CI passing; native acceptance (#155–#164) and native-speaker review remain open | | 2026-10-18 | trivial | M6: Manage Sources imports Word's `Sources.xml` (#2640) | | 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks | | 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged | diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 1ece5c017..deeda7308 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; 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 trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; 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: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously trivial: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (`*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; 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 trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; 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: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -70,6 +70,7 @@ Bullets and Numbering decide whether to toggle off from the selected paragraphs' | Undo after a heading list | one step: paragraph styles and the heading styles' numbers come back | `undo_takes_a_heading_preset_back_in_one_step` | | A paragraph whose numbering was switched off, then Heading 2 from a heading-linked list | numbered (1.1) by the style | `applying_a_numbered_heading_style_numbers_the_paragraph` | | `1. ` typed in a Heading 1 (also `a) ` in a Heading 2, `1. ` in a Subtitle) | numbered, the style kept; only body text becomes List Paragraph (checked in Word for Mac, 2026-10-15) | `typing_1_dot_in_a_heading_keeps_the_heading_style` | +| `---` or `+--+--+` Enter in a heading numbered by a heading-linked list, at the top of the document or below another paragraph | stays text, with no border or table, and the heading keeps its number, as in a paragraph numbered directly (WordCraft's rule; not compared with Word) | `border_and_table_lines_stay_text_in_a_style_numbered_heading` | | ⌘Z right after an AutoFormat | undoes only the AutoFormat (`* ` comes back as text) | `undo_right_after_autoformat_undoes_only_the_autoformat` | ## Undo @@ -189,6 +190,20 @@ ideographic space (`autocorrect_finds_the_word_after_a_wide_space`): `capsLock`, `capCells` on `tools.autocorrect`; on by default) (`capitalisation_fixes_undo_and_turn_off`). - `1/2 1/4 3/4` → `½ ¼ ¾`; `1st 22nd 13th` get superscript suffixes (`ordinals_become_superscript`). +- With `*Bold* and _italic_ as real formatting` on (`tools.autocorrect {"boldItalic": true}`; + off by default), `*word*` becomes bold `word` and `_word_` italic `word`, without the marks, + when the word is finished ¹. The rest of this item is WordCraft's own rule: the marks may + enclose several words; WordCraft converts a span only when the opening mark starts a word and + the text inside holds a letter or digit and does not start or end with a space, so `2*3*`, + `file_name_` and `*?!*` stay as typed, and so do marks around a picture, field or content + control. AutoCorrect then goes on with the word without its marks, so `*teh* ` becomes bold + `The `, and it corrects the first word of a span of several words as it is typed, so + `*teh end* ` becomes bold `The end `. Undo right after brings the marks + back (after Enter, the Enter stays). With Track Changes on, WordCraft converts only marks the + current author typed as tracked insertions, and it converts nothing while formatting is + limited to the document's styles (`bold_and_italic_marks_become_formatting`, + `bold_and_italic_marks_stay_as_typed_unless_they_enclose_words`, + `autocorrect_goes_on_after_bold_and_italic_marks`). - `word -- word` → en dash, `word - word` → en dash, `word--word` → em dash (`dashes`). - Web addresses become links; following text isn't linked (`web_addresses_become_links`). - A hyperlink covers only its own text: typing at either end of it, or after Enter at its end, @@ -472,6 +487,7 @@ again. | 2026-10-24 | trivial | Input methods: reject queued old-composition replacements before mutation after body/equation target replacement (new regression validation pending); equation `DeleteSurrounding` is one transactional command whose checked revision proves a receipt; a caret-only step in a mixed request refuses the whole deletion, and an over-cap request clears the painted pre-edit and the previous replacement join; a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, never a later direct commit; a tracked own-insertion shifts the following range; a body deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its replacement undo join to the matching `text.insert`; refusals clear the painted pre-edit and joins; AutoCorrect/AutoFormat classification compares the paragraph as typed; equation replacement interrupts the composition | | 2026-10-24 | trivial | AutoCorrect: a word typed after a no-break space or an ideographic space is corrected as after a plain space | | 2026-10-24 | trivial | Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). | +| 2026-10-24 | trivial | AutoFormat As You Type: `*Bold* and _italic_ as real formatting` turns `*word*` bold and `_word_` italic without the marks; off by default | | 2026-10-23 | trivial | Input methods: a replaced body (Replace Document Text, version restore, Compare, Undo/Redo) starts a new context and interrupts the composition; smart quotes off keeps an IME quote an `InputMethod` change; equation `DeleteSurrounding` erases counted characters; bounded pre-edit/commit sizes; a partly refused two-sided deletion is rolled back | | 2026-10-22 | trivial | Input methods: while composing, the caret and the candidate window follow the input method's cursor inside the marked text | | 2026-10-21 | 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) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 52184d9e4..c974a537d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** trivial (comment times in the user's time zone, in the pane and the balloons, #2322, #2337; previously trivial: Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; 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: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #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 text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; 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 major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** trivial (Review › Accept and Reject menus: Accept/Reject and Move to Next, Accept/Reject All Changes and Stop Tracking; previously trivial: Reading Mode: the parity catalog counts Read Mode's View menu, Layout missing; previously trivial: comment times in the user's time zone, in the pane and the balloons, #2322, #2337; previously trivial: Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; 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: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #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 text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; 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 major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **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). @@ -50,7 +50,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Web Layout width | Text wraps to the whole work area (window width at the current zoom, 144–20,000 pt) and re-wraps once a window resize has settled (not on every frame of a drag) or at once on a zoom, keeping the caret in view; the sheet runs edge to edge with no centred page. Draft and Outline keep their 800 pt column; headless sessions keep 800 pt (#1876) | text across the window | 95% | — | | Scrollbar drag page tip | ✅ while the vertical scrollbar is dragged, a tip beside the pointer names the page at the top of the window in the status bar's words ("Page 7 of 10"), on one line however long the number (#2551, #3341, `canvas::tests::dragging_the_scrollbar_names_the_page_at_the_top`); its look and wording not yet compared with Word's | not compared | 70% | 0.5 | | Web and Draft Layout rendering | Very long pages render only visible full-resolution raster tiles instead of shrinking one huge texture to the GPU size limit; nearby tiles are cached for scrolling | sharp document text | 95% | — | -| Reading Mode | Existing Read Mode ribbon button opens a read-only, viewport-column layout with animated horizontal paging, transient width/text/spacing controls, heading navigation (Navigation pane) and resize anchoring; print/export layout unchanged. The arrows, Page Up/Down and Escape work straight after entering it; only whole columns show, centred, at any width, text size or window size; the columns end above the page-turn bar (#1772); Page color: None (white, the document's page color and page picture not shown), Sepia or Inverse (a dark page with light text), as Word's View › Page Color in Read Mode, starting at None on each entry (`view.readPageColor`) | reading-optimized view and navigation | 75% | 1–2 | +| Reading Mode | Existing Read Mode ribbon button opens a read-only, viewport-column layout with animated horizontal paging, transient width/text/spacing controls, heading navigation (Navigation pane) and resize anchoring; print/export layout unchanged. The arrows, Page Up/Down and Escape work straight after entering it; only whole columns show, centred, at any width, text size or window size; the columns end above the page-turn bar (#1772); Page color: None (white, the document's page color and page picture not shown), Sepia or Inverse (a dark page with light text), as Word's View › Page Color in Read Mode, starting at None on each entry (`view.readPageColor`); Read Mode's View menu: Column Width and Page Color work, Layout is missing ([parity-checklist.md](parity-checklist.md), Read Mode 2/3) | reading-optimized view and navigation | 75% | 1–2 | | Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ (the page is the caret's while the caret is on screen, else the page at the top of the window, so scrolling updates it (#3880); the language is the caret's, by name; a click sets the proofing language, or opens Set Proofing Language… for any other) | ✅ | 85% | — | | Caret motion: keys and typing glide the caret to its new place (100 ms, `cubic-bezier(0, 0, 0, 1)`), the blink fades (530 ms on and off, 100 ms fades); clicks and scripted moves don't glide. File › Options › Advanced › Animation (`ui.caretAnimation`) turns it off. The caret is black or white by contrast with what lies under it (paper, page colour, dark page, shading, highlight), never by the UI theme (#299). On Windows, whose inverting I-beam pointer stays white over the page, the pointer there is drawn the same way: black on light paper, white on dark (#1746) | ✅ as measured from Word for Windows (`caret_glide.rs`); zoom levels other than 100%, modifier keys and off-screen moves not measured | ✅ on Windows; Word for Mac not measured | 90% | 1–2 | | Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312); on a dark page highlights and character shading are dimmed, keeping their hue, so the text on them stays readable; coloured text below 4.5:1 on them is drawn white, and underlines and strikethroughs follow the same rule (#1252); Interface Theme as a searchable View command (light/dark/system, toggle without a value, #1861) | ✅ | ✅ | 90% | — | @@ -78,6 +78,8 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh |---|---|---| | 2026-10-25 | trivial | Text Highlight Color shows the remembered colour on the ribbon and mini-toolbar buttons; No Color leaves the pen without a misleading yellow bar (#4087) | | 2026-10-24 | trivial | Read Mode: a Page color menu (None, Sepia, Inverse) beside Line spacing; the columns are drawn on white, sepia or a dark page, in place of the document's page color (`view.readPageColor`) | +| 2026-10-24 | trivial | Reading Mode: Read Mode's View menu in the parity catalog; Column Width and Page Color work, Layout is missing | +| 2026-10-24 | trivial | Review › Accept and Reject menus: Accept and Move to Next and Reject and Move to Next resolve the change at the caret and select the next one (`review.accept` / `review.reject` `{"next": true}`); Accept All Changes and Stop Tracking and Reject All Changes and Stop Tracking also turn Track Changes off, in one undo step (`review.acceptAll` / `review.rejectAll` `{"stopTracking": true}`) | | 2026-10-23 | trivial | Insert › Table: hovering the grid shows the table on the page, at the caret, as Word's Live Preview does; it follows the pointer and is laid out on a copy, so the document and Undo change only when a square is clicked (#3931) | | 2026-10-23 | trivial | Comment times: the Comments pane and the balloons show each comment's and reply's time in the user's time zone, under its author, as Word does. The file keeps UTC (`w16cex:dateUtc` when Word wrote one), a time without a zone shows as written, and the web app asks the browser for the zone (#2322, #2337) | | 2026-10-22 | trivial | Mail merge: First Record, Last Record, a record number box, and record choices for Edit Individual Documents and Print Documents (#3028) | @@ -123,6 +125,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | 2026-10-18 | trivial | Split Cells on a merged cell: non-divisor row counts refused with a divisor message, 1 × 1 on a merged cell does nothing (#2592 follow-up) | | 2026-10-18 | trivial | Split Cells merged-cell limits and equal parts, Convert Text to Table Initial column width, both from the Word check (#2592) | | 2026-10-16 | trivial | Split Cells and Convert Text to Table dialogs (#2592): 58 modal dialogs | +| 2026-10-24 | trivial | Convert to Text dialog (Paragraph marks, Tabs, Commas, Other); only the selected rows become text | | 2026-10-16 | trivial | Font list: each entry's capitals are centred in its row, so a font with a very tall ascent (Amiri, Amiri Quran) no longer sits low or is cut in half, and the list reads as one even list (#2570) | | 2026-10-16 | trivial | Read Mode keeps the keyboard on entering, shows only whole columns, centred, and ends them above the page-turn bar (#1772) | | 2026-10-16 | trivial | Comment and revision balloons are readable in the dark theme: the card, the markup area, the author stripes and the leader lines come from theme tokens, and the text reaches 4.5:1 (#2045) |