diff --git a/Cargo.lock b/Cargo.lock index 91c94ce265..58c533363e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "krilla", "thiserror 2.0.21", @@ -1135,7 +1135,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "chrono", "dunce", @@ -2981,7 +2981,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "base64 0.22.1", "bumpalo", @@ -7562,7 +7562,6 @@ dependencies = [ "nix", "typed-path", "windows-sys 0.61.2", - "zopfli", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5e126cf324..993a15f583 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,9 +67,9 @@ wuff = "0.2.9" brotli-decompressor = "5" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", default-features = false, features = ["write"] } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. @@ -134,7 +134,7 @@ debug = 0 # Required by craft-pdf: the backend rejects writers that failed during unwinding. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/ROADMAP.md b/ROADMAP.md index 2f78099006..d2606da820 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,6 @@ # WordCraft roadmap +> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (#3398: persistent screen paper colors; parity estimates unchanged); previously minor (#3693: additional Delete Previous Sentence command and shortcut; Word-parity estimates unchanged) > **CLI path update:** 2026-11-04 · Options before a non-Unicode input filename no longer make the CLI reject that filename; text option values still require Unicode; `run --file` and `run --script` retain native paths. No ranked gap or readiness estimate changes. > **Floating anchor row segments:** 2026-11-07 · A floating anchor after a manual page/column break counts visible content on the far side of the same wrapped row. Marker-only cover anchors retain their previous-page rule. Synthetic picture spacing, placement and history controls; no new native Word measurement. Readiness estimates unchanged. @@ -7,7 +8,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float anchored on a later page of a long paragraph is drawn there, #4250); previously minor (progress log: `file.closeDocument` over the control channel and MCP bridge is refused while the document it names has unsaved changes; previously trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: Shape Format › Size in the interface unit, #4196); previously trivial (progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, observed in Word for Windows on #4938 (#4917)); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: justified and distributed lines keep the width of the spaces before their first character); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (progress log: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (progress log: the Print panel's printer list no longer waits on printer drivers, #5710); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (progress log: Times and Courier take their Windows counterparts' line spacing; 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 (progress log: the status bar's count can be words, characters or characters without spaces, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (progress log: Linux Read Aloud through Speech Dispatcher, #4576); previously trivial (progress log: a click just outside a floating table by turned cell text, #4181; 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 (progress log: MHT quoted MIME parameters); previously trivial (progress log: CLI convert format options); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` is preserved through style resolution and ordered DOCX saves); previously trivial (per-page ink object lookups, #3614); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured); previously trivial (page-number preservation review follow-up, #2201; refreshed field results retained) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** trivial (progress log: ODT columns stating only their shares); previously trivial (progress log: ODT font size for every script); previously trivial (progress log: ODT text turning off what its style turns on); previously trivial (progress log: edited ODT paragraphs keep custom styles with an outline level); previously trivial (progress log: ODT pictures keep the size their file gives); previously trivial (progress log: ODT frames keep the page they are anchored to); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux no longer looks semi-bold); previously trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial (progress log: a shape dropped just after a page break inside a paragraph stays on the new page, #4262); previously trivial (progress log: a float anchored on a later page of a long paragraph is drawn there, #4250); previously minor (progress log: `file.closeDocument` over the control channel and MCP bridge is refused while the document it names has unsaved changes; previously trivial (progress log: ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: Shape Format › Size in the interface unit, #4196); previously trivial (progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, observed in Word for Windows on #4938 (#4917)); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: justified and distributed lines keep the width of the spaces before their first character); previously trivial (print helpers erase loaded plaintext inputs before driver work, #5710); previously trivial (Windows print submissions use isolated helpers so a cancelled driver cannot block a retry, #5710); previously trivial (print cancellation remains available without a document; sending-boundary limitation clarified, #5710); previously trivial (progress log: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it, #5710); previously trivial (progress log: the Print panel's printer list no longer waits on printer drivers, #5710); previously trivial (Page drawing indexes text-box warp lookups per paragraph (#3614)); previously trivial (built-in citation source lookups, #4888; current-main progress records retained); previously trivial (progress log: Times and Courier take their Windows counterparts' line spacing; previously trivial (progress log: the status bar's count can be words, characters or characters without spaces, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (progress log: Linux Read Aloud through Speech Dispatcher, #4576); previously trivial (progress log: a click just outside a floating table by turned cell text, #4181; 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: MHT quoted MIME parameters); previously trivial (progress log: CLI convert format options); previously trivial (MATH construction reuse, #3630); previously trivial (DOCX `w:adjustRightInd` is preserved through style resolution and ordered DOCX saves); previously trivial (per-page ink object lookups, #3614); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; progress docs restructured); previously trivial (progress log: ODT documents keep their language); previously trivial (progress log: ODT table and row backgrounds); previously trivial (page-number preservation review follow-up, #2201; refreshed field results retained) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > > **Recipient CSV update:** 2026-10-27 · Complete quoted header records choose the delimiter (#4249); detection and regressions follow the current mailings module split. Recipient limits and readiness estimates are unchanged. > @@ -151,6 +152,14 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-11-05: ODT tables with a usable width keep their column proportions when the file gives only shares (gap 19): LibreOffice's CV template opened and saved with equal columns. +- 2026-11-05: ODT font sizes reach Asian and complex-script text (gap 19): Chinese text set to 24 pt stayed at the default size in LibreOffice. +- 2026-11-05: ODT text can turn off what its style turns on (gap 19): a word not bold in a bold heading showed bold, and came back bold in LibreOffice after a save. +- 2026-11-06: An edited ODT paragraph keeps its custom paragraph style when that style has an outline level, or is based on a heading, Title, Quotations or Preformatted Text (gap 19). It was saved under the built-in style instead, such as WordCraft's blue Heading 1, and looked wrong in LibreOffice; a style with an outline level also drew that way in WordCraft. The style's outline level and numbering still work in WordCraft. +- 2026-11-05: ODT pictures keep the size their file gives (gap 19): opening a file shrank ones wider than the text, so a save with no edit to them changed how LibreOffice laid them out. +- 2026-11-05: ODT frames keep the page they are anchored to (gap 19): written without its number, a frame on page 3 came back on page 1 in LibreOffice after a save. +- 2026-11-05: ODT documents keep their language (gap 19). It was lost on save after numbering or a new style, and LibreOffice then showed the document in its own default language; a document in another language was also proofed as English. +- 2026-11-05: ODT table and row backgrounds are kept (gap 19): they were dropped, and gone in LibreOffice after a save. - 2026-11-08: Layout: a turned inline picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`, as other tools write) keeps its line to its frame and overhangs it, cut at the line's top and bottom, as Word does: the frame as it is in Word 2007 layout, with width and height exchanged from 45° to 135° and 225° to 315° in Word 2013+ layout. Such a file saves the same way. Pictures with the room recorded (Word's own files) still take their rotated bounds. Readiness unchanged. - 2026-11-08: XML text and CDATA read with their line ends normalised (XML 1.0 §2.11): CR LF and a lone CR are one line feed, ` ` stays a CR, so a line break inside `w:t` in a file saved with Windows line ends is one space, not two (#5897); readiness estimates unchanged. - 2026-11-08: Layout: contextual spacing drops the space after a table cell's last paragraph when that paragraph is in the Normal style, the end-of-cell mark counting as a Normal paragraph next to it; a last paragraph in another style keeps its space after. The rule is inferred from docxide-pdf's notes and its `cell-end-*` fixtures (`cell-end-text-context`, `cell-end-empty-context`); a Word comparison that tells it apart from the neighbouring paragraph's style is still open. Readiness unchanged. @@ -283,6 +292,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-31: Table of contents: with Word's table over entries in columns, where the title sits in a one-column section above a continuous break, Insert Table of Contents with new settings keeps the title above the break and the entries in their columns (the new title went under the break, into the columns), and Remove Table of Contents takes the title, the blank paragraph and its break with the entries, as Word does (observed in Word for Mac); a paragraph of another style above the break stays (#4041). - 2026-10-31: A table of contents writes its page numbers as `PAGEREF` fields to the headings' bookmarks, as Word does, so updating the fields in Word moves the numbers with the headings; they were plain text (#5097). - 2026-10-31: Keytips reach the Backstage: Alt, F badges its pages with Word's letters, and Alt, F, P (or V) opens Print (#4960). +- **2026-10-31 (#3398):** General Options offers persistent Document background / Soft green / Warm cream / Soft gray screen paper. The view.paperColor command reaches the same setting headlessly. Document page colors and pictures, saves, printing and exports retain their own styling; the screen raster/tile cache and page backing share the selected color. Read Mode None uses the preset; explicit Sepia/Inverse keep priority. Parity counts and readiness estimates unchanged. +- **2026-10-31 (#3693, editing extension):** Delete Previous Sentence is a headless registry command and Mod+Shift+Backspace shortcut, using existing sentence boundaries and deletion/undo/protection paths. Paragraph and cell boundaries are preserved. Additional WordCraft behavior; Word-parity counts and estimates unchanged. - 2026-10-29: Documents with hyperlinks or a table of contents print again on the desktop (#4734). The print job scales each page to the paper, and the spooler refused a page with link annotations ("flatten page annotations before using shrink-to-fit"); the PDF sent to the printer now omits link annotations, which print nothing; the link and table-of-contents text stays on the page. Exported PDFs and Save these pages as PDF (`file.printPdf`) keep the annotations. - 2026-10-28: Word count counts each Chinese and Japanese character as a word, as Word does, in the status bar, the Word Count dialog and NUMWORDS fields. Unspaced Chinese text used to count as one word per run of text; Korean, which is written with spaces, still counts by words. (#4547) - 2026-10-28: Aligned-copy review fixes keep the document axis locked through middle-button scrolling and let Ctrl/Command+Shift start on a secondary selected object. Plain Ctrl and Shift clicks keep their existing behavior (#4764). @@ -373,6 +384,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target - 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096). | Date | What landed | +| 2026-11-05 | trivial | Progress log: ODT documents keep their language (gap 19) | | 2026-11-04 | The CLI opens a non-Unicode input filename even after options such as `--page 2` or `--format flatopc`; text option values still require Unicode; `run --file` and `run --script` retain native paths. | | 2026-11-06 | Accept All and Reject All no longer restyle the paragraph after an empty one carrying a section break (#5667): a Compare that added a section, then Reject All, left `Body` in the removed paragraph's Normal style and indents. The empty paragraph now goes whole and its section joins the following one, as deleting the break does. | | 2026-11-07 | Deleting a preceding empty paragraph keeps the following paragraph's style, outline and mark formatting rather than promoting body text to a heading (Leaf #152). Non-empty merges and section absorption retain their existing rules; synthetic model, command, DOCX and Undo/Redo coverage. | @@ -444,12 +456,21 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-19 | Format Page Numbers (Insert › Header & Footer › Page Number ▸ Format Page Numbers…, #1113): the dialog sets the number format (1, 2, 3 / I, II, III / i, ii, iii / A, B, C / a, b, c) and whether the pages number on from the section before or start at a number; the ribbon item was a plain command call that did nothing and cleared the section's start | | 2026-10-19 | A picture, shape or text box dragged near the bottom of a page lands where it is dropped. It used to jump far below the page, cut off at its edge with only its selection box showing over the next page (#3169) | | 2026-10-19 | Insert Caption picks the label used last, as Word does, and keeps it between runs (#3120). | +| 2026-10-20 | Pinned layout test fonts also initialize before the new caret-height and ligature fixtures, keeping isolated test runs independent of system fonts (#2729). | +| 2026-10-19 | Ctrl/⌘+wheel and pinch zoom feel lighter: while the zoom moves, each page's last raster is stretched to the new size instead of rendered at every in-between size; it is rendered once the zoom stops, or again whenever stretching would pass 1.5x. On a 4K screen at 145 % the frames of a zoom went from 11–15 ms to 0–1 ms, one notch from 12 page renders to 1. Wheel steps with animation effects off still land finished in their own frame. | +| 2026-10-19 | Dark-theme interface text on Linux and the BSDs no longer looks semi-bold: egui thickened light-on-dark glyph edges by default (about 21 % more ink than Firefox draws the same text); it now adds a light 7 %, chosen side by side on a 145 % desktop (Firefox's plain weight looked soft on the dark tab row). Light-theme interface text was measured against headless Firefox and already matches it (Inter's ttfautohint hints draw as FreeType's light autohinting does). Headless screenshots take a scale (`{"ppp": 1.45}` in `ui_shot`) to see text as a fractional desktop scale draws it. | +| 2026-10-19 | Page text on Linux and the BSDs is no longer soft: it is hinted the way FreeType's `hintslight` draws text in Firefox and other apps there, its autohinter putting baselines, x-heights and cap heights on whole pixels without moving anything sideways, so lines break as before. Measured against headless Firefox on Arial at 21.27 px: the same rows of pixels and the same weight. Re-rendering costs the same; the first render at a new zoom about 1.5 ms more per page. macOS, Windows and exports are unchanged. | | 2026-10-19 | Save As, Export and Open no longer do nothing on Linux desktops whose file chooser portal fails to show its dialog (#2999): a "cancel" quicker than anyone could make one asks again without the parent window, then opens the built-in file browser. | | 2026-10-20 | Russian sorting keeps ё with е and recognises decomposed ё/й: case-insensitive words and index headings share the corrected collation, with regressions and no new dependencies. | | 2026-10-18 | Long navigation headings wrap onto further lines inside the resizable pane instead of ending in an ellipsis: the row grows with the text and the pane keeps its width (#759, #3015). | +| 2026-10-17 | Layout unit tests use the pinned bundled fonts without system/user fonts or optional craft-fonts inputs (#2729); every test initializes the fixture before font lookup, and pagination fixtures are measured against that fixed set | | 2026-10-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). | | 2026-10-19 | Centred numbered headings after a leading page break keep their number before the text (#2964). The label uses the alignment of the line it is drawn on; heading text positions and page count stay the same. | | 2026-10-17 | Edit Recipient List finds records (#1576): a Find in list box hides rows with no cell containing the text (any case) and counts the matches, and "Show only records that will be merged" hides the rows the filter leaves out; both only change the view. Rows are striped, and edits and removes go to the right record however the grid is narrowed | +| 2026-10-17 | Recent documents can be taken off the list (#2716): hovering a row in File › Home/Open ▸ Recent shows a × that removes that document, a Clear all button under the list empties it, and the change is written to the preferences right away, so it survives a crash. `ui.recentRemove {"path"}` and `ui.recentClear` do the same for CLI, control and MCP | +| 2026-10-31 | Control/MCP #2596 review: write/flush failures after sending begins are outcome-unknown; image exports to a path are not replayed; accept-thread startup failures disable the server and remove the key; invalid UTF-8 is rejected per line without ending stdio. Synthetic regressions cover partial JSON delivery at EOF, flush failure, export vs in-memory retry and failed startup. | +| 2026-10-16 | Control/MCP delivery (#1326, #1331): the MCP bridge never re-sends a mutation whose outcome is unknown (explicit "outcome unknown" error; reads and not-sent requests still retry); a control request whose caller timed out while queued never runs, and a timeout before execution is told apart from an unknown outcome; bounded MCP and reply line reads; capped unauthenticated control connections with fallible thread creation | +| 2026-10-16 | MCP `insert_table` tool (#2242): a filled table in one call from `values`, with `header` (repeating first row) and `style`; `insert.spreadsheet` takes the same `values`/`header`/`style` and rejects ragged, oversized or non-array input | | 2026-10-17 | AccessKit focus guard (#2726): every tree update egui sends carries its focused node, so a frame that drops it falls back to the root instead of panicking in `accesskit_consumer` and closing the window on Windows | | 2026-10-17 | Equation arrays centre a row that has no `&` among rows that do, as Word does, so case equations like Word's "First Property of Radicals" no longer push the aligned row right (#2617). | | 2026-10-17 | Formatting revisions: a table, row, cell or section property change made while tracking now records as `w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange` (structure stays out), so Reject All restores and Accept All keeps it; test tracked_table_changes_are_kept_as_formatting_changes | @@ -522,6 +543,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-17 | Non-overlapping body floating tables clear header tables. Original native controls distinguish never-overlap from allowed overlap. | | 2026-10-14 | Comment text being typed is no longer lost or misplaced (#1348): an edit or reply the document refuses (for example read-only protection) keeps the typed text in its field and says why on the status line; drafts belong to their document, so after New or Open a comment with the same number shows its own text; Save, Export, Print, Close, New and Open write pending comment text first; if the document refuses it, Save, Export and Print carry on with the stored comments and show why on the status line (a refused draft is dropped when Save is the only change), while Close, New and Open show the Save Changes prompt (Don't Save drops text the document refuses); Escape discards what was typed; in a document protected as read only or for forms the comment fields can't be typed in | |---|---| +| 2026-10-12 | RTF reads and writes footnotes, endnotes and comments (author, initials, date, commented range), so they survive a round trip through RTF (#1053) | | 2026-11-07 | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). | | 2026-11-05 | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | | 2026-10-29 | Print Markup discovers comment balloon anchors in one forward paragraph-object walk; anchor positions, filtering and the existing cap stay unchanged (#3614) | @@ -529,6 +551,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-22 | Documents that keep content in another format (`w:altChunk`: a web page, RTF, text or another Word document, as generators and mail-merge tools write them) show it where it stands, as Word does, instead of leaving it out; one such file in the docxide-pdf corpus opened as an empty page and now matches Word's 8 pages. `.mht` web archives open, and web pages open the way Word opens them (style sheets, paragraph margins, Times New Roman, single lines, automatic spacing). Insert › Text from File keeps the inserted file's pictures | | 2026-10-18 | Explicit New/Open keeps the requested document visible when startup recovery discovery finishes; older copies stay available. Recover uses the regular theme. See docs/recovery.md. | | 2026-10-15 | A floating picture or shape in a table cell set to align centre or bottom no longer lands half its height above its line (#1887); it starts at the anchor line | +| 2026-10-26 | View › Window across the native windows (#322): Arrange All, View Side by Side, Synchronous Scrolling | | 2026-10-18 | Picture Format › Size (#2573): width and height in the interface unit, and a size typed in any unit works, pixels too (`300 px` keeps the picture's proportions, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels) in one undo step. The group's launcher opens a Size dialog: height and width, scale of the original size (or of the size it opened with), Lock aspect ratio, the original size with its pixels, and Reset. `picture.size {"original": true, "scale"?}` sizes from the original; sizes past 4000 pt that keep the proportions shrink as a whole. | | 2026-10-16 | Save Changes gives assistive technology a named alert dialog with the loss warning and any save failure; visual layout and save policy unchanged (#1015). Native screen-reader verification remains pending. | | 2026-10-17 | `design.scaleFontSizes` (Design › Document Formatting): grow or shrink every style's font size at once, by points (`delta`) or percent, rounded to half points and clamped to 1–1638 pt; inherited sizes follow, runs are untouched, one undo step (#1475) | @@ -889,6 +912,8 @@ MATH glyphs that reference the same construction now share its immutable variant | Date | Change | Summary | |---|---|---| +| 2026-11-06 | trivial | Progress log: edited ODT paragraphs keep custom styles with an outline level (gap 19) | +| 2026-10-31 | trivial | Control/MCP #2596 review fixes; readiness estimates unchanged | | 2026-11-08 | trivial | Progress log: XML line ends in text normalised (#5897) | | 2026-11-08 | trivial | Progress log: contextual spacing drops the space after a cell's last Normal paragraph (#5450) | | 2026-11-06 | trivial | Progress log: the Arabic interface's Styles gallery reads from the right (#5566) | @@ -918,6 +943,12 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` is preserved with true, false and absent values; OOXML properties follow schema order. Grid layout remains open. | | 2026-11-04 | trivial | Progress log: RTF Unicode fallback counts; readiness estimates unchanged | | 2026-10-28 | trivial | Per-page ink object lookups, #3614. Drawing order, geometry and saved data retain their behavior; no readiness estimate change | +| 2026-11-05 | trivial | Progress log: ODT columns stating only their shares (gap 19) | +| 2026-11-05 | trivial | Progress log: ODT font size for every script (gap 19) | +| 2026-11-05 | trivial | Progress log: ODT text turning off what its style turns on (gap 19) | +| 2026-11-05 | trivial | Progress log: ODT pictures keep the size their file gives (gap 19) | +| 2026-11-05 | trivial | Progress log: ODT frames keep the page they are anchored to (gap 19) | +| 2026-11-05 | trivial | Progress log: ODT table and row backgrounds (gap 19) | | 2026-11-05 | trivial | Progress log: ODT heading text of another size (gap 19) | | 2026-11-05 | trivial | Progress log: ODT table rows that don't break (gap 19) | | 2026-11-05 | trivial | Progress log: a watermark or odd and even headers change to a file is saved | @@ -945,6 +976,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-03 | trivial | Progress log: Language pack languages in every language list (CP4.3) | | 2026-11-03 | trivial | Progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language (CP4.3) | | 2026-11-03 | trivial | Progress log: Options › Language and Proofing redesign (CP4.3) | +| 2026-11-03 | minor | Home › Paragraph Tools (#5043): nine selection- or document-scoped conversion and cleanup commands; one undo step per command, with tracked deletions preserved; readiness estimates unchanged | | 2026-10-30 | trivial | Progress log: layout resource caps — repeated table headers are bounded by item counts, the wrap cache by decoded pixels and linked text box layout by its lines and items; PDF export, hit testing, tracked changes, clipped tables and long endnote selections cost linear time (#3867, #3866) | | 2026-10-17 | trivial | Progress log: RTF on the system clipboard | | 2026-11-03 | trivial | Progress log: craft-proof pin, French elision suggestions and Valencian (CP4.3) | @@ -973,8 +1005,10 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-01 | trivial | #3915: preserve guarded whole-line ordinary-tab alignment with current main. Existing controls retained; source checks only, primary qualification pending. | | 2026-11-01 | trivial | #2437: Preserve finite paragraph-border flow extents and terminal-break keep preflight with current-main changes. Recursive terminal-successor lookahead remains outside scope. Source checks only; primary qualification pending. | | 2026-10-31 | trivial | Progress log: a paragraph naming an undefined DOCX style is formatted as Normal (#5077) | +| 2026-10-31 | minor | #3398: persistent screen-only green, cream and gray paper presets, with document backgrounds and export colors retained. Read Mode None follows the screen preset; Sepia/Inverse retain priority. | | 2026-10-31 | trivial | Progress log: TOC page numbers are PAGEREF fields (#5097) | | 2026-10-31 | trivial | Progress log: Backstage keytips, Alt, F, P prints (#4960) | +| 2026-10-31 | minor | #3693: Delete Previous Sentence command and keyboard dispatch, with sentence/paragraph/cell, selection, undo and tracking regressions. Additional WordCraft behavior; Word-parity estimates unchanged. | | 2026-10-30 | trivial | Progress log: only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them (#4734) | | 2026-10-29 | trivial | Progress log: documents with links print (#4734) | | 2026-10-31 | trivial | Synchronize the existing Proofing pane with main `eecbb8f`; preserve explicit-position lookup, implicit selection, navigation, undo and per-window state; fresh exact-SHA hosted validation pending, readiness unchanged | @@ -1072,6 +1106,9 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-10-19 | trivial | A right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, starts the next line with its text instead of ending the line (a TOC entry's page number keeps its leader); a left tab still past the right indent there takes no room (#1840) | | 2026-10-19 | trivial | Progress log: a picture dragged near the bottom of a page lands there (#3169) | | 2026-10-19 | trivial | Progress log: a Thai run with Latin finds one installed font covering it all on Linux too (#1549) | +| 2026-10-19 | trivial | Progress log: pointer zoom no longer re-renders pages every frame | +| 2026-10-19 | trivial | Progress log: dark-theme interface text on Linux no longer looks semi-bold | +| 2026-10-19 | trivial | Progress log: page text on Linux is hinted like Firefox draws text there | | 2026-10-20 | trivial | Progress log: Times and Courier take their Windows counterparts' line spacing | | 2026-10-19 | trivial | Progress log: forms protection keeps its content controls: no inserting, Properties or Remove Content Control | | 2026-10-19 | trivial | Progress log: text typed into a content control takes the control's formatting; localized placeholder styles recognised | @@ -1079,11 +1116,13 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-10-17 | trivial | A line breaks after a hyphen before a digit, like Word (#2712) | | 2026-10-15 | trivial | Progress log: text above a float's anchor wraps around it, #2048 | | 2026-10-18 | trivial | Progress log: long navigation headings wrap within the pane instead of ending in an ellipsis (#3015) | +| 2026-10-26 | trivial | View › Window across the native windows (#322): View catalog 26/29 → 29/29 | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | | 2026-10-18 | trivial | A word after a slash wraps whole, like Word (#2963) | | 2026-10-19 | trivial | Progress log: render threads reused while typing (#3027) | | 2026-10-19 | trivial | #2964: a centred or end-aligned list label after a leading page break uses its text line's alignment. Synthetic LTR/RTL numbered/bulleted controls and the issue's DOCX export; broader pagination gaps and readiness estimates unchanged | | 2026-10-17 | trivial | Progress log: recipient list search and merged-only view (#1576) | +| 2026-10-17 | trivial | Progress log: removing documents from the recent list (#2716) | | 2026-10-18 | trivial | Progress log: a single selected cell's border drags in its row only | | 2026-10-18 | trivial | Progress log: symbols keep their font when the text font changes (#2036) | | 2026-10-17 | trivial | Progress log: Restrict Editing guards tables, Paste Formatting and merge fields (#1327) | diff --git a/apps/wordcraft/src/control_server.rs b/apps/wordcraft/src/control_server.rs index 2b8f4137d7..ba8553ec35 100644 --- a/apps/wordcraft/src/control_server.rs +++ b/apps/wordcraft/src/control_server.rs @@ -9,6 +9,7 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, Sender, channel}; use std::time::{Duration, Instant}; @@ -21,6 +22,38 @@ const MAX_LINE: usize = 1 << 20; /// A connection that sends no authorised request in this time is closed. const FIRST_REQUEST: Duration = Duration::from_secs(30); +/// How long a caller waits for the UI to answer a request. A request still queued after this +/// is cancelled and never runs. +const REPLY_WAIT: Duration = Duration::from_secs(60); + +/// Connections that have not yet sent an authorised request are limited to this many; more are +/// closed at once. Each one holds a thread until its first request or [`FIRST_REQUEST`]. +const MAX_UNAUTHENTICATED: usize = 64; + +/// Counts one unauthenticated connection until it authenticates or ends. +struct Admission(Option>); + +impl Admission { + fn enter(count: &Arc, max: usize) -> Option { + if count.fetch_add(1, Ordering::SeqCst) >= max { + count.fetch_sub(1, Ordering::SeqCst); + return None; + } + Some(Self(Some(count.clone()))) + } + fn release(&mut self) { + if let Some(c) = self.0.take() { + c.fetch_sub(1, Ordering::SeqCst); + } + } +} + +impl Drop for Admission { + fn drop(&mut self) { + self.release(); + } +} + /// The key file of a running control server; [`KeyFile::remove`] it when the window closes. pub struct KeyFile(PathBuf); @@ -37,6 +70,15 @@ impl KeyFile { /// cannot be bound or there is no random key (the control channel is off); the key file is /// `None` when it could not be written (requests are then refused). pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(Receiver, Option)> { + start_with(port, ctx, settings, |listener, ctx, key| serve_on(listener, ctx, key, FIRST_REQUEST)) +} + +fn start_with( + port: u16, + ctx: egui::Context, + settings: Option<&Path>, + serve: impl FnOnce(TcpListener, egui::Context, String) -> std::io::Result>, +) -> Option<(Receiver, Option)> { let listener = match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => { @@ -67,11 +109,21 @@ pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<( None } }; + let rx = match serve(listener, ctx, key) { + Ok(rx) => rx, + Err(e) => { + if let Some(f) = &key_file { + f.remove(); + } + log::error!("control server off: cannot start the accept thread ({e})"); + return None; + } + }; match &key_file { Some(f) => log::info!("control server listening on 127.0.0.1:{port}, key in {}", f.path().display()), None => log::info!("control server listening on 127.0.0.1:{port}"), } - Some((serve_on(listener, ctx, key, FIRST_REQUEST), key_file)) + Some((rx, key_file)) } /// A new key: 32 random bytes from the operating system, as hex. @@ -103,16 +155,47 @@ fn keys_match(given: &str, key: &str) -> bool { !b.is_empty() && a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } -fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> Receiver { +fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> std::io::Result> { + serve_with(listener, ctx, key, Limits { first, wait: REPLY_WAIT, max_unauthenticated: MAX_UNAUTHENTICATED }) +} + +/// The time and count budgets of a server (small ones in tests). +#[derive(Clone, Copy)] +struct Limits { + first: Duration, + wait: Duration, + max_unauthenticated: usize, +} + +fn serve_with(listener: TcpListener, ctx: egui::Context, key: String, limits: Limits) -> std::io::Result> { + serve_with_spawner(listener, ctx, key, limits, |accept| std::thread::Builder::new().name("control-accept".into()).spawn(accept).map(|_| ())) +} + +fn serve_with_spawner( + listener: TcpListener, + ctx: egui::Context, + key: String, + limits: Limits, + spawn: impl FnOnce(Box) -> std::io::Result<()>, +) -> std::io::Result> { let (tx, rx) = channel::(); let key: Arc = key.into(); - std::thread::spawn(move || { + let unauthenticated = Arc::new(AtomicUsize::new(0)); + let accept = move || { for stream in listener.incoming().flatten() { + let Some(admission) = Admission::enter(&unauthenticated, limits.max_unauthenticated) else { + log::warn!("control server: too many unauthenticated connections, one closed"); + continue; + }; let (tx, ctx, key) = (tx.clone(), ctx.clone(), key.clone()); - std::thread::spawn(move || serve(stream, tx, ctx, &key, first)); + // A thread that cannot start closes this connection only (the guard releases its slot). + if let Err(e) = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx, &key, limits, admission)) { + log::warn!("control server: no thread for a connection: {e}"); + } } - }); - rx + }; + spawn(Box::new(accept))?; + Ok(rx) } /// The read side of a connection with an optional overall deadline: each read waits only for @@ -175,11 +258,11 @@ fn discard_rest_of_line(r: &mut BufReader) { } } -fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context, key: &str, first: Duration) { +fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context, key: &str, limits: Limits, mut admission: Admission) { let Ok(read) = stream.try_clone() else { return }; let mut out = stream; // One overall deadline from accept to the first authorised request; after it, none. - let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + first) }); + let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + limits.first) }); let mut authorised = false; // The key an `auth` request gave this connection. let mut conn_key: Option = None; @@ -228,6 +311,7 @@ fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context, key: } if !authorised { authorised = true; + admission.release(); reader.get_mut().set_deadline(None); } if method == "auth" { @@ -237,12 +321,18 @@ fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context, key: } continue; } - let (req, rrx) = ControlRequest::new(method, params); + let (req, rrx, waiter) = ControlRequest::with_waiter(method, params); if tx.send(req).is_err() { break; } ctx.request_repaint(); - let r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"})); + let r = rrx.recv_timeout(limits.wait).unwrap_or_else(|_| { + if waiter.cancel() { + json!({"ok": false, "error": "timeout before execution: the request was not run and never will be"}) + } else { + json!({"ok": false, "error": "timeout: outcome unknown, the request had started when the wait ended; inspect the document to check"}) + } + }); if !reply(r) { break; } @@ -260,7 +350,7 @@ mod tests { fn boot_with(first: Duration) -> (u16, Receiver) { let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); let port = listener.local_addr().unwrap().port(); - (port, serve_on(listener, egui::Context::default(), KEY.to_string(), first)) + (port, serve_on(listener, egui::Context::default(), KEY.to_string(), first).unwrap()) } fn boot() -> (u16, Receiver) { @@ -512,6 +602,40 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn an_accept_thread_spawn_failure_aborts_start_and_removes_the_key() { + let dir = temp_dir("spawn-failure"); + let path = wordcraft_control_key::key_path(&dir, 0); + let mut bound = None; + let result = start_with(0, egui::Context::default(), Some(&dir), |listener, ctx, key| { + bound = Some(listener.local_addr().unwrap()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), key, "startup wrote the key before spawning"); + serve_with_spawner(listener, ctx, key, small(150, 8), |accept| { + drop(accept); + Err(std::io::Error::other("injected accept thread spawn failure")) + }) + }); + assert!(result.is_none(), "a server without its accept thread must not report success"); + assert!(!path.exists(), "failed startup must remove its published key"); + let _listener = TcpListener::bind(bound.unwrap()).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_complete_json_request_at_eof_can_reach_the_ui() { + // This is why a bridge write failure before the newline is outcome-unknown. + let (port, rx) = boot(); + let mut c = Conn::open(port); + let body = json!({"id": 1, "key": KEY, "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "once"}}}); + c.1.write_all(body.to_string().as_bytes()).unwrap(); + c.1.shutdown(std::net::Shutdown::Write).unwrap(); + let req = answer(&rx); + assert_eq!(req.params["params"]["text"], "once"); + assert_eq!(c.recv()["ok"], true); + assert!(c.closed()); + assert!(rx.try_recv().is_err()); + } + #[test] fn a_stale_key_file_is_replaced() { let dir = temp_dir("stale"); @@ -532,4 +656,52 @@ mod tests { } let _ = std::fs::remove_dir_all(&dir); } + + fn boot_limits(limits: Limits) -> (u16, Receiver) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + (port, serve_with(listener, egui::Context::default(), KEY.to_string(), limits).unwrap()) + } + + fn small(wait_ms: u64, max_unauthenticated: usize) -> Limits { + Limits { first: FIRST_REQUEST, wait: Duration::from_millis(wait_ms), max_unauthenticated } + } + + #[test] + fn a_request_that_timed_out_in_the_queue_never_runs() { + let (port, rx) = boot_limits(small(150, 8)); + let mut c = Conn::open(port); + let r = c.call(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#)); + assert_eq!(r["ok"], false); + assert!(r["error"].as_str().unwrap_or("").starts_with("timeout before execution"), "{r}"); + // The UI gets to it late: it must not run it. + let req = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(!req.start(), "a cancelled request was started"); + } + + #[test] + fn a_request_that_started_before_the_timeout_reports_an_unknown_outcome() { + let (port, rx) = boot_limits(small(150, 8)); + let mut c = Conn::open(port); + c.send(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#)); + let req = rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(req.start()); + let r = c.recv(); + assert!(r["error"].as_str().unwrap_or("").contains("outcome unknown"), "{r}"); + } + + #[test] + fn unauthenticated_connections_are_limited() { + let (port, rx) = boot_limits(small(5_000, 2)); + let mut idle = [Conn::open(port), Conn::open(port)]; + // Give the server time to count both. + std::thread::sleep(Duration::from_millis(200)); + assert!(Conn::open(port).closed(), "the third idle connection was admitted"); + // One authenticates: its slot is free again. + assert_eq!(idle[0].call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true); + std::thread::sleep(Duration::from_millis(200)); + let mut again = Conn::open(port); + assert_eq!(again.call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true); + assert!(rx.try_recv().is_err()); + } } diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 83dc231769..2360a2e510 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -133,6 +133,7 @@ impl eframe::App for App { if let Some(key_file) = &self.2 { key_file.remove(); } + self.0.start_pending_update(); } } @@ -192,6 +193,8 @@ fn schedule_macos_terminate(app: &mut WordApp, key_file: Option<&control_server: if let Some(key_file) = key_file { key_file.remove(); } + // `terminate:` ends the process without dropping the app, so a ready update's helper starts here. + app.start_pending_update(); dispatch2::DispatchQueue::main().exec_async(|| { if let Some(mtm) = objc2::MainThreadMarker::new() { objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None); @@ -299,25 +302,24 @@ fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Opt } fn save_prefs(app: &WordApp) { + if let Err(e) = try_save_prefs(app) { + log::warn!("preferences: {e}"); + } +} + +/// Saves the preferences and says why when it could not. `Ok(false)`: nothing was written +/// because preferences are switched off or there is no settings folder. +fn try_save_prefs(app: &WordApp) -> Result { if !prefs_enabled() { - return; + return Ok(false); } - if let Some(p) = prefs_path() { - if let Some(dir) = p.parent() - && let Err(e) = std::fs::create_dir_all(dir) - { - log::warn!("preferences: cannot create {}: {e}", dir.display()); - return; - } - match serde_json::to_vec_pretty(&app.prefs()) { - Ok(bytes) => { - if let Err(e) = write_prefs(&p, &bytes) { - log::warn!("preferences: cannot save {}: {e}", p.display()); - } - } - Err(e) => log::warn!("preferences: cannot serialize: {e}"), - } + let Some(p) = prefs_path() else { return Ok(false) }; + if let Some(dir) = p.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; } + let bytes = serde_json::to_vec_pretty(&app.prefs()).map_err(|e| format!("cannot serialize: {e}"))?; + write_prefs(&p, &bytes).map_err(|e| format!("cannot save {}: {e}", p.display()))?; + Ok(true) } /// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or @@ -352,6 +354,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services { set_clipboard_rich: Some(Box::new(clipboard::set_rich)), clipboard_rtf: Some(Box::new(clipboard::rtf)), clipboard_formats: Some(Box::new(clipboard::formats)), + save_prefs: Some(Box::new(try_save_prefs)), ..Default::default() } } diff --git a/crates/doc/src/para/float.rs b/crates/doc/src/para/float.rs index 1918a7e5a2..2a5e00e878 100644 --- a/crates/doc/src/para/float.rs +++ b/crates/doc/src/para/float.rs @@ -186,6 +186,11 @@ pub struct Float { /// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats). #[serde(skip_serializing_if = "is_zero_u32")] pub z: u32, + /// The page a drawing anchored to a page is on, from 1 (ODF's `text:anchor-page-number`, + /// §19.872); 0 = anchored to its paragraph or not said. Kept for saving: the layout places a + /// drawing on its anchor paragraph's page. + #[serde(skip_serializing_if = "is_zero_u32")] + pub page: u32, /// Turned, but the file keeps no room for the turn (`wp:effectExtent` missing or zero, as /// files from other tools have): Word then keeps an inline one's line to its frame and lets /// the turned object overhang it (see [`Float::line_pad`]). Cleared by [`Float::set_spin`]. diff --git a/crates/engine/src/cmd/fmt_revisions.rs b/crates/engine/src/cmd/fmt_revisions.rs index 330bdfd93f..06f92831c0 100644 --- a/crates/engine/src/cmd/fmt_revisions.rs +++ b/crates/engine/src/cmd/fmt_revisions.rs @@ -212,6 +212,32 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) { } } +/// Builds the tracked paragraph-property change for a command that also changes the paragraph's +/// text. The ordinary after-command recorder deliberately compares only paragraphs with identical +/// text, so mixed text/paragraph-format transformations use this before assigning their props. +/// Made once per command, before it creates revisions: it keeps the revisions as they were then +/// (all `Recorder::own` needs), instead of copying the list for every paragraph. +pub(crate) struct ParaPropTracker { + before: Vec, +} + +impl ParaPropTracker { + pub(crate) fn new(s: &Session) -> Self { + let tracking = s.doc.settings.track_changes && s.prefs.markup.track_formatting; + ParaPropTracker { before: if tracking { s.doc.revisions.clone() } else { Vec::new() } } + } + + pub(crate) fn changed(&self, s: &mut Session, before: &ParaProps, after: &ParaProps) -> Option>> { + if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting { + return after.fmt_change.clone(); + } + let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &self.before, revisions: &mut s.doc.revisions, rid: None }; + r.change(before, after, ParaProps::formatting, |p| &p.fmt_change) + .map(|Replacement(change)| change) + .unwrap_or_else(|| after.fmt_change.clone()) + } +} + /// Record the paragraph formatting a command changed on the paragraph at `path` whose text it also /// changed, which [`record`] doesn't compare: `before` is the paragraph's properties before the /// command. As in [`record`], the author's own earlier change keeps the formatting before it and diff --git a/crates/engine/src/cmd/objects/arrange.rs b/crates/engine/src/cmd/objects/arrange.rs index af1ad888b8..abdc85695a 100644 --- a/crates/engine/src/cmd/objects/arrange.rs +++ b/crates/engine/src/cmd/objects/arrange.rs @@ -185,6 +185,10 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w, if f.wrap == Wrap::Inline { f.wrap = Wrap::Square; } + // A drawing anchored to a page is on this one now. + if f.page > 0 { + f.page = u32::try_from(page).map_or(f.page, |p| p.saturating_add(1)); + } f.h_rel = Anchor::Page; f.v_rel = Anchor::Page; f.h_align = None; @@ -199,6 +203,8 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w, s.touch(); let origin = s.layout().object(&at, page).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?.origin; edit_float(s, &at, |f| { + // On its paragraph now: a page it was anchored to is gone, not only hidden. + f.page = 0; f.h_rel = Anchor::Column; f.v_rel = Anchor::Paragraph; f.x = (x - origin.x).clamp(-MAX_OFFSET, MAX_OFFSET); diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index a96e16562c..06068ee65d 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -16,6 +16,7 @@ mod lists; mod outline; mod styles; mod tabs_borders; +mod tools; use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog}; pub use format::{cur as selection_para, fmt, indent, outdent}; @@ -26,6 +27,10 @@ use outline::{move_paras, outline_step, sort}; use styles::{apply_style, apply_styles, create_style, delete_style, modify_style, set_visibility, style_id, update_to_match}; pub use tabs_borders::border_sides; use tabs_borders::{borders, tabs}; +use tools::{ + blank_between, convert_breaks, indent_to_spaces, remove_blank, remove_extra_spaces, remove_line_breaks, separate_paragraphs, spaces_to_indent, + trim_leading, +}; pub fn specs() -> Vec { vec![ @@ -270,6 +275,26 @@ pub fn specs() -> Vec { } sel_result(s) }), + // Advanced paragraph tools (#5043, as in WPS Office): conversions and cleanup over the + // selection, or the whole document with `scope: "all"`. One undo step per command. + CommandSpec::new("para.convertBreaks", "Convert Line Breaks to Paragraphs", "Home › Paragraph › Paragraph Tools", convert_breaks) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.blankBetween", "Insert Blank Paragraphs Between", "Home › Paragraph › Paragraph Tools", blank_between) + .params(r#"{"scope"?: "selection|all", "after"?: bool (also after the last paragraph)}"#), + CommandSpec::new("para.separateParagraphs", "Separate Paragraphs", "Home › Paragraph › Paragraph Tools", separate_paragraphs) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.removeBlank", "Delete Blank Paragraphs", "Home › Paragraph › Paragraph Tools", remove_blank) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.trimLeadingSpaces", "Remove Leading Spaces", "Home › Paragraph › Paragraph Tools", trim_leading) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.removeExtraSpaces", "Remove Extra Spaces", "Home › Paragraph › Paragraph Tools", remove_extra_spaces) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.spacesToIndent", "Convert Spaces to Indentation", "Home › Paragraph › Paragraph Tools", spaces_to_indent) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.indentToSpaces", "Convert Indents to Spaces", "Home › Paragraph › Paragraph Tools", indent_to_spaces) + .params(r#"{"scope"?: "selection|all"}"#), + CommandSpec::new("para.removeLineBreaks", "Remove Line Breaks", "Home › Paragraph › Paragraph Tools", remove_line_breaks) + .params(r#"{"scope"?: "selection|all"}"#), ] } diff --git a/crates/engine/src/cmd/para/tools.rs b/crates/engine/src/cmd/para/tools.rs new file mode 100644 index 0000000000..b900b6c205 --- /dev/null +++ b/crates/engine/src/cmd/para/tools.rs @@ -0,0 +1,332 @@ +//! Advanced paragraph tools (#5043): converting and cleaning up paragraphs, like WPS Office's +//! paragraph tools. Each command works on the paragraphs the selection covers, or on the whole +//! document with `scope: "all"`, and runs as one undo step. Deletions respect Track Changes. + +use serde_json::{Value, json}; + +use wordcraft_doc::para::LINE_BREAK; +use wordcraft_doc::{Path, Pos, RevisionKind, StoryRef}; + +use super::{CmdError, CmdResult, Session, p, sel_result}; +use crate::cmd::{has_next_para, new_revision, split_para, track_delete}; + +/// The maximum number of paragraphs one call may touch: the work, and the blank paragraphs a +/// command can add, stay bounded whatever the document says. +const MAX_TOOL_PARAS: usize = 10_000; + +/// Leading spaces one paragraph may gain from an indent, whatever the file's indent says. +const MAX_INDENT_SPACES: usize = 1_000; + +/// The scope of a tools command: `{"scope": "selection|all"}`, `selection` by default. +/// `all` selects the whole document first, so the command, its undo step and the result all +/// follow the full selection like any other command. +fn scope_all(s: &mut Session, v: &Value) -> Result<(), CmdError> { + match p::str(v, "scope").unwrap_or("selection") { + "selection" => Ok(()), + "all" => { + s.run("select.all", &json!({"whole": true}))?; + Ok(()) + } + x => Err(CmdError::Params(format!("unknown scope `{x}` (selection|all)"))), + } +} + +/// Refuse a paragraph rewrite that would touch content locked against editing. Commands operate +/// on whole paragraphs, so checking each full paragraph also covers controls outside the exact +/// selection offsets in a selected paragraph. +fn check_target_edits(s: &mut Session, story: StoryRef, paths: &[Path]) -> Result<(), CmdError> { + let ranges: Vec<(Pos, Pos)> = paths + .iter() + .filter_map(|path| { + let len = s.doc.para(story, path)?.len(); + Some((Pos { story, path: path.clone(), off: 0 }, Pos { story, path: path.clone(), off: len })) + }) + .collect(); + for (a, b) in ranges { + crate::cmd::controls::check_edit(s, &a, &b)?; + } + Ok(()) +} + +/// The paragraphs a command runs over, in document order, and the story they are in. +fn targets(s: &Session) -> Result<(StoryRef, Vec), CmdError> { + let (a, b) = s.sel.ordered(); + let paths = s.doc.para_paths_in(&a, &b); + if paths.is_empty() { + return Err(CmdError::Failed("no paragraphs selected".into())); + } + if paths.len() > MAX_TOOL_PARAS { + return Err(CmdError::Params(format!("select at most {MAX_TOOL_PARAS} paragraphs to process them in one command"))); + } + Ok((a.story, paths)) +} + +/// Deletes `a..b` inside one paragraph, recording a tracked deletion when Track Changes is on. +/// Offsets past the paragraph's end are clamped, so the document is never half-edited. +fn delete_in_para(s: &mut Session, story: StoryRef, path: &Path, a: usize, b: usize) -> Result<(), CmdError> { + let len = s.doc.para(story, path).ok_or_else(|| CmdError::Failed("no paragraph here".into()))?.len(); + let (a, b) = (a.min(b).min(len), b.min(len)); + if a >= b { + return Ok(()); + } + let at = |off: usize| Pos { story, path: path.clone(), off }; + crate::cmd::controls::check_delete(s, &at(a), &at(b))?; + if s.doc.settings.track_changes { + track_delete(s, &at(a), &at(b))?; + } else { + s.doc.delete_range(&at(a), &at(b))?; + } + Ok(()) +} + +/// The deletions to make in one paragraph, as byte ranges, merged where they touch and applied +/// last first so the earlier offsets stay valid. +fn apply_deletions(s: &mut Session, story: StoryRef, path: &Path, mut ranges: Vec<(usize, usize)>) -> Result<(), CmdError> { + ranges.sort(); + let mut merged: Vec<(usize, usize)> = Vec::new(); + for (a, b) in ranges { + match merged.last_mut() { + Some(last) if a <= last.1 => last.1 = last.1.max(b), + _ => merged.push((a, b)), + } + } + for (a, b) in merged.into_iter().rev() { + delete_in_para(s, story, path, a, b)?; + } + Ok(()) +} + +/// The characters of the paragraph that are not tracked-deleted, as (byte offset, char). Under +/// Track Changes deleted text stays in `para.text`, so every scan that decides what to delete or +/// count goes through this. +fn live_chars(para: &wordcraft_doc::Paragraph) -> impl Iterator + '_ { + para.run_ranges().filter(|(_, c)| c.del.is_none()).flat_map(|(r, _)| para.text[r.clone()].char_indices().map(move |(i, c)| (r.start + i, c))) +} + +/// The byte ranges of every manual line break in the paragraph that is not already deleted. +fn break_ranges(para: &wordcraft_doc::Paragraph) -> Vec<(usize, usize)> { + live_chars(para).filter(|(_, c)| *c == LINE_BREAK).map(|(i, _)| (i, i + 1)).collect() +} + +/// `para.convertBreaks`: every manual line break (Shift+Enter) in the selection becomes a +/// paragraph break. Paragraphs are handled last first: a split only shifts the paths after it. +pub(super) fn convert_breaks(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + check_target_edits(s, story, &paths)?; + let breaks: usize = paths.iter().filter_map(|path| s.doc.para(story, path)).map(|p| break_ranges(p).len()).sum(); + if breaks > MAX_TOOL_PARAS { + return Err(CmdError::Params(format!("convert at most {MAX_TOOL_PARAS} line breaks at once"))); + } + for path in paths.iter().rev() { + let Some(para) = s.doc.para(story, path) else { continue }; + for (i, _) in break_ranges(para).into_iter().rev() { + let new = split_para(s, &Pos { story, path: path.clone(), off: i })?; + // The break stays at the head of the new paragraph; the paragraph break replaces it. + delete_in_para(s, story, &new.path, 0, 1)?; + } + } + sel_result(s) +} + +/// `para.blankBetween`: insert one empty paragraph between the selected paragraphs; `after` +/// adds one blank paragraph after the last selected paragraph too. +pub(super) fn blank_between(s: &mut Session, v: &Value) -> CmdResult { + let after = p::bool(v, "after").unwrap_or(false); + scope_all(s, v)?; + let (story, paths) = targets(s)?; + check_target_edits(s, story, &paths)?; + let count = paths.len().saturating_sub(1) + usize::from(after); + if count > MAX_TOOL_PARAS { + return Err(CmdError::Params(format!("insert at most {MAX_TOOL_PARAS} blank paragraphs at once"))); + } + // Last first, each split at a paragraph's end: a split only shifts the paths after it. + if after { + let last = paths.last().ok_or_else(|| CmdError::Failed("no paragraph selected".into()))?; + let len = s.doc.para(story, last).map(|p| p.len()).unwrap_or(0); + split_para(s, &Pos { story, path: last.clone(), off: len })?; + } + for path in paths[..paths.len().saturating_sub(1)].iter().rev() { + let len = s.doc.para(story, path).map(|p| p.len()).unwrap_or(0); + split_para(s, &Pos { story, path: path.clone(), off: len })?; + } + sel_result(s) +} + +/// `para.separateParagraphs`: every selected paragraph becomes a paragraph of its own with a +/// blank line between them. +pub(super) fn separate_paragraphs(s: &mut Session, v: &Value) -> CmdResult { + blank_between(s, &json!({"scope": p::str(v, "scope").unwrap_or("selection"), "after": true})) +} + +/// `para.removeBlank`: delete every empty paragraph in the selection. +pub(super) fn remove_blank(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + check_target_edits(s, story, &paths)?; + // Last first: removing a block leaves the paths before it as they are. + for path in paths.iter().rev() { + let Some(para) = s.doc.para(story, path) else { continue }; + // A paragraph must be empty, carry no section break and have a paragraph after it in + // its container: a story's, part's or cell's last mark can't be removed, and a paragraph + // before a table can't be joined across it. + if !para.is_empty() || para.section.is_some() || !has_next_para(s, story, path) { + continue; + } + let next = path.with_last(path.last() + 1); + let (a, b) = (Pos { story, path: path.clone(), off: 0 }, Pos { story, path: next, off: 0 }); + crate::cmd::controls::check_delete(s, &a, &b)?; + if s.doc.settings.track_changes { + track_delete(s, &a, &b)?; + } else { + s.doc.remove_block(story, path)?; + } + } + sel_result(s) +} + +/// The number of leading spaces in the paragraph's text, deleted ones included (the range +/// `trimLeadingSpaces` deletes). +fn leading_spaces(para: &wordcraft_doc::Paragraph) -> usize { + para.text.chars().take_while(|c| *c == ' ').count() +} + +/// The byte offsets of the paragraph's leading spaces that are not tracked-deleted. +fn live_leading_spaces(para: &wordcraft_doc::Paragraph) -> Vec { + live_chars(para).take_while(|(_, c)| *c == ' ').map(|(i, _)| i).collect() +} + +/// `para.trimLeadingSpaces`: delete the spaces at the start of each selected paragraph. +pub(super) fn trim_leading(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + check_target_edits(s, story, &paths)?; + for path in paths { + let Some(para) = s.doc.para(story, &path) else { continue }; + let n = leading_spaces(para); + apply_deletions(s, story, &path, vec![(0, n)])?; + } + sel_result(s) +} + +/// `para.removeExtraSpaces`: every run of spaces outside the paragraph's start becomes one +/// space. Tabs and page breaks are document structure, not redundant spaces, and stay in place. +/// Leading spaces stay (that is `para.trimLeadingSpaces`). +pub(super) fn remove_extra_spaces(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + check_target_edits(s, story, &paths)?; + for path in paths { + let Some(para) = s.doc.para(story, &path) else { continue }; + // Runs of live spaces (deleted characters are skipped, as if absent). A run at the + // paragraph's start stays; a run inside the text collapses to its last space, and one + // that reaches the paragraph's end to its first. + let mut runs = Vec::new(); + let mut run: Vec = Vec::new(); + let mut leading = true; + for (i, c) in live_chars(para) { + if c == ' ' { + run.push(i); + continue; + } + if !leading && run.len() > 1 { + runs.extend(run[..run.len() - 1].iter().map(|&a| (a, a + 1))); + } + run.clear(); + leading = false; + } + if !leading && run.len() > 1 { + runs.extend(run[1..].iter().map(|&a| (a, a + 1))); + } + apply_deletions(s, story, &path, runs)?; + } + sel_result(s) +} + +/// `para.spacesToIndent`: each paragraph's leading spaces become a first-line indent of 72 pt +/// per space (Word's point-based indent from typing spaces), and are deleted. +pub(super) fn spaces_to_indent(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + check_target_edits(s, story, &paths)?; + let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s); + for path in paths { + let Some(para) = s.doc.para(story, &path) else { continue }; + let live = live_leading_spaces(para); + let spaces = live.len(); + if spaces == 0 { + continue; + } + let current = para.props.indent_first.unwrap_or(0.0).clamp(-1584.0, 1584.0); + let represented = spaces.min(((1584.0 - current).max(0.0) / 72.0).floor() as usize); + if represented == 0 { + continue; + } + let before = para.props.clone(); + let mut after = before.clone(); + after.indent_first = Some(current + represented as f32 * 72.0); + after.cancel_char_indents(false, false, true); + after.fmt_change = tracker.changed(s, &before, &after); + { + let p = s.doc.para_mut(story, &path)?; + p.props = after; + p.touch(); + } + // A space is one byte; deleted spaces in between stay as they are. + apply_deletions(s, story, &path, live[..represented].iter().map(|&a| (a, a + 1)).collect())?; + } + sel_result(s) +} + +/// `para.indentToSpaces`: each paragraph's first-line and left indents become leading spaces +/// (72 pt per space, as typing spaces indents), and the indents go to zero. +pub(super) fn indent_to_spaces(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + check_target_edits(s, story, &paths)?; + let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s); + for path in paths { + let Some(para) = s.doc.para(story, &path) else { continue }; + let pts = para.props.indent_left.unwrap_or(0.0) + para.props.indent_first.unwrap_or(0.0); + let n = (pts / 72.0).round().clamp(0.0, MAX_INDENT_SPACES as f32) as usize; + if n == 0 && para.props.indent_left.unwrap_or(0.0) == 0.0 && para.props.indent_first.unwrap_or(0.0) == 0.0 { + continue; + } + let mut props = s.doc.para(story, &path).map(|p| p.props_at(0)).cloned().unwrap_or_default(); + // The revision first: `new_revision` edits `s.doc`, so it runs before the paragraph borrow. + // The spaces are marked as a tracked insertion through `props.ins`, as typing is. + props.fmt_change = None; + props.del = None; + if s.doc.settings.track_changes && n > 0 { + props.ins = Some(new_revision(s, RevisionKind::Insert)); + } else { + props.ins = None; + } + let before = s.doc.para(story, &path).map(|p| p.props.clone()).unwrap_or_default(); + let mut after = before.clone(); + after.indent_first = Some(0.0); + after.indent_left = Some(0.0); + after.cancel_char_indents(true, false, true); + after.fmt_change = tracker.changed(s, &before, &after); + let p = s.doc.para_mut(story, &path)?; + if n > 0 { + p.insert_text(0, &" ".repeat(n), &props)?; + } + p.props = after; + p.touch(); + } + sel_result(s) +} + +/// `para.removeLineBreaks`: delete every manual line break in the selection, so the lines +/// join into one paragraph. +pub(super) fn remove_line_breaks(s: &mut Session, v: &Value) -> CmdResult { + scope_all(s, v)?; + let (story, paths) = targets(s)?; + check_target_edits(s, story, &paths)?; + for path in paths { + let Some(para) = s.doc.para(story, &path) else { continue }; + apply_deletions(s, story, &path, break_ranges(para))?; + } + sel_result(s) +} diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index c554cc885f..a62b0b6f25 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -14,7 +14,7 @@ mod enter_tab; mod unicode; use autoformat::insert; -use delete::{backspace, delete, delete_word_back, delete_word_fwd}; +use delete::{backspace, delete, delete_sentence_back, delete_word_back, delete_word_fwd}; pub use enter_tab::MAX_TYPED_PARAGRAPHS; use enter_tab::{back_tab, new_paragraph, tab}; pub use unicode::{alt_code_char, oem_code_page, system_code_pages, system_oem_code_page, windows_code_pages}; @@ -36,6 +36,7 @@ pub fn specs() -> Vec { CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"), CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"), CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"), + CommandSpec::new("text.deleteSentenceBack", "Delete Previous Sentence", "Editing", delete_sentence_back).key("Mod+Shift+Backspace"), CommandSpec::new("text.deleteWordForward", "Delete Next Word", "Editing", delete_word_fwd).key("Mod+Delete / Alt+Delete"), CommandSpec::new("text.nbsp", "Nonbreaking Space", "Insert › Symbols", |s, _| ins_char(s, NBSP)).key("Mod+Shift+Space"), CommandSpec::new("text.nbHyphen", "Nonbreaking Hyphen", "Insert › Symbols", |s, _| ins_char(s, NB_HYPHEN)).key("Mod+Shift+-"), diff --git a/crates/engine/src/cmd/text/delete.rs b/crates/engine/src/cmd/text/delete.rs index 5c692e523f..bb78661566 100644 --- a/crates/engine/src/cmd/text/delete.rs +++ b/crates/engine/src/cmd/text/delete.rs @@ -237,3 +237,20 @@ pub(super) fn delete_word_fwd(s: &mut Session, v: &Value) -> CmdResult { crate::cmd::controls::restore_placeholder(s)?; sel_result(s) } + +/// Delete to the current sentence start, or the preceding sentence when exactly at its boundary. +/// Sentence boundaries match Select Sentence. Never remove a paragraph or cell boundary. +pub(super) fn delete_sentence_back(s: &mut Session, v: &Value) -> CmdResult { + if !s.sel.is_collapsed() { + return backspace(s, v); + } + let mut f = s.sel.focus.clone(); + let Some(para) = s.doc.para_at(&f) else { return sel_result(s) }; + f.off = para.clamp(f.off); + let Some((previous, _)) = para.text.get(..f.off).and_then(|text| text.char_indices().next_back()) else { return sel_result(s) }; + let start = para.sentence_at(previous).0; + s.sel = Selection { anchor: Pos { off: start, ..f.clone() }, focus: f }; + delete_selection(s)?; + crate::cmd::controls::restore_placeholder(s)?; + sel_result(s) +} diff --git a/crates/engine/src/cmd/tools/actions.rs b/crates/engine/src/cmd/tools/actions.rs index e917aa7048..28bc721c13 100644 --- a/crates/engine/src/cmd/tools/actions.rs +++ b/crates/engine/src/cmd/tools/actions.rs @@ -341,3 +341,42 @@ pub(super) fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult { } Ok(Value::Object(out)) } + +/// The rows of an `insert.spreadsheet` call: `values` (a rectangular array of cells) or `csv`. +pub(super) fn spreadsheet_rows(v: &Value) -> Result>, CmdError> { + // The CSV fallback is for an absent `values`; an explicit null is a malformed argument. + let Some(values) = v.get("values") else { + return Ok(crate::cmd::mailings::parse_csv(p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6"))); + }; + let list = values.as_array().ok_or_else(|| CmdError::Params("`values` must be an array of rows".into()))?; + if list.is_empty() || list.len() > 1000 { + return Err(CmdError::Params("`values` needs 1 to 1000 rows".into())); + } + let mut rows: Vec> = Vec::with_capacity(list.len()); + for (r, row) in list.iter().enumerate() { + let cells = row.as_array().ok_or_else(|| CmdError::Params(format!("`values` row {} is not an array", r + 1)))?; + if cells.is_empty() || cells.len() > 63 { + return Err(CmdError::Params(format!("`values` row {} needs 1 to 63 cells", r + 1))); + } + if rows.first().is_some_and(|f| f.len() != cells.len()) { + return Err(CmdError::Params(format!( + "`values` row {} has {} cells, row 1 has {}", + r + 1, + cells.len(), + rows.first().map_or(0, Vec::len) + ))); + } + let mut out = Vec::with_capacity(cells.len()); + for cell in cells { + out.push(match cell { + Value::String(x) => x.clone(), + Value::Number(x) => x.to_string(), + Value::Bool(x) => x.to_string(), + Value::Null => String::new(), + _ => return Err(CmdError::Params(format!("`values` row {} holds a cell that is not text, a number or a boolean", r + 1))), + }); + } + rows.push(out); + } + Ok(rows) +} diff --git a/crates/engine/src/cmd/tools/specs.rs b/crates/engine/src/cmd/tools/specs.rs index f9a9684d5f..fc4daab3e4 100644 --- a/crates/engine/src/cmd/tools/specs.rs +++ b/crates/engine/src/cmd/tools/specs.rs @@ -1,5 +1,6 @@ use super::actions::{ customize_keyboard, customize_ribbon, hf_nav, language, macros, preferred_language, quick_parts, search_commands, select_similar, + spreadsheet_rows, }; use super::autocorrect::autocorrect_options; use super::compare::{COMPARE_PARAMS, compare}; @@ -213,10 +214,10 @@ pub fn specs() -> Vec { }) .params(r#"{"path": string}"#), CommandSpec::new("insert.spreadsheet", "Spreadsheet Table", "Insert › Tables", |s, v| { - let csv = p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6"); - let rows = crate::cmd::mailings::parse_csv(csv); + let rows = spreadsheet_rows(v)?; let cols = rows.iter().map(Vec::len).max().unwrap_or(1).clamp(1, 63); - s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": "GridTable1Light"}))?; + let style = p::str(v, "style").unwrap_or("GridTable1Light"); + s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": style}))?; let Some((tp, _, _)) = s.sel.focus.path.cell() else { return sel_result(s) }; let t = s.doc.table_mut(s.sel.focus.story, &tp)?; for (r, row) in rows.iter().enumerate() { @@ -226,9 +227,14 @@ pub fn specs() -> Vec { } } } + if p::bool(v, "header").unwrap_or(false) + && let Some(first) = t.rows.first_mut() + { + first.props.header = true; + } sel_result(s) }) - .params(r#"{"csv"?: string}"#), + .params(r#"{"csv"?: string, "values"?: [[cell, …], …] (rows of equal length, at most 1000 rows × 63 columns; wins over csv), "header"?: bool (first row repeats on each page), "style"?: table style name (default GridTable1Light)}"#), CommandSpec::new("layout.linkToPrevious", "Link to Previous", "Header & Footer › Navigation", |s, v| { let header = p::bool(v, "footer").map(|f| !f).unwrap_or(true); let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize; diff --git a/crates/engine/src/cmd/tools/tests.rs b/crates/engine/src/cmd/tools/tests.rs index 4683817e60..3d2de2d844 100644 --- a/crates/engine/src/cmd/tools/tests.rs +++ b/crates/engine/src/cmd/tools/tests.rs @@ -255,6 +255,59 @@ fn proofing_language_options() { assert!(s.run("review.language", &json!({"lang": "