mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 04:09:53 -06:00
Merge upstream/main
# Conflicts: # ROADMAP.md # docs/ui-parity.md
This commit is contained in:
@@ -43,7 +43,9 @@ cat ~/.config/wordcraft/control-key.local-7981 # Linux, outside Flatpak, port
|
||||
|
||||
`wordcraft-cli mcp --connect` takes the key from `WORDCRAFT_CONTROL_KEY`, or else from the key
|
||||
file for the port it connects to, and reads it again on every new connection (the app may have
|
||||
restarted). It sends the key only to loopback addresses (`127.0.0.0/8`, `::1`, `localhost`). For
|
||||
restarted). It sends the key only to the app's own address, `127.0.0.1` (`localhost` connects
|
||||
there too, never to `::1`): another loopback address such as `::1` or `127.0.0.2` is not where the
|
||||
app listens, so whoever listens there gets no key (#3699). For
|
||||
any other host it sends no key, says so, and the app refuses its requests: to drive an app on
|
||||
another machine, forward its port to `127.0.0.1` (for example `ssh -L 7981:127.0.0.1:7981 host`)
|
||||
and set `WORDCRAFT_CONTROL_KEY` to that app's key.
|
||||
@@ -52,10 +54,10 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
|
||||
|
||||
| Method | Params | Result |
|
||||
|---|---|---|
|
||||
| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does)…) |
|
||||
| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) |
|
||||
| `engine.commands` | — | every command: id, label, location, shortcut, params, enabled |
|
||||
| `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages |
|
||||
| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf |
|
||||
| `document.inspect` | `text?`, `doc?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages |
|
||||
| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf; `document`: the active document's id; `documents`: the open documents (`id`, `title`, `path`, `dirty`, `active`) |
|
||||
| `ui.click` | `x`, `y`, `button?`, `count?`, `shift?`, `cmd?`, `alt?` | real pointer input at window coordinates |
|
||||
| `ui.clickText` | `page` (0-based), `x`, `y` (points from the page's top-left), `count?` | click inside a page |
|
||||
| `ui.move` / `ui.drag` | `x`,`y` / `x`,`y`,`toX`,`toY`,`steps?` | pointer move / drag |
|
||||
@@ -64,13 +66,50 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
|
||||
| `ui.key` | `key` (`B`, `Enter`, `Left`…), `shift?`, `alt?`, `cmd?` | key press through egui (shortcuts apply) |
|
||||
| `ui.text` | `text` | typed text through egui |
|
||||
| `ui.screenshot` | `path?` | PNG of the window: written to `path`, or without one returned as base64 `png` in the reply |
|
||||
| `ui.render` | `path?`, `page?` (1-based), `scale?` | PNG of a page, rendered by the engine: written to `path`, or without one returned as base64 `png` in the reply (never a fixed name in the shared temporary folder) |
|
||||
| `ui.render` | `path?`, `page?` (1-based), `scale?`, `doc?` | PNG of a page, rendered by the engine: written to `path`, or without one returned as base64 `png` in the reply (never a fixed name in the shared temporary folder) |
|
||||
| `ui.parity` | — | feature-catalog parity |
|
||||
| `ui.resize` | `width`, `height` | asks for that window content size in points; sizes that aren't positive and finite are refused, the rest are clamped to the app's minimum (760 × 480) and to the largest surface the GPU can draw (at most 16384; that limit wins over the minimum), and the reply's `width` and `height` are the size asked for |
|
||||
| `ui.focus` / `app.quit` | | window control |
|
||||
|
||||
Any other method name is treated as a command id: `{"method": "format.bold"}` works.
|
||||
|
||||
### Open documents
|
||||
|
||||
Several documents can be open at once. Each has an id, a whole number that stays the same while
|
||||
the document is open and is never reused by the running app. On the desktop each open document
|
||||
has a native window of its own; the web app shows them one at a time in its one window. One
|
||||
document is the active one: on the desktop, the document of the window focused last (the active
|
||||
window); in the browser, the one the window shows. Commands work on it unless told otherwise.
|
||||
|
||||
- `file.new` opens another document in a new window, focused, and makes it active. `file.open`
|
||||
does the same with a file; it replaces a blank document nobody has used yet in that document's
|
||||
window, and a file that is already open becomes active again and its window comes to the front
|
||||
(`"alreadyOpen": true`). Its result carries the document's `id`.
|
||||
- `view.switchWindows` lists the open documents (`{"documents": [{"id", "title", "path",
|
||||
"dirty", "active"}]}`); with `{"id": n}` it makes that document active and brings its window to
|
||||
the front.
|
||||
- `file.closeDocument {"id"?, "force"?}` closes a document; its window closes with it unless it
|
||||
is the last window.
|
||||
- `engine.execute`, `document.inspect` and `ui.render` take `doc`, the id of the document to work
|
||||
on, next to their other params. The command runs on that document and the active window stays
|
||||
active. A dialog or page the command opens (Save As, Print) opens in that document's window,
|
||||
which comes to the front:
|
||||
|
||||
```json
|
||||
{"id": 2, "key": "…", "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "Hello"}, "doc": 3}}
|
||||
```
|
||||
|
||||
`doc` left out, or `null`, is the active document. An id that names no open document, or a value
|
||||
that isn't a whole number from 0 up, is an error and nothing runs. Window commands (`ui.*`) work
|
||||
on the active window only: with the id of another document they are an error.
|
||||
|
||||
### The active window
|
||||
|
||||
Input and window methods go to the active window, the one focused last: `ui.click`, `ui.key`,
|
||||
`ui.text`, `ui.wheel` and the other input methods (coordinates are that window's),
|
||||
`ui.screenshot` (a PNG of that window), `ui.inspect` (its size), `ui.resize` and `ui.focus`. To
|
||||
work in another window, make its document active first with `view.switchWindows {"id": n}`.
|
||||
|
||||
The `CharProps` and `ParaProps` that `format.set`, `para.set` and `styles.modify` take are listed
|
||||
in [MCP server](mcp.md#character-and-paragraph-properties).
|
||||
|
||||
@@ -94,8 +133,11 @@ document with unsaved changes it replies with an error (the app keeps running an
|
||||
stay); save first (`file.save`), or pass `{"force": true}` to discard the changes and close.
|
||||
The bare form `{"method": "file.close", "force": true}` (a method name that is a command id) is
|
||||
guarded the same way, and only a boolean `true` counts as `force`. A document without unsaved
|
||||
changes closes at once (#1268). Closing closes the document, not the app: the window stays and
|
||||
shows no page, and `file.new` or `file.open` goes on from there (#1712).
|
||||
changes closes at once (#1268). Closing closes the document and its window, and the window
|
||||
focused before it becomes the active one. The last window stays open with no page, and
|
||||
`file.new` or `file.open` goes on from there (#1712); closing it doesn't quit the app. With
|
||||
`doc` naming a document in the background, `file.close` closes that document and its window under
|
||||
the same rule (unsaved changes need `{"force": true}`), and the active window stays active.
|
||||
|
||||
### Native printing
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
| Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. Textual `REF` fields with `\h` export internal PDF links without refreshing cached text; existing enclosing links take precedence. `wordcraft-cli convert --update-fields` explicitly runs field updates before saving; ordinary conversion preserves caches and locked fields stay unchanged. Instruction-only paragraphs carry hidden marks instead of blank lines; an empty result paragraph keeps its line. Imported TOCs preserve actual result boundaries, formatted entries and nested fields without relying on TOC styles. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465). A `HYPERLINK` field reads as a link, its result ordinary text (tabs, nested `PAGEREF`), and is written back as `w:hyperlink`; its tooltip (`\o`) and target frame (`\t`) are dropped (#2174) |
|
||||
| Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. ODT/PDF export the result (moved-away text hidden like deletions); RTF writes a move as a plain tracked deletion and insertion |
|
||||
| Content controls (`w:sdt`) | ✅ | ✅ | Block, inline, row and cell level, nested; title, tag, id, lock, placeholder, temporary, rich/plain text, `w14:checkbox`, combo box, drop-down list, date, picture, building block gallery, `w15` repeating sections; other `w:sdtPr` children (data binding, appearance) kept verbatim (#381). A TOC's control is rebuilt by the TOC when the TOC has the heading-and-entries form; around any other TOC it is kept as read (#2427). Replace All respects content-edit and control-deletion locks and refuses a batch before making any edit |
|
||||
| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges; forms mode (`w:edit="forms"`) lets edits inside fillable content controls (`w:lock` content-editable, innermost wins) run, everything else rolls back; row-wrapped controls and control deletion remain refused; `review.restrict` reports `formFields`; Find Next Region covers form fields (the pane button shows for forms documents); under active protection, `developer.properties` refuses `lockEdit`/`lockDelete` changes, as Word blocks property edits until Stop Protection; under forms protection it refuses every property, and inserting or removing content controls is refused too (Word greys out the Developer tab's control buttons and Properties and offers no Remove Content Control), while under the other modes the title, tag and placeholder stay editable; a file command that changes the open document is checked like any other, so the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password rather than the snapshot's stale ones; the protection of a protected document only changes through the explicit stop (`review.restrict {"stop": true}` — the "none" mode is the shorthand for it and doesn't stop a protected document), the session author used for new tracked changes and comments can't change through `file.setAuthor` while the document is protected (the CLI's `--author` uses the same protection check), and the post-check compares the document's settings, metadata, custom properties, list definitions, bibliography sources and the comments' resolved flags as well as its text and formatting; named exceptions match a self-declared user name, not an authenticated identity |
|
||||
| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges; forms mode (`w:edit="forms"`) lets edits inside fillable content controls (`w:lock` content-editable, innermost wins) run, everything else rolls back; row-wrapped controls and control deletion remain refused; `review.restrict` reports `formFields`; Find Next Region covers form fields (the pane button shows for forms documents); under active protection, `developer.properties` refuses `lockEdit`/`lockDelete` changes, as Word blocks property edits until Stop Protection; under forms protection it refuses every property, and inserting or removing content controls is refused too (Word greys out the Developer tab's control buttons and Properties and offers no Remove Content Control), while under the other modes the title, tag and placeholder stay editable; a file command that changes the open document is checked like any other, so the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password rather than the snapshot's stale ones; the protection of a protected document only changes through the explicit stop (`review.restrict {"stop": true}` — the "none" mode is the shorthand for it and doesn't stop a protected document), the session author used for new tracked changes and comments can't change through `file.setAuthor` while the document is protected (the CLI's `--author` uses the same protection check), and the post-check compares the document's settings, metadata, custom properties, list definitions, bibliography sources and the comments' resolved flags as well as its text and formatting; named exceptions match a self-declared user name, not an authenticated identity; formatting-only protection also refuses Copy Styles (`styles.copyFrom`, #3692) |
|
||||
| Legacy form fields (`w:ffData`), check boxes, drop-downs | 🟡 | ✅ | The field definition is kept raw and saved back. Check boxes (`FORMCHECKBOX`) are drawn as Word draws them: a square as tall as the text's line, inset 1.16 pt, 0.72 pt border, an X when checked (`w:checked`, else `w:default`), `w:size` honoured; drop-downs (`FORMDROPDOWN`) show their selected entry; text fields (`FORMTEXT`) show their result. Not yet: filling them in, Protect Document's forms mode for legacy fields |
|
||||
| DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through |
|
||||
| Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | |
|
||||
@@ -274,6 +274,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio
|
||||
| 2026-10-22 | trivial | Hostile input: DOCX package, comment, font, chart and Flat OPC hardening (#3692, #3723, #3704, #3742, #3648) |
|
||||
| 2026-10-22 | trivial | An embedded font file yields at most 256 faces however many collection members or named instances it lists (#3716) |
|
||||
| 2026-10-22 | trivial | Importer hardening (#3692, #3705, #3713, #3714): spreadsheet header numbering and ODS repeated cells, LaTeX wrapper nesting and verbatim ends, Markdown delimiter runs, blank-span trimming and link destinations, and font/link copies per run are bounded or escaped; normal documents unchanged |
|
||||
| 2026-10-22 | trivial | Formatting-only protection refuses Copy Styles, as it does Update Styles (#3692) |
|
||||
| 2026-10-23 | trivial | A relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part (#3411 follow-up) |
|
||||
| 2026-10-21 | trivial | The reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, and an id that only appears as text is not a reference (#3411) |
|
||||
| 2026-10-23 | trivial | DOCX: TOC styles Word stores under ids in another language (`Sumrio1` named `toc 1`, `CabealhodoSumrio` named `TOC Heading`) read as `TOC1`…`TOC9` and `TOCHeading` unless the file uses those ids, so Update Table builds the entries in the document's own TOC styles (#3891); a TOC whose heading ends a section (entries in columns after a continuous break) is written and read as the engine's TOC, with the page-number tab at the column's edge (#3889) |
|
||||
|
||||
+3
-1
File diff suppressed because one or more lines are too long
@@ -59,7 +59,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic
|
||||
| Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — |
|
||||
| Tables: row border exceptions (`w:tblPrEx/w:tblBorders`) | ✅ | `row_borders_stay_on_row`, `row_nil_overrides_style`: explicit cell borders win; row exceptions precede table styles and table borders | — |
|
||||
| Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 |
|
||||
| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; omitted cell-margin edges inherit current table defaults; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — |
|
||||
| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; omitted cell-margin edges inherit current table defaults; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`); a row's `gridAfter` is bounded to 63 columns in native documents too, and repeated header rows stop after 500 000 copied items across a table (hostile documents, #3692) | — |
|
||||
| Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; a table with its own preferred width keeps it, however far past the margin, and only an automatic-width table over 1.5× the text width is squeezed to the margins (Word squeezes it as soon as it is wider) (#1721); cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 |
|
||||
| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; other overlap rules untested | 2–3 |
|
||||
| Nested tables | 🟡 | render; polish missing | 2–4 |
|
||||
@@ -67,8 +67,8 @@ Exact line spacing keeps the run font baseline around tall equations; automatic
|
||||
| Shape fills: gradients and transparency | 🟡 | #1500: linear (angle, `scaled`) and circular gradients and transparent fills drawn on screen, PNG and PDF (axial/radial shadings), soft edges fading the real fill; rectangular and shape-following path gradients drawn circular, `tileRect`/`flip` not drawn, outline gradients drawn in their middle colour, `rotWithShape="0"` drawn as turning with the shape | 2–4 |
|
||||
| Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Text laid out before the anchor (an earlier column) doesn't yet wrap around an object anchored later on the page, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) |
|
||||
| Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Paragraphs above an object's anchor wrap around it when it is placed higher up the page (the page is laid out again with it known from the start, #2048; when that wrap pushes the anchor onto the next page, the first page keeps the room and the object goes with its anchor, and a layout that still hasn't settled moves the object down to its paragraph rather than over text); text in an earlier column beside it doesn't yet, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) |
|
||||
| Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; not compared page-for-page with Word | 1–2 |
|
||||
| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743) | — |
|
||||
| Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; a picture is decoded once for all its crops, pictures whose header says they are opaque are not decoded, and at most 2^28 pixels per layout are decoded (later pictures wrap as rectangles in that layout, #3692); not compared page-for-page with Word | 1–2 |
|
||||
| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743); a chain's story is laid out at no more than 16 box widths (told apart to a twip), later boxes needing another taking the nearest one in their text direction (#3692) | — |
|
||||
| Rotated objects and text | 🟡 | #332: pictures, shapes, charts and groups drawn turned and flipped about their centre (screen and PDF); square wrap and inline lines keep clear of the rotated bounds; hit testing on the turned shape. Text in a rotated text box stays upright | 2–3 |
|
||||
| Group connector rerouting | 🟡 | Glued ends keep their existing connection-site path; free ends, missing targets and invalid sites retain their displayed rotated and flipped position when the connector frame is rebuilt, including under nonuniform group scaling. Model and engine regressions cover all connector kinds, both ends, rotations/flips, unit/uniform/nonuniform scale, idempotent rerouting, degenerate dimensions without nonfinite writes, and fallback spans beyond the 4000 pt display-size cap without endpoint movement or repeated shrinkage. Non-invertible display scales and unrepresentable fallback spans leave connectors needing fallback endpoints unchanged; fully resolved connectors still reroute. Invalid child extents use the renderer's unit-scale fallback. Routing around obstacles remains missing; no new native Word comparison | — |
|
||||
| Page borders (from page edge or text), page colour, watermark | ✅ | | — |
|
||||
@@ -273,6 +273,7 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim.
|
||||
| 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged |
|
||||
| 2026-10-23 | trivial | cu20we: paragraph-relative box floats use the pre-spacing origin; header/footer/cell/text-box regressions; readiness estimates unchanged |
|
||||
| 2026-10-22 | trivial | Layout lookups linear in clipped cells, authors, sections, header paragraphs and bidi offsets (#3692, #3706) |
|
||||
| 2026-10-22 | trivial | Layout work bounds for hostile documents: linked-box chain widths, repeated header items, `gridAfter`, picture decodes (#3692) |
|
||||
| 2026-10-22 | trivial | Hostile fonts (#3692): hint-reliant fonts with huge hinting zones are drawn unhinted, and a face draws its later glyphs unhinted once at least three of its hinted draws took over 50 ms and those are at least 1/16 of its hinted draws; a colour glyph's gradient stops and clip paths share one budget |
|
||||
| 2026-10-22 | trivial | Group connector rerouting preserves displayed free and unresolved endpoints under nonuniform scaling while resolved ends keep their connection-site path; degenerate dimensions and unrepresentable fallback display spans are guarded; synthetic model/engine coverage only, readiness estimates unchanged |
|
||||
| 2026-10-21 | trivial | A word with no break opportunity in it, laid out beside a floating object, no longer lays out quadratically: every word is measured once, before the line is broken (#3412) |
|
||||
|
||||
+39
-6
@@ -36,7 +36,10 @@ PDF in one run without a window:
|
||||
line win. A command is `{"command": id, "params": {…}}` or just the id. `save` is a path or a list;
|
||||
each output's format follows its extension. Relative `file` and `save` paths resolve against the
|
||||
script's folder (the working folder for stdin). `--cmd` flags run after the script's commands and
|
||||
`--save` after its outputs. A failing command stops the run with `command N (id): …` and exit
|
||||
`--save` after its outputs. The run starts with one open document. `file.new` and `file.open`
|
||||
open another one and make it active; later commands and the saves work on the active document.
|
||||
`view.switchWindows` lists the open documents (with `--print` their ids show), and
|
||||
`view.switchWindows={"id": n}` makes one active. A failing command stops the run with `command N (id): …` and exit
|
||||
status 1, and nothing is saved. Every output is checked (known extension, folder exists) before the first is written. Paths inside command params (a picture to insert, say) are relative to the working folder, not the script's. Scripts are capped at 4 MiB.
|
||||
|
||||
## Tracked changes by an agent
|
||||
@@ -66,12 +69,16 @@ config works across the suite) keep the agent to the folders you give it:
|
||||
`file.newFromTemplate`, `insert.textFromFile` and `insert.object`, `insert.picture` and
|
||||
`picture.change` with a `path`, `mailings.recipients` with a `path` (csv, tsv, txt, xlsx, xlsm,
|
||||
ods), `review.compare` (`path` and `original`), the templates and documents `tools.updateStyles`,
|
||||
`styles.organizer` and `styles.copyFrom` read. The pictures an opened HTML file names are read
|
||||
from beside that file, so they are inside the read root too (a link that leads out is not followed).
|
||||
`styles.organizer` and `styles.copyFrom` read, and the folders `file.options` takes
|
||||
(`dictionariesFolder`, `userTemplatesFolder`). The pictures an opened HTML file names are read
|
||||
from beside that file and must be inside the read root too, even when the file was opened through
|
||||
a link (#3736).
|
||||
- Every file a command writes must lie inside the **write root**: `save_document`, `file.save`,
|
||||
`file.saveAs`, `file.rename` (the file and its new name), `file.exportPdf`, `file.exportPng`, `file.printPdf`,
|
||||
`file.saveTemplate`, and `mailings.finish` with a `path`. `font.add` and `font.remove` change the
|
||||
user font folder, so they are refused when it is outside the write root (#3692).
|
||||
user font folder, so they are refused when it is outside the write root (#3692). A save replaces
|
||||
the name it is given, so that name must be inside the write root as well as what it leads to:
|
||||
a link outside the root pointing in is refused (#3735).
|
||||
- Read and write access are separate. Give the same folder twice for both; a root left out grants
|
||||
none of its access (with only `--automation-read-root`, nothing can be written to disk). Without
|
||||
either flag nothing changes.
|
||||
@@ -110,15 +117,41 @@ files it was given rather than guarding against other software on the computer.
|
||||
| `list_commands` | every command (filter with `query`) — over 270 of them |
|
||||
| `execute` | run one command: `{command: "insert.table", params: {rows: 3, cols: 4}}` |
|
||||
| `batch` | run several commands in order |
|
||||
| `new_document` | blank, sample, letter, resume, report |
|
||||
| `new_document` | blank, sample, letter, resume, report; opens beside the open documents |
|
||||
| `open_document` / `save_document` | docx, doc (open only), odt, rtf, html, md, tex, txt, json; save also pdf, png |
|
||||
| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs, in one command and one Undo step: 10,000 lines take ~10 ms; the line breaks are plain breaks, so AutoCorrect doesn't run on them) |
|
||||
| `list_documents` | the open documents: `id`, `title`, `path`, `dirty`, `active` |
|
||||
| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs: one undo
|
||||
step; each break is the Enter it stands for, so AutoFormat and AutoCorrect run and a
|
||||
one-line control refuses; at most 10,000 lines — the cap is `text.insert`'s own, so a
|
||||
control-channel or CLI call is held to it too) |
|
||||
| `select_text` | select the n-th occurrence of some text |
|
||||
| `get_text` / `inspect_document` | read the document back (verify without screenshots) |
|
||||
| `render_page` | a page as PNG |
|
||||
| `parity` | feature coverage |
|
||||
| `screenshot`, `click`, `key`, `ui_inspect` | app only (`--connect`) |
|
||||
|
||||
### Several documents
|
||||
|
||||
`new_document` and `open_document` (the commands `file.new` and `file.open`) open another
|
||||
document beside the ones already open and make it the active one; their reply carries its `id`.
|
||||
`open_document` replaces a blank document nobody has used, and a file that is already open becomes
|
||||
active again (`alreadyOpen`). `list_documents` lists the open documents with their ids.
|
||||
|
||||
Tools work on the active document. `execute`, `batch`, `type_text`, `select_text`, `get_text`,
|
||||
`inspect_document`, `render_page` and `save_document` also take `doc`, the id of another open
|
||||
document: the tool works on that document and the active one stays active.
|
||||
|
||||
```json
|
||||
{"name": "execute", "arguments": {"command": "text.insert", "params": {"text": "Hello"}, "doc": 3}}
|
||||
```
|
||||
|
||||
An id that names no open document, or a value that isn't a whole number from 0 up, is a tool error.
|
||||
`execute` with `view.switchWindows {"id": n}` makes a document active, and `file.closeDocument
|
||||
{"id": n, "force"?: true}` closes one (unsaved changes need `force`). Through `--connect`, `doc`
|
||||
reaches the app's control channel (see [Open documents](control-protocol.md#open-documents)); the
|
||||
app's window keeps showing the document it showed. `screenshot`, `click` and `key` act on the
|
||||
window, so they work on the document on screen.
|
||||
|
||||
`insert.picture` and `insert.icon` leave the caret after the new picture, so typing next doesn't
|
||||
replace it; Picture Format commands (`picture.*`, `arrange.*`) still act on it.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
|
||||
|
||||
**504 of 520 catalog features have commands (96.9%).**
|
||||
**505 of 520 catalog features have commands (97.1%).**
|
||||
|
||||
| Tab | Live | Total |
|
||||
|---|---|---|
|
||||
@@ -17,7 +17,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
|
||||
| References | 25 | 25 |
|
||||
| Mailings | 23 | 23 |
|
||||
| Review | 29 | 31 |
|
||||
| View | 25 | 29 |
|
||||
| View | 26 | 29 |
|
||||
| Read Mode | 2 | 3 |
|
||||
| Developer | 11 | 11 |
|
||||
| Table Design | 8 | 8 |
|
||||
@@ -61,7 +61,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
|
||||
- Window › Arrange All (`view.arrangeAll`)
|
||||
- Window › View Side by Side (`view.sideBySide`)
|
||||
- Window › Synchronous Scrolling (`view.syncScroll`)
|
||||
- Window › Switch Windows (`view.switchWindows`)
|
||||
|
||||
### Read Mode
|
||||
|
||||
|
||||
+31
-14
@@ -6,21 +6,35 @@ unless a document was explicitly created or opened before the startup scan compl
|
||||
In particular, a window launched for New/Open keeps the requested document visible;
|
||||
older copies remain available through File › Recover. Recovery uses the regular interface
|
||||
palette so its labels and buttons stay readable in light and dark themes.
|
||||
Restore opens an unsaved document with no file path. Save As chooses where to keep it.
|
||||
Recovery never writes to the original document. AutoSave's permission to write to an earlier
|
||||
file is cleared on restore.
|
||||
Restore opens the copy as another unsaved document with no file path, beside the open
|
||||
documents. It replaces only an untouched blank document, so it never replaces work and asks
|
||||
nothing. Save As chooses where to keep it. Recovery never writes to the original document.
|
||||
The restored document has no AutoSave permission to write to an earlier file, and keeps its
|
||||
copy in the record it came from.
|
||||
|
||||
Continue without restoring leaves copies available. Discard asks for confirmation and deletes
|
||||
the selected copy, including its retained generation. It does not delete the editing document.
|
||||
After discarding that document's copy, another copy is made only after a further edit.
|
||||
A successful save removes that document's recovery copy on the next worker cycle while it
|
||||
remains open. Copies left after switching documents can still be explicitly discarded.
|
||||
File › Recover lists copies from earlier runs: crashed sessions, documents replaced in this
|
||||
one, and copies whose deletion failed. The copies the open documents keep are not listed;
|
||||
they are not lost. Continue without restoring leaves copies available. Discard asks for
|
||||
confirmation and deletes the selected copy, including its retained generation.
|
||||
|
||||
Documents with a password do not get plaintext recovery copies. Applying a password waits for
|
||||
any pending snapshot, removes the current document's recovery generations, and fails if cleanup
|
||||
fails. Encrypted recovery is not implemented. Documents opened with a password keep recovery
|
||||
disabled for that session. Removing the password allows future recovery again.
|
||||
|
||||
## Several open documents
|
||||
|
||||
Every dirty open document keeps its own copy, whether it is shown or not. A crash with several
|
||||
unsaved documents leaves a copy of each, and the next start lists them all.
|
||||
|
||||
| Event | That document's copy |
|
||||
|---|---|
|
||||
| Save | Deleted on the next worker cycle |
|
||||
| Close without saving (Don't Save, `file.closeDocument`) | Deleted on the next worker cycle |
|
||||
| Don't Save when quitting | Deleted before the app exits |
|
||||
| Clean exit | Waits for the worker, then deletes the copies of documents saved, closed or protected since their last copy; dirty documents keep theirs |
|
||||
| Crash | Kept |
|
||||
|
||||
## Storage and timing
|
||||
|
||||
Copies live in `recovery/` under the app's profile folder. `WORDCRAFT_PROFILE_DIR` selects a
|
||||
@@ -28,9 +42,11 @@ separate profile for isolated runs, including preferences, logs and control keys
|
||||
`WORDCRAFT_NO_PREFS` disables recovery as well as preferences. Headless engine/CLI sessions
|
||||
and the browser do not create recovery files implicitly.
|
||||
|
||||
The desktop host checks for a dirty document every frame but snapshots at most once per ten
|
||||
seconds, and only when its revision changes. It clones the shared document model for a bounded
|
||||
worker queue. Encoding, file I/O, full-document validation and discovery run on the worker.
|
||||
One worker thread and one store serve every open document, so a record is locked once per
|
||||
process. Every frame the desktop host compares each open document's revision and timer without
|
||||
making it active, and queues at most one job. A document is snapshotted at most once per ten
|
||||
seconds, and only when its revision changes. Only a document whose copy is due is cloned (the
|
||||
shared document model) for the worker. Encoding, file I/O, full-document validation and discovery run on the worker.
|
||||
An unchanged document does not schedule repeated recovery repaints. The most recent edits can
|
||||
be lost if a crash happens before the next snapshot or while its write is still pending.
|
||||
|
||||
@@ -68,9 +84,10 @@ interval, English fallback for new labels, and no cloud, version-history or brow
|
||||
|
||||
`file.recover={}` reads cached `{enabled,busy,documents,error}`. Each document has `id`, `name`
|
||||
and `savedAt`. `{"refresh":true}`, `{"restore":"id"}` and `{"discard":"id"}` queue worker
|
||||
operations. Poll until `busy` is false; inspect `error` for failure. A restore is cancelled if the
|
||||
document id or revision changes while it loads. Programmatic restores do not open dialogs;
|
||||
user restores over dirty work use the existing Save Changes prompt.
|
||||
operations, and accept only listed ids. Poll until `busy` is false; inspect `error` for failure.
|
||||
A restore asked for in an untouched blank document replaces it once that document is active,
|
||||
and is cancelled if the document changes or closes while the copy loads. Restores never open
|
||||
dialogs.
|
||||
`ui.recoveryDiscard={"id":"id"}` opens confirmation; `{"answer":"discard"}` or
|
||||
`{"answer":"cancel"}` answers it.
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
WordCraft uses `craft-pdf` with default features disabled and `write` enabled. Both that dependency
|
||||
and the required root Krilla patch use the immutable `storytold/craft-libs` revision
|
||||
`17744430be327d0257496a6a31c8d38fe20a6756`. Rust 1.95 or newer is required. The revision must be
|
||||
`b926b5c03e256c86ca10934f58d03f628810b5b7`. Rust 1.95 or newer is required. The revision must be
|
||||
available from the remote before a fresh checkout can resolve it. The shared writer checks a
|
||||
backend marker at compile time so an unpatched Krilla cannot replace the required backend.
|
||||
|
||||
|
||||
+5
-2
File diff suppressed because one or more lines are too long
+4
-2
@@ -74,8 +74,10 @@ isn't another user's. On Windows and macOS (and the BSDs) the loopback rule is t
|
||||
program listening on the port runs as the same user as WordCraft, before sending anything. If
|
||||
another account on a shared machine took the port while Zotero wasn't running, WordCraft
|
||||
refuses with an error and the document isn't touched. When the table doesn't show the
|
||||
listener (WSL2 mirrored networking with Zotero running on Windows, a local proxy, a sandbox)
|
||||
or can't be read, its owner is unknown and only the loopback rule applies, as below.
|
||||
listener (WSL2 mirrored networking with Zotero running on Windows, a local proxy), its owner
|
||||
is unknown and only the loopback rule applies, as below. That holds only when the tables were
|
||||
read in full: a read error, or an open error other than "not found", **refuses**. Sandboxes
|
||||
that deny `/proc/net/tcp` (some AppArmor or Snap profiles, for example) are therefore refused.
|
||||
- Windows, macOS and the BSDs: **no same-user check.** They have no such table to read
|
||||
without OS calls WordCraft doesn't make (it uses no `unsafe` code), so only the loopback rule
|
||||
applies there (and on Linux when the listener isn't in the table). Remaining risk: on a
|
||||
|
||||
Reference in New Issue
Block a user