Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor ba145e9405 security: enforce HTTPS on curl downloads (SonarCloud S6506)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 17:07:24 -04:00
Dylan M. Taylor f7d7c0288c ci: add sonar-project.properties for CI-based analysis
Marks the project ineligible for SonarCloud Automatic Analysis, resolving the
CI-vs-automatic-analysis conflict so the SonarCloud workflow succeeds.
2026-10-21 16:00:51 -04:00
Dylan M. Taylor e65d188747 ci: add SonarCloud scan via shared reusable workflow
Calls storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
with secrets: inherit. Adds SonarCloud badge to README where missing and
removes the redundant sonar-project.properties (config now passed by the
reusable workflow).
2026-10-21 15:47:09 -04:00
Dylan M. Taylor e05b7fe86f Merge pull request #2381 from dylanmtaylor/ci/shared-nix-workflow
ci: switch to shared nix workflow from craft-common-actions
2026-10-21 02:39:43 -04:00
skylar taylor-barrick e3bb06a298 Merge pull request #800 from isaac-levine/ci-xtask-ci-workflow
CI: run cargo xtask ci on pull requests and pushes to main (#754)
2026-10-20 22:11:52 -06:00
skylar taylor-barrick cd709ea0a9 Merge current main into CI workflow branch 2026-10-20 21:31:13 -06:00
Brandon ThomasandClaude Opus 5.5 d1b0bfc3f9 Skip CodeRabbit reviews on Dependabot PRs (#3302)
Adds .coderabbit.yaml so CodeRabbit no longer reviews or comments on PRs opened by dependabot[bot].

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:39:49 -04:00
Dylan M. Taylor fd24c1ab3f Merge pull request #2935 from dylanmtaylor/docs/add-sonar-coderabbit-badges
docs: add SonarCloud and CodeRabbit badges to README
2026-10-20 11:31:10 -04:00
Brandon ThomasandClaude Opus 5.5 2e6db0bd88 Release 0.9.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:18:50 -04:00
Dylan M. Taylor 9b4b948c6e Merge pull request #2854 from dylanmtaylor/chore/drop-competitor-references
docs: drop references to competing commercial software
2026-10-19 19:22:22 -04:00
e6c5a701dd The caret and clicks land between the letters of a ligature (#1974) (#2006)
* Fix #1974: the caret and clicks land between the letters of a ligature

The glyph's advance is shared evenly among its letters (mirrored in
right-to-left text); ligature letters keep their character spacing.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Fix #1974: a long word never wraps inside a ligature

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Fix #1974: a hyphenation point never splits a ligature

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* #1974: ligature tests use a bundled font that draws fi and ffi as ligatures

They assumed the installed default-font stand-in draws ligatures; Liberation Sans (Linux) draws
none, so they failed there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-19 13:09:22 -04:00
4e2568e2da RTF import: heading and code formatting compared with the file's own stylesheet (gap 19 follow-up) (#2236)
* gap 19: RTF import compares heading and code run formatting with the file's stylesheet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gap 19: review round 1: a style chain with formatting words is complete; unnumbered entries are Normal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-19 12:57:16 -04:00
b8debb3050 Content controls: typed text takes the control's formatting, in any language (#3116)
* Content controls: typed text takes the control's formatting, in any language

Text typed in place of a content control's placeholder takes the
control's own run properties (w:sdtPr/w:rPr, ECMA-376 §17.5.2.27), as
in Word: in a protected form filled in Word for Mac, all 17 entered
values carry the style their control's rPr names. WordCraft kept that
rPr only as XML to write back and gave typed text the placeholder's
formatting minus the placeholder style.

It also knew the placeholder style only by its English id,
PlaceholderText. German Word writes Platzhaltertext (the name stays
"Placeholder Text"), so in forms from German Word every entry stayed
grey, on screen and in the saved file, and restoring a placeholder
added a second Placeholder Text style. The style is now found by its
built-in name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Content controls: typing drops a showing placeholder's own style

A control without run properties that shows its placeholder: text typed
over it drops the placeholder run's character style whatever its id
and name. Word localizes only the placeholder style's id, but another
program may localize its name too, and the name lookup then misses it.
Restoring a placeholder and new controls keep the name lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-19 12:53:44 -04:00
f1b4be8a04 Restrict Editing: forms protection keeps a form's content controls (#3112)
* Restrict Editing: forms protection keeps a form's content controls

With Filling in forms enforced (w:edit="forms"), Word greys out the
Developer tab's control buttons and Properties, and a field's shortcut
menu offers no Remove Content Control: only the fields' content can be
filled in. WordCraft refused these only outside the fillable fields, so
with the caret in one it removed the control (its content left as
plain, no longer fillable text), inserted new controls inside it, and
renamed it; only lock changes were refused.

Under forms protection, inserting any content control, Properties
(every setting) and Remove Content Control are now refused before they
run. Typing, Toggle Check Box, Choose List Item and Set Date still fill
the fields. Other protection modes are unchanged.

Observed in Word for Mac 16.113 on a protected real-world form
(72 content controls: rich text, check boxes, date pickers); the same
form filled in WordCraft opens in Word still protected, with its values,
ticks and dates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restrict Editing: parity row states what forms protection refuses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restrict Editing: correct the comment on content-control property edits in forms mode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-19 12:49:05 -04:00
2304bf6ac6 Fix Paragraph::sentence_at at the first character of a sentence (#3105)
* Fix Paragraph::sentence_at at the first character of a sentence

Found while testing crates/doc/src/para.rs:1397-1427

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* sentence_at: a terminator followed directly by text doesn't end the sentence at the caret

"Hello.World" at offset 6 gave an empty (6,6) range; only treat the caret as just past a
sentence end when the character at the caret is a space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-19 12:45:25 -04:00
NetterSilverandClaude Haiku 5.5 e8866ea58b Keep an ordered list's start number in Markdown import (#3103)
Found while testing crates/formats/src/markdown.rs:160-161,463 and crates/formats/src/model.rs:111 (ListInfo)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 12:32:33 -04:00
NetterSilverandClaude Haiku 5.5 74ab8d2819 Keep a single leading space in HTML export paragraphs (#3102)
Found while testing crates/formats/src/html.rs (export, around line 1744; paragraph text writer)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 12:32:27 -04:00
NetterSilverandClaude Haiku 5.5 517ba09250 Write large whole numbers without decimals in format_number (#3107)
Found while testing crates/doc/src/locale_number.rs:114

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 12:32:21 -04:00
Diego GueriniandClaude Sonnet 5.5 49a934fba7 #3067: script parse test compares paths with Path::join (#3106)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 12:29:17 -04:00
Gabriel MatozoandBrandon Thomas 117aea31a8 Navigation pane: a long heading wraps within the pane instead of ending in an ellipsis (#3015) (#3029)
The pane drew each heading with `Button::selectable(..).truncate()`, so a heading wider than the
pane ended in an ellipsis and its text had to be read from the hover tooltip. Word wraps the row
instead.

`nav_heading` now wraps: the button takes `Button::wrap()` and a minimum size of the available width
and one row, so the label continues on further lines inside the pane, the row grows with the text,
and the pane still keeps its width. The trailing grow atom still keeps the text flush left under its
parent (#1199). The hover tooltip goes with the ellipsis: nothing is hidden now, and a word wider
than the pane is broken by the wrap instead of overflowing.

The navigation test asserts the wrap instead of the ellipsis. For a heading well past the width and
for a single word wider than the pane, the label has more than one line, is not elided, stays inside
the pane, does not widen it, and takes a taller row than a one-line heading in the same pane.

`docs/ui-parity.md` and `ROADMAP.md` record the new behaviour.

Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
2026-10-19 03:08:47 -04:00
SlavenDj fdf791a8b2 #3045: the empty window's ribbon tabs stay live, so File reaches the Start screen (#3064)
With no document open, the empty window greys the Share / Editing /
Comments buttons out by disabling the whole right-to-left line, and the
tab strip is a child of that line: egui gates interactions on a widget's
enabled state, so every tab was dead. File did nothing, and the empty
window had no way back to the Start screen, or to making a document.

Only those three buttons are disabled now. The test presses Escape on
the Start screen (the empty window, as #1129 has it), clicks the File
tab and asserts the Start screen, with its Blank document, is back.
2026-10-19 03:05:41 -04:00
MarekSpodBiedry dbc5c3407b Size the text cursor by the font, not the line spacing (#2920) (#3011) 2026-10-19 03:05:17 -04:00
MarekSpodBiedry 3f9c19d9f3 Opening a missing recent file keeps File open and shows the error (#2938) (#3012) 2026-10-19 03:05:11 -04:00
Martin BallandClaude Opus 5.5 435be0a4cb #2972: regression tests for File page AccessKit focus paths (mac chevron, Options back, Blank document) (#3019)
* #2972: regression tests for File page / sidebar AccessKit focus paths

The macOS back chevron beside the traffic lights (the report's "sidebar
collapse toggle"; there is no other toggle there), File > New/Home > Blank
document, Escape, and File > Options > back (both layouts, with a document
and from the Start screen) all run under kittest's AccessKit. The chevron
and Options tests panic with "Focused ID ... is not in the node list"
before #2856 and pass on main.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* #2972: review — fresh document per Blank document path; Options back asserts closing and focus in every case

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WEhZXSxzS8orX7f9J3qpr

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 03:05:06 -04:00
Martin BallandClaude Opus 5.5 6950c22d73 #2999: a file dialog that "cancels" at once asks again without a parent, then shows the built-in browser (#3030)
* #2999: a file dialog that "cancels" at once asks again without a parent, then shows the built-in browser

rfd folds the xdg-desktop-portal's failure response into "cancelled", so on desktops whose
FileChooser portal fails to show a dialog, Save As, Export and Open did nothing and logged
nothing. A cancel quicker than anyone can make one (300 ms) is now taken as that failure: the
dialog is asked for once more without the parent window (GridCraft, which passes none, works on
the reporter's machine), and if that too comes back at once the app shows its built-in browser.
Each step logs a warning.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WEhZXSxzS8orX7f9J3qpr

* #2999: review — the quick-cancel retry runs only on Linux and the BSDs; Windows and macOS answer a quick cancel as a cancel

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WEhZXSxzS8orX7f9J3qpr

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 03:04:59 -04:00
Juan Camilo Agudelo e0ff4b0c3d Paste: Ctrl+V pastes a clipboard picture even when Ctrl is released before V (#3049) (#3058)
* Paste: Ctrl+V pastes a clipboard picture even when Ctrl is released before V (#3049)

egui-winit keeps the Mod+V press, so a clipboard picture is pasted on V's
release, which required Mod still held. Letting Ctrl up first (the natural
one-hand Left Ctrl+V) pasted nothing. A V release with no V press seen was
Mod+V: use the modifiers held with Mod then, so Mod+Shift+V and Mod+Alt+V
stay apart.

Fixes #3049

* Paste: record the modifiers held with Mod in event order (#3049)

Mod can go down and up within one frame, so the frame's modifiers may no
longer have it when the canvas reads them. Take them from each
ModifiersChanged and key press as they arrive. Review feedback on #3058.

* Paste: document the Mod+V release helpers and tests (#3049)
2026-10-19 03:04:54 -04:00
SlavenDjandBrandon Thomas ebabb57e5e #3039: mini toolbar font colour Automatic returns focus to the document (#3043)
Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
2026-10-19 03:04:49 -04:00
SlavenDj 25ad07f868 #3038: mini toolbar highlight swatch returns focus to the document (#3042) 2026-10-19 02:59:34 -04:00
SlavenDj e1e6c31571 #3040: undo-list row returns focus to the document (#3044) 2026-10-19 02:59:29 -04:00
SlavenDj 396344c31e #3031: Tabs dialog says the list is empty instead of a blank box (#3050) 2026-10-19 02:59:24 -04:00
SlavenDj 6e3f800a8b #3032: Styles pane greys out Delete for built-in styles (#3051) 2026-10-19 02:59:19 -04:00
SlavenDj 83014016c6 #3036: AutoCorrect Replace list says when the filter matches nothing (#3053) 2026-10-19 02:59:14 -04:00
570b992947 #3037: file browser says when a folder (or the filter) shows no items (#3054)
* #3037: file browser says when a folder (or the filter) shows no items

* #3037: Czech and Vietnamese catalogs carry the file browser's no-items string

* #3037: show the no-items hint only for a folder that was read

An unreadable folder already shows its error below the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:59:09 -04:00
SlavenDj 76d8347f77 #2892: rules end flat on screen, like the PDF export (#3061) 2026-10-19 02:44:02 -04:00
SlavenDj 19e5bb48cf #3033: file browser Open/Save uses the themed primary button (#3047) 2026-10-19 02:43:57 -04:00
SlavenDj ab968b072d #3034: SmartArt Pyramid separators follow the theme line colour (#3052) 2026-10-19 02:43:52 -04:00
SlavenDj d26b8dcae7 #3035: WordArt reflection swatch follows the theme's dimmed text colour (#3055) 2026-10-19 02:43:46 -04:00
SlavenDj 68bc118a68 Add @SlavenDj to contributors/people.toml (#3048) 2026-10-19 02:43:34 -04:00
Diego GueriniandClaude Opus 5.5 18c7c1d114 #2845 follow-up: clippy passes on Windows and Linux again (#3006)
`graphics.rs` allowed dead code only on macOS (where the Windows items are unused). Since #2845
the two indirect-validation functions are called only on macOS, so on Windows and Linux
`cargo clippy --workspace --all-targets -- -D warnings` stopped with "function is never used".
They now carry `allow(dead_code)` off macOS.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:43:28 -04:00
NetterSilverandClaude Haiku 5.5 60ad86acb3 Keep a single trailing space when exporting a paragraph to ODT (#3066)
Found while testing crates/formats/src/odt.rs:354-390 (plain_inlines)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 02:43:22 -04:00
Fin Warman d588e0b873 Limit table row deferral to genuine widow splits (#2925) 2026-10-18 17:11:52 -04:00
ReesandBrandon Thomas 61e565cd1c fix(ui): keep recent fonts visible after an unmatched query (#2068) (#2929)
Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
2026-10-18 17:10:01 -04:00
Diego GueriniandClaude Opus 5.5 2d0169e718 A Word 2003 XML or web page saved as .doc opens by what it holds (#2853) (#2942)
* #2853: Word XML, web pages and MHTML saved as .doc open by what they hold

A Word 2003 XML Document named `.doc` went to the Word 97-2003 reader, which refused it ("Invalid
CFB file"), and the window showed a blank page. Word XML (2003 or Flat OPC), a web page and a
Single File Web Page (MHTML) under a `.doc`, `.docx` or `.rtf` name now open by what they hold,
as in Word. MHTML is read for the first time (`.mht`/`.mhtml` too): the MIME parts are split and
decoded, the page is imported as HTML in its charset and its pictures load from the other parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #2853: leave MHTML to #1104, keep Word XML and web pages saved as .doc

#1104 adds an MHT reader (`wordcraft_formats::mht`), so this branch drops its own MHTML reader and
the `.mht`/`.mhtml` extensions instead of adding a second one. A Word XML Document or a web page
under a `.doc`, `.docx` or `.rtf` name still opens by what it holds (the #2853 fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #2853: a web page's signature needs its tag name to end (`<htmlish>` is other XML)

CodeRabbit: `starts_with("<html")` took XML with a root like `<htmlish>` under a `.doc` name for a
web page, though XML that isn't a Word document stays an error. `<html` and `<!DOCTYPE html` now
need white space, `>` or `/` after them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #2853: rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:04:33 -04:00
dhruvLearner8 384d5ef307 Cross-reference: caption labels of your own are Reference types (#2940) (#2957)
* Cross-reference: caption labels of your own are Reference types (#2940)

* Caption labels come from the SEQ field's caption, not a digit (#2940, review)

* Caption label after a chapter number field (#2940, review)
2026-10-18 17:01:41 -04:00
dfgodvananddfgodvan 99ff497180 Layout: a roman page number in the footer stays roman when the body shows the page count (#2966)
A paragraph with a PAGE field is cached by the page number, the totals, the
section and the bookmarks, but not by the number format the field shows the
page number in. Paginating lays the footer out to know where the body ends,
before the section's number format is set in the field context, so as
decimal. When the body shows NUMPAGES or SECTIONPAGES, the pass that has the
right totals made that layout's key equal to the page's own footer's, and the
cached decimal one was drawn: page 2 of a section numbered in lower-case
roman showed "2", not "ii". The number format is now part of the key.

Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com>
2026-10-18 16:55:08 -04:00
dfgodvananddfgodvan 4b18b307be Selection rectangles walk only the paragraphs the range reaches (#2967)
Document::paths_between listed every paragraph of the story (every cell
paragraph of every table) and then kept those between the two paths, so each
selection_rects call cost the whole document. The canvas calls it every frame
for each Find reading highlight (up to 5,000), each content control frame,
Read Aloud and the hyperlink under the pointer. It now walks only the
blocks, rows and cells between the two paths: for a one-paragraph range in a
document of 26,000 paragraphs (four 1,500-row tables), 1.0 ms -> 2.6 us.

Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com>
2026-10-18 16:53:39 -04:00
dhruvLearner8 972252d0b4 Ruler: margin handles on even mirrored pages edit the visible edge's stored margin (#2914) (#2975) 2026-10-18 16:49:22 -04:00
100dfb37c8 Layout: a word after a slash wraps whole, like Word (#2963) (#2985)
* Layout: a word after a slash wraps whole, like Word (#2963)

UAX #14 (LB13) forbids a break between a space and a slash, and the break
after a slash is dropped on purpose (Word keeps "and/or" whole). Together
"caption /word" had no break opportunity at all, so a line too short for
both filled letter by letter and split the word. Add the break before a
slash that follows whitespace and starts a word.

Co-Authored-By: Claude <noreply@anthropic.com>

* Layout: no break before a slash after a no-break space (#2963)

The break before a slash word took any whitespace, U+00A0, U+2007 and
U+202F included, so "Nr. /12" could wrap after the no-break space.
Only a breakable space counts now. The parity checklist lists the rule
on its own row as untested against Word.

Co-Authored-By: Claude <noreply@anthropic.com>

* Layout: test the slash word in a fixed-width table cell (#2963)

The report came from a table cell, where the cell's own width is the
line width.

Co-Authored-By: Claude <noreply@anthropic.com>

* Docs: gaps and typing parity for the slash-word break (#2963)

Doc wording taken from #2994 (maxm11), with the test names of this PR.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude (ThinkSystem) <claude@thinksystem.local>
Co-authored-by: Claude <noreply@anthropic.com>
2026-10-18 16:41:44 -04:00
Max Murphy 00abb59a85 render: keep the legacy font effect test off antialiasing (#2898 x #2899) (#2990)
The shadow copy is solid RGB 128; the old 100-160 grey band also counted the glyph's antialiasing, whose centroid hid the copy's offset with some fonts. This was already failing on origin/main on macOS; count the copy's own grey.
2026-10-18 16:40:22 -04:00
Max Murphy 72328ab521 cli: keep the non-UTF-8 file name test off macOS (#2993)
* cli: keep the non-UTF-8 file name test off macOS

APFS refuses file names that aren't valid UTF-8, so the fixture can't be
made there (as in tests/non_utf8_args.rs); the unit test runs on the
other unixes.

* chore: add @maxm11 to About-window contributors with their own name
2026-10-18 16:37:59 -04:00
Dylan M. Taylor 652a1596aa docs: put SonarCloud and CodeRabbit badges on their own line 2026-10-18 14:47:49 -04:00
Dylan M. Taylor d82f17ac58 ci: configure Dependabot for Cargo and GitHub Actions
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
2026-10-18 12:31:59 -06:00
Dylan M. Taylor 0b3733749d docs: update feature catalog parity link and qualify OpenGL/WARP fallback 2026-10-18 12:50:08 -04:00
3af927501b Ribbon collapse review follow-ups: collapsed groups stay open, Styles kept (#1285) (#1293)
* #662: ribbon groups shrink to fit the window (compact, then one drop-down)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* #1285: collapsed ribbon groups stay open while used; Styles gallery trims before it folds

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Ribbon collapse: accessible name for folded groups, Tables/Links icons (#662)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Ribbon: Esc in a collapsed-group popup doesn't leave full screen; tab-strip test ignores same-named ribbon buttons (#1285)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Ribbon tab-strip test: pick the tab, not a same-named folded group (#662)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Ribbon: a folded group's open state is keyed by its place in the row, not just its title (#1285)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* rustfmt: collapsed_group signature (#1285)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 12:03:32 -04:00
c266e5a751 Scroll the selected command into view in Customize Keyboard (#1731)
Fixes #1696

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:58:10 -04:00
e85fc75808 File (Backstage): an icon beside each side-menu item (#1714) (#1723)
* File (Backstage): an icon beside each side-menu item (#1714)

The side menu (Home, New, Open, Info, Save, Save As, Print, Export,
Close, Options) was text only. Each item now has an 18 pt icon in the
label colour, greyed out with it when no document is open. Existing
icons where they fit (house, new page, open folder, info, save, printer,
close), and three new ones drawn in code: Save As (the save disk with a
pencil), Export (a page with an arrow leaving it) and Options (a gear).
Original artwork, no asset files.

Test: every item names its own icon, and each one is a drawn icon, not
the letter-tile fallback an unknown name gets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: one Backstage row with both the Close (#1712) and side-menu icon (#1714) notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:57:55 -04:00
ambroandBrandon Thomas 12428df221 Italian: fixes from a native speaker review (#1717)
Went through the whole Italian catalog. Fixed the ones that were wrong (Mailings is Lettere in Word, Redo is Ripristina, cursor commands say riga) and made some terms consistent (input penna, Disponi testo, Ombreggiatura).

Also marked Italian as reviewed in the localization doc and added myself to people.toml.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:57:39 -04:00
Fin WarmanandBrandon Thomas 8b789b87d1 Keep fitting exact-height rows together (#1641)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:57:23 -04:00
528da1f6a2 Comments: a draft survives a failed commit (#1348) (#1512)
* #1348: WIP

* #1348: keep comment drafts per document, keep them on a refused edit, write them before save/close/new/open

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1348: a refused comment draft can be dropped; fields read-only where comments can't change

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1348: ui-parity: one Task panes and Context menus row after the rebase

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs after rebasing on main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1348: keep the window open while a web save is unconfirmed; split the roadmap row by operation

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* #1348: pending comment drafts count as unsaved changes for the browser; a save error isn't hidden by a draft refusal

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* #1348: an agent can't replace the document over a pending comment draft without force

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:57:07 -04:00
Brandon ThomasandClaude Opus 5.5 b7579bfe34 ci: recipient-list test passes fields to RecipientFilter::params (#1295 x #2621)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 11:56:43 -04:00
b8b1ec6141 Mail merge review follow-ups: keep the filter on failure, cap spreadsheet text (#1287) (#1295)
* #778: .xls and legacy-encoding recipient lists; filter and sort recipients

- Recipient lists: legacy Excel 97-2003 .xls (BIFF8 reader on the cfb crate already in the
  tree); Excel 5.0/95 and password-protected .xls refused with a clear message; a CSV named
  .xls reads as text, an HTML one gets an error. .xls added to the picker filters (desktop,
  web, built-in browser) and drop handling.
- Text lists (and plain-text import) detect legacy encodings when not UTF-8/UTF-16: GBK/GB18030,
  Big5, Shift_JIS, EUC-KR, Windows code pages (encoding_rs + chardetng, MIT/Apache); an
  `encoding` param overrides.
- mailings.filterRecipients / mailings.sortRecipients: field, comparison (equal, notEqual,
  less, greater, lessOrEqual, greaterOrEqual, blank, notBlank, contains, notContains), value,
  And/Or joins; sort keys. Preview, Finish & Merge and labels use only the matching records
  in sort order. Edit Recipient List edits every record and keeps the filter.
- Recipient List dialog: Filter and Sort section and a merge-order column; catalog rows;
  strings in every catalog; docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n: Hungarian and French strings for this change

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review fixes for #885: total order for recipient sort, Bulgarian strings

- compare_cells now ranks numbers (compared numerically) before text (compared
  case-insensitively), so it is a total order; a column mixing 9, 10 and 1a
  could make slice::sort_by panic. Regression test sorts mixed columns.
- Bulgarian catalog: the 24 Filter and Sort strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1287: placeholder (work in progress)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1287: keep the filter on a failed Edit Recipients; cap .xls/.xlsx cell text; dialog drops rules on unknown fields and caches the merge order

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1287: rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:56:19 -04:00
914e92b163 Ribbon groups shrink to fit the window (#662) (#708)
* #662: ribbon groups shrink to fit the window (compact, then one drop-down)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Ribbon collapse: accessible name for folded groups, Tables/Links icons (#662)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Ribbon tab-strip test: pick the tab, not a same-named folded group (#662)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:56:02 -04:00
8b0f1762a1 Split Cells on merged cells: divisor message and 1x1 top-row cut (#2592) (#2913)
* #2592: Split Cells on a merged cell: divisor message, 1 x 1 cuts the top row off (Word check)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* #2592: Split Cells dialog shows the divisor message; docs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* #2592: clippy

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* #2592: Split Cells 1 x 1 on a merged cell changes nothing (corrected Word check)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Split Cells on a merged cell follows Word: divisor message, 1 x 1 changes nothing (#2592)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:55:32 -04:00
PanyangYandBrandon Thomas de8a81c1e4 References: Word sources file import in Manage Sources (#2640) (#2897)
* References: Word sources file import in Manage Sources (#2640)

Manage Sources gains Import… and references.sources an "import": path
parameter: it reads a b:Sources document (Word's own Sources.xml) into the
document's sources with the same reader as a document's own bibliography
part. A tag already here is left alone, an untagged entry is matched by
title and author or gets a tag like a new source, the whole import is one
undo step, and a file that isn't b:Sources is refused. Desktop only; the
web build hides the button.

The docx reader now shares the b:Sources parser (crates/docx/src/
bibliography.rs) with the import, with the same dedupe and 10,000-source
cap; the hostile-input cap test still passes.

Tests: docx unit tests (names, kinds, accessed/DOI fallbacks, repeated
tags, empty entries, wrong root), engine import test (add, undo, second
import, untagged matching, refused file), ui tests (the dialog button asks
for a "sources" file; finish_pick imports), wordcraft-cli end to end
(import, cite, bibliography, save, reopen; the package's customXml
carries the sources). The button's label is in every interface catalogue.

ROADMAP progress log and docs/roadmap.md, gaps.md, file-format-parity.md
and target-app-parity.md get their rows.

* References: review fixes for the sources import (#2640)

- read_sources validates the root namespace instead of the local name alone:
  an un-namespaced or unrelated <Sources> is refused, and both bibliography
  namespaces read — OOXML's and Word's own 2004/10 (the VBA Source.XML
  format), the latter mapped onto the canonical b prefix with parse_ns.
  Tests: the 2004/10 namespace is accepted; a no-namespace or unrelated
  root is refused.
- The import loop builds the tag and (title, author-or-editor) sets once and
  keeps a per-stem counter, with the stem rule shared with auto_tag
  (auto_tag_base). Importing thousands of untagged sources no longer costs
  O(n^3). Test: untagged sources sharing a stem get Riv21, Riv211, Src, and
  a second import adds none.
- docs/target-app-parity.md and docs/file-format-parity.md no longer claim
  bibliography sources aren't saved in DOCX: #169 and #532 are closed and
  the round trip exists (#790).

* References: doc comments for the sources import helpers (#2640)

Hoist the untagged-source matching helper (by_author) to module level and
document sources, auto_tag_base and sources_ui, so the functions this PR
touches meet CodeRabbit's docstring-coverage pre-merge check.

* References: re-apply the sources import's progress notes (#2640)

The rebase onto current main took upstream's copies of ROADMAP.md,
docs/file-format-parity.md and docs/gaps.md, because the rows this PR
adds conflicted with upstream's own appends. Re-apply them on the current
text: the progress log and revision rows, the bibliography row's
source-format note, and #15's evidence, with each status line's change
note. The other two documents (docs/roadmap.md, docs/target-app-parity.md)
merged cleanly and keep their rows.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:54:47 -04:00
2646700ca0 #2573: Picture Format › Size takes sizes in any unit, pixels too, and opens a Size dialog (#2889)
* #2573: Picture Format › Size takes sizes in any unit, pixels too, and opens a Size dialog

The picture size options were too limited (#2573, and #1901's request to enter pixels without
deforming the picture): the ribbon's W/H fields were points only, rejected "300 px", resized on
every keystroke (one undo step per digit), and there was no way to scale from the original
size, lock or unlock the aspect ratio, or reset the size.

- Picture Format › Size: W/H show the interface unit (Options › Advanced) and take a size typed
  in any unit, `px` included; it applies on Enter as one undo step, and the other side follows
  so the picture keeps its proportions. A drag still applies live.
- The Size group's launcher (and `picture.size` run without a size) opens a Size dialog: height
  and width, scale of the original size or of the size it opened with, Lock aspect ratio,
  Relative to original picture size, the original size with its pixels, and Reset.
- `picture.size {"original": true, "scale"?}` sizes from the picture's original size (its pixels
  at 96 ppi, read from the image adjustments started from, less what is cropped). A size past
  4000 pt that keeps the proportions now shrinks as a whole instead of flattening.
- New strings translated in every interface catalog.

The crop tool already exists (Picture Format › Size › Crop) and is being extended in #2027 and
#2440; options cut off in a narrow window are what #708 and #1293 (ribbon groups that shrink to
fit) address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZArmeAT48gF84aKj7Zx4C

* contributors: credit @matteo-andreuzza by their GitHub profile name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZArmeAT48gF84aKj7Zx4C

* #2573: Size keeps the proportions at the 1–4000 pt limits and follows the selection

Review follow-ups (CodeRabbit on #2889):

- Size dialog Reset on a photo larger than 4000 pt (a 6000 px wide one is 4500 pt) set it to
  the original size, which `picture.size` then clamped side by side: the picture was flattened.
  Reset now shrinks both sides by one factor.
- Locked, a side typed past the limit clamped the other on its own (15×30 pt with width 4000
  became 4000×4000). Both sides now scale by one factor.
- `fit_size` (engine) only shrank: a proportional size under 1 pt (scale 1% of 50×200 pt) became
  1×2 pt. It now picks the factor nearest 1 that brings both sides within 1–4000 pt, falling
  back to side-by-side clamping only for shapes too long and thin for any; the dialog uses it.
- Dialogs aren't modal: picking another picture while the Size dialog was open made OK size it
  with the first one's numbers. The form now follows the selection (starts over on the new
  object, closes when nothing is left to size).
- Doc comments on the Size form's methods.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZArmeAT48gF84aKj7Zx4C

* #2573: Size dialog reads the picture again after a crop, change or resize

Review follow-ups (CodeRabbit on #2889):

- The open Size form only compared the selected object's position. A crop from the ribbon,
  Change Picture or a handle drag while the dialog is open left Reset and OK working from the
  old crop and size. The form now remembers the picture, crop and size it was read from (bit for
  bit, without comparing whole objects that may hold large embedded data) and reads them again
  when any changes.
- An egui harness test drives the dialog end to end: launcher, a height typed in pixels, nothing
  applied before OK, OK sizes in proportion as one undo step and closes the dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZArmeAT48gF84aKj7Zx4C

* #2573: size fields never resize a picture just by showing it

Review follow-up (CodeRabbit on #2889): egui's DragValue clamps a value already outside its
range when it is drawn and reports that as a change. The Size fields act on changes, so:

- Picture Format's W/H resized a picture smaller than 1 pt (a 1×1 px spacer is 0.75 pt) or
  larger than 4000 pt as soon as the tab was shown, an edit and an undo step nobody asked for.
  The code before this PR had the same problem with its 4–2000 pt range.
- The Size dialog's scale fields silently changed a picture enlarged past 10000% (a small icon
  made large), and its lengths a size outside 1–4000 pt, which OK then applied.

The range now bounds only what is typed or dragged (`clamp_existing_to_range(false)`). Both
regression tests fail without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZArmeAT48gF84aKj7Zx4C

* #2573: Shape Format and Chart Design size fields leave existing sizes alone too

Review follow-up (CodeRabbit on #2889, outside the diff): the same egui clamp as in d56a105.
Shape Format's W/H (4–2000 pt) and Chart Design's (36–2000 pt) clamped a size outside their
range as soon as the tab was shown and resized the object unasked. The new Size dialog makes this
easier to reach, since it takes shapes up to 4000 pt, but a thin line under 4 pt was already cut.
Both now show such a size as it is and act only on a new value. Shape Format's range follows
`picture.size` (1–4000 pt), so dragging a 3000 pt shape no longer snaps it back to 2000.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZArmeAT48gF84aKj7Zx4C

* #2573: Esc cancels a size typed in Picture Format; a typed size applies when the field is left

Review follow-up (CodeRabbit on #2889, outside the diff) asked to apply typed sizes on Enter only.
That is not done: a field keeps what was typed when it loses focus, as text fields do, and
otherwise typing a height in the Size dialog and clicking OK would throw it away. A harness test
now pins that path with a real click (press, then release frames later).

Checking it turned up a real bug: Esc did not cancel in the ribbon's W/H fields. egui 0.36 keeps
a DragValue's typed text after Esc and applies it the next frame, when the field still counts as
having just lost focus but Esc is no longer pressed. The fields now remember when Esc ended the
typing and drop that late value. (In the dialog Esc cancels the whole dialog, so nothing applies
there either; both are tested.)

Docs and comments now say "applied once typed (Enter, Tab or a click elsewhere; Esc cancels)"
instead of "applied on Enter".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZArmeAT48gF84aKj7Zx4C

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:54:25 -04:00
9b9eeb8c61 Fit inline text boxes with Resize shape to fit text to their text (#2912)
Fixes #2791

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:52:32 -04:00
72467d037b Keep the selection's font when typing over a backward selection (#2911)
Fixes #2879

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:52:17 -04:00
MarekSpodBiedryandBrandon Thomas ca6faa8c1d DOCX: Word 2007 WordArt (VML textpath) in the body is shown as text, not dropped (#2875) (#2903)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:52:01 -04:00
MarekSpodBiedryandBrandon Thomas 0786b2eae5 Keep the Insert SmartArt dialog within the window (#2832) (#2902)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:51:45 -04:00
MarekSpodBiedryandBrandon Thomas ae5da324ad Proofing: say when a dictionary is installed but unreadable, with file and reason (#2830) (#2900)
* Proofing: say when a dictionary is installed but unreadable, with file and reason (#2830)

* proof test: use SHIFT_JIS as the unsupported encoding (ISO 8859-1 is decoded since #2904)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:51:13 -04:00
MarekSpodBiedryandBrandon Thomas 69302481ad Decode ISO 8859-1 Hunspell dictionaries so the original pt_BR works (#2824) (#2904)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:50:59 -04:00
03a992a46a File page: the back arrow no longer ends WordCraft when accessibility is on (#2856)
* File page: the back arrow no longer ends WordCraft when accessibility is on

A click outside the page hands the keyboard back to it at once
(refocus_after_chrome_click), by the page's widget id from the last frame
that drew it. When the click was the File page's back arrow, the File page
was closed in the same pass, so the page had not been drawn in it: egui kept
focus on a widget missing from that pass, and with AccessKit running (a
screen reader, or any UI Automation client on Windows) AccessKit's tree
rejected the update ("Focused ID ... is not in the node list") and the
program ended without a word. 0.8.0 on Windows vanished after Blank
document, File, then the back arrow.

The page's id is now kept with the pass that drew it, and focus is requested
by id only in that pass; otherwise want_focus hands the keyboard over in the
next frame, as it already did. The same path covered File > New > Blank
document with a document open.

Test: back_arrow_returns_to_the_page_with_accessibility_on clicks the arrow
in the harness (which always runs AccessKit); it panicked with AccessKit's
message before the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* control input: widget_id now carries its pass (match the pair)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:50:41 -04:00
9ce4553899 fix(macos): skip wgpu indirect validation on Intel Macs so launch no longer exits (#2652) (#2845)
wgpu compiles its indirect-call validation compute shaders inside request_device.
On Intel HD Graphics 5000 (Big Sur) Metal's Intel compiler aborts on them, the
device is lost and eframe exits before the window opens. WordCraft issues no
indirect draws or dispatches, so x86_64 macOS builds leave
VALIDATION_INDIRECT_CALL out of the instance flags (WGPU_VALIDATION_INDIRECT_CALL=1
still re-enables it). Same fix as storytold/PhotoCraft#3171.

Fixes storytold/WordCraft#2652

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:50:25 -04:00
Fin WarmanandBrandon Thomas fad83fd876 419: count shared row border clearance (#2789)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:50:03 -04:00
GavinQiErandBrandon Thomas 8bde3b3c25 fix: dropping an object near the top of the next page no longer sends it back (#2829)
move_object anchored the object to the paragraph under the drop point and
computed the offset from that paragraph origin. With a Square-wrapped object
the surrounding text reflows once the object moves away, so the first
paragraph of the target page - the anchor when dropping near its top -
slides back to the previous page and the object follows it.

Re-check the page after placing an object across pages and re-anchor when the
placement did not land on the target page (up to three attempts). Same-page
moves still place once, so resize and nudge are unchanged.

Adds a kittest that presses, moves and releases onto the top of the next page
and asserts the object stays on the page under the pointer.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:49:47 -04:00
GavinQiErandBrandon Thomas fe4faca3f0 fix: do not leave a dragged object on its page (#2720)
While a floating picture, shape or text box is dragged, the page raster is
not repainted (the texture cache key does not include the drag state) and a
translucent preview is pasted at the pointer. The user therefore sees the
original copy at full opacity plus the preview, i.e. two copies of the same
object.

Exclude only the dragged object from that page's display pass, and repaint
that page once when the drag starts and once when it ends; the preview keeps
using the previous texture. The rest of the page is left untouched (no
full-page clear, no per-frame repaint).

Adds three tests: a dragged inline object and a dragged floating shape are
left out of their page, and the view key changes when a drag takes an object
off the page.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:49:30 -04:00
Fin WarmanandBrandon Thomas 596176c794 371: clear header tables for floating body tables (#2660)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:49:00 -04:00
c91c1793a3 #2108: File › Print scales pages to another paper size (#2699)
* #2108: File › Print scales pages to another paper size

Scale to paper size on File › Print (Word: Pages per sheet › Scale to
paper size): No Scaling or any Layout › Size paper (Letter, Legal, A4, …).
Every page is scaled to the paper and centred, the paper turned to the
page's orientation, in the preview, the print job and Save these pages
as PDF. Native jobs also ask the spooler for that media. The document's
page size is unchanged. New `paper` param on ui.print/ui.printSubmit and
file.printPdf; unknown papers are errors.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* pdf: bookmark destinations from #2364 use the scaled Fit too

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:48:17 -04:00
00cd3f3368 Fix stray leading space in RTF list items on export (#2667)
Found while testing crates/formats/src/rtf.rs:269-272 (Writer::para)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:48:02 -04:00
Debarghaya MitraandBrandon Thomas cff4cd35b8 Page setup: resolve section correctly when focus is in a header, footer or note (#2072) (#2663)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:47:46 -04:00
Fin WarmanandBrandon Thomas 5909ab642a T372: wrap body around page-relative footer tables (#2659)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:47:30 -04:00
Fin WarmanandBrandon Thomas 60617d9a71 371: count shared section pages in fields (#2654)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:47:03 -04:00
MarekSpodBiedryandBrandon Thomas e7cd8679ad Keep the ODT paragraph 'Keep with next' option on import and export (#2813) (#2825)
* Keep the ODT paragraph 'Keep with next' option on import and export (#2813)

* file-format-parity: keep with next on the ODT row

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:46:48 -04:00
MarekSpodBiedryandBrandon Thomas 4495e2be53 Use the Default Paragraph Style font when importing ODT (#2804) (#2823)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:46:32 -04:00
833f5b64d9 Keep explicit text:s spaces at paragraph ends on ODT import (#2641)
Found while testing crates/formats/src/odt.rs:1480 (finish_para calls p.trim()); crates/formats/src/model.rs:188 (Para::trim)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:46:15 -04:00
866b48ac55 Recipient list: find box and merged-only view (#1576) (#2621)
* #1576: Recipient list search and filtered view

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* #1576: drive the recipient grid through egui_kittest

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* #1576: recipient search treats final sigma as sigma

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* #1576: removing a row lets go of the focused cell

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:45:59 -04:00
6e30349bbb #1327: protection checks cover tables, Paste Formatting and recipient refresh (#2618)
* #1327: protection checks cover tables, Paste Formatting, template update and recipient refresh

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* #1327: record any content below the protection walk's depth limit

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* #1327: formatting-only protection also refuses table properties and row height

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:45:40 -04:00
8c812bf1d6 #1230: Insert Caption on pictures and shapes; Different First Page in Page Setup (#2615)
* #1230: Insert Caption on pictures and shapes; Page Setup header and footer options

- Right-clicking a picture, shape, text box or chart offers Insert Caption…, as in Word.
- references.caption with a picture or shape selected puts the caption next to the
  paragraph holding it, in the same cell or text box (it used to go after the whole table).
- The Caption dialog proposes Figure for a picture selected in a table, not Table.
- Page Setup: Headers and footers check boxes (Different Odd & Even Pages, Different First
  Page), applied on OK only if changed, in one undo step; a title page can go unnumbered.
- Tests: engine caption_for_an_object_in_a_cell_stays_in_the_cell; kittest
  right_click_on_a_picture_offers_insert_caption and
  page_setup_different_first_page_leaves_the_title_page_unnumbered.
- Docs: ui-parity, target-app-parity, ROADMAP progress log.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8veLveuUsJJm6UEcQPEuW

* ui-parity: keep Insert Caption on the context-menu row

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:45:24 -04:00
dec24458cf #1273: equations from Word files keep all their content (#2613)
- Math straight in the body or a cell (m:oMathPara/m:oMath without w:p,
  allowed by EG_BlockLevelElts) gets its own paragraph instead of vanishing.
- mc:AlternateContent Requires="m" picks the equation, not its fallback.
- Inside an equation, runs in w:hyperlink, w:fldSimple, w:dir, w:bdo and
  mc:AlternateContent are kept (EG_PContentMath); depth stays bounded.
- w:sym, w:noBreakHyphen and Symbol-font text in math runs become Unicode.
- Layout: n-ary operators, delimiters, accents and group characters the math
  font lacks use a fallback font instead of a missing-glyph box.
- Coverage test reads every ECMA-376 §22.1 element and checks no argument's
  text is dropped; tests for body-level math, Requires="m", symbols, hostile
  nesting and the font fallback. Parity docs and progress log updated.


Claude-Session: https://claude.ai/code/session_01P8veLveuUsJJm6UEcQPEuW

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:45:01 -04:00
Fin WarmanandBrandon Thomas 473df388f3 Keep the line in an empty section starting a page (#2609)
* 371: retain the line in an empty page section

* 371: resolve hidden section marks

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:44:45 -04:00
beedd1b30d Drop Cap Options dialog (#2601) (#2602)
* #2601: Drop Cap Options dialog

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* gaps: keep the Drop Cap Options note on the dialog-depth row

* i18n: translate the two Drop Cap disabled reasons in the audited catalogs

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:44:30 -04:00
87cab84e4a #1086: ribbon tooltips show why a control is disabled (#2623)
Hover hints give the command's own reason, cover split-button arrows, menu buttons and the font/size/outline boxes, and show on keyboard focus.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:44:12 -04:00
5fdd0a0e89 Watermarks in header table cells drawn behind the text (#1775) (#2580)
A floating picture or VML shape in a header table cell is placed on the page behind the text.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:43:55 -04:00
Fin WarmanandBrandon Thomas a7abd346e7 layout: preserve subpoint table grid widths (#2589)
* 356: preserve subpoint table grid widths

* 356: normalize tiny positive grid totals

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:43:27 -04:00
bdb7672c0f Dragging the scrollbar names the page at the top of the window (#2551) (#2575)
* #2551: dragging the scrollbar names the page at the top of the window

While the canvas's vertical scrollbar is dragged, a tip beside the pointer
says which page is at the top of the window, in the status bar's words
("Page 7 of 10"), so a drag through a long document shows where it will land.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #2551: the scrollbar tip names the page whose visible band is at the top, also with Hide White Space

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:43:09 -04:00
Fin WarmanandBrandon Thomas b5759bcd99 351: reserve footer flow-table border clearance (#2585)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:42:54 -04:00
Fin WarmanandBrandon Thomas 5b211d6dc5 345: ignore explicit page breaks in table cells (#2574)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:42:39 -04:00
6dd7c3bc3a #1963: wordcraft-cli runs commands from a script file (#2566)
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:42:13 -04:00
ReesandBrandon Thomas 1005c59bfc fix(ui): keep note editing text readable (#2545) (#2562)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:41:58 -04:00
11f8881cd2 #537: test that System pins the title bar to the desktop's light/dark answer on Linux (#2559)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:41:42 -04:00
Marco ArnulfoandBrandon Thomas 913621c1c4 Font dialog: Kerning for fonts and its Points and above size (#2295)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:41:26 -04:00
divyanshu2074andBrandon Thomas 8c328386fa fix(formats): enforce tag boundary when skipping raw text elements in HTML (#2257)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:41:10 -04:00
63ee34c557 #2105: Highlight No Color sticks to the button (#2208)
No Color removed the highlight from the selection, but the Highlight
split button kept the colour picked before, so the next click on it
highlighted again. As in Word, No Color now becomes the button's choice
and the next click removes highlighting. The ribbon and the mini toolbar
share one highlight menu (the ribbon's copy is gone); No Color is
translated in the mini toolbar and no longer shows Ctrl+Alt+H beside it
in the ribbon.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:40:55 -04:00
divyanshu2074andBrandon Thomas e04dd0e785 fix(formats): support cell and paragraph borders in RTF export (#2207)
* fix(formats): support cell and paragraph borders in RTF export

* rustfmt

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:40:31 -04:00
divyanshu2074andBrandon Thomas 9cd34ebc80 fix(formats): preserve tilde characters in LaTeX URLs and raw arguments (#2202)
* fix(formats): preserve tilde characters in LaTeX URLs and raw arguments

* rustfmt

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:40:15 -04:00
divyanshu2074andBrandon Thomas d9ca2573a6 fix(engine): count multi-column spanning cells once in table formula ranges (#2200)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:40:00 -04:00
divyanshu2074andBrandon Thomas a114a79a69 fix(doc, formats): support OEM character set 255 in charset_code_page (#2195)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:39:37 -04:00
Antoine BoxhoandBrandon Thomas 61a626820e #1552: no spelling squiggle under the word being typed (#2183)
Word draws no red squiggle under the word being typed. It appears when the word ends or the
caret leaves it by a key or a click. The session records the word after a text command that
edits the caret's paragraph, with apostrophes and hyphens counted as the spelling check counts
them, and drops it once the caret is anywhere else. The layout leaves that one range out of the
spelling squiggles, and a caret move without an edit lays out only that paragraph again. F7 and
the Spelling pane still report the word, and grammar marks are unchanged.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:39:21 -04:00
divyanshu2074andBrandon Thomas 545673d644 fix(formats): resolve sub-location and external URLs in RTF HYPERLINK fields (#2181)
* fix(formats): resolve sub-location and external URLs in RTF HYPERLINK fields

* rustfmt

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:39:05 -04:00
divyanshu2074andBrandon Thomas 973e6e12f5 fix(formats): support quoted and non-latin charset declarations in HTML decode (#2180)
* fix(formats): support quoted and non-latin charset declarations in HTML decode

* html: clippy (question_mark, collapsible_if) after merging main

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:38:50 -04:00
94e3dda6af SmartArt: type into items; open the Text Pane by double-click or frame button (#2170)
* SmartArt: typing with the graphic selected types into an item

Typing with a SmartArt graphic selected replaced the whole graphic with
the typed text. Now the text goes at the end of the current item (the
first before any is picked) and the Text Pane opens, as in Word. The
typing is one undo step.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* SmartArt: double-click or the frame button opens the Text Pane

A double-click on a SmartArt graphic opens its Text Pane. While a
graphic is selected, a Text Pane button sits on the left edge of its
frame, as in Word, and shows or hides the pane.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* SmartArt: the Text Pane closes with the graphic's selection

It opened by itself on the next graphic selected, because the pane flag
outlived the selection. The frame button hides while the pane is open.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:38:34 -04:00
divyanshu2074andBrandon Thomas 3031033790 fix(layout): support quoted bookmark names with spaces in PAGEREF fields (#2169)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:38:19 -04:00
95b5c71f80 Save writes the chosen file when its folder refuses a temporary file (macOS privacy protection) (#2138) (#2167)
* #2138: save writes the chosen file when its folder refuses a temporary file (macOS privacy protection)

On macOS a Save-panel pick inside a protected folder (Desktop, Documents…) grants
access to that file only, so the sibling .wordcraft-save-*.tmp was likely refused
with EPERM. On PermissionDenied creating the temporary file, write the destination
directly; a remaining permission error on macOS says where to allow access.
Untested on a Mac: the fallback path is pinned on Unix with a read-only folder.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* #2138: a failed save in a folder refusing the temporary file keeps the original

Review (FredApps): the in-place fallback truncated the destination before
writing, so a failure partway through lost the previous document.

The fallback now keeps the existing file's bytes in memory (up to 512 MB)
before writing it in place and writes them back if the save fails, or
removes a new file it created. If that write-back fails too, the error
says the file may be incomplete. A file over the limit isn't written in
place; the error suggests Save As.

Tests: the reviewer's partial-write test (read-only folder), a new file
removed on failure, and the size limit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8veLveuUsJJm6UEcQPEuW

* #2138: keep the original untouched when no safe replacement is possible

When the folder refuses the sibling temporary file, an existing destination
is no longer overwritten in place (no in-memory rollback): it stays
byte-identical and the error says to use Save As to another folder or allow
access to the folder (macOS: Privacy & Security > Files and Folders). A
destination that doesn't exist yet is created exclusively and removed again
if the write fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8veLveuUsJJm6UEcQPEuW

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:38:04 -04:00
divyanshu2074andBrandon Thomas 6cf7159a71 fix(formats): emit \landscape in RTF export when section is landscape (#2159)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:37:42 -04:00
divyanshu2074andBrandon Thomas 65127c2727 fix(formats): handle negative timezone offsets and flexible day digits in spreadsheet iso_datetime (#2154)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:37:27 -04:00
874e5a76a0 #2052: a second picture style, border or artistic effect replaces the first (#2148)
Picture Styles, Picture Border and Artistic Effects now draw again from the
bitmap under them (kept per drawn picture in Session::picture_looks), so
Thick Frame then Simple Frame is only a simple frame, a border replaces the
border and keeps the style, a style replaces the border, and an effect
replaces the effect ("none" removes it), as in Word. Transparency was already
absolute (#1175); the test pins it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:37:12 -04:00
divyanshu2074andBrandon Thomas 720f8acd2c fix(layout): support field format switches without whitespace after \* (#2147)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:36:58 -04:00
2054f8fd88 fix(web): fall back after asynchronous save picker rejection (#2145)
Preserve AbortError cancellation and report download fallback failures without marking saves successful.

Fixes #1246

Assisted-by: OpenAI Codex

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:36:42 -04:00
4c8508cd93 Insert › Shapes menu: an icon for each shape and connector (#2142)
Each item shows a small drawn outline of what it inserts (rectangle,
oval, triangle, star, elbow connector...). The icons are drawn in code in
icons.rs as shape.<kind>; widgets::icon_menu_item draws the item.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:36:27 -04:00
divyanshu2074andBrandon Thomas e7babf364e fix(formats): trim whitespace when parsing numeric XML attributes in ODT (#2137)
* fix(formats): trim whitespace when parsing numeric XML attributes in ODT

* rustfmt

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:36:12 -04:00
divyanshu2074andBrandon Thomas c9468a13cb fix(docx): support self-closing w:compat in settings merge (#2131)
* fix(docx): support self-closing w:compat in settings merge

* Drop the duplicate divyanshu2074 entry in people.toml (already on main)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:35:43 -04:00
divyanshu2074andBrandon Thomas e2d9224996 fix(formats): support relative font size values in HTML font tags (#2130)
* fix(formats): support relative font size values in HTML font tags

* Drop the duplicate divyanshu2074 entry in people.toml (already on main)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:35:28 -04:00
divyanshu2074andBrandon Thomas eedf9c9740 fix(doc): strip negative sign when formatting numbers rounded to zero (#2122)
* fix(doc): strip negative sign when formatting numbers rounded to zero

* Drop the duplicate divyanshu2074 entry in people.toml (already on main)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:35:13 -04:00
divyanshu2074andBrandon Thomas 5977112a0d render, pdf: fix vertical double and wave border rendering (#2113)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:34:58 -04:00
872b2bc99d DOCX: ink inside groups and drawing canvases survives saving (gap 2) (#2097)
* gap: nested ink WIP

* gap 2: native ink nested in groups and drawing canvases survives DOCX saves

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gap 2: review round 1: ungrouping keeps nested ink; no orphan parts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gap 2: Impact cell matches the restated impact

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* gap 2: only Word ink pins a group against Ungroup

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* gap 2: alternate ink must hold an a:graphic to be written as standalone

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* gap: nested ink WIP

* gap 2: native ink nested in groups and drawing canvases survives DOCX saves

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gap 2: review round 1: ungrouping keeps nested ink; no orphan parts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gap 2: Impact cell matches the restated impact

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* gap 2: only Word ink pins a group against Ungroup

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* gap 2: alternate ink must hold an a:graphic to be written as standalone

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:34:42 -04:00
Antoine BoxhoandBrandon Thomas aed0b71786 #1878: Rulers thin out with the zoom and span the caret's page (#2093)
Ruler numbers now thin out as the page shrinks on screen: a number
every 1, 2, 4, 8... units, whichever keeps them at least 30 px apart,
and the small ticks drop out below 4 px, with the text at its usual
size. A number gives way to an indent, tab or column marker sitting on
it, and the margins get ticks only. Both rulers use the caret page's
rect and scale in every layout, including One Page and Multiple Pages,
and follow the caret to another page. Tests cover the density rule, the
marks, the marker rule for indents, tab stops (set or dragged) and
table columns and rows, and the multi-page geometry.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:34:26 -04:00
Antoine BoxhoandBrandon Thomas 0f5e945a69 #1945: Hide the Quick Access Toolbar and show it again (#2089)
The Quick Access Toolbar's menu ends with Hide Quick Access Toolbar, as Word's does. Show Quick Access Toolbar brings it back from the ribbon's right-click menu and from a check box on Customize Ribbon's Quick Access Toolbar page. The setting is kept between runs, and keytips skip the toolbar's digit badges while it is hidden. The UI command ui.quickAccessToolbar drives it from scripts and MCP. Regenerates docs/parity-checklist.md, which was behind the catalog. Adds my contributor entry.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:34:10 -04:00
e50850d4dd RTF: formatting and paragraph-mark revisions are read and written (gap 19) (#2088)
* gap: RTF formatting revisions WIP

* gap 19: RTF formatting and paragraph-mark revisions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gap 19: review round 1: list kept on Reject; old formatting normalised; section marks; hostile borders

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gap 19: review round 2: \oldcprops holds only the changed properties, as Word writes it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gap 19: old formatting in styled paragraphs uses the style's size and font

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gap 19: RTF import keeps main's heading and code normalisation

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gap 19: file-format-parity status line keeps main's history

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* gap 19: a paragraph that was a list item gets its old list back on Reject

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* gap 19: a numbered item made bulleted keeps its numbered list for Reject

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* gap 19: format matrix names the tracked section mark

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* rtf test: Writer has the section fields main added; rustfmt

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:33:46 -04:00
Marco ArnulfoandBrandon Thomas 166b95feba Proofing: a hyphenated word added to the dictionary is no longer flagged (fixes #2082) (#2085)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:33:30 -04:00
072acce71b Legacy form fields are drawn: check boxes, and drop-downs' selected entry (#2076)
* Legacy form fields are drawn: check boxes, and drop-downs' selected entry

Legacy form fields keep their w:ffData since the last round, but were
not drawn: a FORMCHECKBOX has an empty result, so a form of check
boxes came out with none, and a FORMDROPDOWN showed its saved result
rather than its selected entry.

- wordcraft-doc: `legacy_form` reads what layout needs from the kept
  w:ffData (kind, name, check box state and size, drop-down entries and
  selection, text field length); `InlineObject::legacy_form()`.
- wordcraft-layout: a check box takes the text line's height of room
  and is drawn as Word draws it; a drop-down shows its selected entry
  (w:result, else w:default, else the first).

Measured on Word's PDF export with a synthetic document (Calibri):

  text size | Word square | WordCraft
  8 pt      | 7.44        | 7.45
  10 pt     | 9.96        | 9.89
  12 pt     | 12.36       | 12.33
  20 pt     | 22.08       | 22.09
  w:size 15 | 16.08       | 15.99

The square sits 1.16 pt inside the line height on every side (room on
the line within 0.2 pt of Word's), has a 0.72 pt border and, checked,
an X from corner to corner in 0.48 pt lines; w:checked beats
w:default. docs/file-format-parity.md is updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Draw::Shape takes the dash list main added

---------

Co-authored-by: Daniele Mori <Dinasty@hotmail.it>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:33:15 -04:00
70fc00c80f Symbols keep their symbol font when the text font changes (#2036) (#2037)
* Symbols: a w:sym character keeps its symbol font when the text font changes (#2036)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* #2036: review round 3: docx test reads sym_font, paste drops the symbol font, astral symbols stay text

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* #2036: insert.symbol decides symbol or text per character

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 11:33:00 -04:00
20188e4610 A floating object's anchor takes no room on a line (#2033)
A line's first item stays on it even when it is wider than the line;
markers before it don't count. A floating object's anchor (a zero-size
object cluster) did count, so a picture a little wider than the line,
after an anchor, went to a second line and left the anchor an empty
line of its own above it. Word keeps the picture on the first line.
Anchors are now ignored like markers when deciding whether the line
already has content.

Co-authored-by: Daniele Mori <Dinasty@hotmail.it>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:32:22 -04:00
1b45615940 Insert › Shapes keeps the selected shape and text (#2019) (#2030)
Inserting a second shape removed the first: after an insert the new
shape stays selected, and `insert.shape` replaced the selection, so each
shape replaced the one before. Word draws a new shape beside whatever is
selected and never deletes it (checked by hand in Word for Windows: a
second shape is drawn over the first, which stays).

The new shape now goes after the selection, which is left alone, and
becomes the selection. Selected text stays too. A picture still replaces
a selected picture, which is what Word does as well (checked in Word for
Windows), so `insert.picture` is unchanged.

Tests: three shapes inserted in a row are all kept, which fails with the
old code (1 of 3 kept); a shape inserted over selected text keeps the
text and is selected itself; a picture inserted over a selected picture
replaces it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:32:07 -04:00
Dylan M. Taylor ac76375d74 docs: add SonarCloud and CodeRabbit badges to README 2026-10-18 11:01:34 -04:00
Dylan M. Taylor f4c109a0ab docs: clarify that feature percentage is measured against tracked features in docs/gaps.md 2026-10-18 10:44:06 -04:00
Dylan M. Taylor 715f491d29 docs: drop references to competing commercial software 2026-10-18 10:43:43 -04:00
Brandon ThomasandClaude Opus 5.5 7333a302f5 ci: File Close test expects the window kept (#1712); Polish coverage test skips French's newer strings
#2448's imported-Word close test predates #1996 (File › Close keeps the window), and #2492's French catalog carries keys the Polish catalog lacks, as the PR's own test change said.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:37:08 -04:00
c9a5191b70 i18n: French interface catalog completed (2,615 entries) with native-speaker review (#2492)
Adds the 402 entries the French catalog lacked: equation gallery (matrices, accents, brackets, integrals, operators, functions), built-in style and table-style names, citation styles, paper sizes, watermarks, and the Bookmark, Go To and Print dialog strings. Drops the obsolete 'Style: APA' entry. Terminology reviewed by a native French speaker and Word user. The Polish superset test exempts fr like the other catalogs carrying newer strings. Progress docs and people.toml updated.

Co-authored-by: Vivien JANNY <vivien@avertissance.fr>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:29:17 -04:00
492aceb960 Fonts Word embeds as subsets lay out the text set in them (#2240) (#2540)
* #2240: fonts Word embeds as subsets lay out the text set in them

Embedded fonts were registered under the family inside the font file. Word
embeds a subset ("embed only the characters used") under a made-up family
(___WRD_EMBED_SUB_46) and the text asks for it by the font table's name
(OBRBWW+Century Schoolbook), so it still fell back to a substitute. Each
embedded file now comes with its font table name, and its faces go by that
name when they don't already.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #2240: the black-circle fallback test passes its font's table name

main's new `circle_checks_face_coverage` test (#2582) called `add_document_fonts` with bare font
bytes; with this branch each embedded font comes with the name the document's font table gives
it, so the test passes "Times New Roman" alongside the face it renames to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:28:52 -04:00
ReesandBrandon Thomas ff39a5c357 fix(ui): deliver control input in inactive windows (#2420) (#2530)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:28:37 -04:00
ReesandBrandon Thomas 1595343fa0 fix(ui): expose save confirmation context to screen readers (#2489)
* fix(ui): expose save confirmation context (#1015)

* #1015: require alert-dialog semantics in save regression

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:28:20 -04:00
Fin WarmanandBrandon Thomas a5d817a9ef TOC: keep direct number tabs on refresh (task 276) (#2475)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:27:26 -04:00
09b822fdbe Update Field after save and reopen of a table of contents; no extra blank line per cycle (#2182) (#2553)
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:27:11 -04:00
Bryan MarkandClaude Opus 5.5 8107581df2 Opening a DOCX no longer copies its pictures (#2441)
Each picture, and each embedded object or macro part kept for saving, was
copied out of the unzipped package (bytes.to_vec()), so a document with
pictures held them twice while it opened, and the package's copies were
left behind as freed memory. Package now holds its parts as Arc<Vec<u8>>,
the type the document keeps media and passthrough parts in, and hands them
over with get_shared. keep_unmodelled also searches the main part in place
instead of copying it.

A 92 MB document with 20 pictures on macOS 27: the app's footprint once
open 866 -> 772 MB (freed blocks kept 206 -> 118 MB, peak 915-932 ->
821-823 MB); wordcraft-cli info peak 319 -> 231 MB.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 10:26:56 -04:00
Fin WarmanandBrandon Thomas ca45f97ace Layout: exclude floating anchors from line font metrics (task 256) (#2462)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:26:32 -04:00
ReesandBrandon Thomas 9726d6f1cb fix(ui): offer Save As for imported Word files (#2448)
* SCRUM-47: offer Save As for imported Word files

* #2445: reject Save As answers for a replaced document

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:26:16 -04:00
Fin WarmanandBrandon Thomas 57dfacfc07 Layout: preserve equation baselines under exact line spacing (#2435)
* Layout: preserve equation baselines under exact line spacing (task 243)

* layout-parity: keep the cell-margin inheritance note on the Tables row

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:26:01 -04:00
Fin WarmanandBrandon Thomas 4cb4b2d231 244: inherit omitted cell margins (#2434)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:25:38 -04:00
PanyangYandBrandon Thomas a14948a23d DOCX: strict line heights with a unit are points under auto (#2329) (#2439)
Word 2024 saves Strict Open XML with measures written out with their unit:
the issue's file carries `w:spacing w:after="8pt" w:line="13.90pt"
w:lineRule="auto"` in its paragraph defaults. The reader sent `13.90`
straight into the `auto` branch, where a bare number counts 240ths of a
line (240 = single), so 13.90pt became a 13.9 multiple: 191.8 pt lines,
and the issue's document laid out as 10 pages against Word's 1.

Under `lineRule="auto"` a unit-suffixed height now reads through twips, as
Word renders it (ISO 29500 `ST_SignedTwipsMeasure`: 13.90 pt = 278 twips =
the 1.1583 multiple). A bare number still counts 240ths of a line;
`exact`/`atLeast` already read units as points.

Checked with the issue's file: 1 page with baselines on Word's (15.98 pt
line advance against Word's 16.10/15.96; paragraph advance 23.99 against
24.00; table lines 12.65 against 12.60/12.72), and a save/reopen keeps it.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:25:14 -04:00
Fin WarmanandBrandon Thomas 4deb654862 Layout: keep narrow cell line widths (task 245) (#2436)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:24:58 -04:00
Fin WarmanandBrandon Thomas d7bd5bcd3c Layout: preserve additive spacing after section breaks (task 243) (#2433)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:24:41 -04:00
28d6260a81 #1789: Add Font imports every selected font file (#2404)
* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1789: cap font bytes waiting in the web inbox

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:24:26 -04:00
Fin WarmanandBrandon Thomas a492189a84 DOCX: preserve zero effect extents on rotated drawings (#2432)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:23:59 -04:00
5c4408f341 Paste: plain text keeps the formatting of the paragraph it lands in (#2423) (#2424)
Paste of plain text from another program gave every line after the first
a paragraph with default formatting. The text became a fragment of
unformatted paragraphs (Fragment::from_text), and insert_fragment keeps
the target's formatting only in the first one. Pasting several lines into
a right-to-left paragraph turned all but the first left-to-right, and the
text after the caret lost its paragraph formatting too.

Paste now inserts plain text as Keep Text Only does. Each line after the
first splits the paragraph as Enter does, so it keeps the paragraph's
direction, style, alignment, indents, spacing and list. Empty lines become
empty paragraphs with that formatting. Copies from WordCraft and HTML or
RTF from other programs keep their own formatting as before. A paste is
still one undo step.

Tests cover a right-to-left paragraph, empty lines and a final line break,
and a list. The typing and UI parity docs and the ROADMAP progress log
have rows for the change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:23:43 -04:00
Marco ArnulfoandBrandon Thomas 8f0000de63 Text boxes: framed paragraphs run with the box's text, not drawn over each other (part of #2357) (#2383)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:23:27 -04:00
Marco ArnulfoandBrandon Thomas 9bed0156df OpenType ligatures follow w14:ligatures: model, DOCX read and write, shaping (#2351)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:23:12 -04:00
Marco ArnulfoandBrandon Thomas f7de98a711 DOCX: a w:u without w:val draws no underline (fixes #2362) (#2380)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 10:22:57 -04:00
d6f20f93fd Tables: at-least rows taller than a page start on a fresh page; header rows follow (#1742) (#2004)
* WIP for #1742

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1742: parity doc separates the Word-observed case from synthetic-test behaviours

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 09:57:47 -04:00
3348e93140 ASK and SET fields show nothing (#1765) (#1785)
* #1765: WIP

* #1765: ASK and SET fields show nothing

Like Word, ASK and SET only assign text to a bookmark: they draw no
result on the page or in a PDF. The stored result is kept and saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* file-format-parity: note ASK/SET on the existing Fields row instead of a third one

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 09:57:31 -04:00
11f1d0aee6 Lowercase list items no longer get a grammar underline (#1741) (#1783)
* #1741: WIP

* #1741: list items no longer get the sentence-capital grammar squiggle

The English "Capitalize the first word of a sentence" rule skips the first
word of a list paragraph (own or style numbering); later sentences are still
checked. check_text_with and paragraph_issues take a list_item flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1741: review fixes

Thread the capital-first flag into the Polish, Croatian, Italian, Turkish and
Portuguese grammar rules; test the drawn squiggles through layout() for own,
style and num 0 numbering; tidy the proof test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1741: main's Vietnamese proofing test passes the new list-item argument

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* #1741: the third call in main's Vietnamese proofing test

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:57:15 -04:00
b8279c4da5 #1960: add editable table cell margins dialog (#1988)
Co-authored-by: justinjohn0306 <babyjjbabyjj549@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 09:56:48 -04:00
SlavenDjandBrandon Thomas 2caa9db109 #1903: undo-history footer Cancel closes the popup (#1916)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 09:56:34 -04:00
Fin WarmanandBrandon Thomas 4dcd4749ca Layout: align behind-text cell pictures (#1871) (#1871)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 09:56:19 -04:00
d4a3292834 Proofing: -ically adverbs such as "specifically" are no longer flagged (#1694) (#1854)
* #1694: -ically adverbs are no longer flagged as misspelled

English spelling accepts "-ally" adverbs built on an "-ic" adjective in the
word list (specific → specifically, scientific → scientifically), as Word does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* #1694: only -ic adjectives take -ally

Review (FredApps): the "-ally" → stem rule applied to every known word,
so "desktopally" was accepted. The rule is now "-ically" → "-ic"
(specifically → specific); other words don't take "-ally".

Test: non_ic_words_do_not_take_ally (desktop, house, final, green).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8veLveuUsJJm6UEcQPEuW

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 09:56:04 -04:00
7f4b649f6f RTF export: sections are saved (gap 19) (#1776)
* rtf-sect-export: WIP

* Gap 19: RTF export writes sections

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 19: RTF export writes sections — review fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* odt-sect-export: WIP

* Gap 19: ODT export writes sections

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 19: ODT export writes sections — review fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gaps log: RTF and ODT section export both done

* Gap 19: ODT continued lists share the earlier list's style; one RTF and ODT row in the parity table

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:55:42 -04:00
0c8399896f cli: open a password-protected Word file with --password (#1770)
info, text, convert, render and inspect took no password, though file.open
understood one, so an agent driving the CLI could not open a protected
document at all. --password, --password-file and WORDCRAFT_PASSWORD, in
that order.

The file form exists because a password on a command line is readable by
every other process on the machine; a trailing newline is dropped so a
file written by echo works. Never logged, never named in an error.

Rebased onto current main, which moved `open` from &str to &Path so that a
file name that isn't valid Unicode no longer crashes the CLI (#1440). Both
changes touch the same function, so the resolution keeps their shape: the
closure that substitutes the real path still stands, and it takes the
password as a second argument.

Co-authored-by: Bra1nZ <bra1nz@astraoffice.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 09:55:26 -04:00
69c2414615 Fonts: bold Arial Black is not emboldened again, as in Word (#1750)
Old releases of heavy faces (the core-fonts Arial Black) claim weight 400 in
OS/2, so bold text in them was drawn with synthetic bold. Current releases say
900 and Word draws bold Arial Black unchanged. A static face whose file says less
than semibold now takes the weight its family's last word names: Black/Heavy 900,
ExtraBold/UltraBold 800. Files that already declare a heavy weight keep it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 09:55:11 -04:00
Diego GueriniandClaude Opus 5.5 879e688fec File › Close closes the document and keeps the window (#1712) (#1996)
* File › Close closes the document and keeps the window (#1712)

File › Close (Mod+W, Mod+F4) quit the whole app. In Word it closes the
document and the window stays. WordCraft already has that state from
the Start screen (#1129): no page, the ribbon greyed out but for Help,
File › New and Open to go on.

`WordApp::execute` now handles `file.close` itself. After the usual
Save / Don't Save / Cancel, the document is replaced by the placeholder
blank document that marks "no document open" and the Backstage closes.
The window's close button passes `{"quit": true}`, so it still asks and
then quits. The parameter travels with the prompt, so a Cancel leaves no
state behind. With no document open, Close has nothing to close.

Tests: the Close tests that used `quit_requested` as "Close went through"
now check that the document closed and the window stayed. Closing from
the empty window asks nothing, File › New goes on from there, and the
window's close button still quits after Don't Save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Close keeps the window for programmatic closes too (#1712, #1391)

#1391 landed on main meanwhile: a programmatic `file.close` on unsaved
work needs `{"force": true}`. Its tests, and the save prompt's focus
test, took "Close went through" to mean `quit_requested`. With #1712 a
close, forced or not, closes the document and keeps the window, so they
now check that the document closed and the app is still running. The
`force` guard itself is unchanged.

docs/control-protocol.md: a closed document leaves the window open, and
`file.new` or `file.open` goes on from there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1712: a direct file.close never discards unsaved changes unless forced; Don't Save forces it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:38:16 -04:00
.CooCoo f5b3c5d069 Contributors: dotCooCoo's entry shows the GitHub profile name again (#1982)
The merge of main into #1250 (ea0d27a6) resolved its conflict in
contributors/people.toml without the sync_github_name = true line that
#1250 added to [people.dotCooCoo]. The line is back, so the credits
show the name on the GitHub profile as this entry's display name
(crates/ui-egui/build.rs).
2026-10-18 09:38:11 -04:00
Ian Holloway f8ffab72e2 Tests: keep font-cache misses independent of optional Thai fonts (#1981)
Assisted-by: GPT-6

Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com>
2026-10-18 09:38:06 -04:00
NetterSilverandClaude Haiku 5.5 0b1c957233 Escape block syntax after manual line breaks in Markdown export (#1980)
Found while testing crates/formats/src/markdown.rs:1222

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 09:38:01 -04:00
Wade 999e2ae896 PDF tests read ActualText spans, so RTL extraction no longer depends on the installed Arabic font (#1773) (#1973) 2026-10-18 09:37:56 -04:00
.CooCoo 438dec0d61 Fonts: the font scan leaves out faces with no outline or color bitmap tables (#1972)
The system font scan (file_face_names) also catalogs faces with none of
the tables skrifa reads glyph outlines from (glyf, CFF, CFF2, VARC) and
none of the color bitmap tables the renderer draws (sbix, CBDT). On
macOS 27.0.1 the scan of /System/Library/Fonts catalogs "GB18030 Bitmap"
(Supplemental/NISC18030.ttf), and the font list shows it. The face
stores its glyphs as bdat bitmaps and maps 27,544 characters.
FontDb::outline returns an empty path for each of them, and the face has
no color glyph tables. The renderer draws none of them.

file_face_names now leaves out a face whose table directory has none of
those six tables. It reuses OUTLINE_TABLES from the interface CJK
fallback (#1250). VectorCraft's font scan checks the four outline tables
(storytold/vectorcraft#781). WordCraft's renderer also draws sbix and
CBDT bitmaps (#1504). Noto Color Emoji has CBDT and none of the outline
tables, and the scan keeps listing it.
2026-10-18 09:37:50 -04:00
1cea0f7e11 Equations: a script runs until a space or operator, as in Word (V_dd, x_2a) (#1609) (#1951)
* WIP for #1609

* Equations: a script after _ or ^ is the whole run of letters and digits (#1609)

Word for Mac 16.113.4: V_dd and V_(dd) build up the same (dd is all
subscript), likewise x^ab, and Linear format shows V_dd bare.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Equations: bare scripts end at a space, digits continue a script run, upright function names (#1609)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Equations: a bare script also ends before a following period (#1609 review)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* ROADMAP: drop three progress rows main already has

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 09:37:37 -04:00
NetterSilverandClaude Haiku 5.5 d04d756c4e Grey out and guard ribbon split buttons when disabled (#1918)
Fixes #1908

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 09:37:31 -04:00
SlavenDj 85c7d1214b #1895: recovery page buttons keep theme text colors (#1898) 2026-10-18 09:37:27 -04:00
SlavenDj 5e751c848b #1873: Backstage back chevron uses plain inset in macOS full screen (#1879) 2026-10-18 09:37:23 -04:00
Bra1nZandBra1nZ 2707ac403f mcp: let open_document take a password (#1796)
The engine has read passwords since the support landed — file.open pulls
`password` out of its parameters and hands it to open_path_in. The UI asks
and retries, the CLI has --password. The MCP tool never passed it on, so an
agent driving the server or the app could not open a protected document by
any route.

Advertise it in the schema, keep it optional, forward it.

Without a password the parameter goes over as null, not "": file.open reads
an empty string as "no password given", so collapsing the two would open a
protected document with an empty password instead of failing.

Three tests. The first I wrote expecting file.open as the backend method
and it failed — exec wraps everything as engine.execute with the command
under params. The test was wrong about the code.

13 lib + 5 integration tests, clippy -D warnings and fmt clean.

Co-authored-by: Bra1nZ <bra1nz@astraoffice.com>
2026-10-18 09:37:17 -04:00
3bede06646 Restrict Editing: exceptions don't open page setup or styles; tracked-changes protection keeps revisions (#1881)
With read-only (or comments) protection and an editable exception, the check after a
command only compared text and paragraph formatting outside the exceptions, so page setup
(section properties) and style definitions could change for the whole document. They are
now compared too (styles a command adds are allowed). Protected for tracked changes,
Accept/Reject and the Track Changes toggle are refused, as in Word.

Refs #1327

Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:37:12 -04:00
da607a00ee ODT export: sections are saved (gap 19) (#1777)
* odt-sect-export: WIP

* Gap 19: ODT export writes sections

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gap 19: ODT export writes sections — review fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ODT export: a list cut by a section continues with text:continue-list; drop duplicate parity rows

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:37:06 -04:00
JoaoAugusto00andBrandon Thomas 4a251d4b9c docs: how to install spelling dictionaries (pt_BR example) (#2510)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:57:46 -04:00
Marco ArnulfoandBrandon Thomas 4362e6624d Word's Spike: Ctrl+F3 collects cuts, Ctrl+Shift+F3 inserts them (#2507)
* Spike: Ctrl+F3 collects cuts, Ctrl+Shift+F3 inserts them and empties it (gap #24)

* Search test: a lone INSERT's witness within the box's 14 results; Table checked past it

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:57:27 -04:00
Fin WarmanandBrandon Thomas c92d120098 Honor table row keep-next during pagination (#2494)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:57:07 -04:00
86ad3ddb63 DOCX: two lines in one and horizontal in vertical (w:eastAsianLayout) survive a save (#2486)
Read w:eastAsianLayout (ISO/IEC 29500-1 §17.3.2.10) into a new
CharProps::east_asian_layout and write it back after w:lang, in runs and
styles. Values outside the schema are left unset. Layout is unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:56:48 -04:00
Fin WarmanandBrandon Thomas fc08b0e4f6 Fix PDF outline targets after leading page breaks (#2484)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:56:29 -04:00
Fin WarmanandBrandon Thomas 455e1f419f Include table-cell headings in regenerated TOCs (#2482)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:56:10 -04:00
Fin WarmanandBrandon Thomas 56d0a162b3 Share AutoFit donor reductions equally (#2464)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:55:37 -04:00
3a2cea6ca4 #1843: Read Aloud speaks Russian text with a Russian voice (#2430)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:55:18 -04:00
NetterSilverandClaude Haiku 5.5 5a82eafa5a Refresh DATE/TIME fields when opening .doc/.dot by extension (#2418)
Found while testing crates/engine/src/io.rs:183-187 (read_bytes, the "doc" | "dot" arm returns before opened() is called)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 04:55:00 -04:00
divyanshu2074 1880f6162c fix(formats): support html entities, wbr zero-width break, and page-break-after (#2400) 2026-10-18 04:54:50 -04:00
Marco ArnulfoandBrandon Thomas 61ad110e9f Layout: automatic space between Asian text and Latin text or digits (autoSpaceDE, autoSpaceDN) (#2358) (#2397)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:54:39 -04:00
fa755e9de4 Fonts: restore missing strokes in hint-reliant Chinese fonts (#2389)
Co-authored-by: xyz9516 <19662679+xyz9516@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:54:20 -04:00
Marco ArnulfoandBrandon Thomas d2321d05d9 DOCX: positional tabs (w:ptab) keep their alignment, base and leader and go to the margins or indents, as in Word (#2367) (#2387)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:54:01 -04:00
7c95f02050 #1743: Ctrl+wheel zoom no longer scrolls the document (#2386)
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:53:42 -04:00
2db55b5498 fix(smart_art): normalize layout and color identifiers and preserve item text with leading whitespace (#2365)
* fix(smart_art): normalize layout and color identifiers and preserve item text with leading whitespace

* Use Iterator::find (clippy manual_find)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 04:45:17 -04:00
341f370368 fix(pdf): scale bookmark destination coordinates with page Fit and support case-insensitive bookmark lookup (#2364)
* fix(pdf): scale bookmark destination coordinates with page Fit and support case-insensitive bookmark lookup

* cargo fmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 04:45:06 -04:00
divyanshu2074 09f14d5a5e fix(math): parse array column specs, support starred operatorname and tag, preserve delim sep in latex (#2353) 2026-10-18 04:44:55 -04:00
NetterSilverandClaude Haiku 5.5 088156d4ca Keep spaces that follow a tab in the ODT round trip (#2317)
Found while testing crates/formats/src/odt.rs:347 (writer, `plain_inlines`) and crates/formats/src/odt.rs:1550 (reader, `text`)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 04:44:44 -04:00
divyanshu2074 e1b39cd41d fix(docbin): correct sprmTCellWidth operand parsing and handle percentage widths (#2312) 2026-10-18 04:44:34 -04:00
Marco Arnulfo d32119c377 Copying a picture or shape replaces the clipboard's old text, so Paste duplicates it (#2306) 2026-10-18 04:44:23 -04:00
6b3de1c4dc fix(docbin): handle default codepage, VT_I4, multiple property sets, and FILETIME dates (#2304)
* fix(docbin): handle default codepage, VT_I4, multiple property sets, and FILETIME dates

* cargo fmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 04:44:13 -04:00
Marco ArnulfoandBrandon Thomas 5e2b63b710 Contributors: add Marco Arnulfo (#2302)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:44:03 -04:00
a3aab66bfa fix(formats): support none and dual scripts in mmultiscripts, mpadded phantoms, menclose borders, and mtable column align (#2300)
* fix(formats): support none and dual scripts in mmultiscripts, mpadded phantoms, menclose borders, and mtable column align

* cargo fmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Collapse the nested if (clippy collapsible_if)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 04:43:44 -04:00
8fcaa66e19 Turkish case mapping: i/İ and ı/I capitalise correctly (#1923) (#2009)
* WIP for #1923

* AutoCorrect: Turkish and Azerbaijani case mapping (#1923)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* AutoCorrect: Turkic language detection shares proofing's tag normalisation, I to dotless i covered (#1923)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:43:32 -04:00
559f16601d A mouse drag on a table no longer scrolls the view to the caret (#1738) (#2008)
* Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:43:13 -04:00
0254f3afc7 Hebrew: field results like STYLEREF draw right to left; smart quotes match Word (#1670) (#2007)
* Field results (STYLEREF, REF, PAGE text, note marks) shown as one atomic
cluster now place their inner clusters in UAX #9 visual order, in the
direction of the text around the field, so "פרק א" in a header no longer
draws reversed. Smart quotes already matched Word's logical-order result
in Hebrew (” in acronyms, not gershayim); pinned with a typing test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1670: fix round 1: field results shaped per bidi run, so brackets mirror correctly

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:42:54 -04:00
da8b7b7feb Tables: Backspace removes a selected table, rows or columns; Delete clears them (part of #1844) (#2005)
* WIP for #1844

* Tables: Backspace removes a selected table, rows or columns; Delete clears them (#1844)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Tables: delete_range edge-case tests, single-cell Backspace clears, deep tables kept (#1844)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:42:35 -04:00
3787af0a67 Rendering and PDF check image limits before allocating (#1321) (#2001)
* WIP for #1321

* Images: check size budgets before decoding and cap page rasters (#1321)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Images: charge decode peak, valid-CRC header tests, cap text-line rasters (#1321)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:42:17 -04:00
c9f3da2fc5 DOCX: mixed Hebrew/Latin keeps its order in Word (runs split by direction, w:rtl and language) (#1543) (#1950)
* WIP for #1543

* DOCX: split mixed-direction runs and mark right-to-left pieces w:rtl so Word orders them as WordCraft does (#1543)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* DOCX: right-to-left pieces of split runs say their language (w:lang w:bidi) (#1543)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* DOCX: no phantom tracked formatting on split runs; typed text doesn't inherit run direction (#1543)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Engine: text typed or inserted anywhere takes its direction from its own letters, not the caret's run (#1543)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:41:59 -04:00
82bf806118 Web: input-method rewrites erase before retyping, so non-Latin typing no longer repeats (part of #1480) (#1949)
* WIP for #1480

* Web: an input method's DeleteSurrounding erases around the caret, so non-Latin typing no longer repeats (#1480)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Web: input-method rewrites keep formatting and the typing undo group; AutoCorrect resets the web text field (#1480)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:41:39 -04:00
NetterSilverandClaude Haiku 5.5 6b192f58ce Clamp negative margins before checking they leave text room (#1941)
Found while testing crates/engine/src/cmd/page.rs:392 (margins) and :424 (margins_fit)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 04:41:20 -04:00
eff1444f08 Fonts: bounded family caches; math follows font changes (#1354) (#1929)
* WIP for #1354

* Fonts: bound the family caches and refresh math resolution on font changes (#1354)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Fonts: test math font lookup against a really installed and removed font (#1354)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:41:10 -04:00
SlavenDj 228a3eae3e #1905: empty keys list shows (none) (#1919) 2026-10-18 04:26:52 -04:00
SlavenDj db9d9d488a #1904: bookmark dialog placeholder when no bookmarks (#1917) 2026-10-18 04:26:41 -04:00
SlavenDj af90833305 #1902: clipboard and styles pane actions return focus to the document (#1914) 2026-10-18 04:26:30 -04:00
SlavenDjandBrandon Thomas 31cb2c8714 #1861: searchable Interface Theme command (View, light/dark/system + toggle) (#1868)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:26:19 -04:00
bcdad07d00 macOS: dialogs put Cancel left of the default button, and ⌘. cancels (#1835)
Mac order, [Cancel] [OK] with the default button rightmost, in every
dialog with OK and Cancel; Cmd-period cancels wherever Escape does.



Claude-Session: https://claude.ai/code/session_01JTqaRSzgjKv75TzSC55HEY

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:25:59 -04:00
SlavenDj 0c4558cd08 #1906: content-control Properties OK inherits theme text (#1920) 2026-10-18 04:25:04 -04:00
6d83d13257 Update Field works in headers, footers, notes and text boxes; printing updates every field (#1706) (#1780)
* #1706: WIP

* #1706: Update Fields refreshes fields in headers, footers and text boxes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md

# Conflicts:
#	ROADMAP.md

* #1706: review fixes

Revision-history row, update_fields doc comment, test covers body field and locked header field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1706: update the fields of the part being edited, as in Word

Word check: F9 with the caret in the body leaves header fields alone; with the caret in a header it updates that header. SEQ, citations and TOC run only from the body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1706: printing updates every field in the document

references.updateFields takes {"all"?: bool}: the body (with SEQ, citations, TOC) plus every part story, wherever the caret is. Printing passes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1706: ROADMAP status line after rebase

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1706: tests build the field form value added on main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1706: the test's fields take main's new dirty flag

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:24:53 -04:00
c5a9c954f7 Track Changes Options: Moves (move tracking, Moved from/to marks and colours) (gap 5) (#1779)
* track-move-options: WIP

* track-move-options: Moves options in Track Changes Options and w:doNotTrackMoves

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* track-move-options: Moved from Hidden leaves the layout; per-revision hiding

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* track-move-options: Word's mark lists, (none) moves are plain, no borders on collapsed paragraphs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* track-move-options: hr and sv translations for Moved from:/Moved to:

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:24:34 -04:00
1f0aeb4626 Ink to Shape: two-stroke arrows and closed-triangle heads (gap 12) (#1778)
* ink-arrows-2: WIP

* ink-arrows-2: Ink to Shape makes two-stroke arrows and closed-triangle heads

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ink-arrows-2: review fixes — recognise each stroke once, drawing-order pairing, arrow rect

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 04:24:15 -04:00
mbaksa 211d6c0c06 UI: Font dropdown list height doesn't update when filter results increase (#1732)
* Fix searchable combo box height when filter results increase

* Clean up unnecessary blank lines
2026-10-18 04:24:05 -04:00
9acc34f22c i18n(pl): citation style names, audit coverage and attribution (#750) (#1720)
* i18n(pl): citation style names, audit coverage and attribution (#750)

Follow-up to #871.
- pl.tsv: add the 12 citation style names (APA, Chicago, GOST, ISO 690,
  ...) that hu.tsv and bg.tsv already translate.
- tests.rs: Polish also checked against the citation styles; Italian and
  Hungarian may differ only by the obsolete "Style: APA".
- audit.rs: Polish joins the catalogs that must cover every looked-up string.
- ATTRIBUTION.md: credit Aleksander Kutycki (@olekkut) with Gemini 3.8 Flash for pl.tsv.
- contributors/people.toml: add [people.olekkut] entry.

Co-Authored-By: Gemini 3.8 Flash <noreply@google.com>

* i18n(pl): one-line equation gallery pieces (Large, Limit and, Log) so Polish matches Italian

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Gemini 3.8 Flash <noreply@google.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 04:23:54 -04:00
Hamza Naveed c2b97f1180 DOCX: read every VML shape in a w:pict, and a v:group's members (#1662)
* DOCX: read every VML shape in a `w:pict`, and a `v:group`'s members (#1643)

`read_vml` took `pict.els().find(..)`, the first `v:*` child that is not a
`v:shapetype`, and read only that one. So a `w:pict` holding several shapes
kept the first and dropped the rest, and a `v:group` was read as a single
shape -- or, with no text box in it, as nothing at all -- losing every member.
Both are gone after a save, since saving writes the model back as DrawingML.

Read all of them instead:

- `read_vml_all` walks every `v:*` child of a `w:pict` and emits one object
  each; `read_vml` keeps returning the first for the two places that show a
  single drawing (an OLE object's picture, a `mc:Fallback` preview).
- a `v:group` becomes an `InlineObject::Group`, its members mapped from the
  space `coordsize`/`coordorigin` give it onto the group's own box, nested
  groups flattened into it -- what `group_members` already does for a
  DrawingML `wpg:wgp`, so the model, the renderer and the writer need no
  change. Nesting is bounded at `MAX_VML_GROUP_DEPTH`, members at
  `MAX_GROUP_CHILDREN`.
- `read_vml_shape` is the old per-shape body, taking the group's scale so a
  member's `width`/`height` (and a `v:line`'s `from`/`to`) come through in
  the group's coordinates.

`boxes()` in the text-box tests now asks for `text_boxes()` rather than
`text_box()`, which only reports an object that is itself a box and so could
not see one inside a group; its assertions are unchanged.

* DOCX: cover a `v:group` holding several text boxes (#1643)

@KNUFFIXX reported the same loss from the other end: an inline `v:group` with
two `_x0000_t202` boxes side by side came through a convert as box A only, in
both a tall-B and a too-short-B variant, against Word reading both.

A text box is the one member a group used to keep -- `vml_textbox` recurses to
the first box that has content -- so this case looked half-working rather than
broken, and it is worth pinning separately from the shape cases. On `main` the
group collapses to a single `Shape { kind: TextBox, w: 400, h: 100 }` wearing
the group's own size and holding box A's story; here both boxes read, keep
their own stories and positions, and survive a save.

Built in code from the reported fixture rather than committing the .docx.

* DOCX: cover the shapetype lookup for a group's member (#1643)

Word writes each `v:shapetype` once, at its first use, as a child of the
`w:pict` -- so a group's member refers to one that is its parent's sibling,
not its own. Reading all shapes means `read_vml_shape` is now called for a
member as well as for a top-level shape, and both need the picture to find it,
which is why `pict` is threaded through the group walk.

Two tests: a member with no geometry and no colours of its own, taking its
diamond from the shapetype's `o:spt` and its fill from its `fillcolor`, and the
same for a shape outside a group, which is the case the lookup was written for.
2026-10-18 04:23:34 -04:00
FredApps 47c2eda22c Focus the Insert Hyperlink address box (#1610) (#1642)
* chore: reserve draft implementation for #1610

* fix(ui): focus hyperlink address when opening the dialog (#1610)
2026-10-18 04:23:24 -04:00
8701f4e28d Theme: default to system appearance (#718)
* Theme: default to system appearance

Sets Appearance::System as the default variant for ThemePreference,
so fresh installs and unconfigured preference files follow the host
OS appearance dynamically.

* contributors: add the missing blank line

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* contributors: drop the duplicate entry (already listed on main)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Web prefs test: junk storage keeps the new default (System) appearance

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 04:23:13 -04:00
dhruvLearner8 6fefeb72e6 Hover tip on a table of contents link says Current Document (#2177) (#2281) 2026-10-18 04:23:02 -04:00
6311f9d411 AutoCorrect: replace the double-hyphen arrow (#2223) (#2263)
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:22:50 -04:00
divyanshu2074andBrandon Thomas 963df12a11 fix(docx): trim marker id, bound bookmarks map, and support bookmarkEnd name fallback (#2252)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:22:31 -04:00
e7f5d32c35 Numbered headings: heading-linked multilevel list presets keep the heading styles (#1793) (#2239)
* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1793: wording: other paragraph styles become Heading 1, not only body text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:22:12 -04:00
divyanshu2074 8cdd85f7e7 fix(engine): support none in wordart reflection and outline color (#2238) 2026-10-18 04:21:53 -04:00
1d37043128 AutoCorrect: reversing a capitalisation adds an automatic exception, as in Word (#1857) (#2227)
* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1857: forget the capital when a command fails; INitial CAps auto-add checked in Word

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:21:42 -04:00
divyanshu2074andBrandon Thomas 35cbdb8002 fix(docx): support hex without hash, rgb(), and case-insensitive color names in VML and DrawingML (#2222)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:21:24 -04:00
dhruvLearner8 498d9aaa9b Context menu: Remove Table of Contents on a TOC entry (#2179) (#2217) 2026-10-18 04:21:05 -04:00
Antoine BoxhoandBrandon Thomas 72cea2daf4 #1885: dialog close box fills red on hover, whole box clickable (#2090)
Dialogs, the Read Aloud player and the file browser used egui's small close glyph: the hit area was the glyph alone and hovering only changed its stroke. The close control is now a box at the right end of the title bar, the full title-bar height and 1.6 times as wide, as a Windows caption button on a Word dialog. The whole box closes the window. It fills red with a white cross while the pointer is over it and darker red while pressed and still under the pointer, with colours from the theme tokens in both themes. The box is pointer-only, so Tab never lands on it; dragging from it does not move the window and Escape closes dialogs as before. Its accessibility name is translated like its tooltip.

docs/parity-checklist.md is the regenerated checklist.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:04:12 -04:00
ab57696ba7 Draw undrawable private-use list bullets as the level's default bullet, not an icon-font glyph (#1683) (#2206)
A bullet stored as a private-use code (U+F0xx) in a font that isn't
installed, and that isn't a mapped Symbol/Wingdings code, fell back to
any installed face with a glyph at that code - an icon font such as
Font Awesome / Nerd Fonts, where U+F081 is the Twitter logo. Such a
bullet now shows the level's default bullet (• ○ ▪), so level 2 is a
hollow circle.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:03:35 -04:00
a30a1866e2 Web: keep preferences such as dark mode between visits (#2132) (#2205)
The web shell never loaded or saved the UiState the desktop app keeps in
ui.json, so a reload reset the theme and every other preference. It now
restores it from localStorage at startup and writes it back whenever it
changes (a reload runs no exit code).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:03:16 -04:00
Antoine BoxhoandBrandon Thomas 04eadcb2ca #2068: Recently Used Fonts at the top of the font list (#2192)
The font box (ribbon and mini toolbar) opens with a Recently Used Fonts
section: the fonts applied from the box, the mini toolbar or the Font
dialog, most recent first, once each, ten at most, kept in ui.json. All
Fonts follows. The sections show while nothing is typed; a query lists
its matches only. Up/Down walk the recent entries then the list and Enter
applies the highlighted one; a click on a heading keeps the list open.
The typed list is sized again when its length changes, in a discarded
pass so it never blanks; it used to stay one row tall after a one-match
query. Names over 128 bytes are not kept, and reading a saved list stops
at ten names.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:02:57 -04:00
f354e7fc9b fix(engine): support nested quotations and opening delimiters in smart quotes (#2176)
* fix(engine): support nested quotations and opening delimiters in smart quotes

* docs: typing-parity line for smart quotes after guillemets, low quotes and nesting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 04:02:37 -04:00
Antoine BoxhoandBrandon Thomas 1c8c352cf5 #1549: one Thai face for a run in a missing Thai font (#2175)
A paragraph in a Thai font that isn't installed (TH Sarabun New, Angsana
New, Cordia New, the UPC families…) drew its Thai letters in one fallback
face and the Latin letters, digits and baht sign in a Latin substitute at
a visibly different size. Word substitutes one font (Cordia New) for the
whole run.

Thai families now have their own substitute lists (Cordia New and
Leelawadee UI on Windows, Thonburi on macOS, Noto Sans Thai and the TLWG
fonts on Linux; serif families prefer Angsana New, Sathu or Noto Serif
Thai), and the first listed face with Latin letters and digits as well
as Thai wins. When the substitute still lacks some of the run's Thai or
Latin text, the run moves to one face covering it through
FontDb::fallback_for_run, the generalised East Asian run fallback. Only
Thai fonts get this: a missing Latin font keeps Latin text in its own
substitute, a run with its own complex-script font keeps it for the Thai
letters, and a missing roman-classed Thai font is no longer drawn in
Cambria.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:02:18 -04:00
7fc2bbf248 fix(doc): preserve overparen, underparen, and brackets in LaTeX group characters (#2163)
* fix(doc): preserve overparen, underparen, and brackets in LaTeX group characters

* cargo fmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 04:01:59 -04:00
540692515b fix(engine): resolve section and section pages context when unlinking fields (#2156)
* fix(engine): resolve section and section pages context when unlinking fields

* cargo fmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 04:01:48 -04:00
Marco ArnulfoandBrandon Thomas 986d7e2a5a Layout: the caret after spaces hanging past the margin stays at the margin (#2152)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:01:38 -04:00
Marco ArnulfoandBrandon Thomas cdb13144b3 Lists: Enter on an empty nested item numbered by its paragraph style moves it up a level (#2143) (#2151)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:01:19 -04:00
8812fe068d fix(engine): support switches and quoted bookmarks when updating REF and PAGEREF fields (#2149)
* fix(engine): support switches and quoted bookmarks when updating REF and PAGEREF fields

* cargo fmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 04:01:00 -04:00
Marco ArnulfoandBrandon Thomas 7bd3eb8ca7 Backspace and Delete remove a section break, imported or not (#2133)
* Backspace and Delete remove a section break, imported or not (fixes #2127)

* Delete removes a section break on the last paragraph too (#2127)

A continuous break on the mark of the document's last paragraph, with the
last section holding no paragraph (as in the file attached to #2127), had
nothing for Delete to join, so it stayed. Delete at the end now removes it.
The text takes the last section's setup, as in Word, and the headers and
footers that section showed by linking to the removed one stay, here and
when the break goes by joining two paragraphs. Undo brings it back.

* Deleting over several section breaks keeps the headers they linked to (#2127)

A range across two or more breaks dropped the paragraphs in between with
their sections, so only the first section's headers and footers were carried
over. The joined section now takes those it leaves out from every removed
section, nearest first, as "link to previous" resolved them before the delete.
The typing-parity row says that Delete with Track Changes on still leaves a
break on the last paragraph alone.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:00:49 -04:00
357deba3c2 fix(engine): use table_formula parse_number for table sorting (#2126)
* fix(engine): use table_formula parse_number for table sorting

* contributors: drop the duplicate entry (already listed)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* cargo fmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 04:00:30 -04:00
MarekSpodBiedry ecd94e0f8a Ribbon tab clicks no longer scroll the page to the caret while a footer is edited (#2054) (#2121) 2026-10-18 04:00:18 -04:00
1cc3d190a7 Fix Find navigation to show search results (#2096) (#2118)
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:00:07 -04:00
divyanshu2074 35fb78f0cc fix(engine): exclude carriage returns from word count character statistics (#2116) 2026-10-18 03:59:49 -04:00
a93932d491 fix(engine): ensure nextFootnote navigates only footnotes and skips endnotes (#2114)
* engine: filter note_refs in nextFootnote to skip endnotes

* Collapse the nested if (clippy collapsible_if)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 03:59:37 -04:00
6670b9c867 Tab strip never draws tabs over Comments / Editing / Share (#2026) (#2084)
The strip kept File, the active tab and contextual tabs however narrow the window got, so
they were drawn under the buttons on the right. The strip is now planned first: the buttons
keep their room and the tabs give way in order (tab padding, Comments and Editing labels,
Share label, plain tabs into », active and contextual labels cut with …, last the active tab
into »). File always stays. A regression test checks widths 300-1280 in en, pl and de.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:59:19 -04:00
AlikandBrandon Thomas d8b11c69d3 Columns on selected text go in their own section (#2024) (#2071)
With text selected, Layout > Columns used to change the whole section. As in
Word (observed black-box), a continuous section break now goes where the
selection starts and one where it ends, splitting a paragraph there, unless that
end already meets a section boundary (the document's start or end, or an
existing break); the columns go on the section between, in one undo step.
`layout.columns` gains `apply: "selection"`, its default when text is selected
in the main text. More Columns... offers "Selected text" and picks it then.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:59:01 -04:00
NetterSilverandClaude Haiku 5.5 7f94643154 Give the status bar zoom percentage a fixed width (#2059)
Fixes #1975

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 03:58:42 -04:00
Brandon ThomasandClaude Opus 5.5 edee285c36 docs: fix ROADMAP tables (progress-log separator under its header, stray progress row out of the headline table)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 03:50:50 -04:00
2e062168f0 Text effects: legacy outline, shadow, emboss and imprint are drawn (#2534) (#2548)
The legacy run properties w:outline, w:shadow, w:emboss and w:imprint were
read, resolved and saved but never drawn. Layout now turns them into text
effects (hollow outlined letters; a sharp grey copy down-right for shadow
and emboss, up-left for imprint, as Word's PDFs show them) and draws them
through the existing WordArt path, so screen and PDF both get them and the
PDF keeps the text. WordArt w14 effects on the same run take precedence.

Text drawn from outlines keeps synthetic bold (a widened outline, or one
in a solid fill's colour and transparency). Colour glyphs (emoji, no
outlines) and top-to-bottom runs with upright characters are drawn as
plain glyphs, and tracked insertions and deletions keep their legacy
effects.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:50:15 -04:00
8fb4d44d2c Different First Page: page 1's header can be edited and inserted (#2826) (#2833)
With Different First Page on, page 1 shows the first-page header, but
Insert > Header/Footer/Page Number always wrote the default one (which
page 1 no longer shows), Edit Header without a page went to the default
too, and a double-click in page 1's header area was ignored while
another header was being edited (the usual case: edit the header, tick
Different First Page, double-click page 1). Now hf_part and edit_hf use
the slot of the caret's page, and a double-click in a header/footer
zone switches to the one that page shows even from another header.

Fixes #2826
Fixes #2811


Claude-Session: https://claude.ai/code/session_013kKPnx8r1vcYumjyL3kcuz

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:47:13 -04:00
abb36aceb2 #1270: pasted HTML blockquote is an indented paragraph, not Quote (#2737)
LibreOffice puts a paragraph indented on both sides on the clipboard as
<blockquote>, its margins in the page's stylesheet. Paste mapped it to the
centred, italic Quote style. On paste only, it now becomes a Normal
paragraph indented by the blockquote's stylesheet margins (else 0.5 in each
side). Opening .html files still maps blockquote to Quote.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:46:54 -04:00
da11d6634b Shapes: preserve DrawingML outline dashes (#2661)
* Shapes: preserve DrawingML outline dashes

* Shapes: scale dashes to effective path width (#380)

* Keep the outline dash with noFill; cap the raster dashed stroke width at 200 pt

Addresses the two CodeRabbit review notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use clamp for the dashed stroke width (clippy)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 03:46:35 -04:00
ce38b0c372 #1297: copied equations go in the MathML clipboard formats Word reads (#2614)
A copy or cut of one equation on its own now also puts its MathML in the
platform's MathML clipboard formats, beside the HTML (with MathML, #816) and
the linear text: "MathML" and "MathML Presentation" (UTF-16LE) on Windows,
public.mathml.presentation and public.mathml on macOS. These are the names
W3C MathML 3 section 6.2.4 registers, and Microsoft's "MathML Support in
Microsoft 365" doc says Office checks them first when it pastes math. No
public clipboard format carries OMML. A copy with text around the equation
doesn't offer them, so Word keeps the text.

Not yet verified in Word: the parity docs say MathML clipboard formats, not
an editable paste.


Claude-Session: https://claude.ai/code/session_01P8veLveuUsJJm6UEcQPEuW

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:46:15 -04:00
Fin WarmanandBrandon Thomas 81098c52f6 CLI: allow explicit field updates during conversion (#2014)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:45:57 -04:00
Fin WarmanandBrandon Thomas a3fcb9ed47 Fonts: keep Arial Unicode MS fallbacks proportional (#2657)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:45:37 -04:00
Alik ff61b32177 Tests: the Alt+0233 test expects the system code page's character (#2040)
alt_numpad_code_types_its_character_without_keytips always expected é, but on
Windows the app (rightly) reads 0233 in the system locale's ANSI code page, so
a Russian system (1251) types й and the test failed. The test now works out the
expected character the same way, as its Alt+233 half already did.
2026-10-18 03:45:07 -04:00
da84f48040 A cell emptied by widow control takes its row to the next page (#2035)
split_row already keeps widow/orphan control in each cell paragraph.
When that moves all of a cell's lines on although its first line would
fit, the other cells stayed on the page and the row was split beside
an empty cell. Word moves the whole row on instead. Observed with
synthetic documents (exact 14 pt lines, a one-line cell next to a
longer one near the bottom of the page):

  long cell | room for | Word      | before          | now
  4 lines   | 2        | 2 + 2     | 2 + 2           | 2 + 2
  3 lines   | 2        | row moves | short cell kept | row moves
  3 lines   | 1        | row moves | short cell kept | row moves
  4 lines   | 1        | row moves | short cell kept | row moves
  5 lines   | 3        | 3 + 2     | 3 + 2           | 3 + 2
  3, control off | 2   | 2 + 1     | 2 + 1           | 2 + 1

Co-authored-by: Daniele Mori <Dinasty@hotmail.it>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 03:44:56 -04:00
795cad3326 Automatic text in a styled text box takes the shape style's font colour (#2032)
* Automatic text in a styled text box takes the shape style's font colour

A text box shape whose style has a:fontRef with a colour (Word's
default shape style uses lt1, white on the accent fill) shows its
automatic-colour text in that colour. WordCraft drew it black. Word,
observed with a synthetic document: only text whose resolved colour is
automatic takes the fontRef colour; a run's own colour, a paragraph or
character style colour and a colour from the document defaults all win
over it. A box without a fontRef keeps black.

- wordcraft-doc: TextBody gets `auto_color`.
- wordcraft-docx: read from wps:style/a:fontRef (theme colours resolved
  as for SmartArt).
- wordcraft-layout: lines in a text box story resolve automatic colour
  to it.

Saving does not write a shape style yet, so the colour is not kept
through a save; the reader and layout parts stand on their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove stray doc-comment lines from the new test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Daniele Mori <Dinasty@hotmail.it>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:44:45 -04:00
ba8dc7bf1c DOCX: a style ID defined twice is its last definition (#2031)
Some generators write a placeholder Heading 1 and then their own,
under the same w:styleId. The reader kept the first definition and
skipped the rest, so headings came out in the placeholder's font,
size and colour. Word uses the last definition, whole: a synthetic
document with an italic red 20 pt first definition and a bold blue
12 pt second one shows bold blue 12 pt, with nothing of the first
(no italic, no centring), for a custom style and for Heading 1 alike.

The reader now replaces an earlier definition in place instead of
skipping the later one.

Co-authored-by: Daniele Mori <Dinasty@hotmail.it>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 03:44:34 -04:00
EncodeE55 f6115ea7fa Update ROADMAP.md (#2020)
Fixxed the Markdown table formatting—specifically, adding the header separator row (| --- | --- | --- |).
2026-10-18 03:44:23 -04:00
MarekSpodBiedry ace1adc624 Format Painter: double-click keeps it on, armed and sticky look different (#2785) (#2822) 2026-10-18 03:44:13 -04:00
4d8204c2f2 #2634: a table inserted in right-to-left text is right to left, so Tab moves left (#2688)
Tab already follows reading order (next logical cell) and right-to-left
tables (w:bidiVisual) already lay the first cell out at the right. The
bug was that insert.table ignored the caret paragraph's direction, so a
table made while writing RTL text was a left-to-right table and Tab went
rightwards. Like Word, insert.table now makes a right-to-left table when
the caret's paragraph reads right to left.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:43:47 -04:00
Charan Rathore 5b322e1530 fix(commands): Export PDF and PNG without a path ask where to save (#2665)
Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com>
2026-10-18 03:43:27 -04:00
NetterSilverandClaude Haiku 5.5 70c5c44f01 Promote the cell below when deleting a vertical merge's first row (#2649)
Found while testing crates/doc/src/table.rs:129 (Table::delete_row), called from crates/engine/src/cmd/table.rs:526 (del_row)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 03:43:16 -04:00
NetterSilverandClaude Haiku 5.5 33f00e8dfe Keep custom footnote/endnote marks as text inside the note on DOCX save (#2647)
Found while testing crates/docx/src/write/story.rs:512-530 (InlineObject::NoteRef, the `current_note == Some(..)` branch)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 03:43:04 -04:00
4950690479 #498: Indonesian (id-ID) spelling proofing (#2576)
Indonesian spelling reads the installed Hunspell id_ID dictionary at runtime
(Debian/Ubuntu hunspell-id, LibreOffice dict-id, the Dictionaries folder or
WORDCRAFT_DICTIONARIES), like Czech, Polish and Portuguese: nothing is bundled,
and without a dictionary Indonesian text gets no spelling marks.

- ProofLang::Id for id, id-ID, ind and the legacy in tag
- suggestions reuse the dictionary-driven Czech ranking (MAP/REP/TRY/KEY of
  the loaded dictionary), nothing Indonesian hard-coded
- hyphenated reduplication accepted whole or by parts (kupu-kupu, anak-anak);
  hyphenated clitics after capitals/abbreviations (KTP-nya, se-Indonesia)
- generic grammar only: repeated word (offers anak-anak first), space before
  punctuation, double space, with Indonesian messages
- Review > Language, status bar, Set Proofing Language, File > Options >
  Proofing list Indonesian; name translated in every catalog
- tests use a hand-written synthetic dictionary; engine end-to-end test

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:42:52 -04:00
4442390213 DOCX: keep the glossary document (building blocks) (#2631)
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:23:25 -04:00
Fin WarmanandBrandon Thomas bd0521d476 Prefer a covering text face for missing black-circle glyphs (#2582)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:23:05 -04:00
55bef52d38 Comments readable in the dark theme (#2045) (#2563)
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:22:46 -04:00
df9c29f673 Read Mode keeps the keyboard and shows whole, unobscured columns (#1772) (#2591)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:22:26 -04:00
Fin WarmanandBrandon Thomas 03e713b72f Preserve cached field result formatting (#2605)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:22:06 -04:00
b2b8ad8f52 Font list: entries of tall-ascent fonts are no longer cut off (#2570) (#2598)
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:21:48 -04:00
Fin Warman fc15682fb0 Preserve REF field hyperlinks in PDF exports (#2597) 2026-10-18 03:21:29 -04:00
aa665b16a9 #2592: Split Cells and Convert Text to Table dialogs (#2593)
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:21:14 -04:00
8c1a39ba95 A new source's Tag follows Word's format; editing keeps the Tag (#2094) (#2571)
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:20:56 -04:00
16622f0853 #1876: Web Layout text fills the window width (#2539)
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 03:20:37 -04:00
NetterSilverandClaude Haiku 5.5 75122c1178 Keep leading U+3000/NBSP in Markdown import and round trip (#2538)
Found while testing crates/formats/src/markdown.rs:253 (join_lines); also the trim_start() calls at lines 229, 311, 426 and 432, against escape_line_start at line 1077

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-18 03:20:26 -04:00
dhruvLearner8 ad820dce6c Remember Navigation Pane visibility across launches (#2526) (#2528) 2026-10-18 03:20:14 -04:00
Fin WarmanandBrandon Thomas 3507a34430 Retain cell top insets across table row page breaks (#2524)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:19:59 -04:00
Fin WarmanandBrandon Thomas db0affc0d3 Recognize the Symbol PCL6 font alias (#2521)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 03:19:40 -04:00
Fin Warman 74c1f26c4e Resolve exact OpenType full font names (#2516) 2026-10-18 03:19:21 -04:00
Brandon ThomasandClaude Opus 5.5 f2e15cf8f8 Release 0.8.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:43:17 -04:00
Rees 2f6311cc37 M15: keep inherited proofing choices in Language dialog (#2717) (#2736)
Read effective character formatting so unchanged OK preserves language and no-proof choices inherited from defaults and styles.
2026-10-17 17:00:10 -06:00
NetterSilverandClaude Haiku 5.5 5834dc5a19 Shorten Russian "Accessibility" label so it fits the options sidebar (#2672)
Fixes #2668

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-17 16:59:29 -06:00
Dylan M. Taylor 72453d9def ci: switch to shared nix workflow from craft-common-actions 2026-10-15 23:57:51 -04:00
skylar taylor-barrick eae2421761 Merge pull request #2307 from dylanmtaylor/ci/nix-workflow
ci: add nix check workflow
2026-10-15 21:24:56 -06:00
Dylan M. Taylor 40c4ebc432 ci: add nix check workflow 2026-10-15 20:49:45 -04:00
skylar taylor-barrick 86adfd34eb refactor(pdf): adopt the shared PDF writer (PDFW)
PDFW: Migrate WordCraft PDF export to the shared writer while preserving application layout and export policy.

Refresh includes current main through fd0bb2fb. The exact merged tree passed all six local CI gates, 2,769 native tests, 39 PDF tests, 16 WASM package checks, and 104 paired exports / 117 pages on native and executed Node/WASM. Fresh Ubuntu, Windows and macOS Printing checks passed at df28b78.

PR #1558. Preserve the qualified branch history and immutable shared dependency revision.
2026-10-15 12:17:16 -06:00
skylar taylor-barrick df28b78de9 refactor(pdf): reconcile shared writer with current main (PDFW)
Refresh #1558 onto fd0bb2fb while preserving the shared writer migration
and current upstream layout, DOCX, field and table behavior.
2026-10-15 11:47:00 -06:00
fd0bb2fb1e #954: macOS ⌘←/⌘→ go to the line's start and end (#972)
On macOS, Cmd+Left/Right were bound to Word Left/Right (same as Option+arrows).
Bind them to Line Start/End there, as in Word for Mac and every Mac text field;
Option+arrows still move by word. Windows/Linux keys are unchanged.

Co-authored-by: justinjohn0306 <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 01:35:42 -04:00
b3b6c451e3 fix(ui): draw Arabic and other non-Latin comment text in balloons (#950)
* fix(ui): draw Arabic and other non-Latin comment text in balloons

Comment balloons and the Comments pane draw comment text and author
names with the interface faces (Inter, Source Sans, CJK), which have no
Arabic, Hebrew, Thai and so on: every such letter was a box. The app now
borrows a document fallback face that covers the comments, as the window
caption does (#840), checked once per document revision.

egui also lays bidi runs and words out left to right, so a right-to-left
comment read backwards. Read-only balloons now wrap the text by word
widths, put each row in display order (Unicode bidi runs, words of a
right-to-left run reversed, brackets mirrored) and set right-to-left
paragraphs against the right edge. Text without right-to-left letters is
laid out exactly as before. The editable fields (the selected balloon and
the Comments pane) keep logical order.

Also adds my contributor entry (sync_github_name).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* One contributor entry each

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Mohamed Youssef <ms.rightsteps@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 01:34:11 -04:00
6a2bad8afa Tables: formulas with cell references, functions, number formats and F9 (#907)
* Tables: formulas with cell references, functions, number formats and F9

Table Layout › Data › Formula was a fixed set of string checks: any
unknown function was silently summed, cell references weren't read, and
Update Field never recalculated a formula field.

Formulas are now parsed and evaluated (crates/engine/src/cmd/table_formula.rs):
- cell references and ranges by grid column (=B2+B3, =SUM(B2:C3)),
  + - * / ^, parentheses and the comparisons = <> < <= > >=
- SUM, AVERAGE, COUNT, MAX, MIN, PRODUCT, ABS, INT, SIGN, ROUND, MOD,
  IF (only the branch taken is worked out), AND, OR, NOT, TRUE, FALSE,
  DEFINED
- ABOVE/LEFT/RIGHT/BELOW sum the first run of numbers next to the cell,
  as observed in Word; `=SUM()` is a syntax error, as in Word; a bare
  `=SUM` takes ABOVE or LEFT from the cell
- the \# numeric picture (`#,##0.00`, `0%`, `;` sections)
- an unknown function or a syntax error is an error and inserts nothing;
  input is bounded (formula length, nesting depth)

Update Field (F9) re-evaluates formula fields from the cells they read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review fixes for #907: clamp formula ranges to the table, cell budget, depth for ^ and unary +

- A range reads only the rows and grid columns the table has, so SUM(A1:BK32767) over a small
  table no longer walks two million grid positions.
- One evaluation reads at most 100,000 cells; past it the formula fails with "Formula too
  complex" (F9 shows !Formula too complex) instead of hanging on a hostile document.
- Each ^ in a chain and each unary + counts toward the nesting limit of 32, like unary -.
- Regression tests for all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M2.1: locale-aware number parsing and formatting in table formulas

* contributors: credit rbr48

* Merge main; rustfmt; one contributor entry for rbr48 (#906 had added one)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop a stray merge marker from ROADMAP

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: rbr48 <rajbrur48@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 01:31:18 -04:00
dac8c6d6c7 Table style review follow-ups: No Border on inherited parts, cell Top beats the style (#1288) (#1296)
part that inherits its lines sends it, and the picker only shows No Border checked for it.
- CellProps::valign is Option<VAlign>: a cell's own Top (Align Top, w:vAlign="top") beats a
  table style part's vertical alignment, and round-trips through .docx.
- tableStyle edit rejects Part fields at the top level without "part".
- "font": null or "" makes a part's font inherited; clearing the dialog's font box sends it.
- Document ui.dialog's optional "part" in docs/control-protocol.md.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:26:42 -04:00
Brandon ThomasandClaude Opus 5.5 18b680c9aa Recovery: a record lock held for a moment is waited out, not skipped
new_draft_restarts_restores_unsaved_and_explicitly_discards failed in a
workspace run: the reopened session found no recovery record, because a
process another test was starting held the record's lock for a moment (a
forked child shares it until exec) and scan() skips locked records. The
same can happen to a user when the app starts a helper process. lock() now
tries again up to ten times, 10 ms apart, before treating the record as
owned by a live instance.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:26:06 -04:00
Brandon ThomasandClaude Opus 5.5 e127a080a1 CI: drop a duplicate contributor entry in people.toml
#1708 merged with main added [people.finwarman] a second time; the build
then fell back to usernames only.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:14:53 -04:00
5b49d692a5 Table style editor formats every table style part (#714) (#887)
* #714: table style editor formats every table style part

Modify/New Table Style: "Apply formatting to" any of the 13 ECMA-376
§17.7.6 parts (whole table, header/total row, first/last column,
odd/even row and column bands, four corner cells), each with font,
size, bold, italic, underline, colour, fill, borders (line style,
width, colour; all/none/outside/inside/each edge) and the 9-way cell
alignment, over a live preview.

- doc: TablePart, PartFormat, PartSet; TableStyleParts::part/set_part
  (new parts and per-part jc/vAlign in `regions`)
- layout: generic part resolution incl. band2, corner cells, alignment
- docx: every tblStylePr type read/written with pPr jc and tcPr vAlign
- engine: table.newStyle/modifyStyle take ECMA part names, `part`,
  `parts`, per-edge `borders`, `underline`, `align`/`vAlign`/`cellAlign`
- ui: dialog moved to dialogs_table_style.rs; ui.dialog takes `part`

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n: Hungarian and French strings for this change

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review fixes for #887: Bulgarian strings for the table style parts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #714: Vietnamese strings for the table style parts (main's new vi catalog)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* hr: match the catalog's word for banded

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 01:13:56 -04:00
Hamza NaveedandBrandon Thomas ffacc4c08c Fields: refresh unlocked DATE and TIME when a document is opened (#1679)
A `DATE` or `TIME` field showed whatever was cached the last time the file was
saved, so a letter written from a template went out carrying the date the
template was saved. Word refreshes those two whenever it shows, prints or
exports a document; WordCraft only did so on an explicit Update Field(s), on
insert, or when printing with "Update fields before printing" on. Headless
`convert` to PDF was the worst of it: nothing hints the date is old.

Refresh them where a document is read instead, in a new `io::opened` beside
`ensure_nonempty` -- the two things a freshly-read document needs -- so `text`,
PDF, rendering and the window all show the same, current value. The model's
stored result is the one source they share; patching each output path would
have needed a clock in two places and still missed the next one.

Narrow on purpose. Only `DATE` and `TIME`: `CREATEDATE`, `SAVEDATE` and
`PRINTDATE` record when something happened and are not "now", and `REF`, `TOC`
and citations stay on explicit update, as in Word. A field carrying `w:fldLock`
keeps its stored result, which is what the lock is for. Headers, footers and
text boxes are covered too, not only the body.

The field code is untouched, so saving writes the field back as it was, with a
current cached result -- as Word writes one.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 01:07:07 -04:00
109956bb90 Tables: preserve row table border exceptions (#1708)
* Tables: preserve row table border exceptions

* Tests: shorten the row border regression name

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:58:59 -04:00
add4851443 layout.pageSetup validates its section values (#1582) (#1584)
* #1582: WIP

* #1582: layout.pageSetup {section} validates like Size, Margins and Columns

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1582: cargo fmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1582: shared page limits; notes, borders and columns validated too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1582: page limits are pub, as in #1496

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1582: drop page constants #1496 now defines on main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1582: a page smaller than 36 pt passes Page Setup with zero margins

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 00:55:10 -04:00
Brandon ThomasandClaude Opus 5.5 9a19e09d97 CI: rustfmt the proofing language list
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:53:07 -04:00
6e25d482c4 proof: Czech (cs-CZ) spelling from the installed Hunspell dictionary (#1048)
Same pattern as Portuguese (#797) and Polish (#346): the cs_CZ
dictionary is read from the system at runtime (e.g. Debian/Ubuntu
hunspell-cs), nothing is bundled. Suggestions use the dictionary's own
MAP/REP/TRY/KEY; grammar stays generic (repeated word, spacing), with
digit ratios like "3 : 1" exempt. The Czech interface now writes new
documents (and opened .txt/.md) in cs-CZ.

Engine fixes the Czech dictionary needs, also used by pl/pt:
- PFX/SFX header lines may carry a trailing comment (cs_CZ has
  "SFX y Y 80 #…"); rule lines are left literal;
- NOSUGGEST stems are accepted when typed but never offered;
- KEY is parsed for neighbour-key suggestions.

Tests use a hand-written synthetic Czech dictionary only; an
end-to-end engine test mirrors the Portuguese one.

Co-authored-by: Losminek <301412321+Losminek@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 00:52:43 -04:00
94bfc29b0b Tab stops: edit on the ruler (corner selector, click, drag, drag off) and an inline-edit table in the Tabs dialog (#976)
* Ruler tab stops: corner selector, click to set, drag to move, drag off to clear

The ruler's markers were read-only (double-click opened the Tabs dialog,
click sets (Left, Center, Right, Decimal, Bar; right-click cycles back),
clicking the ruler sets one on the half-division grid (Alt places
freely), dragging moves it with a dashed guide down the page and a
position tooltip, and dragging it off the ruler clears it. Markers draw
per kind, mirrored for right-to-left paragraphs. Each gesture runs
para.tabs once on release, so it is one undo step and inherited-stop
clearing keeps working. Double-click still opens the dialog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Progress docs: ruler tab stops (ui-parity Rulers row and revision history, ROADMAP progress log)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Tabs dialog: OK applies what the fields show; double-click a stop to edit its position

Selecting a stop, changing its alignment or leader and pressing OK now
applies the change without Set, and a new position typed while a stop
is selected (the dialog preselects the first) is set on OK, so adding a
stop never requires leaving the dialog for the ruler. Double-clicking a
stop in the list edits its position in place: Enter or clicking away
commits (the interface unit by default, any unit spelled out, either
decimal separator), Escape cancels, and the commit moves the stop in
one clear + set, keeping its alignment and leader. Clear still wins
over a re-typed cleared position, and junk input changes nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Tabs dialog: New button, delete from the list, and the list fills its frame

New stages a fresh stop - deselects, proposes the next free multiple of
the default interval after the last stop (skipping cleared positions),
resets alignment and leader, and focuses the position field - and Set
or OK adds it. Every row gets a small x that clears its stop, and the
Delete key clears the selected one when nothing is being typed; the
selection and an in-progress inline edit follow when a row above them
goes. The list is a fixed 150 pt wide and the rows and scrollbar fill
it, so labels stay left-aligned, the x sits at the frame's edge clear
of the scrollbar, and the scrollbar docks at the edge instead of
floating mid-list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Tabs dialog: the stop list is an inline-edit table

Each row edits its stop in place: a unit-aware position field (rows
resort into position order once the edit ends, never mid-typing; two
stops landing on one position merge, the edited row winning), an
alignment dropdown and a leader dropdown (the closed box shows just the
fill), and the x that clears the row. The double-click editor and
move_stop go: the table subsumes them. The selected row keeps an accent
outline, its edits sync the fields below, and the frame widens to fit
the three columns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Tabs dialog: the table is the whole editor

New adds a row straight into the table at the next free multiple of
the default interval, selected and ready to edit in place, and the
position field, the Alignment and Leader radio groups and the Set and
Clear buttons go: every one of their jobs is a table cell or the row's
x. What stays is New, Clear All, the default interval, the cleared
list and OK/Cancel, with OK applying the staged diff as one para.tabs
call. The staged-field methods (set, clear, on_ok) go with them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Review fixes: safe ruler gestures, one tab tolerance, dialog cleanups

Ruler gestures are primary-button only, a click on an indent marker
places nothing (the markers absorb their clicks), Escape cancels a drag
like a table-border drag (#217), and a drag is stamped with its
paragraph: the caret leaving it mid-drag, or a release the ruler never
saw, abandons the gesture instead of applying it elsewhere or late. The
dragged marker no longer double-draws on its first frame. One
TAB_POS_EPS in the doc layer replaces four hardcoded 0.5s across the
engine, the dialog and the ruler. The dialog's normalize is a no-op
unless a position edit happened, its combos share the ruler's KINDS and
one LEADERS table, dead code and a stale doc comment go, and the
ui-parity revision row moves to the table's top. Regression tests cover
each gesture rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 00:40:35 -04:00
cb6418dc6c Tables: one selected cell's border drags in its row only (#970)
Selecting one cell (Table Layout > Select > Select Cell, or a click along
its left edge) and dragging its left or right border moved the border in
the whole column: one selected cell wasn't told apart from text selected
inside it. `ResizeTarget::limit_to_selection` now takes the selected cells
from `Session::cell_range` (#637) when they're in the dragged table, so one
selected cell's border moves in its row only, as in Word, like the border
of cells selected across (#720). Text selected within a cell still moves
the whole column.

Tests: engine `a_selected_cells_border_moves_only_in_its_row`; kittest
`a_selected_cells_border_drags_in_its_row_only` (a click along the cell's
edge, then a real drag). Progress docs: ui-parity, ROADMAP.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 00:35:32 -04:00
5c8dba229f fix(references): clear imported TOC field result on update (#878) (#953)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:32:19 -04:00
Brandon ThomasandClaude Opus 5.5 0aab56934b Docs: drop a stray merge marker from the ROADMAP progress log (#1962)
#906's branch carried a '>>>>>>> upstream/main' line from its own merge.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:28:09 -04:00
7af9748ace M1: fields keep w:dirty through saves; computing a result clears it (#877) (#946)
A field begin marker flagged w:dirty by a producer that couldn't compute
its result (pandoc-style TOC placeholders) lost the flag on save, so Word
no longer refreshed the field on open. Mirror w:fldLock end to end:
FieldState, InlineObject::Field/FieldStart, the reader (fldChar and
fldSimple) and the writer (buffered, range and TOC-spanning begins).
Update Field/Update Table clear it on the fields they compute, like
Word's F9; untouched fields keep it. Round-trip and engine regression
tests, incl. a pin for #878 (imported TOC placeholder replaced in place,
fixed earlier by #617).

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:27:25 -04:00
5f83a4727a get_text and CLI text omit tracked deletions (#866) (#944)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 00:21:06 -04:00
a9d3597549 Tables: Backspace after a nested table stays in its cell (#906)
Backspace at the start of the paragraph after a table nested in a cell
moved the caret into the last cell of the table around it: the search for
the table's last paragraph compared only the first index of the path, which
a nested table shares with its outer table. It now matches the table's
whole path. Regression test: backspace_after_a_nested_table_stays_in_its_cell.

Also adds my contributor entry (rbr48, GitHub profile name).

Co-authored-by: rbr48 <rajbrur48@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 00:17:03 -04:00
f43928324f Margins and page sizes: follow the measurement unit and keep translated names (#1274)
* Margins gallery: Normal and Narrow follow the measurement unit

The two uniform margin presets were written as `Normal  1" all` and `Narrow  0.5" all` whatever
unit the user chose, so a centimetre or millimetre setting still showed inches. They now take the
size from the measurement unit, like the paper-size menu next to them (`Normal  2,54 cm all`).

The catalog keys become `Normal  {size} all` and `Narrow  {size} all`; every catalog carries the
same translation with its number replaced by `{size}`, so no wording changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Paper sizes: translate the name, show the size in the measurement unit

The Size menu built each entry as "{name}   {w} × {h}" in the measurement unit and then looked
that whole text up in the catalog, so with centimetres or millimetres no entry matched and the
envelope names stayed English. The name is now looked up on its own (`Envelope #10`,
`Envelope DL`); the catalogs that translated the old inch labels carry the same wording without
the size.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 00:15:29 -04:00
Brandon ThomasandNai-ITA e45c0159e8 Ita Translate (#1040)
Italian Reading Mode column widths from #1040 by @Nai-ITA, and the same strings in every catalog.

Co-authored-by: Nai-ITA <info@naict.it>
2026-10-15 00:11:52 -04:00
1e02093462 i18n: add Croatian (hr) interface translation (#1271)
* i18n: add Croatian (hr) interface translation

Croatian interface catalog (2,505 entries: every key of the reference catalog, built-in style
names, equation gallery, print dialog), registered as `hr` ("Hrvatski"). `hr`, `hr-HR` and
`hr_HR.UTF-8` system locales pick it automatically.

- Count-neutral wording (`Riječi: 21`), since Croatian numerals take three noun forms.
- New `template` catalog context: the "Letter" template can be called "Pismo" while the "Letter"
  paper size keeps its name. Other languages fall back to their plain entry, so nothing changes.
- Locale, saved-preference, catalog-coverage and bundled-font tests; `hr` added to the
  interface-audit languages; Polish's coverage test lists `hr` with it/hu/bg.
- Written from the meaning of the English labels (clean room, no Word localisation consulted),
  proofread by a second model and reviewed by a native speaker.
- ATTRIBUTION row, contributors entry for the author, docs and roadmap.

Croatian spelling and grammar proofing is separate (#1114).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* ATTRIBUTION: name the models and the human review for the Croatian catalog

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* i18n(hr): native-speaker corrections to math terms and messages

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* i18n(hr): envelope names without their size

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs: Croatian entry count

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 00:09:07 -04:00
3cbea099f1 i18n: add Swedish (sv) interface translation (#1219)
* i18n: add Swedish (sv) interface translation

A 2,504-entry Swedish catalog: every key of the reference catalog, the equation gallery's
labels, everything the interface audit finds, and 44 newer strings no catalog had yet (the
native print settings among them). Written from the meaning of the English labels in ordinary
Swedish computing vocabulary; no other product's localisation and no other catalog was used.
An AI-assisted draft whose terminology and running app were reviewed by a native speaker.
Counts use count-neutral wording ("21 ord").

- sv, sv-SE, sv_SE.UTF-8 and sv-FI select it automatically; "Svenska" in Options and
  ui.language with {"value":"sv"}.
- The interface audit checks Swedish the way it checks Russian, Hungarian, Turkish and
  Bulgarian.
- Tests: locales and saved preference, catalog coverage and key translations, the search
  hint's example, count wording, and the bundled Inter/JetBrains Mono faces covering å ä ö é.
- native_print: the narrow-window test pins English, since it finds controls by their English
  labels (it failed on a Swedish machine once those labels had a translation).
- The Hungarian locale test used sv-SE as an unsupported language; it now uses fi-FI.
- ATTRIBUTION, README, ROADMAP, docs/localization-parity.md and a new
  docs/localization-sv.md; a contributors/people.toml entry for @holmbom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Swedish rows for the cross-reference dialog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-15 00:03:13 -04:00
Brandon ThomasandClaude Opus 5.5 fbbf9c04eb CI: recovery tests retry a load a lock race refused (#1957)
discovery_reclaims_a_crashed_first_write_but_keeps_valid_records failed in
a full workspace run: load() found the record locked for a moment by a
process another test was starting (the race #1939 handled for scan()).
The three tests that reopen a store and load now retry for up to a second.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:52:28 -04:00
Brandon Thomasandheogun e5d9b3e7cf i18n: add Korean UI catalog and font fallback (#1216)
Korean interface catalog and Hangul font fallback by @heogun (#1216), merged with main.

Co-authored-by: heogun <mumal@MP.local>
2026-10-14 23:44:26 -04:00
3292ba05d3 Table Layout › Formula opens a Formula dialog with Word's suggested formula (#1210)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:34:17 -04:00
3f9b6a029f Table Layout › Sort opens a Sort dialog: column, order and header row (#1205)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:28:36 -04:00
60b9500347 Web: add option to load system installed fonts in dropdown (#1390)
* feat(web): add option to load system installed fonts in dropdown

Add explicit 'Load System Installed Fonts…' button in font dropdowns for web (Wasm).

- Dispatches font.loadSystem via transient user activation on click
- Calls window.queryLocalFonts() to enumerate and load installed fonts into FontDb
- Invalidates font family cache in FontPreviews upon new font installation
- Complete localization across all 18 languages
- Credit GhostMitra in contributors/people.toml

* fix(ui, engine): canvas focus after chrome click and checked file access in sniff

* Revert "fix(ui, engine): canvas focus after chrome click and checked file access in sniff"

This reverts commit b0c925bf79.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:24:19 -04:00
8d04d14d96 fix(formats): preserve leading whitespace in markdown escape_line_start (#1568)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:14:54 -04:00
1fc34fc147 Proofing: Vietnamese text under the default English language gets no marks (#1553)
A document typed in Vietnamese under the default editing language (en-US) was
covered in red underlines, every Vietnamese word checked as English. A
paragraph that reads as Vietnamese (words with Vietnamese-only letters: the
breve and horn letters, đ, ĩ ũ, the tone-marked vowels of Latin Extended
Additional, or stacked combining marks) is now treated like any language
without proofing tools: no spelling or grammar marks, as Word's automatic
language detection does. A Vietnamese name in English text (Nguyễn) is no
longer flagged either; French and Portuguese, which share â ê ô, are unaffected.

Automatic hyphenation also leaves Vietnamese paragraphs and words whole
(single syllables never take a hyphen); the English patterns happen not to
split these words today, and the guard keeps it so.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 23:12:46 -04:00
cd31095163 perf: lowercase HTML tag names during collection to avoid double String allocation (#1261)
* perf: lowercase HTML tag names during collection to avoid double allocation

Inline .map(|c| c.to_ascii_lowercase()) into the tag name iterator chain
for both open and close tags, then drop the subsequent .to_ascii_lowercase()
call. Eliminates a second String allocation and copy per tag in the
HTML tokenizer.

* rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:08:20 -04:00
3ef4d1fb9a feat(print): add orientation support and pass landscape options to spooler (#1389)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:06:58 -04:00
Raj Patel 4062db0ac2 Shortcuts: more of Word's default keys (Insert-key clipboard, F2 file keys, field and outline keys) (gap #24) (#1166) 2026-10-14 23:01:17 -04:00
aa9c31fc69 Fix blurred text in Web and Draft Layout on long documents (#1152)
* M8: keep long Web and Draft pages sharp with raster tiles

* M8: document Web Layout blur fix with before-and-after screenshots

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:54:25 -04:00
Brandon ThomasandClaude Opus 5.5 058fd1bacd CI: both recovery scan-error tests retry a scan a lock race skipped
failed_write_keeps_last_valid_generation_and_discovery_falls_back hit
the same race as #1849's test (a record locked for a moment by a child
another test spawns is skipped, so scan() is Ok). Both now share a
helper that reopens until the scan reports the unreadable record.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:28:53 -04:00
mmazzieriandClaude Opus 5.5 9d4d2ea01c Legacy VML freeforms (header rule lines) are drawn and kept on save (#1659)
* DOCX: read VML freeforms (v:shape with a path) as freeform shapes

Legacy VML shapes with their own `path` (ECMA-376 Part 4), such as rule
lines in headers, were dropped on open and lost on save. They are now
read as freeforms: m/t/l/r/c/v/x/e commands in the coordsize/coordorigin
space, nf/ns, v:fill and v:stroke opacity; arcs drawn straight, formula
references read as 0. Saved as DrawingML custom geometry.

Tests: header rule read and round-tripped; hostile paths and coordinate
spaces stay finite and never panic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DOCX: VML freeforms whose path uses formulas aren't drawn

Formulas (@n, from v:formulas) aren't evaluated; reading them as 0 drew stray lines. Parity row states the fill-opacity limit (none at 0, otherwise opaque, as for DrawingML fills).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DOCX: VML freeforms reuse the watermark's vml_fraction

The watermark reader (#1328, now on main) brought its own vml_fraction;
the freeform reader had an identical copy, which no longer compiles after
the merge. Keep one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:08:59 -04:00
Brandon ThomasandClaude Opus 5.5 850bbb5dbc Docs: merge table rows that conflict resolutions left duplicated
While landing community PRs, a few rows in gaps.md, layout-parity.md and
file-format-parity.md ended up twice (an older and a newer version).
Each pair is now one row: the newer text, plus what only the older one
said (section note options #385; allowSpaceOfSameStyleInTable).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:02:53 -04:00
Fin WarmanandBrandon Thomas 63d5faa9b2 Tables: honor consistent preferred cell widths (#1709)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 22:01:19 -04:00
Mat StrangeandBrandon Thomas 20cf5b45d0 A click on the ribbon's chrome keeps typing in the document (#1093 follow-up) (#1703)
Clicks on ribbon chrome that isn't a command button (Styles gallery tiles, tab
names, empty ribbon space, View check boxes, status-bar items) left the page
without keyboard focus. The click was caught at the end of the frame, after the
page had been drawn: want_focus was only picked up next frame and the page's
focused flag, which gates the keys, not recomputed until then either, so the
keystroke right after the click went nowhere.

Claim the keyboard in the same frame a ribbon command button does, using the
page's widget id from the frame just drawn.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 22:00:42 -04:00
Wesley LuizandBrandon Thomas 097d81a41b M15.1: print-area strings translated in every interface catalog (#1702)
* M15.1: print-area strings translated in every interface catalog

* Add leoncral to contributors (sync GitHub profile name)

* Docs: progress log for the print-area translations

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 22:00:30 -04:00
2c7629fa7a Shift+Delete cuts the selection on macOS and Linux too (#1669) (#1676)
Word for Windows (checked by hand): Shift+Backspace erases like
Backspace, Shift+Delete cuts the selection, and Shift+Delete with nothing
selected does nothing. On Windows egui-winit already turns Shift+Delete
into a Cut event, so WordCraft matched. On macOS and Linux it arrives as
the Delete key with Shift, no shortcut was bound to it, and it did
nothing, even with text selected.

The canvas now runs Cut for Shift+Delete when there is a selection,
through the same clipboard path as Mod+X, and does nothing otherwise.
Shift+Backspace already erased (#707).

Tests: Shift+Backspace erases, Shift+Delete without a selection changes
nothing, Shift+Delete on a selection cuts it to the clipboard (fails
without the change); the key check accepts Shift alone.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 22:00:11 -04:00
35e24aa436 Layout: hide labels on page-break-only list paragraphs (#1673)
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 21:59:59 -04:00
13070ca85e Layout: field instructions do not add blank paragraphs (#1665)
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 21:59:41 -04:00
MarekSpodBiedryandBrandon Thomas 4b33e7b4ab Text typed after a footnote or endnote number is no longer superscript (#1593) (#1663)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 21:59:29 -04:00
daf8caada3 Tables: repeated header rows move to the next page with the first row (#1704) (#1705)
A table with repeated header rows that started near the bottom of a page left the header rows
there alone when the first row after them didn't fit, then repeated them on the next page above
that row. Word starts such a table on the next page.

place_table now checks, before the first row, whether the row after the header rows can start
under them (whole, or split when it may split). If it can't and the page already holds
something, the table starts on the next page. A table at the top of a page keeps its header
there as before.

Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:31:10 -04:00
Mat StrangeandBrandon Thomas babb331528 Open on the Home tab every launch, as in Word (#1699) (#1700)
The active ribbon tab was saved to ui.json and restored verbatim at startup,
so relaunching resumed whichever tab was last used (Review, Help, Draw).
Word always opens on Home. apply_prefs now forces DEFAULT_TAB on load.

Going File and back to the editor still returns to the tab that was showing,
contextual tabs included; tab is still written to ui.json, so the UiState JSON
contract (MCP/control) is unchanged. resolve_tab uses the same DEFAULT_TAB so
the startup default and the contextual-tab fallback can't drift.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 21:30:55 -04:00
056771a33a engine: store replacement images under their true format extension (#1691)
* engine: store replacement images under their true format extension

* rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:30:33 -04:00
67b0428657 TOC page numbers reach the right margin of the TOC's section, and no blank line above imported entries (#1686)
Update Table on a TOC read from a file set the page-number tab at the text width of the
caret's section (after Select All, the last one: a landscape appendix pushed level-one
leaders past the margin), kept the style's number tab stops so a short entry or the last
line of a wrapped one stopped there without its leader, and left the paragraph that held the
field's start empty under the TOC heading. The width now comes from the TOC's own section,
entries without a tab of their own clear the style's left/centre/decimal stops before the
page-number stop, and a range that filled its own paragraphs under a TOC heading puts the
field at the end of that heading, where Table of Contents puts it.

Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:30:21 -04:00
Martin BallandClaude Opus 5.5 e5a055a310 CLI PDF export: Arabic and Persian fall back to the craft-fonts Arabic face (#1652) (#1668)
With no system fonts (a headless CLI), Arabic and Persian text drew as
empty boxes: the document font database only loaded the craft-fonts
Japanese and Chinese faces. Load every non-Japanese craft-fonts face.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 21:30:09 -04:00
MarekSpodBiedryandBrandon Thomas 1c814e0269 Copy puts the formatted text on the system clipboard as HTML, not only copies with equations (#1523) (#1667)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 21:29:56 -04:00
MarekSpodBiedry b53796d412 Dropping several images inserts all of them (#1592) (#1664) 2026-10-14 21:29:37 -04:00
29320804f2 RTF import: Mac OS Roman and DOS code pages (\mac, \pc, \pca) (#1653) (#1661)
* RTF import: Mac OS Roman text (`\mac`, `\fcharset77`) (#1653)

A Macintosh RTF document (`{\rtf1\mac …}`) had its `\'xx` bytes read as
Windows-1252: LibreOffice's fdo85889-mac.rtf showed "ñòó" instead of
"ÒÚÛ". Mac OS Roman (code page 10000, with the euro sign at 0xDB) joins
the single-byte code pages, and the reader uses it for `\mac` documents,
fonts with the Mac charset (`\fcharset77`) and `\cpg` / `\ansicpg` 10000.

Tests: the code page table (accents, euro, quotes) and two RTF cases, the
issue's file and a Mac-charset font in a Windows document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* RTF import: DOS code pages (`\pc`, `\pca`, OEM `\cpg`) (#1653)

With the OEM tables from #1146 now on main, the RTF reader decodes DOS
text too: `\pc` documents as code page 437, `\pca` as 850, a font's
`\cpg` 437/737/850/852/857/866 and `\fcharset254` (PC 437).

`encoding::code_page_byte` decodes a byte of document text in any code
page RTF names (Windows, OEM or Mac OS Roman; ASCII below 0x80 stays
ASCII), and `is_byte_code_page` says which those are.
`SINGLE_BYTE_CODE_PAGES` stays Windows-only, so `text.typeCode`'s `ansi`
pages are unchanged.

A `\cpg` / `\ansicpg` number out of the u16 range is now ignored; before,
`pv as u16` truncated it to another code page (65973 read as 437).

Tests: document text in DOS, Mac and Windows pages; `\pc`, `\pca` with a
`\cpg437` font, and an out-of-range `\ansicpg`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 21:29:21 -04:00
Fin WarmanandBrandon Thomas faa621104c Layout: empty line breaks do not move floating tables (#1658)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 21:22:19 -04:00
47907a820b ODT import: text after a drawing shape stays in its paragraph (#1654) (#1657)
A shape anchored in a text:p (draw:custom-shape, draw:rect, ...) had its
own text:p read as a body paragraph. That closed the paragraph the shape
sits in, and the text after the shape was dropped. LibreOffice's
MadeByLO7.odt lost three of its seven body paragraphs.

Drawing shapes are now skipped with their text, as text boxes already
are. Groups are still walked, so the pictures inside them are read.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 21:22:00 -04:00
Fin WarmanandBrandon Thomas 6528f972db Shapes: draw flowchart OR spokes (#1644)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 21:21:49 -04:00
8b0194bc23 M12: preserve document properties when importing legacy DOC and DOT (#1640)
* M12: preserve document properties when importing legacy DOC and DOT

M12: preserve document properties when importing legacy DOC and DOT


Integrate current upstream while retaining import behavior and embedded-font loading.

* docbin: as_chunks for fixed-size records (clippy::chunks_exact_to_as_chunks on Rust 1.99); rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: LabDennis <341377186+LabDennis@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:21:35 -04:00
718eb02b89 DOCX: keep row-level w:tblPrEx when saving (#1637)
Co-authored-by: Larry Hymes <19394645+lhymes@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 21:21:23 -04:00
87410b4e3f DOCX: keep w:formProt when saving (#1636)
Co-authored-by: Larry Hymes <19394645+lhymes@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 21:21:11 -04:00
skylar taylor-barrick c3ba9011a2 fix(pdf): preserve shared writer across main updates (PDFW)
Retain the shared writer and its regressions alongside the current PDF text
behavior and the equivalent bounded find-pattern condition.

PR #1558.
2026-10-14 19:18:05 -06:00
Fin WarmanandBrandon Thomas 2c26fa58a8 Draw arrowheads on open freeform paths (#1645)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 21:04:33 -04:00
Larry HymesandLarry Hymes ed980e4df7 DOCX: keep an explicit horizontal w:textDirection when saving (#1635)
Co-authored-by: Larry Hymes <19394645+lhymes@users.noreply.github.com>
2026-10-14 21:04:15 -04:00
Larry HymesandLarry Hymes 98cda1ea50 DOCX: keep a bare w:pgNumType when saving (#1634)
Co-authored-by: Larry Hymes <19394645+lhymes@users.noreply.github.com>
2026-10-14 21:04:04 -04:00
116e7697a1 View Gridlines outlines header and footer tables (#1628) (#1633)
* View Gridlines outlines header and footer tables (#1628)

Table Layout › View Gridlines drew only the body's table cells; a table
in a header or footer showed no gridlines. Walk the page's header and
footer items too.

Fixes #1628

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Test: Placed::Cell names its clip flag (#1617)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 21:03:53 -04:00
6eeb6d10db A floating object anchored after a page break stays on the page its paragraph starts on (#1632)
Word's cover pages are one paragraph holding a page break and then the
page-anchored text box with the title and the approval table. Word
draws a floating object on the page its anchor paragraph starts on;
WordCraft drew it with the line holding the anchor, which after the
break is the first line of page two, so the cover was blank and the
title box sat over the body text of page two.

`place_objects` now places a paragraph's floating objects from its first
piece, whichever of its lines holds the anchor, and none from later
pieces; inline objects still go with their own line. The wrap areas were
already registered from the paragraph's first page, so nothing changes
for text flowing around the object.

Co-authored-by: Daniele Mori <Dinasty@hotmail.it>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 21:03:41 -04:00
a8c1c228d7 i18n(it): the interface audit checks Italian; Privacy Settings wording; envelope label test (#1627)
- Envelopes: the size list was drawn as English text (`#10   4.13 × 9.5 in`)
  with a decimal point in every region. The label is now a catalog template
  (`{name}   {height} × {width} in` / `mm`) filled with the region's decimal
  separator; ru, hu, tr, bg, pl and it carry it. Regression test included.
- Italian: the 18 strings the interface audit looks up that it.tsv lacked
  (Reading Mode bar, Go To, command search, status and command messages);
  `Privacy Settings` no longer says "current".
- The audit's catalog-completeness check covers Italian too.
- docs/localization-parity.md: status line, Italian note, revision history.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 20:43:13 -04:00
Yasas SriandBrandon Thomas 4ad625eef1 Escape selects a text box or WordArt, so Delete removes it (#1538) (#1604)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 20:39:24 -04:00
63ed4daab2 Fonts: a Bidi theme slot names the theme's complex-script font (#1619)
w:asciiTheme="minorBidi" (and majorBidi, and w:cstheme) read as the
theme's Latin font, so a template's titles came out in Calibri where
Word draws Arial. Measured in Word: the slot names the theme's a:cs
font, and an empty a:cs names nothing, so the font inherited from the
style applies.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 20:35:37 -04:00
74a279d5f2 Fonts: a missing roman font is drawn in Cambria, as in Word (#1613)
WordCraft chose a substitute for a font that isn't installed from its
name alone, so an unknown brand font always came out sans. Word reads the
font table instead: measured with the same missing font, it draws it in
Cambria when w:family is roman and in Calibri when it is swiss or auto.
A report whose font table calls its brand font roman got a sans footer
where Word draws serif.

The font table's roman fonts are read into the settings, and layout looks
a missing one up as Cambria (Caladea where Cambria isn't installed). The
other classes keep the existing substitutes, which already start with
Calibri's metric clone.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 20:31:42 -04:00
fe9d560b49 Layout: paragraph spacing adds up when HTML auto spacing is off (#1612)
space after and the second's space before, as Word does by default. Word
adds them instead when the document's compatibility options include
w:doNotUseHTMLParagraphAutoSpacing. Measured in Word with five paragraphs
of 12 pt before and after: 24 pt between them with the option, 12 pt
without, the same in compatibility modes 14 and 15. A 60-page report with
the option lost 6 pt per paragraph, about 60 pt on its cover.

The option is read, laid out in the body and in boxes, and saved back.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 20:29:43 -04:00
f3f110b442 Layout: an inherited footer sizes its percentage tables by its own section (#1620)
A header or footer a section inherits (Same as Previous) keeps the
percentage widths of its tables relative to the section that defines it.
Measured in Word: a 100%-wide footer table defined by a portrait section
stays 451 pt wide, centred, on landscape pages that inherit it, and spans
the landscape width only when that section names the footer itself.
Paragraph alignment in the same footer follows the landscape page.

Found on a report whose landscape calculation pages drew their footer
table full width where Word draws it at the portrait width.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 20:25:49 -04:00
6d5cc9ef84 Layout: an empty section mark stays on a full page (#1614)
* Layout: an empty section mark stays on a full page

An empty paragraph that carries a section break, when the next section
starts on a new page, needs no room of its own in Word. Measured: 50 lines
filling a page, then that paragraph, then the next section makes two pages
in Word; WordCraft made three, the middle one blank. A plain empty
paragraph in the same place still moves on, in both.

Found after a landscape drawing page in a 60-page report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layout: drop an unneeded mut in the section mark test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 20:23:15 -04:00
1b60773196 Tables: a row with less room than its minimum height moves on whole (#1618)
* Tables: a row with less room than its minimum height moves on whole

Measured in Word with about 49 pt left on a page: a table row of eight
lines splits there, but the same row with an at-least height of 63.6 pt
goes to the next page whole, and so does one holding a nested table.
Without the height both split, as before. WordCraft split them all, so a
report's title row (a logo beside a small nested table) started at the
foot of its cover page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tables: preserve splitting for oversized minimum-height rows

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 20:19:27 -04:00
fee9b984eb Tables: text running past an exact-height row is cut off (#1617)
* Tables: text running past an exact-height row is cut off

A row with an exact height (w:trHeight w:hRule="exact") keeps that
height whatever its cells hold. Word cuts the text that doesn't fit at
the cell's edge; WordCraft drew it over the rows below, so a certificate's
identification block came out as overlapping lines.

The cell is marked as clipped in layout (Placed::Cell clip), and display
draws its lines inside that cell's clip, on screen and in PDF. Hit
testing is unchanged: the lines stay where they are in the layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tables: keep row clipping compatible with Rust 1.90

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 20:15:39 -04:00
2444e64a25 Layout: a numbered paragraph opening with a page break takes its number along (#1616)
A heading whose text opens with a page break had its list number drawn on
the line holding the break, so the number stood alone at the bottom of one
page and the heading text started the next. Word draws the number with the
text. The label now goes on the first line after a leading break.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 20:11:50 -04:00
241743b4ab Layout: a bookmark after a page break takes no line (#1615)
Word writes the next heading's TOC bookmark start into the paragraph that
ends with a page break. The line after the break then held that marker,
so it wasn't the empty mark kept with the break (#932): it went onto the
next page with the paragraph's spacing and pushed the heading 17 pt down.
Lines holding only zero-width markers (InlineObject::is_marker) now count
as empty there.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 20:08:02 -04:00
c2872f23c3 Layout: a page break followed by another page start makes one page (#1611)
A paragraph holding only a page break keeps its mark on the page before
(#932), but its space after still went onto the new page, so a heading
with page break before no longer stood at the top and broke again: a
blank page. The space after now stays with the mark. A next-page section
break carried by such a paragraph also started a second page; the empty
page the break started is now used instead.

Found on a 60-page inspection report: two blank pages Word doesn't have.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 20:04:14 -04:00
Brandon ThomasandClaude Opus 5.5 3b16face49 CI: the rtTriangle VML fixture draws a right triangle
Follow-up to #1900: the fixture test vml_preset_shapes_are_drawn also
expected the isosceles triangle for VML shape type 6 (#1599).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:00:19 -04:00
Brandon ThomasandClaude Opus 5.5 4570952069 CI: VML shape type 6 is a right triangle in the preset-mapping test
#1599 made VML spt 6 (rtTriangle) a RightTriangle; #1442's mapping test
still expected the isosceles Triangle, so it failed on main.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:54:31 -04:00
64e430354c Tabs left of the margin stay on a default stop they already sit on (Word) (#1631)
Left of the left margin Word's next default tab stop is the first
multiple of the interval *at or after* the current position, not after
it: its arithmetic truncates toward zero. So a tab that already sits on
a stop, a second tab in a row or a leading tab on a line that starts at
-72 pt, doesn't advance; a tab preceded by text or a space, or a line
starting between stops, moves to the next multiple as usual, and right
of the margin nothing changes.

Measured on Word with synthetic documents (40 positions): "a\t\tX" at
-60 pt puts X at -36 pt where the old rule gave 0; "\tX" starting at
-72 pt leaves X at -72, at -60 moves it to -36; "\t \tX" at -60 reaches
0. Letterheads that line a footer up with spaces and tabs under a
negative indent rely on it: one had its registration number drawn 72 pt
too far right, over a vertical rule. It now lands within 0.2 pt of Word.

Co-authored-by: Daniele Mori <Dinasty@hotmail.it>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 19:51:35 -04:00
Marco Arnulfo 39af5b17ca Equations: a multi-letter subscript stays whole after build-up (#1623) 2026-10-14 19:51:24 -04:00
90d8844e44 Shapes: a right triangle keeps its right angle (#1599)
DrawingML's rtTriangle was read as an isosceles triangle, so a cover
page's white corner cut-outs pointed up into a peak instead of cutting a
V into the picture above them. It is now its own shape kind, drawn with
the right angle at the bottom left, wrapped and glued along that outline,
and saved back as rtTriangle. The .doc reader maps msosptRightTriangle (6)
to it too.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:51:13 -04:00
952eb57688 Respect the image byte limit during DOC DIB conversion (#1590)
* Engine: the Word 2003 format check reads through io, so commands still never touch files themselves

is_word_2003_file (#1081) opened the file with std::fs, which the file-access guard test
(commands_do_not_touch_the_file_system_themselves) forbids outside io.rs; main failed it.
A new io::read_file_head reads the first bytes inside the read root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UI: a chrome click counts the page as focused at once, so the next keystroke isn't dropped; the ribbon-focus test passes again

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: DOCのDIB画像変換でバイト上限を守る

* Drop the duplicate read_file_head left by the merge with main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: haranishi <223786623+haranishi@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:50:54 -04:00
a3ec498f2a Italian proofing: po', qual è, È and un/un' grammar rules; more elisions in spelling (#1585)
* Italian proofing: po', qual è, È and un/un' grammar rules; more elisions in spelling

- grammar (works without a dictionary): un pò / un po -> un po'; qual'è -> qual è;
  E' -> È; un'altro -> un altro and un altra -> un'altra (also nessun, ciascun,
  qualcun, alcun), gender from the ending with masculine -ista/-ma/-cida... left alone;
  more emphatic doubles allowed (pian piano, poco poco, così così...)
- spelling: more elisions (dov'è, cos'è, anch'io, senz'altro, nessun'altra, n'ha...),
  truncations po'/mo'/be' accepted, pò and qual'è left to grammar (no double marks)
- tests: rule tests, grammar proptests, Italian suggestion proptest, It in mixed-span proptest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Contributors: credit lupant by their GitHub profile name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:50:35 -04:00
2c5249d13e All Caps draws ß as SS, as Word does (#1581) (#1583)
* #1581: WIP

* #1581: All Caps and Small Caps draw ß as SS, one character for the caret

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1581: exact caret tests, small-caps scaling, symbol runs never expand

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs after rebasing on main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:50:23 -04:00
da0370c30b Comments: deleting a comment leaves no dangling anchors or replies (#1345) (#1565)
review.deleteComment removed anchors from the body only, so a comment
anchored in a header, footer, note or text box left its CommentStart and
CommentEnd behind, pointing at a deleted definition. It also collected only
direct replies, so a reply to a reply survived with its parent gone.

Anchors are now removed from every story, and the deleted set is the whole
thread below the comment. The thread is collected from a parent-to-replies
map built once, with a seen set, so a parent cycle from a hostile file ends
and a long reply chain doesn't take quadratic time. Undo restores everything.

Regression tests: a header comment (anchors checked in the header story), a
three-level reply chain (with its text parts and Undo), a parent cycle, and a
50,000-reply chain.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:50:04 -04:00
AlikandBrandon Thomas b70ab1a6e7 Save Changes prompt takes the keyboard from the page (#1597) (#1602)
Save got focus only when nothing else had it, but the page keeps focus while typing and claims Tab and the arrow keys, so the prompt opened without a focused button and the keys moved the caret behind it. Save now takes the focus whenever none of the prompt's buttons has it.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:38:54 -04:00
ba1e28382d Layout: page numbers can restart at 0 (#1601)
w:pgNumType w:start="0" was read but laid out as 1: the page counter
was set to one less than the restart and could not go below zero. A
report numbering its cover 0 read "Page 2" on the page Word calls 1.
The restart is now the next page's number itself.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:38:36 -04:00
e9e8af517c Layout: a table's percentage width past 100% is kept (#1598)
* Layout: a table's percentage width past 100% is kept

Word keeps a preferred width over 100% of the text column (w:tblW
w:type="pct" above 5000). A cover-page footer table at 131% with the
left margin as a negative indent spans the page edge to edge in Word;
WordCraft capped it at 100%, so it ended three-quarters of the way across.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Contributors: add Fin Warman

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:38:18 -04:00
rabis7575andClaude Opus 5.5 4175d68be5 Text boxes in tables nested deeper than 16 survive edits (#1339) (#1591)
The model's paragraph walk (each_para / each_para_mut) stopped 16 tables
down, while a .docx opens with tables nested up to 24 deep and pasting can
nest them further. Text box pruning, which runs after every edit, took a
box shown only from deeper for one shown nowhere and dropped its story,
so an unrelated edit lost the box's text. The same walk decides which
boxes a copy takes along and which pasted shapes get stories of their
own, and serves reading order, the next drawing id and note conversion.

The walk now keeps its own stack instead of recursing, so it reaches
every paragraph however deep tables nest, in the same document order and
with no call stack to run out of; a paragraph on its own still needs no
allocation. The signature is unchanged (the depth argument is now
unused), so every caller builds as before.

Tests: a box 17 tables down in an opened .docx survives an unrelated
edit and a save, while a box shown nowhere still goes; boxes 17 and 40
tables down, in a box and in a header are kept, counted in reading order,
and new drawing ids go above the deep shape's; a copied deep table
pastes into another document showing its own box's text.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:37:59 -04:00
rabis7575andClaude Opus 5.5 663de991df Flat OPC: reading bounded everywhere in the file, CDATA base64 read (#1353) (#1580)
The Word XML Document (Flat OPC) reader counted only the parts it kept:
a repeated or nameless part, a child of the package that isn't a part
and anything nested inside one were read without being counted, nesting
had no cap, and a part's inline XML was copied before its size was
checked. Every child of the package now counts against the part limit
and every part's data against the part and package size limits, kept or
not; nesting and the elements of each child are capped as the XML tree
reader caps a part's; and data is checked before it is copied.

Base64 in pkg:binaryData was taken from plain text only, so CDATA and
character references were dropped and a picture written that way lost
bytes. Both now read as the same text, and an entity there is an
invalid base64 error.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:37:48 -04:00
AlikandBrandon Thomas 2a1cdbb17f Lists: Russian letter numbering draws а) б) в) instead of digits (#1577)
russianLower/russianUpper showed 1), 2), 3). They now follow Word's 29-letter sequence (no ё, й, ъ or ь; ы kept), then repeat the letter (аа, бб, ааа), as observed black-box in Word.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:37:37 -04:00
8fd4951d76 Layout: widow control moves a three-line paragraph whole (#1575)
With room for two of its three lines at the bottom of a page, a paragraph was
split two and one, leaving a widow; taking a line along would leave an orphan,
so Word moves the paragraph whole. A paragraph continued from the page before
may still take one line along from two (no orphan there).

Co-authored-by: przemek-arch <263159043+przemek-arch@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:37:18 -04:00
rabis7575andClaude Opus 5.5 1b05f9a668 .doc import: notes, headers and text boxes read their text once (#1319) (#1572)
The story tables of footnotes and endnotes (PlcffndTxt, PlcfendTxt),
headers and footers (Plcfhdd) and text boxes (PlcftxbxTxt) were read
one range at a time, and each range only had to run forwards: a table
stepping back to an earlier CP after every story pointed many notes,
header slots or text boxes at the same text, and each was read into a
part of its own (2,000 note references on one story made 1,001 copies
of it). The CPs of a well-formed PLC ascend, so a story that starts
before the end of the last one kept is now dropped (a header or footer
slot is left empty, which links it to the previous section's): every
subdocument CP becomes at most one story, and the stories together
never hold more than the file's text.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:36:59 -04:00
divyanshu2074 21f2b938e2 fix(engine): trim arguments when parsing sample text formulas (#1571) 2026-10-14 19:36:47 -04:00
divyanshu2074 8ba285cdef fix(engine): handle multi-byte UTF-8 characters before em-dash in dash_autoformat (#1570) 2026-10-14 19:36:36 -04:00
3cc0c16302 A file name that isn't valid Unicode no longer crashes wordcraft-cli or the app (#1440) (#1444)
* A file name that isn't valid Unicode no longer crashes wordcraft-cli or the app (#1440)

Both binaries read std::env::args(), which panics on a non-Unicode
argument such as a Latin-1 file name. The CLI now reads args_os(): the
file a command opens keeps its real name and opens (convert, info, text,
inspect, render, zotero, run --file), and any other such argument is an
error naming it, exit 1. The app leaves such a file out with a warning,
as it does a file that fails to open, since file.open takes its path as
text; a --control port that isn't valid Unicode exits 2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* The app opens a file whose name isn't valid Unicode too (#1440)

file.open takes its path as text but also accepts the file's bytes
under a name, as dropped files use. The app reads such a file, opens
its bytes under the lossy name and keeps the real path on the session,
so Save writes back to it. A unit test covers the open and the save.

The CLI test now checks that every command in OPENS_FILE reaches its
open with the file, and no longer pins `pos` counting a `--page` value.
ROADMAP says which saves go back to the file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* #1440: skip non-UTF-8 file name tests on macOS

APFS refuses file names that aren't valid UTF-8, so the fixtures fail
with "Illegal byte sequence" there, and the app-start test keeps running
because macOS opens a window without a display variable. The unit test
for the CLI's argument checks and the control-port test still run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:32:48 -04:00
bbdcc7c62f Proofing: status bar flags a language whose dictionary isn't installed (#1167) (#1441)
* Proofing: status bar flags a language whose dictionary isn't installed (#1167)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* vi: missing-dictionary status strings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:32:30 -04:00
aec3f50582 macOS: Word for Mac's keys for Save As, Insert Endnote, New Comment, Replace and the ribbon (gap #24) (#1438)
On macOS five of Word for Mac's default shortcuts were missing or unreachable:

- Cmd+Shift+S is Save As (it opened Apply Styles, which keeps Ctrl+Shift+S
  on Windows and Linux).
- Cmd+Option+E inserts an endnote. macOS takes Cmd+Option+D to show and
  hide the Dock, so the old key never reached the app.
- Cmd+Option+A adds a comment (Cmd+Option+M still works).
- Control+H opens Find and Replace (Cmd+H still works).
- Cmd+Option+R collapses or expands the ribbon (ui.collapseRibbon).

Keys on Windows, Linux and the web are unchanged. Tests pin the bindings
on each platform.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:32:13 -04:00
81b5602bf0 insert.picture and insert.icon leave the caret after the picture (#965) (#1437)
* contributors: add Nael-Nathanael

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* insert.picture and insert.icon leave the caret after the picture (#965)

A script that typed right after inserting a picture replaced it, so the
picture was missing from the saved file. Commands now leave the caret
after the new picture; Picture Format commands still act on it. The app
still selects a picture the user inserts, as in Word.

Fixes #965

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Select a pasted picture again, keep pictures inserted in a row (#965)

Desktop paste inserts through execute, so after the caret change it
left a pasted picture unselected. WordApp::select_inserted_picture
selects the picture just before the caret; run and the paste path call
it, paste once after the last picture so a multi-file paste keeps them
all. Tests for paste selection and for two inserts in a row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:31:55 -04:00
9c57b950b9 Page Setup: Justified vertical alignment (#1320) (#1435)
* #1320: justified vertical page alignment, in DOCX, layout.verticalAlign and Page Setup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1320: a section's last page takes its own vertical alignment; pin space-after and multi-column cases

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs after rebasing on main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:31:37 -04:00
e4e389cdb3 Free part, list and comment ids after a file's largest ones (#1338) (#1428)
New parts, lists and comments took "largest id + 1": after an id of
u32::MAX (a .json model or a .docx can carry one) that overflowed, a
panic in debug builds and a wrap to 0 in release, and list.define's
saturating_add reused the taken id. free_id gives one past the largest,
or the lowest free id once that would overflow; try_add_part,
try_add_list and try_add_levels report ids running out and add nothing,
and the commands that add parts and lists use them.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:31:25 -04:00
3370fd1ba2 JSON open repairs hostile run lengths (#1337) (#1412)
Paragraph::normalize now uses saturating sums, moves a run end that splits
a character to the next boundary, and makes the runs cover the text
exactly. Opening a .json document normalizes every paragraph in every story.

Co-authored-by: justinjohn0306 <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:31:14 -04:00
rabis7575andClaude Opus 5.5 4d2649738e Markdown import: brackets no longer turn later links in the paragraph into plain text (#1452) (#1453)
* #1336: Markdown looks up the backtick run closing a code span instead of rescanning the text

Code spans and link brackets searched the rest of the text for the run
of backticks closing a code span. Every run nothing closes (one per
length) cost a scan to the end, and the bracket scan repeated those
searches for each `[` over them, outside its look-ahead budget: 2 MB of
such runs took 2-18 s. The runs are now indexed by length once per text
and found with a binary search; what reads as a code span or a link is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1452: Markdown brackets no longer turn later links in the paragraph into plain text

The link and image bracket scans share a look-ahead budget per text, so
hostile input stays linear, but it was charged more than they read: a
`[` nothing closes took the whole budget, and every bracket pair and
every link took the rest of the text, as if a destination had been read
to the end. An interval like `[0, 1)`, a dozen references in a long
paragraph or ten links were enough to make every later link plain text.

A scan now pays for the bytes it reads and records the pairs it passes,
and the brackets still open at the end of the text, so later brackets
are looked up. A destination is read, and paid for, only when `(`
follows the `]`; one that parses costs its own length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:09:07 -04:00
spiritbearlabsandClaude Opus 5.5 a4b335f6a3 Canvas: a redrawn page goes to egui as rendered, without a round trip through straight alpha (#1450)
Each time a page was redrawn (every keystroke on it, a zoom step, a page
scrolled into view) its raster was un-premultiplied (`Rendered::to_straight`)
and handed to `ColorImage::from_rgba_unmultiplied`, which premultiplied it
again. Both `Rendered` and egui's `Color32` hold premultiplied RGBA8, so the
round trip gave back the same pixels after two more passes over the whole
page.

The raster now goes across with `ColorImage::from_rgba_premultiplied`. A
test checks every premultiplied pixel value (each channel from 0 to its
alpha, for each alpha) and every page of the sample document against the
old path: identical.

Measured on an Apple Silicon MacBook Pro (120 Hz, 2x, 100% zoom, a
1632x2112 px page), typing into the sample document: the canvas redraw per
keystroke goes from 17.2 to 11.4 ms on page 1 and from 13.0 to 7.5 ms on
page 2.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:08:56 -04:00
52190491ff Picture Format › Crop: crop handles on the canvas (#1447)
* Crop handles: Picture Format › Crop crop mode on the canvas, picture.crop takes the new size

ui.crop toggles crop mode for the selected (unturned) picture: black crop
handles trim a side or two, keeping the scale and the visible part in place;
the cropped-away part shows dimmed; Esc, Enter, a click outside or Crop again
keep the crop. A drop is one picture.crop with width/height, one undo step;
a floating picture moves by what a left/top crop trims.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Crop handles: flipped pictures trim the side under the handle, stale drags drop unapplied

Review round 1: the crop maths, the dimmed preview and the floating
position shift map the on-screen side to the source side (flip H swaps
left/right, flip V top/bottom). A drag remembers its picture and is
dropped unapplied if that picture is no longer the one in crop mode.
Sizes stay within 1-4000 pt at the same scale. Pointer-path tests drive
a real handle drag and a click outside. The dim is darker, and the
canvas keeps following a crop drag's pointer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs after rebasing on main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Crop handles: ui-parity row merged with main; #1242's Cell Size note moved to the Tables row

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:08:44 -04:00
c80f51e810 DOCX: draw VML preset shapes such as Word's right arrow (#1377) (#1442)
* DOCX: draw VML preset shapes such as Word's right arrow (#1377)

A VML v:shape that takes its geometry from a preset shapetype
(type="#_x0000_t13", o:spt) was dropped on import. Its preset number now
comes from the shape's o:spt, its v:shapetype, or Word's _x0000_tNN id,
and the presets whose geometry matches a DrawingML preset WordCraft draws
are read with their fill, outline and size (paint left out by the shape
comes from its shapetype): rectangle, rounded rectangle, ellipse,
diamond, isosceles triangle, five-point star, right arrow, line and
straight connector, heart, text box, and the flowchart process, decision
and connector. Saving writes them as DrawingML presets. Text in a preset
keeps the shape around it. MSOSPT numbers from [MS-ODRAW] 2.4.24.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* DOCX: draw unmatched VML presets as their nearest shape (#1377)

VML preset numbers now map to the DrawingML preset of the same shape and
go through preset_kind, so a preset WordCraft has no shape for (left, up
and down arrows, right triangle, callouts, four-point star...) is drawn
as the nearest one, as DrawingML presets are, instead of being left out.
The exact mappings are unchanged. WordArt text shapes, picture frames
without a picture and non-primitive shapes are still not drawn.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:08:24 -04:00
85aa9cdea4 #1376: a word too wide for the strip beside a floating picture moves on whole (#1439)
* #1376: a word too wide for the strip beside a floating object moves on whole

Text in the narrow strip beside a square-wrapped picture was split mid-word
("docu" | "mentazione") when the strip was wider than the 20pt minimum but
narrower than the word. A span narrowed by a floating object is now used only
if the line's first word fits it; otherwise the word tries the row's next span,
or goes below the object. A word wider than the whole line still breaks in
place, and the 20pt minimum measured for floating tables is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* #1376: a line after a skipped strip starts its row; word width skips hyphenation points

A line placed in a row's second span after the first was skipped was marked as
beside a line before it, so line numbering and pagination treated it as the
second half of a row. The word measured against the full line now runs to the
next real break, not the first hyphenation point, and a row with nothing on it
jumps below an object that actually crosses the column.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:08:04 -04:00
18ecff6fbb wordcraft --control checks its port (#1426) (#1436)
* wordcraft --control checks its port (#1426)

--control took whatever followed it as the port and dropped it when it
didn't parse, so `--control --sample` lost --sample, `--control abc`
started without the control channel, and a bad value cleared
WORDCRAFT_CONTROL_PORT. A flag after --control is now left for the loop,
and a missing, non-numeric, zero or out-of-range port prints an error
naming it and exits with status 2, after the loop so --version still wins.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* wordcraft --control names any negative number it rejects (#1426)

A value after --control counts as the port when it starts with a digit
or a minus and a digit, so a negative too long for i64 is named in the
error instead of left in the file list. NonZeroU16 replaces the
separate zero check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:07:45 -04:00
NathanaelandClaude Opus 5.5 e3ed7da1be #967: keep with next, keep lines together, page break before and widow control list their {"value"?: bool} (#1427)
* #967: para.keepNext, keepLines, pageBreakBefore, widowControl document {"value"?: bool}

The four commands already set the property when given a bool value and
toggle without one, but listed their params as {}. List them as format.bold
does and pin the behaviour with a test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* #967: test that the params mention value, not the whole string

Other toggles annotate it, e.g. "(omit to toggle)"; a later hint should not
break this test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 19:07:26 -04:00
rabis7575andClaude Opus 5.5 68d15dafba Layout: formats past 65,536 in one paragraph keep their own style (#1340) (#1421)
The index was a u16 clamped to u16::MAX while the style was still
appended, so every distinct format past the 65,536th in a paragraph
drew with the last one that fit: its font, size, link and tracked-change
marking. It is now a u32, like the glyph ranges beside it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:07:15 -04:00
MarekSpodBiedry 910a2bb312 Convert Text to Table refuses to drop fields or a table in the selection (#1318) (#1416) 2026-10-14 19:07:04 -04:00
MarekSpodBiedry 1024fc471b DOCX: cap the depth of header/footer parts that reference further headers/footers (#1322) (#1415) 2026-10-14 19:06:52 -04:00
MarekSpodBiedryandBrandon Thomas 81c52fecc0 Outline Promote, Demote, Move Up and Move Down add one undo step instead of two (#1363) (#1403)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:06:41 -04:00
a09162603d Styles gallery shows Word's quick styles for DOCX files that mark none, e.g. from LibreOffice (#1238) (#1394)
* #1238: Styles gallery falls back to Word's quick styles when a DOCX marks none

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs after rebasing on main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:06:29 -04:00
MarekSpodBiedry 3a201c8246 Document::delete_range checks both ends before editing, so an error leaves the document unchanged (#1350) (#1410) 2026-10-14 19:01:47 -04:00
MarekSpodBiedry d1b14504dc Restart Numbering keeps the list's own level definitions (#1351) (#1409) 2026-10-14 19:01:36 -04:00
MarekSpodBiedry 4262276418 Print to PDF and PNG export replace the file only after it is fully written (#1355) (#1408) 2026-10-14 19:01:25 -04:00
MarekSpodBiedry 825538fc8b MCP type_text reports a refused paragraph break instead of typing the next line (#1356) (#1407) 2026-10-14 19:01:12 -04:00
MarekSpodBiedry f8d06ebb27 RTF import decodes Unicode escapes in font names (#1360) (#1406) 2026-10-14 19:01:01 -04:00
7188d8ae04 Save preferences through a temporary file and log write failures (#1361) (#1404)
* Save preferences through a temporary file and log write failures (#1361)

* Move the preferences test module to the end of main.rs (clippy::items_after_test_module)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:00:49 -04:00
MarekSpodBiedryandBrandon Thomas 7591283266 Mouse wheel keeps scrolling while a text selection is being dragged (#1388) (#1402)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 19:00:37 -04:00
rabis7575andClaude Opus 5.5 cf8aea6886 xtask assets: fail when git ls-files fails, read raw NUL-separated paths (#1364) (#1401)
The attribution check spawned `git ls-files` but never looked at its
exit status: outside a usable checkout, or on a git error, the empty
output read as "no files" and the check passed with nothing checked.
A non-zero status is now an error with git's message.

The list is read NUL-separated (`-z`): without it git quotes a name
with non-ASCII letters ("assets/\303\251.png"), which matched no file
on disk and was silently left out of the check.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:00:24 -04:00
krinry f732c1a370 fix(geom): measurement parser rejects overflow to infinity during unit conversion (#1359) (#1398) 2026-10-14 19:00:12 -04:00
rabis7575andClaude Opus 5.5 b574cb9638 Paragraph dialog: leave mixed formatting alone, apply as one undo step (#1346) (#1397)
Over differently formatted paragraphs the dialog showed the caret
paragraph's values and OK gave them to every selected paragraph, through
para.rtl/ltr, para.align and para.set (three undo steps). It now reads
every selected paragraph: a field they share shows as before, a field
they differ in shows blank (number boxes empty, alignment empty, check
boxes neither on nor off, no direction picked) and OK leaves it as each
paragraph has it until the user sets it. OK sends only the fields that
changed, as a single para.set.

para.set takes "onPage": alignment and indents as seen on the page,
which each paragraph turns into its own start and end once `props` is
applied, so mixed directions get the right sides in one command. The
line spacing and space boxes no longer pull an out-of-range value from
the document (line spacing 6) into their range, which counted as an
edit.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:00:01 -04:00
31b622ad79 Scripts can't close a document with unsaved changes without force (#1268) (#1391)
* #1268: programmatic file.close on unsaved work errors unless force is passed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1268: guard file.close in every control form; force must be boolean true

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:59:41 -04:00
rabis7575andClaude Opus 5.5 7052cfc80d An empty .docx opens as a blank document that saves back to it (#1384) (#1387)
File Explorer's New › Microsoft Word Document makes a zero-byte .docx.
file.open read it as a Word package, failed, and left the untitled
document in place, so Save asked for a name. An empty file (from disk or
as bytes) now opens as the Normal-template blank document File › New
gives (region's paper, editing language) and keeps its path.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:59:30 -04:00
23e26628ed Insert Hyperlink: Link to › Place in This Document (#1234) (#1370)
* Insert › Link › Place in This Document: link.targets, links to bookmarks, headings and the top (#1234)

* Insert Hyperlink: Link to › Place in This Document (#1234)

The Insert Hyperlink dialog gets Word's "Link to:" choice. Place in This Document
lists the top of the document, its headings (indented by level) and its
bookmarks, and links to the chosen one; the text to display follows the choice
until it is typed over. It uses insert.link with `top`, `heading` or `bookmark`
and link.targets for the list.

* Docs: update the progress docs for this change

* Docs: put the new sentence in the WordCraft / evidence column

* i18n: Insert Hyperlink link-target strings in every catalog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:59:19 -04:00
2c33a66ca9 Word's text watermark in DOCX: drawn on screen and in the PDF, and kept on save (#1290) (#1328)
* #1290: read Word's VML text watermark from DOCX headers

Word's Design › Watermark is a VML shape with a v:textpath in the headers.
The reader dropped it, so screen and PDF showed no watermark. It now maps
it to the document's watermark model (text, font, fill colour,
semitransparent from v:fill opacity, diagonal when turned), which the
renderers already draw. Only Word's watermark shape (PowerPlusWaterMarkObject
id) or text centred on the page both ways counts; other header WordArt,
body WordArt and shapes inside text boxes are left alone. Writing it back
to DOCX is still missing (it was already lost on save).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1290: write the watermark to DOCX as a VML text shape in the headers

The DOCX writer dropped settings.watermark, so a watermark read from Word
(or set in WordCraft) was lost on save. Every header part now starts its
first paragraph with a VML shape (ECMA-376 Part 4) whose v:textpath carries
the text, font, fill colour, semitransparency (v:fill opacity) and diagonal
turn, centred on the margins behind the text, with the reader's
PowerPlusWaterMarkObject id so it reads back as the watermark, not a shape.
The shape type is our own: a straight path the text follows, fitted to the
box. Slots the pages show with no header of their own or linked from an
earlier section (default; first page with a title page; even pages with
even/odd headers) get a header holding only the watermark; linked sections
stay linked. A blank watermark isn't written. Check Compatibility no longer
warns about watermarks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1290: give the watermark's text path a top and a bottom edge so Word draws it

With a single line as the shape type's path, Word loaded the watermark as
WordArt (right text, size and turn) but drew nothing. Checked black-box in
Word: diagonal, horizontal and header-less documents now show it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1290: test the watermark through a Word-shaped file, table-only and even-page headers

Read → write → read of a file with Word's watermark keeps the watermark, the
headers' text and paragraphs and one shape per header; header WordArt that
isn't a watermark doesn't become one. A header holding only a table gains no
line; with odd and even pages an even header is added and later sections
stay linked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DOCX: read VML freeforms (v:shape with a path) as freeform shapes

Legacy VML shapes with their own `path` (ECMA-376 Part 4), such as rule
lines in headers, were dropped on open and lost on save. They are now
read as freeforms: m/t/l/r/c/v/x/e commands in the coordsize/coordorigin
space, nf/ns, v:fill and v:stroke opacity; arcs drawn straight, formula
references read as 0. Saved as DrawingML custom geometry.

Tests: header rule read and round-tripped; hostile paths and coordinate
spaces stay finite and never panic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DOCX: VML freeforms whose path uses formulas aren't drawn

Formulas (@n, from v:formulas) aren't evaluated; reading them as 0 drew stray lines. Parity row states the fill-opacity limit (none at 0, otherwise opaque, as for DrawingML fills).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DOCX: VML freeforms reuse the watermark's vml_fraction

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1290: move the VML freeform reader out of this PR

The three VML freeform commits (b7a6cc7, d8fa665, 945cbb4) aren't part of
#1290; they go to their own pull request so this one stays on the
watermark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 18:59:01 -04:00
a21b069390 fix(ui): drag a Behind Text picture or shape over the text it overlaps (#1313)
A picture with Wrap Text > Behind Text could not be dragged where it overlapped text, in the body or in a header/footer. Pressing it did nothing; once it was selected some other way (Selection Pane, arrow keys) the arrow keys moved it, but the mouse never started a drag. Reported from the field (#82).

Root cause: the hit test deliberately lets body text win over an object behind it (crates/layout/src/hit.rs: 'objects behind the text only where there's no text'), so objects::pointer never began a frame drag.

Fix: grab_at/pointer now treat a Behind Text picture, shape or group as a drag candidate even where text covers it, in body and header/footer stories. The press does not take the gesture at once: it becomes an object drag only after the pointer passes the drag threshold (pending_behind), so a plain click still places the text caret in the overlapping text. Text boxes are unchanged (their inside is their text; only their border grabs them).

Verified with three egui_kittest UI tests driving real pointer events, plus a headless ui_shot run against the real app:
- floating_picture_can_be_dragged_in_front_of_or_behind_text
- floating_header_picture_can_be_dragged_in_front_of_or_behind_text
- behind_picture_dragged_over_text_without_being_selected_first
Full wordcraft-ui-egui suite: 365 passed.

Docs: ROADMAP progress log and revision, docs/ui-parity.md, docs/gaps.md (#3).

Co-authored-by: Muthiullah Hib <thye911@mail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 18:58:44 -04:00
Eduardo SoaresandBrandon Thomas 41cf3c7563 Restrict Editing: filling in forms opens fillable content controls (#1077) (#1309)
* #1077: forms protection fills content controls (w:edit="forms")

* #1077: forms walk stays conservative for unclosed control markers

* #1077: forms protection edges — nested locks, orphan markers, tables, empty interiors

* #1077: forms protection — formFields state and Find Next Region over fillable controls

* #1077: forms protection round-trip, password guard, docs

* #1077: final review fixes — lock-param gate, per-list pairing, docs, pane perf

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 18:58:25 -04:00
f47cd975ae UI: view-mode tooltips, SmartArt labels and TOC style names follow the interface language (#1272)
* UI: view-mode tooltips, SmartArt labels and TOC style names follow the interface language

Found while translating the interface:
- The status bar's Web/Print/Read view buttons showed their internal ids (`readMode`) as tooltips;
  they now show the translated names ("Web Layout", "Print Layout", "Read Mode").
- SmartArt layout tiles drew each label on one line, so long labels (and long translations) ran out
  of the 96 px tile. A label wider than the tile now breaks at the space nearest its middle; the tile
  is 96 px high to make room for the second line.
- Table of Contents › Options listed built-in styles (Normal, Title, Caption …) in English in every
  language; they now use the same display names as the Styles pane. The document keeps its names.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* TOC options: the style list's scroll bar takes its own room

A floating scroll bar was drawn over the level boxes and hid their right edge. The list now uses a
solid scroll bar, so the list is as wide as its widest label and level box plus the bar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 18:58:07 -04:00
ahutsunshinandBrandon Thomas 2ffe707b03 refactor: deduplicate ZIP and OLE magic number constants in io.rs (#1260)
Both signature_contradicts and open_by_content defined identical local
ZIP and OLE magic number consts. Extract them to module-level ZIP_SIG
and OLE_SIG with doc comments so there is a single source of truth.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 18:57:56 -04:00
e0a1346590 Layout: inside/outside floats swap sides on even pages; Page Setup shows mirror margins (#1181) (#1239)
* Layout: inside/outside floats swap sides on even pages; Page Setup shows mirror margins (#1181)

Floats aligned inside or outside, or placed relative to the inside or
outside margin, were always put as on an odd page, so shapes Word puts
in a book's outside margin all came out on the right. They now follow
the page they're on (ECMA-376 ST_AlignH, ST_RelFromH): on an even page
the inside is the right side. Floating tables (w:tblpXSpec) too.

With mirrored margins, even pages are laid out as odd ones and then
shifted; float positions now allow for that shift, so floats placed
from the page edge stay put (the "known limit" in #452) and the left
and right margin areas are each page's own.

Mirrored margins read from a file were invisible in the UI: the Margins
menu ticks Mirrored when they're on, and Page Setup gets a Mirror
margins check box (labelling the margins Inside and Outside), applied
through a new `mirror` param of layout.margins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n: Mirror margins in every catalog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 18:57:44 -04:00
Brandon ThomasandClaude Opus 5.5 cf140ae600 CI: Select All + Delete test presses Select All until the document is selected
#1202's test selected all once with the caret in a table; since #1478,
Select All in a table grows a step at a time (cell, row, table,
document), so one press selected only the cell and the table stayed.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:25:20 -04:00
5f2e18e1cd fix(ui): prevent options pane labels from overflowing (#1266) (#1301)
* fix(ui): prevent options pane labels from overflowing (#1266)

Clip each navigation label to its fixed row while preserving the full
accessible name, click region and hover/selection backgrounds.

Cover real painting bounds and row interactions in English and Russian
at 100%, 125% and 150% interface size with 2x native display scaling.

Co-Authored-By: Codex <noreply@openai.com>

* Test: Options nav labels under the absolute interface size (#1142)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Zaxaerith <189452125+Zaxaerith@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:17:18 -04:00
neronek22andneronek22 ae8048e3f1 Improve Polish spelling suggestions for final consonant devoicing (#1298)
Co-authored-by: neronek22 <aantoszek@gmail.com>
2026-10-14 18:17:07 -04:00
40cf85c25b Insert › Bookmark: Delete and Go To, laid out as Word's (#1236) (#1277)
* Insert › Bookmark: Delete and Go To, laid out as Word's (#1236)

The Bookmark dialog had only Add: a bookmark could not be deleted, and a
click in its list jumped to the bookmark at once. It is now laid out as
Word's (observed in Word 2019 on Windows): the name box over the list, Add,
Delete and Go To beside them, Sort by Name or Location and Hidden bookmarks
below. A click in the list picks a name; Add (Enter) adds and closes; Delete
and Go To leave the dialog open, and after a delete Cancel reads Close. The
dialog opens on the bookmark around the caret, and Add is off for a name
the command would refuse.

insert.deleteBookmark {name} removes a bookmark's start and end wherever
they are, also across paragraphs, as one undo step, keeping the selection
on its text; from the command search without a name it opens the dialog.
Adding a bookmark under a name in use goes through the same removal: it
looked for the old end only in the start's paragraph, so a bookmark across
paragraphs left a stray end behind. edit.goto with a bookmark selects its
text, as Word's Go To does, instead of placing the caret at its start.

Closes #1236

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Insert › Bookmark: Ctrl+click on a link to a bookmark keeps a caret (#1236)

Word's Go To selects a bookmark's text, but Ctrl+click on a link to a
bookmark only puts the caret at the bookmark's start (checked in Word 2019
on Windows). Following a link runs edit.goto, which now selects, so it
collapses the selection to its start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Resolve the doc conflict markers left by the previous merge

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 18:16:55 -04:00
NetterSilverandBrandon Thomas 44787764a9 Table Layout: the Cell Size boxes apply to the whole selection (#1242) (#1276)
* Table Layout: the Cell Size boxes apply to the whole selection (#1242)

table.rowHeight and table.columnWidth read the caret's cell only. With the whole
table selected the caret is in the last cell, so only the last row's height and
the last column's width changed.

Both now use the cells the selection covers (selected_cells): every selected
row gets the height and every grid column the selected cells touch gets the
width, as one edit. Without a selection that spans cells they still change the
caret's row and column.

* Docs: update the progress docs for this change

* Docs: put the new sentence in the WordCraft / evidence column

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 18:16:35 -04:00
ahutsunshin 48ab796792 feat: add SVG and ICO MIME types to mime_of helper (#1262)
The mime_of helper is missing SVG (most common web vector image) and
ICO formats, causing SVG data URIs and HTML img references to be marked
as generic application/octet-stream. Add image/svg+xml and image/x-icon.
2026-10-14 18:16:16 -04:00
7658819bf7 Desktop: a window too big for its screen shrinks to fit when it opens (#1215) (#1253)
eframe shrinks the start size to the monitor's, but not by the frame, the
title bar or the taskbar, and Windows places a new window away from the
screen's corner. On a laptop screen the first window's bottom, with the end
of the scroll bar, sat under the taskbar or past the screen's edge, so a
drag of the scroll bar stopped where the pointer met the edge. The window
was saved that size and opened so again on every launch.

On the frame that first knows the monitor, a window that doesn't fit with
48 pt to spare on each side (WindowGeometry::fit_on_monitor) gets a smaller
size, never below the minimum, and is centred. A window the system placed
counts its position too; a restored one, maybe on another monitor, only its
size. Maximized, full-screen and minimized windows are left alone. Checked
on Windows with no saved settings: the window now opens wholly on screen.

Closes #1215

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 18:16:05 -04:00
e5780698f2 Accessibility: Windows Magnifier follows the caret onto a new line (#1119) (#1247)
Windows Magnifier keeps the text cursor in view through UI Automation: the
caret's own box has no width, so it takes the box of the character after
the caret, and when that has none either, of the two characters before it.
The canvas described a line's end mark (the paragraph mark, a line break)
with no width, so on the empty line Enter or Shift+Enter makes Magnifier
looked at the end of the line above and stayed there until two more
letters were typed.

A line's end mark now takes the room a selection shows a paragraph mark
with: PARA_MARK_WIDTH (5 pt, now shared with selection_rects) at the line's
visual end (Line::mark_box). Checked live with Windows Magnifier, its view
read with MagGetFullscreenTransform: after Enter and Shift+Enter it moves
to the new line at once.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 18:15:46 -04:00
ahutsunshinandBrandon Thomas 5ffd7a99f5 fix: remove unreachable empty-string branch in open_bytes_with match (#1258)
The match arm included | "" but ext.is_empty() is already handled above
by the guard that routes empty extensions to open_by_content. This branch
could never be reached, making it dead code that misleads readers.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 18:15:27 -04:00
78c37cafa7 Cut copies to the system clipboard from every control (#1256)
* Cut copies to the system clipboard from every control

The ribbon, the context menu and the native menu's Cut deleted the
selection but wrote nothing to the system clipboard: only egui's Cmd+X
Copy/Cut event put the text there, in each UI caller that handled it.
Move the write into WordApp::run (execute), where edit.copy and edit.cut
already return the text, and drop the per-caller copies so nothing writes
twice.

Regression test: run edit.cut and edit.copy and check the text reaches
the context's clipboard output.

* Test Cut publishes to the clipboard from every entry point (#1265)

The one clipboard sink is WordApp::run; assert the ribbon button path
(run_from_ribbon) and a real Cut key event both reach it, alongside the
existing generic-dispatch test the context menu, native menu, command
search and CLI/MCP all go through.

* rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:15:15 -04:00
ahutsunshin 36553301d8 fix: remove dead-code unwrap_or magic string in percent_decode (#1257)
Replace from_utf8(h).unwrap_or("zz") with .ok() and let_chaining.
The fallback was unreachable because h is always valid UTF-8 (it comes
from slicing a valid UTF-8 input string). The magic string "zz" made
the intent unclear to readers.
2026-10-14 18:01:25 -04:00
.CooCooandBrandon Thomas 00c8ee3407 Fonts: the interface CJK fallback skips faces without outlines (#1250)
* Fonts: the interface CJK fallback skips faces without outlines

The interface CJK fallback has listed PingFangUI.ttc from
FontServices.framework as its last macOS candidate since #495. The skrifa
crate reads glyph outlines only from the glyf, CFF, CFF2 and VARC tables.
On macOS 27.0.1 the 32 faces in PingFangUI.ttc have none of them and keep
their outlines in Apple's hvgl table. pick_face counts coverage from each
face's cmap alone. The file's first face maps all 1,114 CJK characters of
the zh-hans and zh-hant catalogs. When no earlier candidate maps all of
them, find_ui_font returns that face for a Chinese interface built without
craft-fonts, and the interface would draw the characters it takes from
that face as blank space.

pick_face now skips a face with none of those four tables, and find_ui_font
goes on to the next candidate. VectorCraft's font scan has the same check
(storytold/vectorcraft#781).

* Contributors: add dotCooCoo (Robert Lee)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 18:01:13 -04:00
fabiodalez-devandBrandon Thomas d129fa2daa i18n(it): translate the 24 strings other catalogs added since #598 (#1244)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 18:00:54 -04:00
Jeremy YaoandClaude Fable 5.1 c3bdcd73b4 Harden against hostile-document DoS and non-web link export (#1235)
* Harden against hostile-document DoS and non-web link export

A security pass found several small files that make the app allocate or
compute without bound when opened (GUI, wordcraft-cli, or the MCP server),
and link targets leaving on export without the scheme check the HTML and
Markdown writers already apply. None lets a document run code or read files;
all are denial-of-service or credential-leak-on-click. Each fix bounds an
attacker-controlled size and lands with a regression test, per the
never-crash standard.

- layout: a table repeats its header rows on every page it spills onto, so
  many tall header rows paginate to O(headers x pages) placed items. Cap the
  total header-row repeats per table (lib.rs). 2 KB docx: 1.1 GB -> 124 MB.
- formats/model, html: an HTML table of many <tr> with large rowspan expanded
  to rows x 64 covered cells, and `model::table` pre-allocated a full
  rows x cols grid only to clear it. Cap rows at MAX_ROWS before
  insert_covered, bound its total work, and build the grid template with one
  row. 640 KB html: >1.5 GB -> 214 MB (a plain large table is faster too).
- docx/read/freeform: a custom-geometry Bezier segment took any number of
  points, making de Casteljau flattening quadratic. Cap points per segment.
  3 KB docx: 12 s -> 0.15 s.
- docbin: footnote/header/text-box story tables and the piece table can name
  the same backing text many times. Hold the total characters materialised to
  twice the WordDocument stream size (a ceiling no real file reaches, since
  each char costs >=1 stream byte). 45 KB doc: >1.2 GB -> 14 MB.
- render, pdf, formats/rtf: document pictures were decoded with no dimension
  limit, so a tiny file declaring a huge image could allocate before the size
  check. Decode through image::Limits with a side cap first.
- doc/link, formats/rtf, odt, latex: route exported hyperlink targets through
  is_safe_href (as HTML/Markdown already do), and extend it to reject
  protocol-relative (//host) and UNC (\host) references. Keeps file:,
  javascript:, ms-msdt: and UNC targets from being written into exported RTF,
  ODT, LaTeX and PDF where a reader would follow them (UNC leaks NTLM on
  Windows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Align picture-decode limits with the bounds already in force

Review of the previous commit: `image::load_from_memory` and
`load_from_memory_with_format` already applied `Limits::default()`, whose
512 MB allocation cap bounded every picture decode. The PDF change had set
`max_alloc = None`, loosening that; restore the default. The render and RTF
side caps (16 384 px) newly rejected wide-but-short pictures that fit in
memory, so render now uses the pixmap's own `u16` side limit (what
`premul_pixmap` enforced after decoding, applied before it instead) and the
RTF re-encode keeps only the explicit allocation cap. Net effect: rejection
moves to header time with no change to the sizes accepted. Also fix a
comment: the header-row repeat cap is per table, not per document.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-14 18:00:35 -04:00
Marco Arnulfo 1190fa8881 Ctrl+wheel zoom keeps the place under the pointer instead of scrolling the page away (#1226) 2026-10-14 18:00:23 -04:00
Marco Arnulfo 6dd027fcfa Ctrl+Q resets the paragraph without also closing the window (#1220) 2026-10-14 18:00:11 -04:00
Marco ArnulfoandBrandon Thomas 0c5804767c Layout menus mark the current margins, orientation, size and columns (#1214)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 17:59:59 -04:00
Marco Arnulfo f7dc68d543 Navigation pane headings align left, indented under their parent (#1204) 2026-10-14 17:59:47 -04:00
9710db73da Right-click on a table of contents entry offers Update Field (#1203)
* Right-click on a table of contents entry offers Update Field (part of #1197)

* Test: the TOC context menu check allows more than one Paste item

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:59:36 -04:00
Marco ArnulfoandBrandon Thomas 70a4917973 Tables: Select All then Delete removes a table the document starts with; right-click Delete offers Delete Table again (#1202)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 17:59:17 -04:00
8ae23cee29 Desktop: a maximized window no longer flashes on and off at start-up on Windows (#23) (#1194)
* Desktop: a maximized window no longer flashes on and off at start-up on Windows (#23)

winit shows a window the moment it creates it maximized, even though eframe starts it hidden
until the first frame. A window saved as maximized therefore appeared and disappeared about four
times in a third of a second before settling. On Windows it now opens at its normal size, still
hidden, and the first frame maximizes it (WindowGeometry::maximize_after_open). Other platforms
are unchanged. Measured by polling the window's visibility every 15 ms: before, show/hide four
times; after, hidden until the first frame, shown once.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Contributors: add my display name

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 17:59:04 -04:00
Marc-Antoine Girard 6655ba675d UI: fix Select icon polygon rendering (#1193) 2026-10-14 17:58:44 -04:00
Marco Arnulfo 01efd53aea Modify Style picks its font from the font box the ribbon uses (#1187) 2026-10-14 17:58:30 -04:00
Marco ArnulfoandBrandon Thomas b309af4148 Keytips: Alt, J, T and Alt, J, L open the table tabs like Word (#1185)
Table Design and Table Layout were in the keytip tab list inside a table but had no letter, so no badge showed and no key reached them. They now take Word's two-letter badges, JT and JL: J waits for the second letter and narrows the tab badges to the J ones, then the tab's control badges show as on any other tab.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 17:58:18 -04:00
9f7330664b Word forms keep their legacy form fields and rows' skipped grid columns when saved (#1184)
* Word forms keep their legacy form fields and rows' skipped grid columns when saved (part of #1174)

* Tests: field initializers name the new form field

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:58:07 -04:00
Brandon ThomasandClaude Opus 5.5 373df5adf1 CI: recovery pruning test retries a scan that a lock race skipped
failed_pruning_cannot_push_a_record_beyond_the_discovery_limit failed
once in a full workspace run: a record whose lock is briefly held (by a
child another test is spawning, which shares the descriptor until it
execs) is skipped rather than reported, so scan() returned Ok. The test
now retries for up to a second before asserting.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:39:06 -04:00
b3af58602b STYLEREF review follow-ups: headers stay cached, switch labels in our own words (#1286) (#1294)
* #1286: key STYLEREF header caches on their per-page results; STYLEREF N finds a localized heading N

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1286: reword the STYLEREF switch labels in our own words, in every catalog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1286: drop a stray table row from the ROADMAP progress log

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1286: layout-parity STYLEREF row (carried over the rebase)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* vi: STYLEREF switch labels as reworded

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 17:22:36 -04:00
ReesandBrandon Thomas bfacaf3627 fix(layout): preserve legacy table paragraph spacing (#577)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 17:22:17 -04:00
3d8f8144d9 File: recover unsaved work after a crash (#518) (#586)
* M10: retain local crash recovery on current main

* i18n: Backstage Recover in every catalog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:21:57 -04:00
92c0036d72 Russian proofing: spelling, suggestions, grammar and word division (#894)
* Russian proofing: spelling, suggestions, grammar and word division

Runs tagged ru get Russian tools instead of none:
- spelling against an installed ru_RU Hunspell dictionary (not bundled;
  LibreOffice dict-ru folders searched; KOI8-R and Windows-1251 decoded),
  with ё optional, hyphenated words and solid compounds understood, не with
  adjectives, participles and verbal nouns, prefixed verbs, abbreviations
  with endings and camel case left alone, plus our supplement of modern
  words with their declensions (assets/spelling/ru-extra.txt);
- suggestions tuned for Russian: е/ё, letter groups, common confusions,
  missing spaces, typos and a stem scan;
- grammar rules with Russian messages (repeated words, spaces, sentence
  capitals with Russian abbreviations, likely missing commas before
  clause openers, doubled punctuation);
- Russian word division from the spelling rules, written as code.

* Options: Russian in the dictionaries list (ProofLang::Ru arm)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Магда М.Г. <mail@mgmagda.ru>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:21:39 -04:00
ReesandBrandon Thomas 248dae4bd1 fix(layout): prevent list labels from inflating line height (#601)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 17:21:20 -04:00
575c5f98df .doc: open password-protected Word 97–2003 files (RC4, RC4 CryptoAPI, XOR) (#1079) (#1087)
docbin decrypts XOR-obfuscated, RC4 and RC4 CryptoAPI files after verifying the
password against the stored verifier; the engine reuses the .docx password
prompt errors. Written from [MS-DOC] and [MS-OFFCRYPTO]; synthetic tests only.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 17:21:08 -04:00
eb54d88ec0 Mail merge: .xls recipients, legacy encodings, Filter and Sort (#778) (#885)
* #778: .xls and legacy-encoding recipient lists; filter and sort recipients

- Recipient lists: legacy Excel 97-2003 .xls (BIFF8 reader on the cfb crate already in the
  tree); Excel 5.0/95 and password-protected .xls refused with a clear message; a CSV named
  .xls reads as text, an HTML one gets an error. .xls added to the picker filters (desktop,
  web, built-in browser) and drop handling.
- Text lists (and plain-text import) detect legacy encodings when not UTF-8/UTF-16: GBK/GB18030,
  Big5, Shift_JIS, EUC-KR, Windows code pages (encoding_rs + chardetng, MIT/Apache); an
  `encoding` param overrides.
- mailings.filterRecipients / mailings.sortRecipients: field, comparison (equal, notEqual,
  less, greater, lessOrEqual, greaterOrEqual, blank, notBlank, contains, notContains), value,
  And/Or joins; sort keys. Preview, Finish & Merge and labels use only the matching records
  in sort order. Edit Recipient List edits every record and keeps the filter.
- Recipient List dialog: Filter and Sort section and a merge-order column; catalog rows;
  strings in every catalog; docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n: Hungarian and French strings for this change

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review fixes for #885: total order for recipient sort, Bulgarian strings

- compare_cells now ranks numbers (compared numerically) before text (compared
  case-insensitively), so it is a total order; a column mixing 9, 10 and 1a
  could make slice::sort_by panic. Regression test sorts mixed columns.
- Bulgarian catalog: the 24 Filter and Sort strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* vi: mail merge Filter and Sort strings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 16:57:06 -04:00
cf9a4ff7fa STYLEREF fields show each page's result in headers and footers (#775) (#882)
* #775: STYLEREF fields in headers and footers show each page's result

Running headers/footers with STYLEREF (e.g. the current chapter title) are
evaluated per page at layout time (ECMA-376 §17.16.5.59): the page's first
paragraph of the style (\l: last), else the nearest before, else after.
Style names/ids, heading levels (STYLEREF 1), character styles, and the
\n \r \w \s \t \p switches. Body STYLEREF fields update with the other
fields. STYLEREF added to Insert › Quick Parts › Field with its options.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #775: STYLEREF error text in our own words

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Review fixes for #882: translate the STYLEREF Field dialog strings

Adds the style picker label, the six STYLEREF switch labels and the
STYLEREF description to all 18 interface catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* STYLEREF: reuse the style index while the body and styles are unchanged

Headers with STYLEREF rebuilt and hashed the body's style index on every
layout pass (echelon's follow-up note on #882). The layout cache now keeps
the index with the body blocks it came from (shared, so an edited block is
a different pointer) and the styles/numbering hash, and rebuilds it only
when either changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* vi: STYLEREF field strings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 16:56:47 -04:00
ReesandBrandon Thomas 8e0f42e4bc fix(layout): match Word's contextual paragraph spacing (#568)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 16:56:29 -04:00
Valentin KleinandClaude Opus 5.5 f822dcb23c macOS packaging: strip extended attributes before signing and verify the DMG's app strictly (#525) (#527)
hdiutil makehybrid -hfs gave every file and folder in the image a
com.apple.FinderInfo, so WordCraft.app failed codesign --verify --deep
--strict inside the released DMG and after copying it out with ditto.

- Build the DMG with hdiutil create -srcfolder -fs HFS+ -format UDZO
  (retried: attaching a device is flaky on CI runners).
- Clear extended attributes on the bundle before signing.
- After signing the DMG, mount it read-only and strictly verify the app
  inside it and a ditto copy of it (plus spctl when notarizing), so the
  release job fails if this regresses.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:56:10 -04:00
Gert Pellin - GPEandBrandon Thomas 1e4ed377d8 README: install on Gentoo from the ::snakebyte overlay (#16)
* README: install on Gentoo from the ::snakebyte overlay

* Contributors: credit switch87 as Gert Pellin

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 16:55:59 -04:00
2686e20330 Docs: gaps.md re-audited against main (#1071)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 16:55:40 -04:00
3e20488c15 Comments: an option to show only the selected text's comment (#1055) (#1066)
* test(engine): drop a needless drop() that clippy 1.97 rejects (drop_non_drop)

* Comments: a click inside a selected balloon moves the caret in its text (#1013)

- The canvas under the markup area also "contains" the pointer, so a press on the balloon counted as a click on the page and left it
- Presses over a balloon card now belong to the card, in both the deselect check and the document click in mouse()
- Test: press, drag and release inside the selected balloon; a click on the page still leaves it

* Comments: an option to show only the selected text's comment (#1055)

- Track Changes Options › Comments: "In the margin" (default, as in Word) or "Only for the selected text"
- The second mode takes comments out of the margin column; the comment under the caret, or the clicked one, floats beside the page, over its edge in a narrow window
- The floating card has its own foreground layer, so presses on it never reach the canvas
- review.trackingOptions takes commentDisplay: margin|selected
- comment_range_at_caret: the comment whose range holds the caret
- Strings in all catalogs; tests for the option, the helper and the card's clicks

* vi: comment display options

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:55:27 -04:00
77da439d0b DOCX: carry unmodelled parts; keep comment ids, style defaults, latent styles, list ids and app props (#1034) (#1060)
* DOCX: merge settings.xml into the original instead of regenerating it (#1034)

A save regenerated word/settings.xml from the model, so every setting the
model doesn't hold was dropped: compatibility options (lost in 18 of 18
real-world Word files), rsids, document variables, proofing state and the
colour scheme mapping.

The reader now keeps the original part when it holds such settings, and
the writer merges into it: the original root and every unowned child stay
byte for byte; owned children follow the model in CT_Settings order; in
w:compat only compatibilityMode is replaced. The privacy options are the
model's since #1062, so they are owned too. Kept children that point at a
relationship (a mail-merge data source) are dropped. Anything unexpected
falls back to the generated part. Files WordCraft wrote itself keep
nothing extra.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* DOCX: carry unmodelled parts and keep comment ids, style defaults, latent styles, list ids and app props (#1034)

Builds on the settings.xml merge.

- Parts the package or main document relate to that WordCraft neither
  models nor writes (webSettings, stylesWithEffects, people, add-in task
  panes, the thumbnail, future parts) are carried verbatim with their
  relationships and content types; relationship types the writer
  produces, and any the body refers to by id, are not (HANDLED_RELS).
- commentsIds / commentsExtensible: durable ids and UTC dates are read
  into the model and regenerated against the written paragraph ids.
- w:default on localized default styles, w:latentStyles, lists'
  nsid/tmpl/multiLevelType, and app.xml Template/Company/Manager/
  HyperlinkBase round-trip; counts are not carried, and Company/Manager
  are dropped when the file asks to remove personal information.
- Comment reference marks use the document's "annotation reference"
  style id instead of a hard-coded CommentReference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: przemek-arch <263159043+przemek-arch@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 16:55:15 -04:00
Brandon ThomasandClaude Opus 5.5 2215e52aa4 CI: park the mini-toolbar scroll test that #1501 and #1069 disagree on
#1069's the_bar_scrolls_under_the_ribbon_not_over_it expects the bar to
slide under the ribbon with a selection scrolled above the document;
#1501 (landed first) anchors the bar to the visible selection only, so
the selection has no anchor there and the test fails on main. Ignored
with the reason until the owner picks the behaviour.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:37:03 -04:00
e1f34a90d6 Proofing: a Dictionaries folder setting, and the dictionary each language found (#1063) (#1084)
File › Options › Proofing gets a Dictionaries folder: a folder of Hunspell
.aff/.dic files searched before the system locations, saved with the settings
and applied at start-up. An app started from Finder or the Dock doesn't see
WORDCRAFT_DICTIONARIES, so this is how it finds a dictionary kept in a folder
of the user's own. Changing it reloads the Polish and Portuguese dictionaries
and checks the text again. The pane lists the dictionary file each language
found, or that none was found, so missing spelling marks are explained.

Agents set it with file.options {"dictionariesFolder": path | "auto"}; a
missing folder or a relative path is refused and changes nothing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 16:25:00 -04:00
Todd PedersonandClaude Opus 5.5 b8e46b244d UI: combo boxes join their dropdown arrow (field rounded left, arrow right) (#1083)
* Clippy 1.96: simplify boolean guards, drop a needless drop()

Rust 1.96's clippy flags nonminimal_bool in find_pattern, table_mouse and
table_resize, and drop_non_drop in a ribbon test; `cargo xtask ci` failed at
clippy on main. Same logic, rewritten as clippy suggests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UI: combo boxes join their dropdown arrow (field rounded left, arrow right)

The font and size boxes looked apart from their arrows: the text field was
rounded on all four corners and the arrow on none, leaving a notch where they
meet. The field now rounds only its left corners (in every widget state,
including focus and hover) and the arrow only its right ones, so the two read
as one control. Applies to every combo drawn by widgets::combo/font_combo:
the ribbon's font, size and outline-level boxes, the mini toolbar, and the
Font and Symbol dialogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UI: one divider between a combo's field and its arrow

The field's right border and the arrow's left border stood side by side, a
divider twice as wide as the rest of the outline. The arrow's box is now
painted under the field and reaches one border width beneath it, so the
field's right border is the only line between them (in the field's focus
colour while typing). The test draws with the app's light and dark themes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:24:40 -04:00
Sazid Al BayazidandBrandon Thomas 3962d397e5 Contributors: add Sazid Al Bayazid (albayazid) to people.toml (#1074)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 16:24:28 -04:00
3e9dde7799 Docs: encrypted DOCX row, duplicate document-grid rows, dated issue count (#1072)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 16:24:09 -04:00
a04356933c UI: mini toolbar steps aside for the ribbon (#1069)
* Clippy 1.96: simplify boolean guards, drop a needless drop()

Rust 1.96's clippy flags nonminimal_bool in find_pattern, table_mouse and
table_resize, and drop_non_drop in a ribbon test; `cargo xtask ci` failed at
clippy on main. Same logic, rewritten as clippy suggests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UI: mini toolbar steps aside for the ribbon

The mini toolbar covered the ribbon's drop-downs (the font size list opened
under it). A press outside the document and the bar (the ribbon, a pane, the
rulers, the status bar) now puts the bar away until the selection changes or
the next press in the document; presses on popups (the bar's own lists, an
open ribbon list over the page) neither hide nor restore it. The bar also
moves from the Tooltip layer to Middle, so every popup and menu draws over
it, as its comment always said.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UI: mini toolbar scrolls under the ribbon, not over it

With text selected, scrolling the selection up toward the ribbon carried the
mini toolbar along on top of the rulers and ribbon. The bar is now clipped to
the document area: it slides under the rulers and ribbon (or the status bar)
with the text and goes once none of it shows. Its area is constrained to the
document for hit testing too, so the hidden part no longer swallows clicks
and drags meant for the ruler or the ribbon underneath.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 16:23:50 -04:00
gossardlaandBrandon Thomas 896c1bbebf Comments: a click inside a selected balloon moves the caret in its text (#1013) (#1051)
* test(engine): drop a needless drop() that clippy 1.97 rejects (drop_non_drop)

* Comments: a click inside a selected balloon moves the caret in its text (#1013)

- The canvas under the markup area also "contains" the pointer, so a press on the balloon counted as a click on the page and left it
- Presses over a balloon card now belong to the card, in both the deselect check and the document click in mouse()
- Test: press, drag and release inside the selected balloon; a click on the page still leaves it

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 16:23:31 -04:00
Brandon ThomasandClaude Opus 5.5 a4870128b5 CI: Hebrew font substitution test expects David only on Windows
#1052's test asserted that every Hebrew font falls back to David, but
only the Windows fallback list has it, so it failed on Linux and macOS.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:11:27 -04:00
d2ff87856f DOCX: merge settings.xml into the original instead of regenerating it (#1034) (#1059)
A save regenerated word/settings.xml from the model, so every setting the
model doesn't hold was dropped: compatibility options (lost in 18 of 18
real-world Word files), rsids, document variables, proofing state and the
colour scheme mapping.

The reader now keeps the original part when it holds such settings, and
the writer merges into it: the original root and every unowned child stay
byte for byte; owned children follow the model in CT_Settings order; in
w:compat only compatibilityMode is replaced. The privacy options are the
model's since #1062, so they are owned too. Kept children that point at a
relationship (a mail-merge data source) are dropped. Anything unexpected
falls back to the generated part. Files WordCraft wrote itself keep
nothing extra.

Co-authored-by: przemek-arch <263159043+przemek-arch@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 16:05:06 -04:00
b53fc8ca6e fonts: Add platform-specific Hebrew font support (#1052)
* fonts: Add platform-specific Hebrew font support

Add proper Hebrew font substitution and fallback for Windows, macOS, and Linux:
- Windows: Prioritize David/Miriam (Office) and Segoe UI fonts
- macOS: Prioritize Helvetica and Geeza Pro (system defaults)
- Linux: Use Noto Sans Hebrew and open-source fonts

Fixes Hebrew character display issue (showing rectangles) when editing
text in fonts without Hebrew glyphs by ensuring the system falls back
to Hebrew-capable fonts. Includes platform-optimized font lists in
both document font resolution (word.rs) and RTL text fallback search
(fontdb.rs).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 16:04:47 -04:00
f9f8b67c54 feat(ui): implement Ctrl+drag to copy objects on canvas (#1046)
* feat(ui): implement Ctrl+drag to copy objects on canvas

* feat(ui): implement Ctrl+drag to copy objects on canvas and update translations for "Copy to"

* vi: Copy to

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:04:34 -04:00
1f01a13dfc Track Changes: deletions inside a field result are tracked, accepted and saved (#1037) (#1043)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 16:04:13 -04:00
gossardlaandBrandon Thomas 85fcbabcb1 Ribbon: holding a press no longer scrolls the document (#1019) (#1041)
* test(engine): drop a needless drop() that clippy 1.97 rejects (drop_non_drop)

* Ribbon: a held press no longer scrolls the document (#1019)

- Double-click release paths in the canvas returned before ending the text drag
- The stale drag made any later press drive the edge auto-scroll
- A drag now ends once the button is up, wherever it was released
- Test: header double-click, then a held press on the ribbon

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 16:03:52 -04:00
e29fbdb0bf Page background: picture and texture fills are drawn and kept on save (#999) (#1036)
* Page background: picture and texture fills are drawn and kept on save (#999)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 16:03:32 -04:00
Sergiu Târpescu 060a3333b8 Ribbon menu buttons carry their name for accessibility (#1050)
The Breaks, Margins, Orientation, Size, Columns and other drop-down
buttons are custom-painted and never called widget_info, so assistive
tech and automation saw them as unlabelled. Register the tip as the
button's name, as the small buttons already do.

Regression test: the Layout > Breaks menu is reachable and its Page and
Next Page items insert a page break and a section break.
2026-10-14 15:48:44 -04:00
Sazid Al Bayazid 2fd3b556f3 Layout: keep with next moves the heading when the next paragraph moves for widow control (#966) (#1049) 2026-10-14 15:48:32 -04:00
85dd93a2ba Table eraser: borders beside vertically merged cells merge as in Word (#1039) (#1044)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 15:48:19 -04:00
Marco ArnulfoandBrandon Thomas e486f7c39d DOCX: keep Office 2016 charts (cx:chart) and their data when saving (#1029)
An mc:AlternateContent whose Choice holds a cx:chart is read as a read-only chart drawn from its fallback picture, keeping both branches and every part they reach (chartEx part, style, colours, workbook, picture). Saving writes the AlternateContent back with the chart's current frame on both branches, instead of only the fallback picture.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 15:47:59 -04:00
Marco ArnulfoandBrandon Thomas 68501de224 One Page and the zoom are saved with the document and restored when it is reopened (#1023)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 15:47:46 -04:00
6ed1877ce6 i18n(cs): complete the Czech catalog (built-in styles, equation gallery) (#978)
* i18n(cs): wording fixes after #387

SmartArt graphic and layout terms consistent with the rest of the
catalog, inline equation, plural-safe print count, grammar terminology
(mluvnice), tab stop type, file-not-found gender, review author label.
Also adds the [people.Losminek] contributor entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n(cs): complete the Czech catalog (built-in styles, equation gallery)

Adds the 386 keys the Russian and Spanish catalogs have and Czech did
not: built-in style names and numbered families, the equation gallery,
watermarks, paper sizes, table styles, preview tiles and the remaining
ribbon/menu labels. The Czech coverage test now requires every Russian
and Spanish key and checks built-in style names; localization-parity
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n(cs): shorter Proofing label

"Kontrola pravopisu a mluvnice" overflows the fixed 170 px Options
pane list into the content. "Kontrola pravopisu" fits and also reads
well as the Review ribbon group name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* cs: cross-reference strings; drop paper-size rows the menu no longer uses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Losminek <301412321+Losminek@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 15:47:35 -04:00
482401f3c4 DOCX: a hyperlink with an empty target is no link (#1005) (#1030)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 15:31:53 -04:00
Marco Arnulfo 4fcf7da693 macOS: Control-click opens the context menu, with its spelling suggestions (#1025) 2026-10-14 15:31:35 -04:00
8196624e83 Caret: Down and Up from a line start beside a table go into it, not past it (#1021)
Down from the start of the line above a table (or Up from the start of the
paragraph after it) skipped the whole table: `DocLayout::vertical` took the
nearest line covering the caret's x, and a cell's lines start a cell margin in
from the table's edge, so the paragraph beyond the table won. Lines between
the caret's paragraph and that line in document order now come first: the
caret goes into the nearest such line's table row, to the cell under it or the
horizontally nearest. Another cell of the caret's own row never counts, so Down
in a table still goes to the cell below. Pinned in `tests_typing.rs` and
`docs/typing-parity.md`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 15:31:22 -04:00
e9d5da31d4 Track Changes: rejecting a whole inserted paragraph keeps the next paragraph's style and numbering (#1020)
Reject All (and Accept All of a whole deleted paragraph) joined the next
paragraph into the emptied one, so the next paragraph took the removed
paragraph's properties: a § heading after a rejected list item became a
list item and later § numbers shifted. An emptied paragraph is now removed
whole, as Word does. Found while round-tripping a Word-saved contract.

Co-authored-by: przemek-arch <263159043+przemek-arch@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:31:04 -04:00
aedbbc9325 feat(engine): rename documents from the title bar and open account settings from the badge (#1016)
* M10: enable document renaming and account settings

* no-mistakes(review): Allow case-only document renames without overwriting files

* no-mistakes(review): Keep unsaved rename as session file name, not Title

* no-mistakes(review): Keep AutoSave and Recent across renames; Compare clears name

* no-mistakes(document): Document file.rename write-root rule; dedupe rename gap note

* vi: Rename Document strings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:30:52 -04:00
5597351213 #916: Cross-reference opens a dialog (#1003)
* #916: Cross-reference opens a dialog

The ribbon's Cross-reference button ran insert.crossReference with no
target, which only returns the target list, so nothing happened. It now
opens a Cross-reference dialog: reference type (heading, bookmark,
equation, figure, table), what to insert (text, number, page,
above/below) and the item, ending in insert.crossReference. Scripts and
agents still get the target list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n: Cross-reference dialog strings in the audited catalogs (ru, hu, tr, bg)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n: Cross-reference dialog strings in pl, it and vi

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: justinjohn0306 <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 15:30:32 -04:00
90762b0529 i18n(cs): wording fixes after #387 (#973)
* i18n(cs): wording fixes after #387

SmartArt graphic and layout terms consistent with the rest of the
catalog, inline equation, plural-safe print count, grammar terminology
(mluvnice), tab stop type, file-not-found gender, review author label.
Also adds the [people.Losminek] contributor entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* i18n(cs): shorter Proofing label

"Kontrola pravopisu a mluvnice" overflows the fixed 170 px Options
pane list into the content. "Kontrola pravopisu" fits and also reads
well as the Review ribbon group name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Losminek <301412321+Losminek@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 15:30:20 -04:00
Marco ArnulfoandBrandon Thomas 6be7ef10fb Ribbon tabs that don't fit move to a » menu instead of under Comments (#958)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 15:30:07 -04:00
Marco Arnulfo ee33d71173 Focus hides the ribbon, status bar and panes; Escape brings them back (#1017) 2026-10-14 15:09:32 -04:00
Navid Zaman Khan 163462338e fix(markdown): preserve table cells containing code spans and pipes (#1009) 2026-10-14 15:09:21 -04:00
Yousef Al-Harbi b34a8744d0 Reset table tools on table deletion and preserve Draw Table outside tables (#1006)
When a user deleted a table while Draw Table or Eraser was active, the
table was removed and the contextual Table Layout tab closed, but the
canvas remained trapped in drawing/erasing mode because `table_pen::stop`
was never called.

- UI: In `WordApp::after_command`, stop active table tools if `table.deleteTable`
  runs, or if `TableTool::Erase` is active and the caret is no longer in a
  table cell.
- Parity: Keeps `TableTool::Draw` active outside tables so users can still
  draw new tables from Insert › Tables.
- Lifecycle: Stop active table tools in `WordApp::execute` when the document
  is replaced.
- Tests: Added regression tests in `crates/ui-egui/src/table_pen.rs`
  verifying that table deletion (explicit or via last row) resets the tool.
2026-10-14 15:09:09 -04:00
987c41364d Tables: AutoFit in a nested table, whole cells shaded for a selection through a table, Shift+Right at a cell's end (#1004)
* Tables: AutoFit in a nested table fits within its cell (#971)

A double-click on a nested table's column border let the table widen up to
the page's right margin, far past the cell around it. It now widens only up
to the right edge of that cell's text (a right-to-left table's start margin
on the right); the columns right of the border still keep their widths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tables: a selection through a table shades its rows' whole cells (#971)

A selection from text into or across a table takes whole rows (#722), but
only the letters in the cells were highlighted. Every row it takes from start
to end now shades all its cells (an empty one too), as selected cells do, and
the letters in them aren't shaded twice (`table_mouse::selection_rects`, used
by the canvas). A selection that starts and ends in one table keeps its text
highlight, so selecting all the text of a cell doesn't look like selecting the
cell; a nested table in a shaded row shades with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Typing: Shift+Right at a cell's end selects the cell first (#971)

Shift+Right out of the cell the selection is in selected that cell and the
next one at once. As in Word, the first step out of the cell now takes the
cell alone (its end-of-cell mark) and the next press goes on into the next
cell; in an empty cell the first press selects it. Shift+Left in
right-to-left text, the forward step there, does the same. Pinned in
`tests_typing.rs` and `docs/typing-parity.md`; progress docs for all three
table fixes of #971.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 15:08:57 -04:00
639a4043b8 Native print test: fixed PDF creation date, no flaky byte comparison (#987) (#990)
Without a creation date in the document, each PDF is stamped with the
current time, so the expected PDF and the submitted one differed whenever a
clock second ticked over between them (seen on the macOS runner). The test
document now has a fixed creation date, and the test checks that both PDFs
carry it.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:08:38 -04:00
Navid Zaman Khan 5311069ae1 fix(cli): reject invalid page and scale arguments (#982) 2026-10-14 15:08:27 -04:00
1c2f0fe65e --help and -h print the usage for the app and every CLI command (#968) (#977)
* --help and -h print the usage for the app and every CLI command (#968)

wordcraft --help / -h printed nothing useful: the app tried to open a
file named --help and started. wordcraft-cli <command> --help failed with
"unknown option --help" for every command, and -h was taken as a file
name or ignored.

The app now prints its options and returns before the window opens.
The CLI checks for --help / -h after any known command before options
are validated or files and folders opened, and prints the shared usage.
Tests run both binaries for each command and flag; the app's test runs
without a display, so a regression fails instead of opening a window.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* #968: test bare --help and -h on wordcraft-cli

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 15:08:15 -04:00
Brandon ThomasandClaude Opus 5.5 6b107250a5 CI: Turkish review-command test skips without a Turkish dictionary
#1527's engine test needs a system Turkish Hunspell dictionary; on
machines without one it failed the workspace tests.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:03:01 -04:00
Kameron KilchristandClaude Opus 5.5 1a34583e50 macOS: the title bar fits around the traffic lights as macOS draws them (#1559)
The traffic lights grew in macOS 26, so the zoom button overlapped
AutoSave. Measure the zoom button through AppKit at start-up and size the
bar and its left inset from it, keeping the old layout as the minimum and
the fallback.



Claude-Session: https://claude.ai/code/session_01JTqaRSzgjKv75TzSC55HEY

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:45:57 -04:00
rabis7575andClaude Opus 5.5 3e11278a93 ODT and .doc import: pictures are read once, within their byte budget (#1334) (#1554)
ODT: every frame inflated its picture entry again, and the byte budget
was checked only after inflating, so frames pointing at an entry that
didn't fit (or at any entry once the budget was used up) inflated it
again each time. Each entry is now inflated at most once, and only when
the size it declares fits in what is left; the bytes inflated and every
later copy are charged, so an entry that inflates past its declared
size uses the budget up and nothing is inflated after it.

.doc: inline pictures remembered only successes, so a picture that
couldn't be read or didn't fit was located, decoded and copied again
for every anchor on it, and an inline shape's BLIP was decoded against
the whole media cap rather than what was left. Failures are remembered
too, bytes are copied and decoded only within what is left of the cap,
and nothing is read once it is used up.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:45:44 -04:00
3fc4ef2cf4 proof: Turkish spelling, suggestions, grammar and hyphenation (#1527)
* proof: Turkish spelling, suggestions, grammar and hyphenation

* Turkish: dictionary tests skip when no Turkish Hunspell dictionary is installed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:45:30 -04:00
0a2b429b05 fix(pdf): inline pictures keep their alt text in tagged PDF (#1502) (#1526)
* fix(pdf): inline pictures keep their alt text in tagged PDF (#1502)

Inline pictures were tagged as Figure with no alt, so the exporter fell
back to "picture" and screen readers announced every inline image that
way, while floating pictures kept their alt text.

Draw::Image now carries the picture's alt text from the InlineObject, and
the Placed::Lines branch of the PDF exporter passes it to the Figure tag,
including for rotated inline pictures. A picture without alt text still
falls back to "picture".

Adds a regression test in crates/pdf/src/tests.rs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 14:45:18 -04:00
e7e4522d0d ODT: read and write tracked changes (text:tracked-changes) (#1517)
* ODT: read and write tracked changes (gap 19)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	ROADMAP.md
#	crates/engine/src/tests.rs
#	crates/formats/src/model.rs
#	crates/formats/src/odt.rs
#	docs/file-format-parity.md
#	docs/gaps.md

* ODT tracked changes: deleted paragraph marks don't take the body's insertion; one region per deletion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:45:07 -04:00
Eduardo GarciaandClaude Sonnet 5.5 2814dfcae9 fix(proof): British spellings such as honour and prioritise are not flagged (#40) (#1536)
The bundled Moby list already knows many British forms (judgement,
defence, programme) but still flagged others: honour and its inflections,
colourful, colourless, rigour, glamour, prioritise, privatise,
synthesise, paediatric and a few more.

Adds those base forms to assets/spelling/en-extra.txt (inflections come
from the existing rules) and a regression test. A separate en-GB
dictionary chosen from w:lang is still open on the issue.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:29:23 -04:00
mjohnson7cd 4d79e18ae8 fix(math): preserve equation text color on save/load (#1467) (#1525)
Lift equation color from run-level OMML to CharProps during read,
push it back to runs during write. Keep source OMML until the color
changes to preserve control properties the model can't hold.
2026-10-14 14:29:11 -04:00
666b9afc5a Draw: Ink to Shape recognises arrows (#1520)
* feat-ink-to-shape-arrows: WIP

* Draw: Ink to Shape recognises arrows (gap 12)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ink to Shape arrows: hand-drawn shafts, more tests, docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ink to Shape arrows: the tip is the first near-farthest point

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ink to Shape arrows: step to the real tip on dense strokes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 14:28:59 -04:00
b4f6b603ac Canvas: alignment guides while dragging pictures and shapes (#1519)
* feat-smart-guides: WIP

* Canvas: alignment guides while dragging pictures and shapes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Alignment guides: Alt during a drag doesn't open keytips; translated options

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* vi: alignment-guide strings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 14:28:39 -04:00
60c4a372d5 RTF: read and write tracked changes (insertions, deletions, authors, times) (#1516)
* feat-rtf-revisions: WIP

* RTF: read and write tracked changes (gap 19)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* RTF tracked changes: Unicode authors, \pard keeps revision marks, pasted deletions stay deleted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 14:28:20 -04:00
b10c97b765 Document Inspector: find hidden text outside the body and from styles (#1344) (#1515)
* #1344: WIP

* #1344: Inspect Document finds and removes hidden text in every story and from styles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1344: hidden paragraphs, note references and table-style text handled; faster removal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1344: a hidden paragraph between tables stays; note echo capped

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 14:28:00 -04:00
03ba9e483f Web: New/Open waits for Save before discarding the document (#1315) (#1510)
* #1445: Save in the close prompt that can't write the file says so instead of looking like Cancel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1445: a failed save from the close prompt brings the prompt back with the reason, Save As included

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1315: web Save before New/Open waits for the save picker; cancel or failure keeps the document

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1315: overlapping saves hold the replacement; cancel and waiting messages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 14:27:42 -04:00
52e56f8d76 PDF export: All Caps text extracts correctly (#1473) (#1509)
* #1473: WIP

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1473: list labels and field results in caps extract as shown

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 14:27:23 -04:00
37d41150e6 Track Changes and content controls: table delete, Change Case and Unlink Fields respect them (#1329) (#1514)
* #1329: WIP

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 14:07:47 -04:00
53b7debea5 Web: image paste goes to the active edit target (#1349) (#1513)
* #1349: WIP

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1349: pastes onto the page work again; the guard uses page focus

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1349: pastes onto the page work again; the guard uses page focus (clippy)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1349: a focused text field outside dialogs ignores picture pastes (test)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 14:07:28 -04:00
5e8c5b3c4e PDF export: Hebrew and Arabic text extracts in reading order (#1383) (#1508)
* #1383: WIP

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 14:07:08 -04:00
b1e8ae07e7 ODT import: keep each section’s page properties (#1379) (#1506)
* #1379: WIP

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1379: master pages on table styles start sections too; spec tidy-ups

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 14:06:49 -04:00
3d2002b699 Layout: a paragraph continued into the next column wraps around a shape there (#1374) (#1505)
* #1374: a paragraph continued into the next column or page wraps around the shapes there

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1374: wrap relays keep equation numbers, need the same line top, skip far-off shapes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:06:29 -04:00
0c7b4a72f2 Layout: long tables keep one layout per cell and relayout without re-measuring (#1492)
- Table autofit measured every cell paragraph at an unbounded width through
  the paragraph cache, so a long table held two layouts per cell. The cache
  now keeps only the measured narrowest/widest widths (`LayoutCache::measures`,
  same key and eviction as the layouts).
- The cache key formatted each cell's table-style text formatting with Debug
  for every cell paragraph on every pass; the hash is now memoised per pass.
- Update Fields added the hidden ListEntry style even without a generated
  list, which changed the layout environment and dropped the whole cache.
  The style is now added only when a list is rebuilt.

Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:06:17 -04:00
b78adf2def .doc import: cell shading, column count and merged cells (#1380) (#1507)
* #1380: WIP

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1380: nil raw shading keeps the style colour; no zero-cell rows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:59:43 -04:00
0a1f2ce7a8 Canvas: draw colour emoji on screen as the PDF does (#1269) (#1504)
* #1269: WIP

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1269: smaller bitmaps are accepted; dark page keeps emoji colours (test)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:59:25 -04:00
shlok2721-web 7471c33fe0 Fix mini toolbar positioning while scrolling (#1501) 2026-10-14 13:59:06 -04:00
a124d58e5b Dark page: highlights are dimmed so the text on them stays readable (#1252) (#1498)
* #1252: dim highlights on a dark page so the text on them reads

On a dark page (View > Switch Modes) highlight and character-shading fills
were lightness-inverted like every other colour, which leaves saturated
hues such as yellow bright under white text. They are now marked as text
backgrounds (Draw::Fill text_bg) and dimmed toward black keeping their hue
(RenderOptions::ink_bg; yellow -> ~#4a4a10), and text drawn on them
(Draw::Glyphs on_text_bg) takes the lighter of its colour and its inverted
colour (RenderOptions::ink_text), so black and Automatic text draw white
with WCAG contrast >= 4.5 on all 16 highlight colours. Light page, print
and PDF are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1252: text on a dimmed highlight always reaches 4.5:1; lines too

Glyphs and lines now carry the text background they sit on
(Draw::Glyphs/Draw::Line text_bg: Option<Rgb>) instead of a bool. On a
dark page RenderOptions::ink_text takes the lighter of the colour and its
inversion, and falls back to white when that is under 4.5:1 against the
dimmed background (pure blue on a dim olive was 1.34:1). Underlines and
strikethroughs on a highlight use the same rule. The dim constants are
crate-private and documented as assuming the default dark paper grey.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1252: docs: the 4.5:1 rule for text and lines on highlights

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:58:53 -04:00
42c1f71b52 Custom page sizes: Size › More Paper Sizes and Page Setup width/height (#1433) (#1496)
* #1433: WIP

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1433: one OK is one undo step; errors stay in the dialog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:58:33 -04:00
bf2bd69470 HTML import: bold header cells, cell alignment and the region paper (#1381) (#1494)
* #1381: WIP

* #1381: HTML import: bold <th>, cell text-align, region paper for HTML/MD/TXT

Header cells are bold unless their own CSS says otherwise. text-align on a
td/th, or inherited from its tr/row group/table, aligns the cell's
paragraphs; a paragraph's own alignment wins, and a table's align attribute
(which places the table) no longer counts. Files that give no page size
(HTML, Markdown, plain text) open on the region's paper, as a new blank
document does: file.open uses the session's region, wordcraft-cli reads the
system region the way the app does (Region::from_locale_vars, shared with
ui-egui). Tables size to that page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1381: the CLI uses the system region everywhere; help mentions WORDCRAFT_REGION

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:58:14 -04:00
686f71cabb Save As: fill the file type list (#1399) (#1497)
* #1399: WIP

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:44:21 -04:00
1f2b2287b6 RTF import: keep section breaks and their page settings (#1378) (#1495)
* #1378: WIP

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1378: pasted sections link their headers; section orientation wins over the document's

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1378: Text from File clears inserted sections' headers too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:44:02 -04:00
MexandBrandon Thomas 4cf13cd178 Fix Replace All bypassing content-control locks (#1488)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:43:43 -04:00
93175f979f Word 365 cover pages: page-relative positions and freeforms drawn back to their start (#743) (#1487)
- A position wrapped in mc:AlternateContent inside wp:anchor (wp14:pctPosVOffset)
  takes the Fallback's wp:posOffset; the shapes were placed at the top of the column.
- A custGeom path that ends where it started without a:close is closed, so it's
  filled as in Word; the cover's panel (and its white title) was invisible.
- Credits: add benandez to contributors/people.toml.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:43:24 -04:00
ff4e560e7d Ctrl+A stays on this page, and in a table grows cell, then row, then table, then the document (#1424) (#1478)
* Ctrl+A stays on this page, and in a table grows cell, then row, then table, then the document (#1424)

Fixes #1424

* Table: Option::map for the outer-cell selection (clippy::manual_map on Rust 1.99)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:43:06 -04:00
cyril265andBrandon Thomas 1be06ca323 Fields: PAGEREF in headers and footers shows its bookmark's page (#1477)
* Fields: PAGEREF in headers and footers shows its bookmark's page

Headers and footers are laid out once the body is paginated, so a page
reference there now shows the number of the page its bookmark starts on, in
that page's number format, instead of the result stored in the file. Like the
page totals in headers and footers, it is filled in after pagination and
doesn't make the body lay out again. Mail-merged letters use this for
"Page X of Y" per letter (Y = PAGEREF to a bookmark at the letter's end). Page
references in the body keep their result until fields are updated.

* Contributors: add cyril265

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:42:53 -04:00
dea3e217fa Shapes: rounded rectangles keep their own corner radius (#1476)
* Shapes: rounded rectangles keep their own corner radius

A roundRect's adj (its corner in 100000ths of the shorter side) is read, kept
on the shape, saved back, and used for the screen, the PDF and Tight/Through
wrapping. Without one the corner stays at 16%.

* Render: test rounded rectangle corners through layout and rendering

* Docs: progress log and file-format parity for rounded rectangle corners

* Render: pass the shape's corner to the caret-background hit test (#299 code from main)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:42:34 -04:00
Saad AbdullahandSaad Abdullah a9afeb4bfe fix(proof): classify each whitespace token once when checking spelling (#1335) (#1490)
Co-authored-by: Saad Abdullah <saadkhantareen@users.noreply.github.com>
2026-10-14 13:26:10 -04:00
b8b8ff6128 Layout: PAGE fields on the later pages of a paragraph show their own page (#1352) (#1481)
* #1352: PAGE fields on the pages a paragraph runs onto show that page's number

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1352: keep the page map local; assert no overflow

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Layout: re-laying out a paragraph keeps its equation numbers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:25:58 -04:00
cyril265andBrandon Thomas 5ff69b8bab PDF: draw horizontal and vertical lines inside groups (#1474)
* PDF: draw horizontal and vertical lines inside groups

A line or connector inside a group keeps its zero height or width (a:ext cy="0"),
and the PDF export skipped every shape without area, so dividers drawn in Word
groups were missing from exported PDFs while the screen showed them.

* PDF: test hostile sizes for flat lines

* Docs: progress log and file-format parity for lines inside groups in PDF export

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:21:21 -04:00
47ae2d495d References: TOC entries carry their headings' list numbers (#1470)
Update Table took an entry's text from the heading's plain text, which has
no list label, so headings numbered through a multilevel list (1, 1.1)
lost their numbers in the TOC. The entries now walk the body's list
counters and put the label and the level's separator (tab, space or
nothing) before the text, as Word writes it.

Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:21:03 -04:00
Bihacsy LászlóandBrandon Thomas 26cdede600 i18n(hu): align two new rows with the catalog terms (#1469)
"Title Bar" was "Címsor", the word the catalog uses for Heading (Címsor 1, Címsor 2); it is now
"Címsáv". "AutoFit Column by Double-Clicking" now uses "automatikus méretezés", as AutoFit
reads elsewhere in the catalog.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:20:50 -04:00
45d2546b63 Metafile: WMF object table without scanning, in-place shape ops, cropped DIB decode (#1463) (#1466)
* Metafile: WMF handle table finds its lowest free slot without scanning (#1463)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Metafile: map shape ops in place and decode only the clipped part of a DIB (#1463)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* ROADMAP: progress log row for the metafile reader (#1463)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Metafile: write cropped DIB pixels through as_chunks_mut (#1463)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:20:31 -04:00
f2a96a21f9 Close prompt: a failed Save says why and keeps the document open (#1445) (#1465)
* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:20:11 -04:00
17ec132c7c Tables: thick cell borders meet in closed corners (#1461)
A horizontal cell edge ran from cell edge to cell edge, so at a thick
vertical edge it started on that edge's centre line and left a notch half
the border width wide at every corner. Horizontal edges now run on over
half of each visible vertical edge, as Word draws them.

Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:19:51 -04:00
1059b196c1 i18n: add Vietnamese (vi) interface translation (#1458)
* i18n: add Vietnamese (vi) interface translation (#1434)

* vi: translate the strings main gained since the catalog was drafted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:19:39 -04:00
533615620e Equations: bound parser tree depth, escape \text in LaTeX, merge runs everywhere (#1456) (#1457)
* Math: bound the depth the equation parsers build (#1456)

x^1^1^1…, a/a/a… and \sum\sum… built left-nested trees thousands of levels deep within the
input cap; clean, Clone, PartialEq, Drop and serde then overflowed small stacks. Wrapping an
existing node (script base, numerator, accent, limit base, n-ary body) is skipped once it is
already 32 levels deep (iterative check); the operator is text (linear) or dropped (LaTeX).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Math: escape \text{…} in LaTeX output so it reads back unchanged (#1456)

Normal-text runs were written verbatim, so a}b\c became \text{a}b\c} and read back as ab\c.
Escape \ { } % $ # & _ ~ (backslash as \backslash{}); \backslash is now a named symbol and
raw_arg ignores the empty braces after a control word. A # inside normal text no longer
numbers the equation.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Math: merge runs inside every structure, not just some (#1456)

merge_runs only recursed into fractions, scripts, radicals, n-ary operators, delimiters and
functions, so the LaTeX parser left \overline{ab} as two runs where the linear parser gives
one. It now recurses through map_args for every node kind.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:19:28 -04:00
Raymond Thurman 4cb013faa1 Stop Space and Enter from resetting the X11 input context (#1179)
Space and Enter count as a click on the focused canvas. request_focus
always interrupts IME, and on X11 winit then destroys the XIM context.
fcitx swallows every later key. Ignore that keyboard click. Pointer
clicks, accessibility clicks, and drags still focus the canvas.
2026-10-14 13:06:38 -04:00
0128e117b3 Layout: table rows share their cell margins and split across pages like Word (#1177)
- Every cell in a row gets the row's largest top and bottom cell margin, so
  a cell without its own margins lines up with its neighbours.
- An exact row height covers the top border, top margin and text; the
  bottom margin comes on top. Dragging a row border, or drawing a line
  through an exact row, accounts for it.
- A row split across pages must keep its cells' bottom margin, border and
  last paragraph's space after on the first page, and widow/orphan control
  applies to the paragraphs in its cells. A row whose text fits but whose
  margin doesn't no longer moves to the next page whole.
- Each cell of a split row goes on at the top of the continuation (one
  cell's widow line no longer pushes its neighbours down), and the
  continuation is as tall as the cell needing the most room, and at least
  the rest of the row (a minimum row height holds over the pages).
- A nested table's cell the page bottom cuts through goes on from the cut
  with what is left of it, and the text after it stays below it.
- A row of an exact height never splits: it moves to the next page whole.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:06:24 -04:00
Marco Arnulfo 6e6cfba765 Fonts embedded in a .docx lay out and export its text (#1176)
* Fonts embedded in a .docx lay out and export its text (fixes #1171)

* Fonts: forget lookups made while an embedded family was missing (#1171)
2026-10-14 13:06:05 -04:00
DevCoderMax 5b9010f6ad Picture transparency/corrections/color are absolute from the original + transparency slider (#1175) 2026-10-14 13:05:50 -04:00
37193a14ba Layout: a paragraph ending with a page break leaves its space after on the old page (#1168)
A paragraph whose last line holds the page break keeps its mark on the old
page, but its space after was still added after the break, so the next page
started that much too low (and the next paragraph lost its "no space before
after a page-break mark" treatment). Word adds none of it there, in
compatibility modes 12, 14 and 15 alike.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:05:38 -04:00
25ca4b0752 Full screen on F11 and View › Window › Full Screen (#980) (#1153)
* #980: full screen on F11 and View › Window › Full Screen

view.fullScreen toggles OS-level full screen (ViewportCommand::Fullscreen),
on F11 and ⌃⌘F (macOS); Escape leaves it when nothing else takes Escape.
The window's own full screen changes (WM, macOS button) keep the flag in
step. Disabled on the web (the browser's own F11). Next Field moves from
F11 to Word's other keys, Alt+F1 / Alt+Shift+F1 (Shift+F11 kept).
Ribbon button with a drawn icon, keytip, translations in all catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #980: Escape collapses a selection before leaving full screen; F11 only off macOS

In full screen, Escape left it even when it had a selection to collapse
or a drag to cancel; it now leaves only when nothing else takes Escape.
The shortcut read Ctrl+Ctrl+F on Windows and Linux: macOS gets ⌃⌘F and
F11, others F11. Docs: Alt+F1 may be taken by GNOME/KDE; keyboard
counts re-measured from the registry (152 commands, 172 combinations).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 13:05:18 -04:00
Brandon ThomasandClaude Opus 5.5 abe572e7fb CI: translate Croatian in every interface catalog (#1719)
#1114 added Croatian to the proofing-language menu, and #1143 put the
interface-translation audit back in the test suite; together they left
"Croatian" untranslated, failing untranslated_interface_text and the
catalog-coverage tests.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:01:49 -04:00
156029fe63 Layout: a list label moves with its text when the paragraph is aligned (#1159) (#1163)
* Layout: a list label moves with its text when the paragraph is aligned (#1159)

Alignment shifted the text of a line but not the list label of the first line,
so a centred or end-aligned bullet or number stayed at the margin while its text
moved away. In Word the label stays attached to the start of the text. The
first line's label now moves by the same shift (not after a tab stop, where the
text before the tab stays too). Left-to-right and right-to-left paragraphs are
covered by one test.

* Docs: update the progress docs for this change

* Docs: put the new sentence in the WordCraft / evidence column

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 12:51:19 -04:00
Marco ArnulfoandBrandon Thomas 7d3767ba99 Zotero: text typed just after a citation goes outside its field (#1162)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 12:51:00 -04:00
Marco Arnulfo 3f5d8feebe Print: the preview fills the pane, with a zoom bar and scrolling both ways (#1157) 2026-10-14 12:50:40 -04:00
Marco Arnulfo 48f1b6b842 Dark theme: the ruler text area is lighter than its margins (#1154) 2026-10-14 12:50:29 -04:00
b3132a0b11 Review › Language reaches every proofing language (#860) (#1151)
* #860: Language menu opens Set Proofing Language… and Language Preferences…

Review › Language (and the status bar's language menu) offered only four
languages since #346 replaced the button that opened the Language dialog,
so French and the other 25 languages, and Set As Default, were out of
reach. The menu now ends with Set Proofing Language… (the Language dialog)
and Language Preferences… (Options › Language); a language outside the
short list shows by name and checked, and the status bar names it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #860: other regions of a language show by name, and no language shows twice

A tag outside the Language dialog's list (fr-CA, en-AU, de-AT) shows as the
language and region (French (Canada)) instead of the raw tag; pt-AO is
Portuguese (Angola), not Brazil. A current language named like a short-list
entry checks that entry rather than showing again above the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 12:50:16 -04:00
086d3d0384 Alt+numpad character codes (#961) (#1146)
* #961: Alt+numpad decimal character codes (ANSI, OEM 437/850, Unicode)

Hold Alt, type a decimal code, release Alt: the character is typed at the
caret through the new text.typeCode command. A leading 0 means
Windows-1252, no leading 0 the OEM code page (437, or 850 for Western
European locales), above 255 the Unicode character. Not on macOS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #961: Alt codes follow the system code pages; ignore auto-repeat, drop on blur

text.typeCode takes optional ansi (874, 1250-1258) and oem (437, 737, 850,
852, 857, 866) code pages; OEM 852/866/737/857 tables added from the public
mapping files. On Windows the shell passes the system locale's pages and, for
codes up to 255, lets the character Windows composes replace ours (above 255
ours stays and Windows' byte-reduced text isn't typed). Auto-repeat of a held
digit adds nothing, and losing focus drops a half-typed code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 12:49:57 -04:00
44086d08c8 Double-click the margin to create and edit a header or footer (#960) (#1144)
* #960: double-click the margin to create and edit a header or footer

Double-clicking the top or bottom margin in Print Layout now edits the
header/footer that page shows, creating it (first page, even page or
default slot) when the page has none. insert.editHeader/editFooter take
{"page"}; insert.closeHeader takes away a created one left empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #960: making a header by double-click is an undoable, protected edit

insert.editHeader/editFooter stay pure and hand creation to the new
mutating insert.createHeader/createFooter; closeHeader removes empty new
ones through insert.removeEmptyHeaders. An empty new header is never
saved, one an undo brings back goes on the next close, and a new header
goes in the first section (linked to later ones, as in Word).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 12:49:37 -04:00
646349d521 Interface-translation audit green and back in CI (#1128) (#1143)
({name}   {height} × {width} in, {kind}, {columns} × {rows}, …) and use the
  region's decimal separator; the fixed-format paper rows dead since #838 are
  removed from six catalogs.
- 20 strings added to all 18 catalogs: status-bar messages (table split and
  merge, ink replay, citations, SmartArt, Researcher, Design defaults), the
  Reading Mode toolbar, Go To, Search's Suggested, envelope paper names.
- Audit: citation style names stay exempt as proper names; `bad parameters:`
  errors (JSON parameter names for scripts and MCP) stay English like the
  other engine errors; placeholder-only patterns need no entry.
- Flakiness: the Read Aloud player drew the speech worker's error straight
  from shared state, so with no TTS engine an error landing between poll and
  draw was shown without being known to the app (the rare failure under
  load); it now draws the error poll saw. The audit holds Read Aloud (no
  speech thread, no sound) and gathers process-wide names (fonts, custom
  dictionary) every frame, since tests running alongside change them.
  #[ignore] removed.

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:49:18 -04:00
68cf67a6c6 CI: main passes again (Word 2003 check reads through io; first keystroke after a chrome click isn't dropped) (#1578)
* Engine: the Word 2003 format check reads through io, so commands still never touch files themselves

is_word_2003_file (#1081) opened the file with std::fs, which the file-access guard test
(commands_do_not_touch_the_file_system_themselves) forbids outside io.rs; main failed it.
A new io::read_file_head reads the first bytes inside the read root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UI: a chrome click counts the page as focused at once, so the next keystroke isn't dropped; the ribbon-focus test passes again

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:42:37 -04:00
skylar taylor-barrick 2773295938 PDFW: refresh shared writer on the audited snapshot 2026-10-13 13:11:47 -06:00
skylar taylor-barrick 442a7226e7 PDFW: refresh the qualified shared writer revision 2026-10-13 09:37:58 -06:00
skylar taylor-barrick d1dff91f6f PDFW: use shared writer without reader dependencies 2026-10-12 23:06:05 -06:00
Isaac Levine 76f5d4b817 CI: run cargo xtask ci on pull requests and pushes to main (#754) 2026-10-11 22:30:39 -04:00
406 changed files with 96328 additions and 5975 deletions
+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+30
View File
@@ -0,0 +1,30 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+62
View File
@@ -0,0 +1,62 @@
# The CLAUDE.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers,
# wasm) on every pull request and every push to main, so a merge burst can't land a red tree that
# only the next contributor's local run discovers.
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
# Same craft-fonts commit as release.yml and freebsd.yml, so layout tests see the fonts a
# release build embeds (the checkout is an optional build input, see crates/fonts/build.rs).
CRAFT_FONTS_REF: 8dcdacd5153e64560d109541a47d806f26f048c0
jobs:
gate:
name: cargo xtask ci
runs-on: ubuntu-24.04
timeout-minutes: 60
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: "0"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: ${{ env.CRAFT_FONTS_REF }}
path: craft-fonts
- name: Install Linux build dependencies (winit, wgpu, rfd)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
- name: Install Rust (stable + clippy, rustfmt, wasm32)
run: |
rustup toolchain install stable --profile minimal --component clippy,rustfmt \
--target wasm32-unknown-unknown
rustup default stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Quality gate
env:
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
run: cargo xtask ci
+23
View File
@@ -0,0 +1,23 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
+20
View File
@@ -0,0 +1,20 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+5 -2
View File
@@ -10,7 +10,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|---|---|---|---|---|
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) |
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation |
| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~220 lowercase English base forms supplementing spelling (#520) |
| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~240 lowercase English base forms supplementing spelling (#520), including British spellings (#40) |
| `assets/spelling/ru-extra.txt` | WordCraft contributors | original word list written for this repository (common modern Russian words older Hunspell dictionaries lack, with their declension classes); no other dictionary copied | MIT OR Apache-2.0 | ~200 Russian base forms supplementing spelling; their forms are generated by `crates/proof/src/ru/extra.rs` |
| `assets/hyphenation/pl.pat` | Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko (Polish TeX hyphenation patterns, 1987–1995); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pl.tex`, SHA-256 `83ddbbd21bc06a44bc4f4a4c49c38782e809cff5f026b764c044daa29e818843`), bundled unmodified | MIT (the file is offered under MIT or Knuth's public-domain terms; WordCraft uses MIT, whose notice is in the file's header) | 4,053 Liang patterns and 20 exception words; Polish hyphenation |
| `assets/hyphenation/pt.pat` | Pedro J. de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides (Portuguese TeX hyphenation patterns, 1987–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pt.tex`, SHA-256 `c110a39e501db372d1d755e1c64464321a2efa7d8e9617f66001000389b941f2`), bundled unmodified | BSD 3-clause (the file's licence header names it and carries the notice) | 426 Liang patterns and 2 exception words; Portuguese hyphenation |
| `assets/hyphenation/it.pat` | Claudio Beccari (Italian TeX hyphenation patterns, 2008–2014); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-it.tex`, SHA-256 `6ce56ed6ed2ca7c0688838e366e6595e0429f984f4fc6e96ec045cebb1e76a47`), bundled unmodified | MIT (the file is offered under LPPL 1.3+ or MIT; WordCraft uses MIT, whose notice is in the file's header) | 355 Liang patterns, no exception words; Italian hyphenation |
@@ -66,7 +67,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback |
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Ukrainian interface catalog; spelling checked with external VESUM and ULIF dictionaries, neither bundled |
| `crates/ui-egui/src/i18n/et.tsv` | @henno with Codex | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Estonian interface catalog |
| `crates/ui-egui/src/i18n/pl.tsv` | Konrad Witkowski with Claude (AI agent) | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Polish interface catalog; not yet reviewed by a second native speaker |
| `crates/ui-egui/src/i18n/pl.tsv` | Konrad Witkowski with Claude (AI agent); Aleksander Kutycki (@olekkut) with Gemini 3.8 Flash | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Polish interface catalog; not yet reviewed by a second native speaker |
| `crates/ui-egui/src/i18n/pt-br.tsv` | @edurbs with ZCode | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Brazilian Portuguese interface catalog; spelling checked with the external pt_BR Hunspell dictionary, not bundled |
| `crates/ui-egui/src/i18n/cs.tsv` | @DanielKnourek with Antigravity for VS Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Czech interface catalog; review suggestions by @Losminek |
| `crates/ui-egui/src/i18n/de.tsv` | @arekown with Kimi | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | German interface catalog |
@@ -75,3 +76,5 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `crates/ui-egui/src/i18n/bg.tsv` | Dimitar Klaturov (@bulgariamitko) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Bulgarian interface catalog |
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Hungarian interface catalog |
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | French interface catalog |
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Swedish interface catalog; AI-assisted, terminology and the running app reviewed by a native speaker |
| `crates/ui-egui/src/i18n/hr.tsv` | Andrej Škvorc (@andrejskvorc) with Claude Code (Sonnet 5.5); proofread with Gemini | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Croatian interface catalog; reviewed and corrected in the running interface, menu by menu, by the native-speaker author, who also checked the Gemini suggestions |
Generated
+81 -35
View File
@@ -693,6 +693,17 @@ dependencies = [
"libc",
]
[[package]]
name = "chardetng"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13de944a44b5064ee5d3a5ceccc49a41bfec50f2580e66f82e87703acdb88b53"
dependencies = [
"cfg-if",
"encoding_rs",
"memchr",
]
[[package]]
name = "cipher"
version = "0.5.2"
@@ -819,6 +830,12 @@ dependencies = [
"libc",
]
[[package]]
name = "core_detect"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "core_maths"
version = "0.1.1"
@@ -843,6 +860,15 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
dependencies = [
"krilla",
"thiserror 2.0.21",
]
[[package]]
name = "craft-print"
version = "0.1.0"
@@ -1184,6 +1210,20 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "encoding_rs"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
dependencies = [
"cfg-if",
"core_detect",
"multiversion_no_op",
"rustversion",
"scopeguard",
"simdutf8",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1843,17 +1883,6 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "hayro-write"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0b3db92c4917aad24ff30d61413a9a7509ae88b27aee1ae34eff881e1d2723a"
dependencies = [
"flate2",
"hayro-syntax",
"pdf-writer",
]
[[package]]
name = "hermit-abi"
version = "0.5.3"
@@ -2185,15 +2214,13 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
dependencies = [
"base64",
"bumpalo",
"flate2",
"float-cmp",
"gif",
"hayro-write",
"image-webp",
"imagesize",
"indexmap",
@@ -2363,6 +2390,12 @@ dependencies = [
"pxfm",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "naga"
version = "30.0.1"
@@ -5098,7 +5131,7 @@ dependencies = [
[[package]]
name = "wordcraft"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"arboard",
"clipboard-win",
@@ -5111,6 +5144,7 @@ dependencies = [
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"pollster 0.4.0",
"rfd",
@@ -5130,9 +5164,10 @@ dependencies = [
[[package]]
name = "wordcraft-cli"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"serde_json",
"sys-locale",
"wordcraft-doc",
"wordcraft-engine",
"wordcraft-mcp",
@@ -5142,11 +5177,11 @@ dependencies = [
[[package]]
name = "wordcraft-control-key"
version = "0.7.0"
version = "0.9.0"
[[package]]
name = "wordcraft-doc"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"proptest",
"serde",
@@ -5158,7 +5193,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"cfb",
"image",
@@ -5166,6 +5201,7 @@ dependencies = [
"miniz_oxide 0.8.9",
"proptest",
"serde_json",
"sha1",
"thiserror 2.0.21",
"wordcraft-doc",
"wordcraft-geom",
@@ -5173,7 +5209,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"aes",
"base64",
@@ -5187,6 +5223,7 @@ dependencies = [
"sha1",
"sha2",
"thiserror 2.0.21",
"unicode-bidi",
"wordcraft-doc",
"wordcraft-geom",
"zeroize",
@@ -5195,8 +5232,11 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"cfb",
"encoding_rs",
"getrandom 0.3.4",
"image",
"js-sys",
"log",
@@ -5221,7 +5261,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"harfrust 0.13.3",
"kurbo",
@@ -5234,9 +5274,13 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"cfb",
"chardetng",
"encoding_rs",
"image",
"js-sys",
"log",
"proptest",
"quick-xml",
@@ -5247,14 +5291,14 @@ dependencies = [
[[package]]
name = "wordcraft-geom"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"serde",
]
[[package]]
name = "wordcraft-layout"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"image",
"js-sys",
@@ -5273,7 +5317,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"log",
"serde_json",
@@ -5285,7 +5329,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -5293,14 +5337,14 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"craft-pdf",
"hayro-cmap",
"hayro-interpret",
"hayro-syntax",
"image",
"js-sys",
"krilla",
"kurbo",
"log",
"thiserror 2.0.21",
@@ -5313,7 +5357,7 @@ dependencies = [
[[package]]
name = "wordcraft-proof"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"log",
"miniz_oxide 0.8.9",
@@ -5322,7 +5366,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"image",
"log",
@@ -5336,8 +5380,9 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"cfb",
"craft-ui",
"egui",
"egui_kittest",
@@ -5348,6 +5393,7 @@ dependencies = [
"serde_json",
"sys-locale",
"toml",
"unicode-bidi",
"wordcraft-doc",
"wordcraft-engine",
"wordcraft-fonts",
@@ -5361,7 +5407,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"eframe",
"egui",
@@ -5380,7 +5426,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"log",
"proptest",
@@ -5493,7 +5539,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"serde_json",
]
+12 -3
View File
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.7.0"
version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/wordcraft"
[workspace.dependencies]
@@ -52,12 +52,17 @@ harfrust = "0.13"
wuff = "0.2.9"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
krilla = { version = "0.8", features = ["pdf"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
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.
miniz_oxide = "0.8"
cfb = "0.15"
# Legacy text encodings (GBK/GB18030, Big5, Shift_JIS, EUC-KR, single-byte code pages) for plain
# text and CSV recipient lists (#778): encoding_rs ((Apache-2.0 OR MIT) AND BSD-3-Clause) and its
# detector chardetng (Apache-2.0 OR MIT), both pure Rust (Firefox's).
encoding_rs = "0.8"
chardetng = "1"
# Password-protected (agile-encrypted) Word documents: pure-Rust RustCrypto primitives.
aes = "0.9"
sha1 = "0.11"
@@ -95,3 +100,7 @@ opt-level = 3
lto = "thin"
codegen-units = 16
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 = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8" }
+6 -1
View File
@@ -76,6 +76,11 @@ list (author, source, license) is in ATTRIBUTION.md.
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io.
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources.
The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision
c3487320b2d1de657b860b3c8abb44e1f2b32bd8. The backend retains its MIT OR Apache-2.0 licensing
and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md.
The backend patch prevents finalization during an unwind and rejects a writer after failed
cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled.
No Microsoft, Adobe, Avid or Autodesk icons, images, templates or artwork are included.
+42 -21
View File
@@ -10,11 +10,11 @@
<h1 align="center">WordCraft</h1>
<p align="center">
<b>Writing and document design; an open-source, clean-room reimplementation of Microsoft Word, rebuilt in pure Rust.</b>
<b>Writing and document design; an open-source, modern word processor, built in pure Rust.</b>
</p>
<p align="center">
A fast, open-source word processor with the Word workflow you already know: the ribbon, styles,
A fast, open-source word processor with a familiar workflow: the ribbon, styles,
tables, track changes, references and mail merge. It reads and writes .docx, runs natively on
macOS, Windows, Linux and BSD, and in the browser via WebAssembly.<br>
<i>By the ArtCraft team.</i>
@@ -25,6 +25,9 @@
<img alt="Runs on macOS, Windows, Linux, BSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20BSD%20%C2%B7%20Web-3b5bdb?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-2b47b5?style=flat-square">
<img alt="Agent-drivable over MCP" src="https://img.shields.io/badge/agents-MCP%20%C2%B7%20CLI-3b5bdb?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_wordcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/wordcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -86,10 +89,10 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
## Why WordCraft
- **Familiar.** Word's ribbon tabs, groups, shortcuts and behaviour: Enter continues a list,
- **Familiar.** Ribbon tabs, groups, shortcuts and behaviour: Enter continues a list,
Tab demotes it, Ctrl/⌘+B bolds the word under the caret, the Styles gallery previews styles live,
F4 repeats, F7 checks spelling, F8 extends the selection.
- **Your files.** Opens and saves .docx (OOXML), and also .odt, .rtf, .html, .md, .tex (LaTeX), .txt; opens Word 97-2003 .doc; exports PDF
- **Your files.** Opens and saves .docx (OOXML), and also .odt, .rtf, .html, .md, .tex (LaTeX), .txt; opens legacy 97-2003 .doc; exports PDF
with real, selectable text, links and bookmarks.
- **Fast.** Paragraph layout is cached, so typing in a 188-page document re-lays it out in about
1.4 ms; pages render on demand.
@@ -117,8 +120,8 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français or Български (follows the system language by default) |
| **Files** | .docx read/write (opens in Word), Word 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어 or Svenska (follows the system language by default) |
| **Files** | .docx read/write, legacy 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the
[parity assessment](docs/target-app-parity.md) and the generated
@@ -141,6 +144,7 @@ wordcraft-cli text report.docx # plain text
wordcraft-cli inspect report.docx # structure as JSON
wordcraft-cli run --template sample \
--cmd 'select.text={"text":"Membership"}' --cmd format.bold --save out.docx
wordcraft-cli run --script job.json # the same from a file: commands, then save to docx and pdf
```
Web: `cd apps/wordcraft-web && trunk serve`, then open <http://127.0.0.1:8771/?sample>.
@@ -188,10 +192,10 @@ covers every target starting with it (`wordcraft*=debug`). The logger is
| L0 | `wordcraft-geom` | units and measurements |
| L1 | `wordcraft-doc`, `wordcraft-fonts`, `wordcraft-proof` | document model and editing; fonts and shaping; spelling, grammar, hyphenation |
| L2 | `wordcraft-layout`, `wordcraft-docx`, `wordcraft-formats` | line breaking, pagination, tables, notes, hit testing; OOXML; ODT/RTF/HTML/Markdown/TXT |
| L3 | `wordcraft-render`, `wordcraft-pdf` | rasteriser (vello_cpu); PDF (krilla) |
| L4 | `wordcraft-engine` | session, undo, 422 commands, Word feature catalog |
| L3 | `wordcraft-render`, `wordcraft-pdf` | rasteriser (vello_cpu); PDF ([craft-pdf](docs/shared-pdf.md), Krilla) |
| L4 | `wordcraft-engine` | session, undo, 422 commands, document feature catalog |
| L5 | `wordcraft-mcp` | MCP server |
| L6 | `wordcraft-ui-egui` | the Word-style front end (swappable) |
| L6 | `wordcraft-ui-egui` | the word processor front end (swappable) |
| apps | `wordcraft`, `wordcraft-cli`, `wordcraft-web` | desktop, command line, browser |
`cargo xtask ci` runs formatting, clippy, ~800 tests, the asset-attribution check, the layering
@@ -199,12 +203,13 @@ check and the wasm build. Contributor and agent instructions: [AGENTS.md](AGENTS
## Roadmap
WordCraft is in **alpha**. It covers 88% of Word's ribbon features with commands today; counting
depth, file fidelity, stability and the rest, we estimate it is about 60% of the way to replacing
all of Word for real work, about 55% for a typical professional's weekly work and about 63% for
everyday essentials. Beta needs testing against real-world .docx files, keeping charts and SmartArt
through a round trip, pagination closer to Word's, a stability sweep and native printing. Details
and estimates: [ROADMAP.md](ROADMAP.md) and [where we fall short](docs/gaps.md).
WordCraft is in **alpha**. It has commands for 97% of the ribbon and menu features in its feature catalog
([`docs/parity-checklist.md`](docs/parity-checklist.md)) today; counting
depth, file fidelity, stability and the rest, we estimate it is about 60% ready for real work,
about 55% for a typical professional's weekly work and about 63% for everyday essentials. Beta
needs testing against real-world .docx files, keeping charts and SmartArt through a round trip,
closer pagination fidelity, a stability sweep and native printing. Details and estimates:
[ROADMAP.md](ROADMAP.md) and [where we fall short](docs/gaps.md).
## Downloads
@@ -247,9 +252,10 @@ WordCraft compiles DirectX 12 shaders with FXC, the compiler built into Windows,
also avoids DirectX 12 altogether.
**If the app is slow on an older Windows PC:** when DirectX 12 finds no graphics card with a
driver for it (for example Intel 4th-generation graphics on Windows 11), it only offers Microsoft's
software renderer, which draws every frame on the processor. In that case WordCraft uses OpenGL
instead, without setting anything. The log file records the choice (lines starting with
driver for it (for example Intel 4th-generation graphics on Windows 11), it only offers the built-in
software renderer, which draws every frame on the processor. WordCraft normally uses OpenGL instead,
without setting anything. If OpenGL has no usable adapter, it can use DirectX 12's WARP software
adapter. The log file records the choice (lines starting with
`graphics:`). `WGPU_BACKEND` still overrides it, e.g. `$env:WGPU_BACKEND = "gl"` or `"dx12"`.
### macOS
@@ -289,6 +295,21 @@ to the AUR itself. Install one of them:
| [`wordcraft`](https://aur.archlinux.org/packages/wordcraft) | The latest release, built from source with `cargo` | You prefer to build releases yourself |
| [`wordcraft-git`](https://aur.archlinux.org/packages/wordcraft-git) | The current `main` branch, built from source | You want unreleased changes and accept an occasional broken build |
For example `yay -S wordcraft-bin` or `paru -S wordcraft-bin`.
#### Community packages
These packages are maintained by community members, not by the WordCraft team.
On Gentoo, the [::snakebyte overlay](https://github.com/switch87/snakebyte-overlay) packages the
Linux release as `app-office/wordcraft-bin` (prebuilt) and `app-office/wordcraft` (built from source):
```sh
eselect repository add snakebyte git https://github.com/switch87/snakebyte-overlay.git
emaint sync -r snakebyte
echo 'app-office/wordcraft-bin ~amd64' >> /etc/portage/package.accept_keywords/wordcraft
emerge --ask app-office/wordcraft-bin
```
### FreeBSD
| Build | File |
@@ -351,7 +372,9 @@ Bundled fonts, icons, images and other assets keep their own open licenses; each
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
The English spelling dictionary and hyphenation come from Grady Ward's public-domain Moby
Hyphenator II word list. Polish spelling reads the Hunspell `pl_PL` dictionary installed on the system (not bundled) and
Hyphenator II word list. Polish spelling reads the Hunspell `pl_PL` dictionary installed on the system (not bundled), as
do Russian, Portuguese, Czech, Italian, Croatian and Turkish spelling (how to install them:
[docs/spelling-dictionaries.md](docs/spelling-dictionaries.md)), and
Polish hyphenation the TeX patterns by Hanna Kołodziejska, Bogusław Jackowski and Marek Ryćko (MIT
option). The sample documents and templates are original text written for WordCraft.
@@ -360,8 +383,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and WordCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Microsoft and Microsoft Word are trademarks of the Microsoft group of companies. Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. WordCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation or Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+406 -16
View File
@@ -2,8 +2,8 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** trivial (progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
@@ -15,7 +15,7 @@ agent control. This page is the summary; the assessment is
## Headline numbers (2026-10-10)
| Number | Value | Kind |
|---|---|---|
| --- | --- | --- |
| Ribbon/menu catalog coverage | **487 / 502 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated |
| **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions |
@@ -104,18 +104,19 @@ Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parit
| Spanish | es | partial | ~95% |
| Hindi | hi | none | 0% |
| Arabic | ar | none (document shaping and bidi work; no mirrored UI) | 0% |
| French | fr | partial (interface) | ~95% |
| French | fr | partial (interface complete, native-speaker reviewed; no proofing) | 100% (2,615 entries) |
| Portuguese | pt-br (pt-PT none) | partial | ~95% |
| Indonesian | id | none | 0% |
| Japanese | ja | partial | ~95% |
| German | de | partial (#574) | ~95% |
| Korean | ko | none | 0% |
| Vietnamese | vi | none | 0% |
| Korean | ko | partial (interface catalog; proofing unavailable) | 100% (2,463 entries) |
| Vietnamese | vi | partial (#1434) | ~95% |
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Bulgarian (`bg`) covers everything the interface audit finds (2,419 entries). Estonian (`et`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs, and Turkish (`tr`) a complete 2,325-entry one (every key of the Russian catalog), all with
system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish, Italian and Czech proofing are not included. Hungarian (`hu`) has a
complete 2,419-entry catalog, also without proofing.
Proofing: English, Polish and Portuguese (spelling, grammar, hyphenation), chosen per run from its language. Word ships
Also shipped: Traditional Chinese (`zh-hant`), Ukrainian (`uk`), Serbian (`sr`, `sr-latn`, #250), Russian (`ru`) and Polish (`pl`), all partial (~95%). Bulgarian (`bg`) covers everything the interface audit finds (2,419 entries); Swedish (`sv`) covers that too, plus 44 newer strings no other catalog has yet (2,504 entries). Estonian (`et`), Italian (`it`, #594) and Czech (`cs`) now have complete interface catalogs, and Turkish (`tr`) a complete 2,325-entry one (every key of the Russian catalog), all with
system-locale selection and a saved language choice (Italian also has 51 strings none of the others had yet); Estonian, Turkish and Italian proofing are not included. Hungarian (`hu`) has a
complete 2,419-entry catalog, also without proofing. Vietnamese (`vi`, #1434) has a complete
2,448-entry catalog, also without proofing.
Proofing: English, Polish, Portuguese and Russian (spelling, grammar, hyphenation), plus Czech and Indonesian (system Hunspell spelling, generic checks), chosen per run from its language. Word ships
30 interface languages and proofing for ~50. Detail: [docs/localization-parity.md](docs/localization-parity.md).
## Upcoming
@@ -133,8 +134,146 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
## Progress log
Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target on interactive Save (#2445). Save before Close waits for that write; cancel or failure keeps the draft and original file. This does not add binary Word output. Late Save As answers are rejected before writing if automation has replaced the document.
- 2026-10-19: Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in.
- 2026-10-19: Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey.
- 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-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-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-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-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-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture |
| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 |
| 2026-10-18 | Symbols keep their font (#2036): a `w:sym` or Insert › Symbol character in Wingdings, Symbol or Webdings remembers its symbol font apart from the text's font, so selecting all and changing the font (or Clear Formatting) no longer turns the symbols into boxes; saved back to DOCX as `w:sym`, so Word shows them too. Typed text and the Format Painter don't pick up the symbol font. |
| 2026-10-16 | `wordcraft-cli run --script FILE.json` (`-` for stdin) runs a JSON list of commands, then saves to one or more outputs (docx and pdf in one run); errors name the command number and id and nothing is saved, #1963 |
| 2026-10-17 | Restrict Editing holds for structure too (#1327): an exception inside a table cell no longer lets table, row or cell formatting change the protected rest of the table; formatting limited to styles refuses Paste Formatting, table shading, borders, cell alignment, cell margins, text direction, table-style edits and locked table styles; loading, filtering or sorting recipients no longer rewrites merge-field results in a protected document. |
| 2026-10-18 | Manage Sources › Import… (#2640) reads a Word bibliography file (`b:Sources`: Word's own `Sources.xml` master list) into the document: the tags, names, kinds and fields Word saves are read by the same code that reads the bibliography custom XML part of a `.docx`, a tag already in the document is left alone (an import never overwrites an edited source), an untagged source gets a tag the way a new one does, the whole import is one undo step, and a file that isn't `b:Sources` is refused. |
| 2026-10-14 | RTF export writes every section (gap 19): `\sect` ends each section and `\sectd` carries its break type, page size and orientation, margins, columns, page numbering and title page, so multi-section documents survive an RTF round trip |
| 2026-10-14 | `ASK` and `SET` fields no longer show their stored result on the page or in PDFs (#1765): like Word, they only assign text to a bookmark and draw nothing; the stored result is kept and saved |
| 2026-10-15 | Table rows with an at-least height start the next page whole when less than their minimum is left on a page that already holds something, so one-row-per-page booklets keep their pages; rows taller than a page split from the top of the next page and repeated header rows go with the row (#1742) |
| 2026-10-16 | Strict OOXML line heights render as Word renders them (#2329): `w:line="13.90pt"` under `lineRule="auto"` is a height read through twips (13.90 × 20 = 278; the 1.1583 multiple Word shows), not the 13.9 multiple the bare transitional number counts, so a Word 2024 file with 13.9 lines of line spacing opens at Word's 1 page with matching baselines instead of 10 |
| 2026-10-15 | RTF keeps more tracked changes (gap 19): tracked paragraph marks (a `\par` inside a `\revised`/`\deleted` group) and character and paragraph formatting changes (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) are read and written, so their balloons and Accept/Reject survive an RTF round trip |
| 2026-10-14 | ODT export writes sections (gap 19): every new-page section gets its own page layout and master page (the first stays "Standard"), named by its first paragraph or table with the page-numbering restart; a continuous section with columns is written as a columned `text:section`, so a landscape, two-column or restarted part survives a save as .odt |
| 2026-10-14 | A bulleted or numbered list item that starts with a lower-case word no longer gets the grammar squiggle for a missing sentence capital (#1741): list items are often fragments, so the sentence-capital rule (every proofed language) skips the first word of a list paragraph (own or style numbering); later sentences in the item are still checked |
| 2026-10-15 | The caret, clicks and selection edges fall between the letters of a ligature (fi, ffi, lam-alef), as in Word: the glyph's advance is shared evenly among its letters, mirrored in right-to-left text (#1974) |
| 2026-10-16 | The Spike, as in Word: Ctrl+F3 (⌘F3 on macOS) cuts the selection and adds it to the end of the Spike, each piece followed by a paragraph mark; Ctrl+Shift+F3 (⌘⇧F3) inserts everything collected, in order and with its formatting, and empties the Spike. The clipboard is left alone; each is one undo step. The Spike is the AutoText entry "Spike", so Insert › Quick Parts inserts it without emptying it. It lasts for the session (Word keeps it in Normal.dotm) |
| 2026-10-16 | Update Field is offered on a table of contents after a save and reopen, and repeated save and update adds no blank line (#2182; the flow in that report already worked since #1197) |
| 2026-10-16 | DOCX two lines in one and horizontal in vertical (`w:eastAsianLayout`: id, combine, brackets, vert, vertCompress) are read and kept on save, in runs and character styles, instead of being dropped. Layout doesn't apply them yet. |
| 2026-10-16 | Read Aloud speaks Russian, Ukrainian, Greek, Arabic, Hebrew, CJK and Thai text with a voice for its language instead of spelling out letters (#1843): the sentence's language comes from its run's tag (`w:bidi` for right-to-left text) or, when that disagrees with the script, from its letters, even when English letters outnumber them; Windows reads the text as UTF-8 and picks an installed voice of that language, macOS `say -v` and Linux `espeak -v` get the same language. The user's own voice stays whenever it already speaks the language (English documents read exactly as before), and a voice espeak lacks falls back to the default voice instead of silence. Not run on Windows or macOS here. |
| 2026-10-16 | Hint-reliant CJK fonts execute TrueType contour assembly; Windows DFKai-SB no longer loses strokes. Synthetic local-font regression; no font binaries bundled. |
| 2026-10-16 | Ctrl/⌘+wheel only zooms: a coast or wheel notch still gliding no longer scrolls the page on top of the zoom, which keeps the page point under the pointer (#1743). |
| 2026-10-15 | AutoCorrect follows Turkish and Azerbaijani case mapping: `istanbul` → `İstanbul`, `ılık` → `Ilık`, `İNgilizce` → `İngilizce` with no combining dot (#1923) |
| 2026-10-16 | Releasing a table drag with the mouse (column or row border, move handle, selection areas) leaves the view where it is instead of scrolling back to the caret (#1738) |
| 2026-10-16 | Hebrew in fields and quotes (#1670): a STYLEREF (or any field) showing right-to-left text such as "פרק א" reads right to left in headers and the body instead of reversed; smart quotes in Hebrew pinned to Word's logical-order result (“ before, ” after, ” in acronyms like צה"ל) |
| 2026-10-15 | Backspace with a whole table selected removes it, as in Word, and selected whole rows or columns go the same way; Delete clears them and keeps the table. A selection that covers a table and the paragraph before or after it removes the table, also when it starts inside the table or the table is nested (#1844) |
| 2026-10-15 | Image size limits are checked before allocating (#1321): a picture whose header declares more RGBA bytes than the 512 MiB budget is refused before decoding (render and PDF share one helper in `wordcraft-geom`), and page rasters are capped at 100 million pixels in total, scaled down rather than only per side. |
| 2026-10-15 | Saved DOCX files keep the order of mixed right-to-left and left-to-right text in Word: Hebrew around a Latin name, numbers, brackets or an e-mail address no longer changes places, and Word reports the Hebrew as Hebrew rather than Arabic (#1543). |
| 2026-10-15 | Web: typing in Cyrillic or another non-Latin script no longer repeats text. WordCraft now honours the input method's request to erase around the caret before retyping (#1480). Chrome can still move the hidden text field's caret, so later letters may still land out of order. That cause is still open |
| 2026-10-15 | Equations: typing `V_dd` or `x^ab` and pressing Space keeps `dd` and `ab` whole in the subscript or superscript, like `V_(dd)`, as Word for Mac does; Linear format shows the bare form |
| 2026-10-15 | Plain text pasted from another program keeps the formatting of the paragraph it lands in: Paste inserts it as Keep Text Only does, so every pasted line, empty lines too, becomes a paragraph with that paragraph's direction, style, alignment, indents and list, and the text after the caret keeps them. The lines after the first had default formatting, so pasting several lines into a right-to-left paragraph turned all but the first left-to-right |
| 2026-10-14 | Add Font… imports every file selected in the file chooser (#1789), not just the first: the desktop dialog and the web picker are multi-select, each file is added, a bad file is reported ("Added 11 fonts; 1 couldn't be read: X.ttf") without stopping the rest, and one pick adds at most 2,000 (the status line counts the skipped). On the web each font is added and reported on its own, with no single summary line. The built-in file browser still picks one file. |
| 2026-10-15 | Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. |
| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture |
| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 |
| 2026-10-15 | Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. |
| 2026-10-16 | Zero-extent drawing anchors contribute no inline font height. |
| 2026-10-16 | Control-channel keyboard and text input reaches the focused widget while the native window is inactive, without activating it; normal widget targets, dialogs and document protection remain in force (#2420) |
| 2026-10-15 | Hide Quick Access Toolbar (#1945): the toolbar's menu ends with Hide Quick Access Toolbar, as Word's does; the ribbon's right-click menu and Customize Ribbon › Quick Access Toolbar offer Show Quick Access Toolbar; kept between runs; `ui.quickAccessToolbar {"value"?}` for scripts and MCP |
| 2026-10-15 | Rulers: the numbers thin out as the page shrinks on screen (every 1, 2, 4… units, at least 30 px apart, the small ticks at least 4 px apart), give way to the indent, tab and column markers, and the margins get ticks only; both rulers span the caret's page at its own scale in One Page and Multiple Pages and follow the caret to another page (#1878) |
| 2026-10-15 | Spelling as you type no longer flags the word being typed; the squiggle comes when the word ends or the caret leaves it, as in Word (#1552) |
| 2026-10-15 | Text Highlight Color: No Color becomes the Highlight button's choice, as in Word, so the next click on the button removes highlighting instead of applying the colour picked before; the ribbon and the mini toolbar share one menu (#2105) |
| 2026-10-16 | Footnote and endnote editing keeps the body and active note text at normal opacity (#2545), without changing document colours or print/PDF output. |
| 2026-10-16 | A picture watermark in a header that starts with a table is drawn centred on the page behind the body text, on every page, instead of in the header's cell at the top of the page (#1775); `layoutInCell="0"` and VML `o:allowincell="f"` survive save and reopen |
| 2026-10-17 | Ribbon hover hints: a disabled control says why (the command's own reason), split-button arrows, menu buttons and the font, size and outline boxes have hints, and hints show on keyboard focus (#1086). |
| 2026-10-17 | Insert › Drop Cap is a menu (None, Dropped, In margin, Drop Cap Options…); the dialog sets position, font, lines to drop and distance; letters in the margin; DOCX keeps them (#2601). |
| 2026-10-16 | An otherwise empty section starting a new page retains its paragraph line before a continuous section, matching an original native Word control. |
| 2026-10-16 | Equations from Word files keep all their content (#1273): math straight in the body or a table cell, math behind `mc:AlternateContent Requires="m"`, runs inside hyperlinks, simple fields and markup-compatibility blocks within an equation, and `w:sym`/Symbol-font characters in math; operators, delimiters and accents the math font lacks come from a fallback font. Every ECMA-376 §22.1 element is pinned by a coverage test. |
| 2026-10-16 | Captions and title pages (#1230): Insert Caption… on the right-click menu of pictures, shapes, text boxes and charts, the caption going under (or over) the object in its own cell or text box; Page Setup's Different First Page and Different Odd & Even Pages check boxes, so a title page can go without its page number |
| 2026-10-17 | SECTIONPAGES includes shared continuous-section pages. A native original control confirms a section starting on page 1 and continuing on page 2 reports 2. |
| 2026-10-17 | File › Print's Scale to paper size (#2108): pick Letter, Legal, A4 or any Layout › Size paper and every page is scaled to it and centred, the paper turned to each page's orientation, in the preview, the print job (with the spooler's media) and Save these pages as PDF; the document keeps its page size. `paper` param on `ui.print`/`file.printPdf` |
| 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-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-15 | The font family caches are bounded (4096 families for text, 1024 for math, names over 256 bytes aren't remembered), so a document naming thousands of missing fonts can't grow memory; equation fonts are looked up again after a font is added or removed instead of keeping the old choice (#1354) |
| 2026-10-15 | AutoCorrect recognizes `-->` as `→` (#2223), respecting replacement settings and undo without changing paragraph-start arrow bullets. |
| 2026-10-15 | Numbered headings (#1793): the Multilevel List gallery offers four lists linked to Heading 1–9 (1 / 1.1 / 1.1.1, Article I. / Section 1.01 / (a), I. / A. / 1., Chapter 1; `list.multilevelPreset`). The heading styles carry the numbers, so paragraphs already in a Heading style keep their style and level, any other selected paragraph (Normal, Quote, Title) becomes Heading 1 (checked in Word), a new Heading 2 is numbered 2.1 by itself and body text stays unnumbered; Numbering or typing `1. ` keeps a heading a heading |
| 2026-10-15 | Reversing an automatic sentence capital (Undo, or Backspace and retype) adds the word before it to AutoCorrect's First Letter exceptions, as Word does; "Automatically add words to list" switches for First Letter and INitial CAps (#1857). |
| 2026-10-15 | Dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, in both themes (#1885) |
| 2026-10-15 | Font box: a Recently Used Fonts section at the top of the list (the fonts applied lately, most recent first, ten at most, kept between runs), then All Fonts, as in Word (#2068) |
| 2026-10-15 | A missing Thai document font (TH Sarabun New, Angsana New, Cordia New, the UPC families…) is substituted by one Thai face for the whole run, Latin letters and digits included, as Word substitutes Cordia New; Thai no longer sits in one font next to larger Latin in another (#1549) |
|---|---|
| 2026-10-17 | Paste from LibreOffice: a paragraph indented on both sides (HTML `<blockquote>` on the clipboard) pastes as an indented Normal paragraph with LibreOffice's margins, not centred and italic in the Quote style (#1270). |
| 2026-10-17 | DrawingML shape outlines retain explicit preset/custom dashes on screen and in PDF, including zero-width path strokes. |
| 2026-10-17 | Missing Arial Unicode MS tries Arial and its proportional substitutes before code-font heuristics. |
| 2026-10-16 | Web Layout text fills the window width and re-wraps once a resize settles, or at once on a zoom, instead of a fixed 800 pt column; Draft and Outline keep their 800 pt column (#1876). |
| 2026-10-16 | Create Source: the automatic Tag is three letters of the first author and the last two digits of the year (`Smi20`), with 1, 2… added when taken, as Word for Mac 16.113.4 does (user-checked). Editing Author or Year keeps the Tag, as in Word, and the citations in the text show the new author and year (#2094; the reporter expected the Tag to follow). |
| 2026-10-18 | Split Cells and Convert Text to Table dialogs (#2592), matched to Word: Split Cells starts from the selection (twice its columns, its rows; Merge cells before split offered for several cells or one cell merged across columns); `table.split` gains `rows` and `mergeFirst`, a merged cell splits into 1 to its height rows (its height cuts it into rows, 1 × 1 changes nothing, a number that doesn't divide the height or more than it is refused with Word's messages) and column splits make equal parts without moving other rows' grid lines. Convert Text to Table guesses columns live, shows the row count, and offers Initial column width (Auto or up to 22 in, kept as each column's preferred width), AutoFit to contents/window and Paragraphs/Commas/Tabs/Other separators; `table.fromText` gains `paragraph`/`other` + `char`, `columns`, `autofit` and `width`. |
| 2026-10-16 | Font list entries of fonts with a very tall ascent (Amiri, Amiri Quran) sit in their row like the others instead of low or cut in half (#2570) |
| 2026-10-16 | Read Mode draws documents cleanly (#1772): the arrows, Page Up/Down and Escape work as soon as it opens (the canvas kept a stable widget id, so it no longer lost the keyboard), only whole columns show, centred, at any column width, text size or window size, and the columns end above the page-turn bar instead of under it. |
| 2026-10-16 | Comment and revision balloons are readable in the dark theme (#2045): they were drawn on a fixed white card under light text; the card, markup area and author colours are now theme tokens with a contrast test. |
| 2026-10-17 | DOCX glossary documents (building blocks) open into the model: Insert › Quick Parts and AutoText list and insert the document's own entries, and saving an entry with `"in": "document"` keeps it in the file (undoable). An untouched glossary is saved back byte for byte; an edited one is written from the model |
| 2026-10-16 | Indonesian proofing (#498): id-ID spelling reads the installed Hunspell `id_ID` dictionary (`hunspell-id` on Debian/Ubuntu, LibreOffice's `dict-id`, or the Dictionaries folder), suggestions use its MAP/REP/TRY/KEY tables, generic repeated-word (offering `anak-anak`) and spacing checks explain in Indonesian; Review › Language, Set Proofing Language and File › Options › Proofing offer Indonesian. No dictionary data bundled; hyphenation keeps the English fallback; parity estimates unchanged |
| 2026-10-17 | A table inserted in a right-to-left paragraph is a right-to-left table, like Word, so Tab moves right to left through its cells (#2634) |
| 2026-10-16 | French interface catalog completed: 402 entries added (equation gallery, built-in style and table-style names, citation styles, paper sizes, watermarks, Bookmark / Go To / Print dialogs), 2,615 in all, with a native-speaker review of the terminology; no French proofing yet |
| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
| 2026-10-15 | Layout › Columns with text selected puts only that text in columns, as in Word (#2024): a continuous section break goes where the selection starts and one where it ends (splitting a paragraph there; none at the document's start or end, or where a section already begins or ends), and the columns go on the section between, one undo step; More Columns… offers "Selected text" and picks it. Before, the whole section took the columns |
| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture |
| 2026-10-12 | Table formulas: cell references and ranges (`=B2+B3`, `=SUM(B2:C3)`), arithmetic and comparisons, IF, AND, OR, NOT, TRUE, FALSE, DEFINED and the `\#` number format; ABOVE and LEFT sum the run of numbers next to the cell as Word does; an unknown function or a syntax error is an error instead of a sum; Update Field (F9) recalculates formula fields |
| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 |
| 2026-10-18 | Different First Page works from page 1 (#2826): Insert › Header and Edit Header with the caret on page 1, and a double-click there while another header is being edited, go into page 1's own header instead of the default one page 1 no longer shows. |
| 2026-10-15 | Track Changes Options › Moves (gap #5): Moved from and Moved to each have a mark and a colour (by default green double strikethrough and double underline, as before and as in Word for Mac), saved per user; `review.trackingOptions` takes `moveFromMark`, `moveFromColor`, `moveToMark`, `moveToColor`, and `trackMoves`, the document's Track moves setting: off, a tracked cut and paste is an ordinary deletion and insertion. DOCX `w:doNotTrackMoves` round-trips |
| 2026-10-17 | Language dialog keeps inherited proofing language and no-proof choices (#2717). Opening and accepting unchanged no longer replaces style-derived values with English US and proofing enabled. |
| 2026-10-16 | Text with the legacy Outline, Shadow, Emboss or Engrave font effects (`w:outline`, `w:shadow`, `w:emboss`, `w:imprint`) is drawn with them on screen and in PDFs, no longer as plain text: hollow outlined letters, or a grey copy behind the letters; the PDF keeps the text (#2534) |
| 2026-10-15 | Table Layout › Cell Margins opens an editable dialog initialized from the table defaults; confirming an unchanged dialog preserves imported margins, and changing one side retains the others (#1960) |
| 2026-10-15 | Word's native ink inside groups and drawing canvases survives DOCX saves (gap 2): the ink member keeps its InkML part, relationship, content type and picture fallback, stays in its place among the members, moves with the group and is drawn from its fallback picture; deleting the group drops its part |
| 2026-10-14 | Consistent preferred cell widths override a stale table grid when both totals agree, for fixed and AutoFit tables. Conflicting widths, spans and percentages remain unchanged. |
| 2026-10-14 | Legacy VML freeforms (`v:shape` with its own `path`), such as the rule lines in many older headers, are read as freeform shapes: they show on screen and in PDFs and are saved back as DrawingML custom geometry instead of vanishing |
| 2026-10-14 | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. |
| 2026-10-16 | Opening a DOCX no longer copies its pictures: each picture, and each embedded object or macro part kept for saving, was copied out of the unzipped package, so a document with pictures held them twice while it opened and left the package's copies behind as freed memory. The document now shares the package's bytes: a 92 MB document with 20 pictures opens in about 90 MB less (866 → 772 MB in the app on macOS; the command line's peak, 319 → 231 MB) |
| 2026-10-14 | Page-break-only list paragraphs no longer print stray bullets or numbers. The next item keeps its number, matching the reference export. |
| 2026-10-12 | Backspace at the start of the paragraph after a table nested in a cell moves into the nested table's last cell, no longer into the last cell of the table around it |
| 2026-10-14 | Grouped `flowChartOr` shapes draw their circle and crossing spokes, as in Word |
| 2026-10-14 | Deleting a comment removes its anchors from headers, footers, notes and text boxes too, not just the body, and deletes the whole thread below it, replies to replies included; no anchor or reply is left pointing at a deleted comment (#1345) |
| 2026-10-14 | Legacy DOC DIB conversion checks the BMP header and copy before allocation, uses the caller's decode allowance, and caps PNG output while encoding; a BMP fallback is kept when it fits. A generated 1-pixel regression covers 57/58/512-byte limits. Resource-exhaustion consequences remain unmeasured. Related: #1334 and #1554 |
| 2026-10-15 | Draw: Ink to Shape makes two-stroke arrows and arrows with a closed triangle head. A line plus a separate head stroke (an open V or a small closed triangle, drawn after the line or before it) whose apex sits at one of the line's ends becomes one straight arrow connector, and the head stroke goes, in one undo step; this works while drawing with Ink to Shape on and on lasso-selected strokes with Convert to Shapes. A shaft with a small triangle drawn round its tip in the same stroke is an arrow too; a triangle without a shaft stays a triangle. Our own design (Word for Mac has no Ink to Shape). |
| 2026-10-13 | Justified vertical page alignment (#1320): a section set to Justified spreads each page's paragraphs and tables from the top margin to the bottom one, as Word does, including the last page and pages ending in a manual page break. DOCX `w:vAlign="both"` opens as Justified (it showed centred) and saves back; `layout.verticalAlign` takes `justify`, and Page Setup has a Vertical alignment box |
| 2026-10-13 | macOS shortcuts as in Word for Mac: ⌘⇧S Save As, ⌘⌥E Insert Endnote, ⌘⌥A New Comment, ⌃H Replace, ⌘⌥R collapse the ribbon |
| 2026-10-13 | The status bar flags a proofing language whose Hunspell dictionary isn't installed (Polish, Portuguese): "Polish (no dictionary)", with a tooltip naming the dictionary and where it is looked up (#1167) |
| 2026-10-14 | A file name that isn't valid Unicode, such as a Latin-1 `café.docx`, no longer crashes `wordcraft-cli` or the app (#1440). The app and the CLI's `convert`, `info`, `text`, `inspect`, `render`, `zotero` and `run --file` open such a file, and saving it without a new name (`run --file … --cmd file.save`, or Save in the app) writes back to it. Any other such argument is an error naming it, an output name included; the app exits with status 2 on a `--control` port that isn't valid Unicode |
| 2026-10-13 | A picture (or shape) set to Behind Text can be dragged over the text it overlaps, whether or not it was selected first, in the body and in a header/footer. The press becomes an object drag only after the pointer moves, so an ordinary click still places the text caret in the overlapping text. The UI regression tests cover In Front of Text too |
| 2026-10-13 | Saving to DOCX keeps the watermark: it is written as Word's own text-watermark shape in every header, and a document without headers gains one to hold it (#1290) |
| 2026-10-13 | DOCX files with Word's text watermark (Design › Watermark, a VML text shape in the headers) show it on every page on screen and in the PDF (#1290) |
| 2026-10-13 | Insert › Link has Word's Place in This Document: a link to a bookmark, a heading (through a hidden _Toc bookmark) or the top of the document, from the dialog's Link to: choice or `insert.link` with `bookmark`, `heading` or `top`; `link.targets` lists the places and Go To follows `_top` (#1234) |
| 2026-10-13 | Cut copies to the system clipboard however it is run (#1265): the ribbon, the context menu and the native menu cut deleted the words without copying them (only the Cmd+X event wrote the clipboard, so Paste re-inserted the previous copy); Copy/Cut publication is centralised in one sink and every entry point is tested |
| 2026-10-13 | Insert › Bookmark deletes bookmarks (#1236): the dialog is laid out as Word's, with Add, Delete and Go To beside the list, Sort by Name or Location and Hidden bookmarks; it opens on the bookmark around the caret, Delete and Go To keep it open, and Go To selects the bookmark's text. `insert.deleteBookmark {name}` removes both ends of a bookmark, also one across paragraphs (adding a bookmark under a name in use left such a one's end behind) |
| 2026-10-12 | Password-protected Word 97-2003 `.doc` files open after a password prompt (XOR obfuscation, RC4, RC4 CryptoAPI; wrong passwords are rejected by the stored verifier, #1079); written from the public specs and tested on synthetic files only, AES-encrypted `.doc` files are still refused |
| 2026-10-14 | Update Field works inside headers, footers, notes and text boxes, on the part being edited, as in Word (#1706); with the caret in the body it still leaves them alone, and printing updates every field in the document |
| 2026-10-12 | Comments can show only for the selected text (#1055): Track Changes Options › Comments › "Only for the selected text" drops the margin column, and the comment under the caret, or the clicked one, floats beside the page (over its edge in a narrow window). The default stays "In the margin", as in Word; `review.trackingOptions {"commentDisplay"}` for agents |
| 2026-10-12 | Clicking inside a selected comment's balloon moves the caret in its text (#1013); the click used to leave the balloon, so the caret could only be moved with the arrow keys. A press on any balloon now belongs to the balloon, not the page behind it |
| 2026-10-12 | Page background pictures and textures (Design › Page Color › Fill Effects) are drawn (tiled or stretched) and kept on save instead of being dropped from the package (#999); PDF export paints them too |
| 2026-10-12 | Holding a click on the ribbon or toolbar no longer scrolls the document up (#1019): a double-click in the header or footer area left a text drag running, so the next press anywhere drove the edge auto-scroll; a drag now ends with the button, wherever it's released |
| 2026-10-12 | Tracked insertions and deletions inside a field result (a `=SUM(ABOVE)` subtotal) are kept as revisions: Accept All and Reject All act on them and a save writes the `w:del` back (#1037) |
| 2026-10-12 | Document title opens Rename Document; the account badge opens Options › General for user name and initials. `file.rename {name}` preserves file extensions and refuses an existing destination; unsaved names become the suggested save name without changing the Title property. |
| 2026-10-13 | Rounded rectangles (#1476): a `roundRect` keeps the corner radius its file gives it (`adj`), on screen, in PDF and when saved; all were drawn with a 16% corner |
| 2026-10-13 | PAGEREF in headers and footers (#1477): "Page X of Y" with Y a page reference to a bookmark at each letter's end shows each letter's own total instead of the result stored in the file |
| 2026-10-13 | Replace All respects locked content controls with Track Changes on or off; it refuses the entire batch before editing when any match would change locked content or remove a control locked against deletion |
| 2026-10-14 | File › Print translated in all 18 interface languages — printer list, duplex, grayscale, discovery/submission feedback and print commands (18 strings that shipped English-only with the craft-print integration; written from the English meaning, pending native-speaker review like the rest of the catalogs) |
| 2026-10-14 | All Caps and Small Caps draw ß as SS, as Word does (#1581): "Straße" in All Caps reads STRASSE, not STRASE, and other letters whose capital is two characters (ff, ʼn) draw in full. The ß is still one character: the caret never stops between the two S, and a click on them lands before or after it |
| 2026-10-13 | Windows Magnifier follows the caret onto a new line: after Enter or Shift+Enter the view moves to the empty line at once, instead of staying on the line above until two more letters are typed (#1119) |
| 2026-10-13 | A window too big for its screen shrinks to fit when it opens (#1215): on a laptop screen the first window reached under the taskbar or past the screen edge, with the end of the scroll bar, so dragging the scroll bar stopped where the pointer met the edge. A window saved that size is fitted on its next launch too |
| 2026-10-14 | `layout.pageSetup` with a `section` checks its values like Margins, Size and Columns (#1582): non-finite numbers and margins that leave no room for text are refused with nothing changed; page sides, column count and gap, header/footer distances and line-numbering values are clamped to the same ranges |
| 2026-10-13 | `wordcraft --control` checks its port (#1426): a missing port, one outside 1–65535 or one that isn't a number prints an error naming it and exits with status 2, instead of starting without the control channel (and dropping `WORDCRAFT_CONTROL_PORT`); a flag right after it, such as `--sample` or `--version`, is read as a flag rather than as the port |
| 2026-10-14 | Text wrap (#1376): in the narrow strip beside a square-wrapped picture, a word that doesn't fit moves on whole to the next row; it used to be split across the strip ("docu" / "mentazione") |
| 2026-10-14 | DOCX files with legacy VML preset shapes show them (#1377): Word's right arrow (`_x0000_t13`) and the other presets appear with their fill, outline and size, drawn as WordCraft draws the same DrawingML preset (the nearest shape when it has no exact one), and saving keeps them as DrawingML shapes; before, they were dropped |
| 2026-10-14 | Lists numbered with Russian letters (`russianLower`, `russianUpper`) show а), б), в) … я), then аа), бб), as in Word, instead of 1), 2), 3); the 29-letter sequence (no ё, й, ъ or ь) was observed in Word |
| 2026-10-14 | "Do you want to save changes?" answers from the keyboard after typing too (#1597): Save takes focus from the page when the prompt opens, so the arrow keys and Tab move between Save, Don't Save and Cancel instead of the caret behind it, and Enter answers |
| 2026-10-12 | Text boxes from older Word files (VML) keep their internal margins, Resize shape to fit text and vertical alignment, so letterhead address blocks and form headers sit where Word puts them instead of 7 pt in and 4 pt down. Hidden drawings stay hidden (they were drawn as empty boxes) and are kept on save |
| 2026-10-13 | Word's privacy options are kept and honoured when saving .docx (#1062): with Remove personal information, comments and tracked changes are saved by "Author" and the file has no creator or last-modified-by; with Remove date and time, they are saved without dates. The open document keeps the real names and dates; `file.privacyOptions` sets both |
| 2026-10-13 | A selected comment balloon's Delete, Reply and Resolve buttons work again (#1085): pressing them no longer counts as a click on the page that closes the balloon first, so a new empty comment, or any other, can be deleted from its balloon |
@@ -146,25 +285,75 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-13 | Dragging a Square-wrapped shape or picture on a multi-column page wraps the text of the column it lands in around it, and its old place closes up (#884) |
| 2026-10-13 | Pagination with footnotes follows Word (#1116): a paragraph ending on a page with footnotes keeps its space after above the separator, otherwise its last lines move to the next page with the note; pages without notes are unchanged |
| 2026-10-13 | Interface size means what it says (#822): a chosen size is absolute, as in PhotoCraft and PdfCraft, so "100%" is one screen pixel per point even where the system reports 200 % (a bare window manager on X11, where winit guesses the scale from the monitor's DPI). The new default, Auto, keeps the system's display scale and its menu entry shows the size it draws at; sizes run 75–300 %. A saved size from before stays the same if it was the default (now Auto) and becomes absolute otherwise |
| 2026-10-14 | Copied text keeps its formatting in other programs (#1523): Copy and Cut put HTML beside the plain text on the system clipboard for every selection, not only one holding an equation, so a browser, mail client or another word processor pastes bold, italic, fonts and sizes. RTF on the clipboard is not written yet |
| 2026-10-13 | Copying an equation also puts MathML in HTML on the clipboard beside the linear text like `y=2x_s` (#816; Word/LibreOffice paste not yet verified; OMML is #1297). HTML export writes equations as MathML too, and opening or pasting HTML with MathML gives equations back |
| 2026-10-16 | Copying one equation on its own also puts it in the clipboard's MathML formats, which Microsoft documents Word reading first when it pastes math. Whether it pastes into Word as an editable equation hasn't been confirmed yet; testers welcome (#1297) |
| 2026-10-13 | The Equation tab's galleries, structure buttons and symbols keep the theme's colours on Windows: their pictures share a few textures written in place (no wipe of pictures already on screen, room for the built-ins at high display scales), instead of hundreds of small textures freed in bulk, which on DirectX 12 showed inverted, red or teal tiles (#1002) |
| 2026-10-13 | `file.close` over the control channel or MCP bridge no longer quits and discards unsaved changes (#1268): it returns an error unless the document is saved or `{"force": true}` is passed; the user's Save / Don't Save / Cancel prompt is unchanged |
| 2026-10-14 | Text copied out of an exported PDF reads as shown (#1473): All Caps and Small Caps text comes out in capitals, as from Word's PDFs, and plain text after it keeps its own case (before, a capital first drawn for a lowercase letter made later capitals extract lowercase) |
| 2026-10-14 | Web: Save in the "save changes?" prompt before New or Open now waits for the browser's save picker: the new or opened document replaces yours only once the file is written and closed. Cancelling the picker, or a write that fails, keeps your document open with its edits and undo, still unsaved, and the prompt comes back with the reason; a save that ends after another document has replaced it no longer runs the New/Open on that one or marks it unsaved, and New/Open while a picker save is still in flight ask first (#1315) |
| 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) |
| 2026-10-14 | File › Info › Inspect Document finds hidden text the way Word does (#1344): hidden by direct formatting, a character style or a paragraph style, in the body, headers, footers, footnotes and endnotes, comments and text boxes, and Remove All deletes all of it (and nothing visible) in one undo step, whole hidden paragraphs and the notes of hidden references included. The result lists anything it couldn't reach (`residual`), so a sanitised file is never reported clean by mistake |
| 2026-10-14 | ODT files keep their tracked changes (gap 19): insertions and deletions made in LibreOffice open as revisions with their author and date, deleted paragraph breaks and text inserted then deleted included, so Accept All and Reject All give the right text; saving to ODT writes them back, and the Track Changes setting goes both ways |
| 2026-10-14 | RTF keeps tracked changes (gap 19): insertions and deletions open as tracked changes with their authors and dates (`\revtbl`, `\revised`, `\deleted`, `\revdttm`) instead of plain text, so deleted text no longer reappears as normal text; saving to RTF writes them back, and Accept All / Reject All work after reopening |
| 2026-10-14 | Alignment guides: moving a picture, shape or text box snaps its edges or centre to the page's edges and centre, the margins and the other floating objects on the page, with a thin line across the page for each guide while dragging; Alt moves freely; File › Options › Advanced › Editing turns them off (`ui.alignmentGuides`). Our own design: Word for Mac shows no guides |
| 2026-10-14 | ODT files open with their sections (#1379): a paragraph that switches to another page style starts a new section on a new page, with that page's size, orientation, margins and columns and, when the file asks, page numbering restarting; a two-column `text:section` becomes a continuous two-column section. Before, the whole document was one portrait section, so landscape pages, columns and restarts were lost on open and on save to DOCX |
| 2026-10-14 | Hebrew and Arabic in exported PDFs copy and search as whole words (#1383): right-to-left glyphs are written left to right as they appear, the order Chrome and Edge (PDFium) and poppler read text back from (checked by hand), instead of in reading order, which they reversed (`שלום` came back as `םולש`, Arabic in pieces). Lam-alef stays whole and vowel marks follow their letters, though Chrome and poppler can still break a fully vowelled word with spaces. The page looks the same. Readers that take glyphs strictly in file order get only plain Hebrew runs right, through `/ReversedChars`; screen readers are unverified |
| 2026-10-14 | HTML import (#1381): header cells (`<th>`) are bold unless their own CSS says otherwise, and `text-align` on a cell, its row, row group or table aligns the cell's paragraphs (a paragraph's own alignment wins), so `wordcraft-cli convert page.html out.docx` writes the bold header and `w:jc` right-aligned cells. HTML, Markdown and plain text, which give no page size, open on the region's paper (A4 in Italy, Letter in the US) like a new blank document, in the app and in `wordcraft-cli`; documents with their own page size keep it |
| 2026-10-14 | Custom page sizes (#1433): Layout › Size ends with More Paper Sizes…, which opens Page Setup's new Paper group — a Paper size list (the named sizes, or Custom size) and Width and Height in the measurement unit; OK sets the size only when it changed, and a wider-than-tall size comes out landscape. `layout.size` now clamps each side to Word's 0.1"–22" and refuses a page the margins don't fit (the margins rule: 36 pt of text room), and Page Setup checks the size and margins together before applying either |
| 2026-10-14 | Bold text in Arial Black and other Black or Heavy fonts looks as in Word: older copies of these fonts (Arial Black in the core fonts) call themselves regular weight, so bold was drawn with an extra synthetic bold; a family named Black/Heavy now counts as that heavy |
| 2026-10-13 | Highlighted text reads on a dark page (View › Switch Modes, #1252): highlights and character shading are dimmed, keeping their hue (yellow turns a dim olive, as in Word), and the text on them is drawn light (coloured text below 4.5:1 is drawn white, and underlines and strikethroughs follow the same rule), instead of white text on bright yellow; light page, print and exports unchanged |
| 2026-10-13 | Full screen (#980): F11, or View › Window › Full Screen, fills the screen with the window (⌃⌘F on macOS); F11 again or Escape gives back the window as it was. Next Field, which Word puts on F11, keeps Word's Alt+F1 and Alt+Shift+F1 (and Shift+F11). The web app leaves full screen to the browser's own F11 |
| 2026-10-13 | Double-clicking the top or bottom margin of a page creates its header or footer when it has none and puts the caret in it, as in Word (#960): the one that page shows (different first page, odd and even pages), only in Print Layout; one left empty is taken away again when you leave it, so nothing empty is saved. `insert.editHeader`/`insert.editFooter` take `{"page"}` for agents |
| 2026-10-13 | Alt+numpad character codes, as in Word on Windows (#961): hold Alt, type a decimal code, release Alt — `0233` → é (Windows-1252), `233` → Θ (OEM code page 437; 850 in Western European locales and English outside the US), codes above 255 are Unicode (`8364` → €). Not on macOS; `text.typeCode` for agents |
| 2026-10-13 | Review › Language and the status bar's language menu offer Set Proofing Language… again (#860): the Language dialog marks text as French, German or any of 29 languages and Set As Default makes it the document's language; Language Preferences… opens Options › Language. Text in a language outside the short list shows by name in the status bar and checked in the menu. Spelling and grammar remain English, Polish and Portuguese; text in other languages gets no marks |
| 2026-10-12 | A list label moves with its text when the paragraph is centred or aligned to the end (it stayed at the margin), in left-to-right and right-to-left paragraphs (#1159) |
| 2026-10-13 | A paragraph that ends with a page break keeps its space after on the page before, as in Word: the next page no longer starts up to a line too low, and a page break before right after it no longer leaves a blank page |
| 2026-10-13 | Tables break across pages like Word's: a cell without its own margins lines up with its neighbours, exact row heights include the bottom margin, and a row whose margin doesn't fit at a page bottom is split with widow control instead of moving to the next page whole |
| 2026-10-13 | Equation parsers are hardened (#1456): deeply chained input (`x^1^1…`, `a/a/…`, `\sum\sum…`) no longer builds a tree thousands of levels deep that overflows small stacks, `\text{…}` is escaped in LaTeX output so it reads back unchanged, and runs merge inside every structure. |
| 2026-10-13 | A WMF with a million object records opens in milliseconds instead of a second, and a large polygon or a small crop of a large bitmap no longer builds a second full-size copy while it is read (#1463) |
| 2026-10-13 | PDF export (#1474): a horizontal or vertical line inside a group (a divider in a grouped box) is drawn, as on screen; it was left out because it has no height |
| 2026-10-14 | A paragraph that runs on into the next column or onto the next page wraps around the shapes there (#1374): its continued lines are broken again for that column's wrap areas instead of running through a shape there (or around one left on the page before); the lines before, and the paragraph's own floating objects, stay where they were. Paragraphs with nothing new to wrap around are not laid out again |
| 2026-10-13 | A PAGE field in the body shows the number of the page it is actually on (#1352): after a page break inside its paragraph, or when the paragraph runs over onto the next page, it no longer repeats the first page's number; the rest of the paragraph is broken again for the wider number (page 9 to 10). Only paragraphs with PAGE fields are laid out again |
| 2026-10-14 | RTF import keeps sections (#1378): a file with portrait and landscape parts, a two-column stretch or a page-numbering restart opens with one section each, as in Word, instead of one landscape section for the whole document. Each keeps its page size and orientation, margins, header/footer distance, columns, break type (continuous, next page, odd/even, column), page-number restart, start and format, title page and its own headers and footers; a section without its own links to the previous one's |
| 2026-10-14 | The system Save As dialog's file-type list names every format (Word Document, PDF, OpenDocument Text, Rich Text Format…) instead of one "Documents" entry, with the document's own format first (#1399) |
| 2026-10-14 | Colour emoji show on the page (#1269): 🧠, ✅ and other emoji were blank on screen and in `render_page` (only PDF export had them). Glyphs from colour fonts are now drawn in the font's own colours: bitmap emoji (Apple Color Emoji, Noto Color Emoji) and COLR fonts (Segoe UI Emoji, newer Noto builds, with gradients). A dark page doesn't invert them, the same as pictures |
| 2026-10-14 | `.doc` tables keep their cell shading (#1380), from Word 97 through Word 365, so a dark header row with white text is readable again, and lose the extra empty column at the right of every row; a table no longer takes the previous table's columns, borders or alignment. Rows merged across the table (a legend) come with #1123 |
| 2026-10-12 | On the web, pasting a picture while a comment, the Find box, a dialog field or the Backstage has the keyboard no longer inserts it into the document; it inserts once only when the page is the edit target (#1349) |
| 2026-10-14 | Table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls (#1329): with tracking on, Delete Table, Rows, Columns and Cells mark the cells' text deleted and keep the table (like Word), and Change Case is a tracked deletion and insertion, so Reject All restores the original; all of them refuse to delete a control locked against deletion or change content locked against editing |
| 2026-10-14 | Draw: Ink to Shape recognises arrows. A line and a V-shaped head drawn in one stroke (shaft first or head first, any direction) becomes a straight arrow connector with the head at the tip, in the stroke's colour and width; a lone V, a line with one barb and scribbles stay as they are. Our own recogniser: Word for Mac has no Ink to Shape to compare. Two-stroke arrows and arrows with a closed triangle head not yet. |
| 2026-10-12 | A DOCX hyperlink whose relationship has an empty target (as LibreOffice writes) is no link (#1005): no "Ctrl+Click to follow link" tooltip over the text, which keeps its Hyperlink style |
| 2026-10-12 | Table eraser beside vertically merged cells (#1039): the border between two cells merged down the same rows merges them into one cell that stays merged down those rows; a border whose two sides can't make one rectangle is hidden on both sides along the whole edge instead of refused |
| 2026-10-13 | Styles gallery for DOCX files that mark no style `w:qFormat` (LibreOffice) shows Word's usual quick styles: Normal, No Spacing, Headings, Title, Subtitle… (#1238) |
| 2026-10-13 | Pictures can be cropped on the page: Picture Format › Crop shows black crop handles; dragging one trims that side (a corner, two) while the picture keeps its scale and the visible part stays put, dragging back out restores it, and the cropped-away part shows under a dark dim. Esc, Enter, a click outside or Crop again keep the crop; each drag is one undo step |
| 2026-10-13 | Table Layout › Cell Size: the Height and Width boxes apply to every row and column the selection covers (a selected table changed only its last row and column), and to the caret's row and column without a selection (#1242) |
| 2026-10-12 | Forms protection: filling content controls under `w:edit="forms"` (#1077) — entries and typing inside fillable controls run, every other edit rolls back ("only the editable regions of this protected document can change"); `review.restrict` reports `formFields`; Find Next Region covers form fields (pane button shows for forms documents); a save/reopen keeps the protection, the filled values and the password guard |
| 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word |
| 2026-10-12 | `--help` and `-h` print the usage and exit (#968): `wordcraft --help` lists the app's options without opening a window (it used to try to open a file named `--help`), and `wordcraft-cli <command> --help` works for every command instead of failing with `unknown option --help` |
| 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them |
| 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started |
| 2026-10-11 | More of the table mouse: arrows above a column, left of a row and along a cell's left edge select it (a drag selects more; a selected column is edited cell by cell, numbering it makes one list), "+" buttons beside the borders insert a row or column, the move handle past the top-left corner selects the table or drags it to float where it's dropped, and a double-click on a column border fits the column; Select Column in the ribbon; `table.selectAt`, `table.insertAt`, `table.moveDrag`, `table.fitColumn` and `table.selectColumn` for agents |
| 2026-10-12 | Table fixes (#971): a double-click on a nested table's column border fits the column within the cell around the table (it could run out to the page's right margin); a selection from text into or across a table shades the whole cells of the rows it takes, not just their letters; Shift+Right at the end of a cell's text selects that cell first, as in Word, and the next press takes the next cell too |
| 2026-10-12 | AutoCorrect keys its built-in English replacement list by language (#835): Polish, Croatian, Bosnian and Serbian `i` ("and") is no longer turned into `I`; English and untagged text unchanged |
| 2026-10-12 | Ribbon groups fit above their labels: View › Show splits its four check boxes into two columns (Hide White Space, #645, had pushed the fourth onto the "Show" label), Developer › Controls loses the gaps that ran into its label, and Layout › Paragraph (#654) no longer trips a debug assertion and sits 2.5 pt higher; a test draws every tab in every language and fails on any group taller than the ribbon |
| 2026-10-12 | Down and Up from the start of a line beside a table go into it (#1007): the caret, left of the first cell's text there, used to skip the whole table to the paragraph beyond it; it now lands in the cell under it or the nearest one, as in Word |
| 2026-10-12 | Window caption: a non-Latin file name is drawn with a document fallback face (or the CJK UI font), instead of `?` |
| 2026-10-12 | Comments: Arabic, Hebrew and other non-Latin comment text and author names are drawn with a document fallback face (or the CJK UI font) in the balloons and Comments pane, instead of boxes; read-only balloons show right-to-left text in reading order, set against the right edge |
| 2026-10-13 | Word 365 cover pages (#743): shapes placed relative to the page's height now sit where Word puts them (they were at the top of the column), and a freeform drawn back to its start without being closed is filled, so the cover's coloured panel and its white title show |
| 2026-10-12 | 32-bit Windows starts on OpenGL. DirectX 12 graphics pipeline creation returns "The parameter is incorrect" and the app exited before the window appeared |
| 2026-10-12 | New documents follow the system region (#838): A4 and centimetres outside the US (Letter in the CLDR Letter territories, inches in the US), lengths written with the region's decimal separator (`2,54 cm`) and typed with either; an explicit Measurement units choice wins; `file.options {"region"}` for agents |
| 2026-10-12 | Paste from other programs keeps their formatting (#687): Ctrl+V and Home › Paste read the system clipboard's HTML (and RTF on Windows and Wayland); Merge Formatting takes the destination's font and paragraph and keeps bold, italic, underline and strikethrough; Paste Special lists what the system clipboard holds (HTML, RTF, picture, text). Linux: pictures copied in a browser or Spectacle paste on KDE and other Wayland desktops (data-control clipboard), and a paste that finds nothing usable names what the clipboard holds instead of "empty" (#833) |
| 2026-10-12 | Table style editor (#714): New/Modify Table Style edits each of the 13 table style parts (header row, total row, first/last column, odd/even bands, corner cells) with font, colour, fill, borders and cell alignment; every table using the style follows; all parts round-trip in DOCX; `table.modifyStyle` takes `part`/`parts` and per-edge borders |
| 2026-10-12 | STYLEREF fields (#775): running headers and footers show each page's chapter title (page's first, or `\l` last, paragraph of the style, else the nearest before), style names, ids, heading levels and character styles, number switches; body STYLEREF updates with the other fields; STYLEREF in the Field dialog; PDF export follows the layout |
| 2026-10-12 | Undo history list (#701): a ▾ beside the Quick Access Toolbar's Undo lists recent steps newest first; hovering highlights what a click undoes ("Undo 3 Actions"), and a click undoes them in one go. `edit.undoTo {count}` and `edit.undoHistory` (newest first, up to 100) for CLI and MCP |
| 2026-10-12 | Start screen (#766): launched with nothing to open, WordCraft starts on File › Home (Blank document, templates, Recent) like Word, desktop and web; Esc leaves a blank document; File › Options › General › Start-up turns it off |
| 2026-10-13 | No document behind the Start screen (#1129): Esc or Back from it shows an empty window (no page, ribbon greyed out but for Help) until Blank document, a template or a file is opened, as in Word; closing it asks nothing |
| 2026-10-12 | Word 365 cover pages (#743): a freeform inside a group keeps its shape (it was drawn as a rectangle); text boxes keep their own internal margins and Resize shape to fit text, so a tight title box no longer loses its second line; both survive saving. The missing fills in the report were theme-coloured shapes, fixed by #611 |
| 2026-10-12 | Alt+Q moves to "Tell me" command search, as in Word for Windows (#819); not on macOS, where Option+Q types a character |
| 2026-10-12 | PDFW: PDF export uses the shared craft-pdf writer for metadata, font preparation and serialization while retaining WordCraft layout, drawing, tagging, links and font-outline retry. Writer-only builds do not enable PDF import codecs. |
| 2026-10-12 | PDF export of long documents with many internal links (a linked table of contents) is linear: NIH's 67-page protocol template 10.6 s → 0.9 s |
| 2026-10-12 | Keytips: Word for Windows' tab letters (File F, Design G, References S, Review R, View W, Help Y) |
| 2026-10-13 | Keytips reach the table tabs as in Word: Alt, J, T for Table Design and Alt, J, L for Table Layout, then their controls' badges (#1183) |
| 2026-10-12 | Table-of-contents links look like Word's: plain text, not blue and underlined (Word ignores the Hyperlink style on TOC entry links; measured in 9 cases) |
| 2026-10-12 | Line heights like Word's: spaces and tabs never make a line taller (13 cases measured in Word for Windows); tables of contents with big tab runs were a third taller |
| 2026-10-12 | `.doc` import: tab stops (centred titles, right-aligned dates, leaders) and sections ending at their section marks (continuous breaks, footers); EPA .doc templates now paginate like Word |
@@ -172,30 +361,40 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-12 | A list label makes its line taller above the baseline only, as in Word (6 cases measured in Word for Windows): Symbol bullets no longer add their deeper descent, so bulleted lists keep Word's height and page breaks |
| 2026-10-12 | Hidden end-of-cell marks (`w:hideMark`) read, written and laid out like Word (9 cases measured in Word for Windows): a cell's empty last paragraph takes no room, so rows of such empty cells shrink to their borders |
| 2026-10-12 | Text frames (`w:framePr`) in headers, footers, cells and text boxes: placed apart with text wrapping around them, and kept on save (a framed footer page number took a line of its own and was lost on save) |
| 2026-10-13 | A paragraph with more than 65,536 distinct formats keeps each one (#1340): the layout numbered a paragraph's formats with 16 bits, so every run past that drew with the 65,536th format's font, size, link and tracked-change marking |
| 2026-10-12 | List indents like Word's when a numbered style has indents of its own: the style giving the list (and styles based on it) beat the level's indents, and a paragraph with its list switched off drops the styles' indents (27 cases measured in Word for Windows); NIH's Heading 3s now sit at their 0.9" |
| 2026-10-12 | Keep with next like Word (20 cases measured in Word for Windows): a kept paragraph moves to the next page only when it would fit on this one by itself, otherwise it breaks as usual; chains of kept paragraphs move together |
| 2026-10-12 | An empty paragraph that only carries a continuous section break takes no room, as in Word (10 cases measured in Word for Windows): text after it follows right on and balanced columns don't count it |
| 2026-10-12 | Floating objects positioned relative to their paragraph start above its space before, as in Word (10 cases measured in Word for Windows) |
| 2026-10-12 | Unequal columns: text after a column break is broken for the column it lands in (NIH's SARP form had a paragraph a word or two wide) |
| 2026-10-13 | New comments, notes, text boxes and lists in a document whose file used the largest ids (#1338): they take a free id instead of overflowing (a crash) or reusing one already taken (a new list could pick up another's definition); `Document::try_add_part` and `Numbering::try_add_list` report ids running out instead |
| 2026-10-12 | Space before at the top of a page like Word after page breaks (32 cases measured): a paragraph ending with a page break keeps its mark with it, the next paragraph gets no space before; page break before drops it in Word 2013 mode |
| 2026-10-12 | Automatic paragraph spacing drops between list items, as in Word (16 cases measured in Word for Windows): bulleted items that ask for automatic space before and after now touch, as in NIH's monitoring plan |
| 2026-10-12 | Rendering like Word for Windows, found by comparing real .gov documents page by page: text is kerned only where Kerning for fonts asks for it (line breaks now match Word's), and automatic paragraph spacing (`w:beforeAutospacing`/`w:afterAutospacing`, common in pasted web text) is 14 pt, dropped at the document's and cells' edges |
| 2026-10-12 | Keyboard context menu (#821): Shift+F10 opens the page's right-click menu below the caret; `ui.contextMenu` |
| 2026-10-12 | The Font list no longer keeps every family it previews in memory (#848): the Font box drew each family's name in its own face by loading the whole family for good, so every trip through the list kept tens of megabytes (1.47 GB after 40 changes on macOS); entries are now drawn from a face read for the preview alone and freed (120 families previewed: +17 MB RSS instead of +411 MB); only fonts actually used load |
| 2026-10-13 | An empty file opens (#1384): the zero-byte .docx that File Explorer's New › Microsoft Word Document makes opens as a new blank document (the region's paper, the editing language), and Save writes it back to that file instead of asking for a name |
| 2026-10-12 | Scrolling a document with one very large table stays smooth (#811): pages are fingerprinted for the texture cache once per layout and only when shown (not every page every frame), the markup-area check is cached per revision, and textures far from the view are dropped. The issue's 1,096-page table: ~600 ms → ~2.4 ms idle frames, ~590 ms → ~4.5 ms scroll frames |
| 2026-10-13 | Paragraph dialog over differently formatted paragraphs (#1346): a field they differ in shows blank (a check box neither on nor off, no direction picked) and OK leaves it as each paragraph has it; OK sends only the fields that changed, as one undo step, instead of giving every paragraph the caret paragraph's formatting in three undo steps |
| 2026-10-12 | Search (#819): Alt+Q, or Alt then Q, opens the Search box from anywhere with nothing typed into it; queries match every word; `tools.search` for agents |
| 2026-10-12 | Section breaks (#852): Layout › Breaks with text selected puts the break before the selection and keeps the text, as Word does; it used to delete it, leaving the first section empty |
| 2026-10-12 | Find, Replace and Go To like Word (#712): Ctrl+F types into the Navigation pane's search, one tabbed Find and Replace dialog, Go To by page (absolute or relative) and bookmark; `edit.findReplace`, `edit.goto` `pageBy` |
| 2026-10-12 | Symbol fonts (#776): text in Wingdings, Wingdings 2, Wingdings 3, Webdings and Symbol (proprietary, never bundled) shows its intended symbols — full code tables from Unicode's published correspondence, both storage forms (`w:sym` private-use codes and plain codes); PDF text, copy and TXT export use the Unicode characters; the saved text is unchanged |
| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged |
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|---|---|
| 2026-10-12 | Czech proofing: cs-CZ spelling reads the installed Hunspell dictionary (`hunspell-cs` on Debian/Ubuntu), suggestions use runtime MAP/REP/TRY/KEY tables, generic repeated-word and spacing checks explain in Czech; Review › Language offers Czech/Čeština; the Czech interface writes new documents and opened TXT/Markdown in cs-CZ. Affix headers accept trailing comments. No system dictionary data or Czech hyphenation patterns bundled; parity estimates unchanged |
| 2026-10-12 | Tables: dragging the border of one selected cell (Select Cell, or a click along its left edge) moves it in that cell's row only, as in Word, like the border of cells selected across (#962) |
| 2026-10-13 | Korean interface catalog completed: all 2,463 entries translated; the ko-KR system locale and saved language choice select the interface. The UI prefers Korean embedded/system font faces so Hangul renders instead of boxes, and print settings and printer feedback are translated. Korean proofing remains unavailable; macOS Korean IME fixes are still pending (#163, #164) |
| 2026-10-12 | Word default shortcuts expanded (gap #24): 140 commands bound on macOS (169 combinations), 158 on Linux/Windows (188 combinations). Bound Style Inspector (Shift+F1), Macros (Alt+F8), Strikethrough (Mod+Shift+X), Find Next (Shift+F4), Copy/Paste (Mod+Insert, Shift+Insert), Go Back (Mod+Alt+Z), File Open/Save/Save As/Print (Mod+Alt+F2, Alt+Shift+F2, Alt+F2, Mod+Alt+I), Field navigation (Alt+F1, Alt+Shift+F1), Outline show first line (Alt+Shift+L), Next Footnote (Alt+Shift+>), Link to Previous (Alt+Shift+R), AutoText (Alt+F3), Spelling on Mac (Mod+Alt+L) |
| 2026-10-13 | Web and Draft Layout now render visible full-resolution raster tiles for exceptionally tall pages rather than downsampling the entire page into one texture; verified with a 440-paragraph headless UI reproduction |
| 2026-10-12 | Track Changes: Accept All after a tracked deletion from the end of one paragraph into a later one no longer joins the paragraph that follows the range, and Reject All after typing into an empty paragraph keeps that paragraph (#864) |
| 2026-10-12 | Ctrl+drag copies an object (#1014): holding Ctrl (⌘ on macOS) while dragging a picture, shape or text box leaves the original and drops a copy where you let go (text boxes get their own text); the copy is selected, the clipboard is untouched, one undo step; `arrange.copyTo` |
| 2026-10-11 | Table border drags like Word's (#711): Ctrl shares a column border's change among the columns right of it (the table keeps its width), the table's left edge moves a left-aligned table's indent (a centred or right-aligned table keeps its alignment, as in Word), a border of cells selected across moves only in their rows, and Alt shows the column widths on the ruler |
| 2026-10-11 | Tables on the rulers (#700): with the caret in a table the horizontal ruler marks its row's column borders and the vertical ruler its rows' bottoms; dragging a marker resizes the column (the columns right of it move along; with Shift the neighbour makes up the difference) or the row, one undo step, Escape cancels; in a cell the indents and tabs are measured from the cell |
| 2026-10-12 | Save Changes prompt answers from the keyboard: Save has focus when it opens; arrow keys and Tab move between Save, Don't Save and Cancel; Enter answers (#861) |
| 2026-10-12 | Builds with craft-fonts draw simplified-Chinese characters in documents from the bundled Noto Sans CJK SC (it was only used by the interface), so a CJK run gets one face covering it; fixes the FreeBSD job's `east_asian_run_uses_one_face_covering_the_whole_run` failure after #567 |
| 2026-10-12 | Insert › Picture, drag-and-drop and paste take Windows Metafiles (`.wmf`) and Enhanced Metafiles (`.emf`) at their own frame size (#802) |
| 2026-10-14 | A picture or icon inserted by a command (`wordcraft-cli run`, MCP, control channel) leaves the caret after it, so the next `text.newParagraph` or `text.insert` no longer deletes it, and two inserted in a row are both kept; Picture Format commands still act on it, and a picture the user inserts or pastes in the app is still selected (#965) |
| 2026-10-12 | AutoCorrect capitals (#544): Correct TWo INitial CApitals (with exceptions), accidental cAPS LOCK, first letter of table cells; each undoable and switchable |
| 2026-10-11 | Turkish (`tr`) interface completed (#774): 2,325 entries, every key of the Russian catalog (equation gallery, built-in style names, watermarks, paper sizes, preview tiles); the interface audit checks Turkish too |
| 2026-10-12 | Navigation headings truncate within the resizable pane, with an ellipsis for long titles and full text on hover (#759) |
@@ -206,7 +405,9 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | Polish: interface (`pl`, the thirteenth language, every catalog key) and proofing — spelling against the installed Hunspell `pl_PL` dictionary (read at runtime, not bundled) through a new Hunspell-style affix engine, suggestions tuned for Polish, Polish grammar rules with Polish messages, TeX Polish hyphenation patterns (MIT option); proofing language per run (`w:lang`) for squiggles, F7, suggestions and hyphenation, languages without tools left unmarked; Review › Language menu and the status bar's language; a Polish interface writes new documents in Polish |
| 2026-10-11 | German (`de`) interface (#574): 2,021 translated entries covering the full zh-hans key set, locale and saved-preference tests, bundled-font checks; no German proofing resources |
| 2026-10-12 | Sorting and table numbers follow the text's language (#836, #837): Table › Sort, Home › Sort, the index (with its letter headings), the bibliography and the Table of Authorities use the language's alphabet (Polish `Łódź` between `Kraków` and `Zakopane`, Swedish `å ä ö` after `z`, German `ä` with `a`); table sort and formulas read `1,5` and `1 250,00` as Polish decimals and `=SUM` writes `5,75` |
| 2026-10-14 | `.doc` notes, headers and text boxes read their text once (#1319): a story table that steps back no longer has many footnotes, endnotes, headers or text boxes copy the same text into a part each; a story starting inside an earlier one is dropped (a header or footer left empty is linked to the previous section's), so the stories together never hold more than the file's text |
| 2026-10-11 | `.doc` import keeps pictures and drawings (#551): inline and floating pictures (PNG, JPEG, DIB, EMF, WMF), shapes, lines, groups and text boxes with position and wrap, from [MS-ODRAW] |
| 2026-10-14 | ODT and `.doc` pictures stay within their byte budget (#1334): an ODT picture entry is inflated once however many frames point at it, and only when the size it declares fits in what is left (one inflating past that uses the budget up); a `.doc` inline picture that can't be read or doesn't fit is no longer decoded and copied again for every anchor on it, and is decoded only within what is left |
| 2026-10-11 | Tables resize with the mouse (#217): drag a column border (the neighbour makes up the difference; Shift moves one column), a row's bottom border, or the handle past the bottom-right corner to scale the table; a dashed guide while dragging, one undo step per drag; `table.resizeDrag` for agents |
| 2026-10-11 | Animation effects setting: File › Options › Accessibility › Animation effects (Use system setting, On, Off). With the system's animations off (Windows' Animation effects, macOS' Reduce motion, the Linux/BSD desktop's setting, a browser's reduced motion) the page no longer smooth-scrolls: wheel notches and Ctrl+wheel zoom land at once, in the document and in every list and pane, and menus and buttons don't fade; `ui.animations` for agents |
| 2026-10-11 | Linux/BSD: Open, Save As, Export and Insert Picture work without a file chooser portal or zenity (labwc, sway…): WordCraft shows its own file browser and says how to get the system dialog; File › Open › Browse in WordCraft… is always there (#576) |
@@ -217,8 +418,11 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | File › Print: page preview, page range (all, current, selected, custom 1-3,5), odd/even, copies and collate go into the printed PDF (`file.printPdf`, `ui.print` params); printer, duplex and pages per sheet are still the system dialog's (#641, stage 1) |
| 2026-10-11 | A vertically merged table cell (eraser on a horizontal border) lays out, shows and clicks as one cell spanning its rows (#640) |
| 2026-10-11 | Word XML files (#548): Word XML Document (Flat OPC `.xml`) opens and saves through the DOCX reader and writer; Word 2003 XML opens (styles, lists, tables, sections, notes, comments, pictures, hyperlinks) |
| 2026-10-14 | Open DrawingML freeform paths draw `a:tailEnd` at the final path segment |
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
| 2026-10-11 | Font fallback indexes which characters each installed font maps, reading each font file once (#605): text in a script no installed font covers no longer re-reads every system font per distinct character (200 such characters: 13 s → 0.2 s) |
| 2026-10-13 | Markdown import of text full of backtick runs that never close is linear (#1336): every code span and link bracket over them searched the rest of the text for a closing run (2 MB in brackets: 17.8 s → 0.06 s) |
| 2026-10-13 | Markdown import keeps every link in a paragraph (#1452): a `[` nothing closes, bracketed text that is not a link and each link used up the bracket scans' look-ahead budget as if they had read the rest of the paragraph, so later links were plain text; a scan now costs what it reads, and brackets it has passed are looked up |
| 2026-10-11 | Find and Replace special characters (`^p`, `^t`, `^#`, `^&` …) and Use wildcards (ranges, counts, word anchors, groups with `\1` in Replace); `^p` finds and splits paragraphs; Special ▾ menu and search options in the dialog (#543) |
| 2026-10-11 | Typing parity (#546): `+---+` Enter makes a table, `> ` `-> ` `=> ` arrow bullets, smileys inside words (`ok:)`), Undo right after a border line brings the characters back |
| 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, built into Windows, so the app no longer quits at launch with "Parent device is lost" when no matching `dxcompiler.dll` + `dxil.dll` pair is around (#603) |
@@ -226,7 +430,9 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | Outline view (#493): Outlining tab, outline symbols and indents, collapse/expand, Show Level, first line only, plain text; catalog 428/450 → 441/463 (95.2%) |
| 2026-10-11 | Vertical sections (#491): Layout › Text Direction turns the section (or the cell or text box the caret is in); lines run top to bottom, right to left, ideographs upright; `w:textDirection` in `w:sectPr` read and written |
| 2026-10-11 | File › Options in Word's panes (#485): General, Display, Proofing, Save, Language, Accessibility, Advanced, Agents; initials, individual formatting marks, spelling ignore options, custom dictionary, overtype, typing replaces selection, measurement units, recent-documents count, print options; `file.options` reads and sets them headlessly |
| 2026-10-14 | Word XML Document (Flat OPC) reading is bounded everywhere in the file (#1353): nesting and elements are capped as the XML tree reader caps a part's, every child of the package and every part's data count against the part count and size limits (a repeated part name too), and data is checked before it's copied; base64 written as CDATA or with character references reads as the same bytes, and an entity in it is an error instead of bytes gone missing |
| 2026-10-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) |
| 2026-10-14 | Text boxes in tables nested deeper than 16 survive edits (#1339): the paragraph walk behind text box pruning, copy and paste, reading order, drawing ids and note conversion goes through tables however deep they nest instead of stopping 16 tables down, so an unrelated edit no longer drops the text of a box shown only from deeper, and a copied deep table takes its boxes along |
| 2026-10-11 | Caret animation: keys and typing glide the caret to its new place in 100 ms along the curve measured from Word for Windows, and the blink fades; clicks and scripted moves don't glide. File › Options › Advanced › Animation turns it off, kept between runs; `ui.caretAnimation` for agents |
| 2026-10-11 | Paragraph dialog options (#512): mirror indents (`w:mirrorIndents`, laid out on even pages), same-style spacing, suppress line numbers, don't hyphenate |
| 2026-10-11 | Screen readers can read the document canvas (#488): paragraphs, headings, lists, tables, links, pictures and the caret exposed through AccessKit |
@@ -262,11 +468,17 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-12 | Native saves use exclusively created sibling temporary files, synchronize before replacement and clean up on failure; existing temporary-name files/links and original files on failed saves are protected. Synthetic CLI and focused tests on macOS; no new Word parity or readiness claim. |
| 2026-10-11 | Field codes like Word's field keys (#339): show codes (Alt+F9, Shift+F9), edit, unlink, lock and step through fields |
| 2026-10-11 | Word's common keyboard shortcuts (#330): 101 → 125 of ~250 |
| 2026-10-13 | Swedish (`sv`) interface: 2,504 catalog entries (every key of the reference catalog, the equation gallery, everything the interface audit finds, and 44 newer strings no catalog had yet, among them the native print settings), `sv-SE`/`sv-FI` system locales and a saved language choice; locale, catalog and bundled-font tests; AI-assisted, terminology and the running app reviewed by a native speaker; no Swedish proofing resources |
| 2026-10-12 | Bulgarian (`bg`) interface: 2,419 catalog entries (every key of the reference catalog, the equation gallery and everything the interface audit finds), `bg-BG` system locales and a saved language choice; locale, catalog and bundled-font tests; no Bulgarian proofing resources |
| 2026-10-13 | Croatian (`hr`) interface: 2,507 catalog entries (every key of the reference catalog, built-in style names and the equation gallery), `hr-*` system locales and a saved language choice; locale, catalog and bundled-font tests; no Croatian proofing yet |
| 2026-10-11 | Hungarian (`hu`) interface: all 2,419 catalog entries, built-in style names translated, `hu-HU` system locales and a saved language choice; locale, catalog and bundled-font tests; no Hungarian proofing resources |
| 2026-10-11 | Turkish (`tr`) interface: all 1,307 catalog entries, `tr-TR`/`tr-CY` system locales and a saved language choice; command search keeps Turkish dotted and dotless I apart (`italik` finds İtalik); locale, catalog, search and bundled-font tests; no Turkish proofing resources |
| 2026-10-11 | Italian (`it`) interface (#594): 2,399 entries, feature names as Italian Word users know them, locale and saved-preference tests, bundled-font checks; no Italian proofing resources |
| 2026-10-12 | Native drawing-level Word ink preserved through DOCX edits and repeated saves, including its InkML part and optional VML picture fallback; native ink rendering and editing remain missing |
| 2026-10-12 | List-label line height: automatic spacing scales body text before adding label ascent overflow; AtLeast spacing keeps text descent. Original Word for Mac probes and deterministic layout tests cover symbol/decimal labels, wrapped lines, fixed/minimum spacing and inline pictures. Overall readiness unchanged; [evidence](docs/layout-parity.md#list-label-line-spacing) |
| 2026-10-12 | Local desktop recovery: unsaved drafts and edits are snapshotted in the profile; File › Recover restores an unsaved copy or confirms discard. Full model storage includes media and opaque parts. Password sessions skip plaintext snapshots. See [recovery.md](docs/recovery.md); stability estimates remain unchanged. |
| 2026-10-12 | Legacy table paragraph spacing: `allowSpaceOfSameStyleInTable` survives DOCX save; enabled modes 11/12/14 keep contextual same-style spacing in cells. Mode 15 and non-cell controls unchanged; measured against Word for Mac 16.113.3 |
| 2026-10-11 | Contextual paragraph spacing: same-style first-only 12/8 pt and 10/12 pt gaps corrected to 0 pt and 2 pt in body and header layout, with per-side controls; measured against Word 16.113.3. Ordinary overlap remains #108 |
| 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) |
| 2026-10-11 | Interface size setting (#475): File › Options › General scales the interface 80–200 % on top of the system display scale, kept between runs; `ui.interfaceSize` for agents |
| 2026-10-11 | DOCX paragraph defaults: preserve omission separately from explicit empty defaults, with repeated-save and default-edit regressions |
@@ -282,11 +494,135 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-07 | v0.1.0 released; contributor credits in About (#6) |
| 2026-10-06 | Layout arc: wrap around floats, text boxes, page borders, line numbers, row splitting, drop caps, hyphenation, comment balloons; release pipeline for every platform; first estimate (~62% real feature parity, 120–150 h to 100%) |
| 2026-10-05 | M0–M12 first versions: model, layout, render, engine with 200+ commands, Word-style UI, DOCX I/O, PDF and other formats, references, review, mailings, CLI, MCP, web |
| 2026-10-12 | Mail-merge recipients (#778): legacy Excel .xls workbooks; CSV/TXT lists in GBK, Big5, Shift_JIS, UTF-16 and other encodings read correctly (plain-text import too); Edit Recipient List filters (equal, not equal, less, greater, blank, contains… joined by And/Or) and sorts, and preview, Finish & Merge and labels use only the matching records in that order (`mailings.filterRecipients`, `mailings.sortRecipients`) |
| 2026-10-13 | The interface-translation audit runs in CI again (#1128): the size labels of Envelopes, Labels and Layout › Size are translated (`4,13 × 9,5 дюйма`) and use the region's decimal separator, and status-bar messages (table split and merge, ink replay, citations, SmartArt, Researcher, Design defaults), the Reading Mode toolbar, Go To and Search's Suggested heading are translated in every interface language, and the Read Aloud player no longer shows a speech error a frame before the status bar does, the race behind the audit's rare failure under load |
| 2026-10-15 | Lists (#1683): a bullet stored as a private-use character its font can't draw (the font isn't installed) shows the level's default bullet (• ○ ▪); it used to be whatever an installed icon font has at that code, such as Font Awesome's Twitter logo |
| 2026-10-15 | The web app keeps its preferences between visits (#2132): theme (dark mode), interface size, editing options and the rest of what the desktop app saves in `ui.json` are kept in the browser's `localStorage`, written as soon as they change |
| 2026-10-17 | Saving into a folder that refuses a temporary file (macOS privacy protection, read-only folders, #2138) never overwrites an existing document in place: it is left untouched and the error says to use Save As to another folder or allow WordCraft access to the folder (System Settings › Privacy & Security › Files and Folders on macOS). A new file is created directly. Unit tests on Linux; not yet confirmed on a Mac |
Footer page-table height and body wrapping are corrected. Other pagination gaps remain open.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 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 |
| 2026-10-18 | trivial | Progress log: long navigation headings wrap within the pane instead of ending in an ellipsis (#3015) |
| 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-17 | trivial | Progress log: recipient list search and merged-only view (#1576) |
| 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) |
| 2026-10-18 | trivial | Progress log: Picture Format › Size, sizes typed in any unit and the Size dialog (#2573); readiness estimates unchanged |
| 2026-10-18 | trivial | Progress log: Manage Sources imports Word's `Sources.xml` (#2640) |
| 2026-10-14 | trivial | Progress log: a list item's first word is not flagged for a missing capital (#1741) |
| 2026-10-18 | trivial | #2445: pending Save As answers keep their originating document identity, so a replacement document is never written to an old picker destination; real Save-button and focused-editor shortcut controls verified |
| 2026-10-16 | trivial | #2445: interactive Save on import-only .doc/.dot paths uses the existing DOCX Save As flow; cancel and failed writes keep the draft, and Close waits for a successful save. Explicit/script saves still report unsupported legacy output |
| 2026-10-16 | trivial | Save Changes exposes an alert-dialog role, the document-specific question and loss/failure context to accessibility; full-app focus/ancestry regression. Native screen-reader verification remains pending. |
| 2026-10-16 | trivial | Progress log: the Spike |
| 2026-10-16 | trivial | Progress log: `w:eastAsianLayout` kept on save |
| 2026-10-15 | trivial | Progress log: undrawable private-use list bullets show the level's default bullet (#1683) |
| 2026-10-15 | trivial | Progress log: web preferences kept between visits (#2132) |
| 2026-10-15 | trivial | Progress log: Recently Used Fonts in the font box (#2068) |
| 2026-10-15 | trivial | Progress log: one Thai face for a run in a missing Thai font (#1549) |
| 2026-10-18 | trivial | Progress log: Different First Page headers reachable from page 1 (#2826) |
| 2026-10-18 | trivial | Progress log: bold Black/Heavy fonts are not emboldened again |
| 2026-10-16 | trivial | Progress log: Web Layout fills the window width (#1876) |
| 2026-10-17 | trivial | Progress log: DOCX glossary documents (building blocks) |
| 2026-10-15 | trivial | Progress log: move options in Track Changes Options (gap #5) |
| 2026-10-15 | trivial | Progress log: image size limits checked before allocation (#1321) |
| 2026-10-17 | trivial | Progress log: Read Aloud speaks each sentence in its own language, #1843 |
| 2026-10-18 | trivial | Progress log: `wordcraft-cli run --script` (#1963) |
| 2026-10-18 | trivial | Progress log: header-cell picture watermarks drawn behind the text (#1775); readiness estimates unchanged |
| 2026-10-14 | trivial | Progress log: comment drafts kept and written before saving or closing (#1348) |
| 2026-10-17 | trivial | Language dialog keeps inherited proofing language and no-proof choices (#2717); readiness estimates unchanged |
| 2026-10-14 | trivial | Progress log: ASK and SET fields show nothing (#1765) |
| 2026-10-15 | trivial | Progress log: at-least table rows start the next page whole (#1742) |
| 2026-10-14 | trivial | Progress log: ODT sections on export (gap 19) |
| 2026-10-17 | trivial | Explicit DrawingML preset/custom outline dashes survive import/save and paint in raster/PDF, scaled to the effective stroke width even for zero-width paths. Synthetic controls; theme-inherited dashes and line caps remain unverified. |
| 2026-10-15 | trivial | `wordcraft-cli convert --update-fields` explicitly refreshes unlocked fields before conversion; ordinary conversion keeps cached results. Field refresh can change pagination |
| 2026-10-17 | trivial | Arial Unicode MS uses Arial and its proportional substitutes; glyph coverage and exact Word line height remain font-dependent. |
| 2026-10-18 | trivial | Progress log: Split Cells and Convert Text to Table dialogs, matched to the Word check (#2592) |
| 2026-10-16 | trivial | Progress log: tall-ascent font list entries no longer cut off (#2570) |
| 2026-10-16 | trivial | Progress log: Read Mode keeps the keyboard, whole columns, page-turn bar clear (#1772) |
| 2026-10-16 | trivial | Progress log: comments readable in the dark theme (#2045) |
| 2026-10-16 | trivial | Progress log: Indonesian proofing (#498) |
| 2026-10-16 | trivial | Progress log: legacy outline, shadow, emboss and imprint text effects drawn (#2534) |
| 2026-10-15 | trivial | Progress log: Columns on selected text (#2024) |
| 2026-10-15 | trivial | Navigation Find and query edits expose Results (#2096) |
| 2026-10-15 | trivial | Progress log: numbered headings (#1793) |
| 2026-10-15 | trivial | Add the missing `-->` → `→` AutoCorrect entry (#2223), with typing, undo and preference regression coverage; existing arrow bullets unchanged |
| 2026-10-15 | trivial | Progress log: Update Field updates the part being edited, as in Word (#1706) |
| 2026-10-14 | trivial | Progress log: Update Fields reaches headers, footers and text boxes (#1706) |
| 2026-10-15 | trivial | Progress log: font family caches bounded; math font resolution follows font changes (#1354) |
| 2026-10-15 | trivial | Progress log: web typing in non-Latin text no longer repeats (#1480) |
| 2026-10-15 | trivial | Progress log: Backspace removes a selected table, rows or columns (#1844) |
| 2026-10-16 | trivial | Progress log: Hebrew field results and smart quotes (#1670) |
| 2026-10-16 | trivial | Progress log: Ctrl+wheel zoom no longer scrolls the document (#1743) |
| 2026-10-16 | trivial | Hint-reliant CJK fonts execute TrueType contour assembly; Windows DFKai-SB no longer loses strokes. Synthetic local-font regression; no font binaries bundled. |
| 2026-10-14 | trivial | Progress log: RTF export writes sections (gap 19) |
| 2026-10-15 | trivial | Progress log: multi-letter equation scripts (#1609) |
| 2026-10-15 | trivial | Progress log: plain-text paste keeps the paragraph formatting where it lands |
| 2026-10-14 | trivial | Progress log: Add Font imports every selected font file (#1789) |
| 2026-10-15 | trivial | Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. |
| 2026-10-15 | trivial | Exact line spacing keeps the run font baseline around tall equations; automatic and minimum spacing still grow for math. |
| 2026-10-16 | trivial | Zero-extent drawing anchors contribute no inline font height. Synthetic regressions. |
| 2026-10-16 | trivial | Control keyboard/text input works in the inactive native window (#2420) |
| 2026-10-15 | trivial | Progress log: Hide Quick Access Toolbar (#1945) |
| 2026-10-15 | trivial | Progress log: native ink nested in groups and drawing canvases kept on save |
| 2026-10-15 | trivial | Progress log: the word being typed gets no squiggle until the caret leaves it (#1552) |
| 2026-10-15 | trivial | Progress log: Highlight No Color sticks to the button (#2105) |
| 2026-10-16 | trivial | Body and active note text keep normal opacity while editing footnotes or endnotes (#2545); header/footer dimming is unchanged |
| 2026-10-17 | minor | Progress log: Drop Cap Options (#2601) |
| 2026-10-16 | trivial | An otherwise empty section at page start retains its line before continuous content; later closing marks stay quiet |
| 2026-10-16 | trivial | Progress log: equations from Word files keep their content (#1273) |
| 2026-10-16 | trivial | Progress log: Insert Caption… on objects and Page Setup header and footer options (#1230) |
| 2026-10-17 | trivial | SECTIONPAGES includes shared continuous-section pages in body and header/footer fields |
| 2026-10-17 | trivial | Footer page tables no longer inflate footer text height; body lines wrap at their physical bounds |
| 2026-10-17 | minor | Progress log: Scale to paper size on File › Print, #2108 |
| 2026-10-17 | trivial | Non-overlapping body floating tables clear header tables; overlapping tables keep their requested position |
| 2026-10-15 | trivial | Progress log: caret inside ligatures (#1974) |
| 2026-10-14 | trivial | Progress log: row table border exceptions |
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
| 2026-10-12 | trivial | Progress log: one selected cell's border drags in its row only (#962) |
| 2026-10-16 | trivial | Progress log: opening a DOCX no longer copies its pictures |
| 2026-10-12 | minor | Progress log: Word default shortcuts expanded to 140 commands on macOS (169 combinations) and 158 on Linux/Windows (188 combinations), addressing gap #24 |
| 2026-10-13 | trivial | Web/Draft long-page blur fixed with visible full-resolution raster tiles |
| 2026-10-14 | trivial | Progress log: VML freeforms read and kept on save |
| 2026-10-14 | trivial | Progress log: consistent preferred cell widths |
| 2026-10-14 | trivial | Field instructions spanning paragraphs no longer add blank lines during pagination; empty result paragraphs still take a line. |
| 2026-10-14 | trivial | A list paragraph containing only a page break has no visible label; it still advances the list counter. Ordinary empty list items keep their label. |
| 2026-10-14 | trivial | Floating-table overlap checks use visible line content; empty line-break lines no longer move a table |
| 2026-10-14 | trivial | Progress log: deleting a comment leaves no dangling anchors or replies (#1345) |
| 2026-10-14 | minor | Legacy DOC DIB conversion byte limits only; related #1334/#1554, stage and readiness estimates unchanged |
| 2026-10-15 | trivial | Progress log: Ink to Shape two-stroke and closed-head arrows |
| 2026-10-13 | trivial | Progress log: justified vertical page alignment (#1320) |
| 2026-10-14 | trivial | Progress log: file names that aren't valid Unicode (#1440) |
| 2026-10-13 | trivial | Behind Text pictures and shapes drag over overlapping text in the body and headers, selected or not, while ordinary clicks keep editing text; In Front of Text drag covered too |
| 2026-10-13 | trivial | Progress log: the watermark written to DOCX (#1290) |
| 2026-10-13 | trivial | Progress log: Word's text watermark read from DOCX (#1290) |
| 2026-10-13 | trivial | Progress log: Insert Hyperlink › Place in This Document (#1234) |
| 2026-10-13 | trivial | Progress log: Insert › Bookmark deletes and goes to bookmarks (#1236) |
| 2026-10-12 | trivial | Progress log: an option to show only the selected text's comment (#1055) |
| 2026-10-12 | trivial | Progress log: clicking in a comment's text moves its caret (#1013) |
| 2026-10-12 | trivial | Progress log: a press on the ribbon no longer scrolls the document (#1019) |
| 2026-10-12 | trivial | Progress log: Ctrl+drag copies an object (#1014) |
| 2026-10-12 | trivial | Document title opens Rename Document; the account badge opens Options › General for user name and initials. |
| 2026-10-13 | trivial | Progress log: rounded rectangle corners (#1476) |
| 2026-10-13 | trivial | Progress log: PAGEREF in headers and footers (#1477) |
| 2026-10-13 | trivial | Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; existing deletion-lock rules reused, with tracked/untracked regression coverage |
| 2026-10-14 | trivial | Progress log: print-area strings translated in every catalog |
| 2026-10-14 | trivial | Progress log: All Caps and Small Caps draw ß as SS (#1581) |
| 2026-10-13 | trivial | Progress log: Windows Magnifier follows the caret onto a new line (#1119) |
| 2026-10-13 | trivial | Progress log: a window too big for its screen shrinks to fit (#1215) |
| 2026-10-14 | trivial | Progress log: Page Setup sections are validated (#1582) |
| 2026-10-13 | trivial | Progress log: `--control` checks its port (#1426) |
| 2026-10-14 | trivial | Progress log: words beside a floating picture are no longer split (#1376) |
| 2026-10-14 | trivial | Progress log: VML preset shapes (#1377) |
| 2026-10-14 | trivial | Progress log: Russian letter list labels |
| 2026-10-14 | trivial | Progress log: the save prompt takes the keyboard from the page (#1597) |
| 2026-10-12 | trivial | Progress log: VML text box margins, fit and alignment; hidden drawings |
| 2026-10-13 | trivial | Progress log: comment balloon buttons work, empty comments can be deleted (#1085) |
| 2026-10-12 | trivial | Progress log: sample-text formulas on Enter (#1067) |
@@ -294,14 +630,50 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-13 | trivial | Progress log: the caret contrasts with its background (#299) |
| 2026-10-13 | trivial | Progress log: clicks on ribbon chrome keep typing in the document (#1093) |
| 2026-10-13 | trivial | Progress log: dragged shapes wrap in their new column (#884) |
| 2026-10-14 | trivial | Progress log: All Caps and Small Caps text extracts from PDFs as shown (#1473) |
| 2026-10-14 | trivial | Progress log: the web's Save before New/Open waits for the save picker (#1315) |
| 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) |
| 2026-10-14 | trivial | Progress log: Inspect Document removes hidden text in every story (#1344) |
| 2026-10-14 | trivial | Progress log: ODT tracked changes (gap 19) |
| 2026-10-14 | trivial | Progress log: RTF tracked insertions and deletions (gap 19) |
| 2026-10-14 | trivial | Progress log: alignment guides while dragging pictures and shapes |
| 2026-10-14 | trivial | Progress log: ODT sections on import (#1379) |
| 2026-10-14 | trivial | Progress log: right-to-left text in exported PDFs extracts as whole words (#1383) |
| 2026-10-14 | trivial | Progress log: HTML header cells, cell alignment and region paper for opened HTML/Markdown/text (#1381) |
| 2026-10-14 | trivial | Progress log: custom page size from More Paper Sizes… and Page Setup (#1433) |
| 2026-10-13 | trivial | Progress log: highlighted text on a dark page (#1252) |
| 2026-10-13 | trivial | Progress log: full screen on F11 (#980) |
| 2026-10-13 | trivial | Progress log: interface audit back in CI (#1128) |
| 2026-10-13 | trivial | Progress log: double-click the margin to create and edit a header or footer (#960) |
| 2026-10-13 | trivial | Progress log: Set Proofing Language… in the Language menu (#860) |
| 2026-10-12 | trivial | Progress log: list label follows paragraph alignment (#1159) |
| 2026-10-13 | trivial | Progress log: space after a page break |
| 2026-10-13 | trivial | Progress log: table row margins and splitting |
| 2026-10-13 | trivial | Progress log: lines inside groups in PDF export (#1474) |
| 2026-10-14 | trivial | Progress log: continued paragraphs wrap around the next column's shapes (#1374) |
| 2026-10-13 | trivial | Progress log: PAGE fields on the pages a paragraph runs onto (#1352) |
| 2026-10-14 | trivial | Progress log: RTF import keeps sections (#1378) |
| 2026-10-14 | trivial | Progress log: colour emoji drawn on screen and in page renders (#1269) |
| 2026-10-14 | trivial | Progress log: `.doc` table shading and the extra column (#1380) |
| 2026-10-12 | trivial | Progress log: web picture paste respects the edit target (#1349) |
| 2026-10-14 | trivial | Progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls (#1329) |
| 2026-10-14 | trivial | Progress log: Ink to Shape recognises single-stroke arrows |
| 2026-10-13 | trivial | Progress log: Styles gallery fallback for DOCX files without qFormat (#1238) |
| 2026-10-13 | trivial | Progress log: crop handles on the canvas |
| 2026-10-13 | trivial | Progress log: cell size boxes act on the whole selection (#1242) |
| 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) |
| 2026-10-12 | trivial | Progress log: `--help` for the app and every CLI subcommand (#968) |
| 2026-10-12 | trivial | Progress log: Down and Up no longer skip a table (#1007) |
| 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) |
| 2026-10-12 | trivial | Progress log: table style editor (#714) |
| 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) |
| 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) |
| 2026-10-11 | trivial | Progress log: the table context menu |
| 2026-10-12 | trivial | Progress log: AutoFit in a nested table, whole cells shaded for a selection through a table, Shift+Right at a cell's end (#971) |
| 2026-10-11 | trivial | Progress log: drags and Shift+arrows over table cells select whole cells |
| 2026-10-11 | trivial | Progress log: table selection areas, insert buttons, move handle and double-click AutoFit; catalog 484/499 |
| 2026-10-12 | trivial | Progress log: Undo history list (#701) |
| 2026-10-14 | trivial | Progress log: Save As type list (#1399) |
| 2026-10-12 | trivial | Progress log: Start screen at launch (#766) |
| 2026-10-13 | trivial | Progress log: no document behind the Start screen (#1129) |
| 2026-10-12 | trivial | Progress log: Alt+Q command search (#819); log table header fixed |
@@ -309,9 +681,12 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-12 | trivial | Font list previews no longer grow memory (#848) |
| 2026-10-12 | trivial | Progress log: section break keeps the selected text (#852) |
| 2026-10-12 | trivial | Progress log: save prompt from the keyboard (#861) |
| 2026-10-13 | trivial | Progress log: an empty .docx opens and saves back to itself (#1384) |
| 2026-10-12 | trivial | Progress log: craft-fonts Simplified Chinese in documents |
| 2026-10-12 | trivial | Progress log: symbol-font text drawn as Unicode symbols (#776) |
| 2026-10-13 | trivial | Progress log: the Paragraph dialog leaves mixed formatting alone (#1346) |
| 2026-10-12 | trivial | Progress log: WMF and EMF pictures can be inserted (#802) |
| 2026-10-14 | trivial | Progress log: a picture inserted by a command survives the next typing (#965) |
| 2026-10-11 | trivial | Progress log and shipped languages: Turkish interface completed, 2,325 entries (#774) |
| 2026-10-12 | trivial | Progress log: Navigation heading truncation (#759) |
| 2026-10-11 | trivial | Progress log: Reading Mode implementation |
@@ -321,12 +696,14 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-12 | trivial | Progress log: AutoCorrect replacement list follows the text's language (#835) |
| 2026-10-11 | trivial | Progress log: German interface (#574) |
| 2026-10-12 | trivial | Progress log: language-aware sorting and table numbers (#836, #837) |
| 2026-10-13 | trivial | Progress log: Word 365 cover pages, positions and closed freeforms (#743) |
| 2026-10-12 | trivial | Progress log: Word 365 cover pages (#743) |
| 2026-10-11 | trivial | Progress log: tables resize with the mouse (#217) |
| 2026-10-11 | trivial | Progress log: Animation effects setting |
| 2026-10-11 | trivial | Progress log: built-in file browser when the system file dialog is unavailable (#576) |
| 2026-10-12 | trivial | Progress log: large-table scrolling (#811) |
| 2026-10-11 | trivial | Progress log: security review fixes (temp files, exported link schemes) |
| 2026-10-13 | trivial | Progress log: every distinct format in a paragraph keeps its own layout style (#1340) |
| 2026-10-11 | trivial | Progress log: Find and Replace special characters and wildcards (#543) |
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, collation, duplex and grayscale; the upstream preview, ranges, parity and Print Markup remain available, along with browser printing and PDF export. Remaining print parity includes driver-specific tray/options and pages-per-sheet UI; physical printer fidelity needs hardware validation. |
| 2026-10-11 | trivial | Progress log: Windows DX12 shaders compiled with FXC (#603) |
@@ -342,6 +719,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-12 | trivial | Progress log: selection bar in the left margin (#856) |
| 2026-10-11 | trivial | Progress log: content controls (#381) |
| 2026-10-11 | trivial | Progress log: Insert › Chart (#409); catalog 408/439 (92.9%) |
| 2026-10-13 | trivial | Progress log: free part, list and comment ids after a file's largest ones (#1338) |
| 2026-10-11 | trivial | Progress log: Line Numbers, Hyphenation, Language, Envelopes and Labels dialogs (#407) |
| 2026-10-11 | trivial | Progress log: Alt Text pane, decorative objects, deeper accessibility checker (#386) |
| 2026-10-11 | trivial | Progress log: Draw lasso, Add Pen, Ink to Shape, Ink Replay (#395); catalog 406/434 |
@@ -351,6 +729,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | trivial | Quick Access Toolbar KeyTips and persisted enable/disable setting (#620) |
| 2026-10-11 | trivial | Progress log: mail-merge recipients from .xlsx/.xlsm/.ods (#334) |
| 2026-10-11 | trivial | Insert › Icons landed (#309): catalog 402/434 → 403/434 (92.9%) |
| 2026-10-13 | trivial | Progress log: Markdown backtick runs read in linear time (#1336) |
| 2026-10-13 | trivial | Progress log: Markdown brackets no longer turn later links into text (#1452) |
| 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) |
| 2026-10-12 | minor | Progress log: opaque custom XML store preservation, with synthetic package regressions and explicit limits |
| 2026-10-12 | minor | Preserve imported TOC ranges and retain #538 refresh/undo/save coverage on current main; readiness unchanged |
@@ -360,17 +740,27 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-12 | trivial | Native saves protect existing temporary-name entries and originals on pre-replacement failure; synthetic macOS evidence, explicit durability/platform limits; readiness unchanged |
| 2026-10-11 | trivial | Word's common keyboard shortcuts (#330): 125 of ~250 |
| 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges |
| 2026-10-13 | trivial | Swedish interface (`sv`): progress log and shipped languages |
| 2026-10-12 | trivial | Bulgarian interface (`bg`): progress log and shipped languages |
| 2026-10-13 | trivial | Croatian interface (`hr`): progress log and shipped languages |
| 2026-10-11 | trivial | Hungarian interface (`hu`): progress log and shipped languages |
| 2026-10-11 | trivial | Turkish interface (`tr`): progress log and shipped languages |
| 2026-10-14 | trivial | Progress log: `.doc` notes, headers and text boxes read their text once (#1319) |
| 2026-10-12 | minor | Native drawing-level ink and its picture fallback survive DOCX edits and repeated saves; adapted to the current graphic writer; rendering and editing InkML remain missing |
| 2026-10-12 | minor | List-label automatic/AtLeast line-height inflation corrected; gap #4 remains open and readiness unchanged |
| 2026-10-12 | minor | Local desktop recovery: unsaved drafts and edits are snapshotted in the profile; File › Recover restores an unsaved copy or confirms discard. Full model storage includes media and opaque parts. Password sessions skip plaintext snapshots. See [recovery.md](docs/recovery.md); stability estimates remain unchanged. |
| 2026-10-12 | trivial | Legacy table paragraph spacing preserved; modes 11/12/14 versus 15 checked with original synthetic documents |
| 2026-10-11 | minor | Progress log: contextual paragraph spacing in body paragraphs and headers |
| 2026-10-11 | trivial | Progress log: eight more bibliography styles (#383) |
| 2026-10-14 | trivial | Progress log: ODT and `.doc` pictures read once, within their byte budget (#1334) |
| 2026-10-10 | trivial | Progress log: Draw tab ink (#307) |
| 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 393/431 (91.2%) |
| 2026-10-10 | trivial | Progress log: charts, SmartArt and OLE objects kept on DOCX save (#319) |
| 2026-10-10 | trivial | Progress log: contour wrap (#354) |
| 2026-10-14 | trivial | Progress log: text boxes in deeply nested tables kept through edits (#1339) |
| 2026-10-10 | trivial | Progress log: free rotation and flips (#332) |
| 2026-10-10 | trivial | Progress log: Windows OpenGL fallback without a DirectX 12 driver (#316) |
| 2026-10-14 | trivial | Progress log: Flat OPC reading bounded everywhere in the file, CDATA base64 read (#1353) |
| 2026-10-10 | minor | Password-protected .docx (#281): files encrypted with a password (agile encryption) open and save; File › Info › Encrypt with Password |
| 2026-10-10 | minor | Relanded clipboard pane, table styles editor, Manage Styles, Asian typography (kinsoku), Group/Ungroup and Shape Effects (#264 #263 #270 #271 #267 #280): catalog 390/431 (90.5%) |
| 2026-10-10 | minor | Polish interface and proofing landed: Localization 25% → 27%, Proofing 35% → 40% |
@@ -381,8 +771,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-10 | major | Full re-measure; restructured to the progress-docs standard (stage banner, two numbers, dimensions, languages, upcoming, progress log); parity tables moved to `docs/target-app-parity.md`, milestones to `docs/roadmap.md` |
| 2026-10-09 | minor | Recently landed: RTL, Ukrainian and Brazilian Portuguese |
| 2026-10-06 | major | First honest estimate: 88% catalog, ~62% real parity, alpha ~85% of the way |
### Windows installer unification — release preparation, 2026-10-12
The shared craft-installer UI preserves this revision's install options and provides persistent installation, repair and Windows Settings removal completion screens. Silent modes stay unattended. Earlier Windows acceptance is recorded in packaging/windows/README.md; normal CI and production-profile upgrade qualification against pinned current releases are in progress. No release or PR has been published.
| 2026-10-12 | trivial | Progress log: recipient filter and sort, .xls and legacy-encoding recipient lists (#778) |
| 2026-10-15 | trivial | Progress log: dialog close box (#1885) |
| 2026-10-15 | trivial | Progress log: mixed Hebrew/Latin text keeps its order in Word (#1543) |
| 2026-10-15 | trivial | Progress log: ruler numbers by zoom, rulers follow the caret's page (#1878) |
| 2026-10-17 | trivial | Progress log: saving into a folder that refuses a temporary file (#2138) |
+1
View File
@@ -20,3 +20,4 @@ wordcraft-render = { workspace = true }
wordcraft-mcp = { workspace = true }
wordcraft-zotero = { workspace = true }
serde_json = { workspace = true }
sys-locale = "0.3"
+716 -48
View File
@@ -2,9 +2,12 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod automation_args;
mod script;
use std::ffi::{OsStr, OsString};
use std::fmt;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use serde_json::{Value, json};
@@ -14,9 +17,10 @@ const USAGE: &str = "\
wordcraft-cli — WordCraft from the command line
USAGE:
wordcraft-cli convert <in> <out> [--markup] [--format wordml2003|flatopc]
wordcraft-cli convert <in> <out> [--markup] [--update-fields] [--format wordml2003|flatopc]
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png,
xml); an .xml file is a Word XML Document (Flat OPC), or with
xml); --update-fields refreshes unlocked fields before saving;
an .xml file is a Word XML Document (Flat OPC), or with
--format wordml2003 a Word 2003 XML Document;
--markup (Print Markup): comments beside each page and
tracked changes in pdf/png, a comment list in html/md/txt
@@ -24,9 +28,15 @@ USAGE:
wordcraft-cli text <file> plain text
wordcraft-cli inspect <file> document structure (JSON)
wordcraft-cli render <file> <out.png> [--page N] [--scale S]
wordcraft-cli run [--file F | --template T] --cmd 'id={json}' [--cmd …] [--save OUT] [--print]
[--author NAME] run commands headlessly, then save (--author names
who tracked changes and comments are by)
wordcraft-cli run [--file F | --template T] [--script FILE|-] --cmd 'id={json}' [--cmd …]
[--save OUT] [--print] [--author NAME]
run commands headlessly, then save (--author names
who tracked changes and comments are by).
--script runs a JSON file (- is stdin) first:
{\"file\"?, \"template\"?, \"commands\": [{\"command\": id,
\"params\"?: {…}} | id, …], \"save\"?: [OUT, …]}; save
formats follow the extension (docx and pdf in one run);
relative paths resolve against the script's folder
wordcraft-cli commands [--json] list every command
wordcraft-cli parity [--markdown] feature-catalog parity
wordcraft-cli mcp [--connect HOST:PORT | --author NAME] [--automation-read-root DIR] [--automation-write-root DIR]
@@ -42,16 +52,127 @@ USAGE:
running): addEditCitation, addEditBibliography,
addNote, refresh, removeCodes, setDocPrefs
(--cmd runs first, e.g. --cmd caret.docEnd)
wordcraft-cli [<command>] -h | --help this help
wordcraft-cli --version
A password-protected Word file is opened with --password PASS, --password-file PATH or the
WORDCRAFT_PASSWORD environment variable; a password given on the command line is readable by
every other process, so the other two are better. A file opened with a password is written with
it again, as the app does. Exports that cannot carry a password (pdf, html, txt) stay plain.
Paper: documents created here, and HTML, Markdown and text files (which give no page size), use
the system region's paper (A4, or Letter in the US and other Letter regions), read from
LC_ALL / LC_PAPER / LANG or the platform. Set WORDCRAFT_REGION=en-US (or another tag) for the
same output on every machine.
";
fn open(path: &str) -> Result<Session, String> {
let doc = wordcraft_engine::io::open_path(std::path::Path::new(path))?;
let mut s = Session::new(doc);
/// The subcommands, in `USAGE` order; `--help` or `-h` after any of them prints `USAGE` (#968).
const SUBCOMMANDS: &[&str] = &["convert", "info", "text", "inspect", "render", "run", "commands", "parity", "mcp", "zotero"];
fn wants_help(cmd: &str, rest: &[String]) -> bool {
SUBCOMMANDS.contains(&cmd) && rest.iter().any(|a| a == "--help" || a == "-h")
}
/// The error every subcommand gives when a required path is not there.
fn missing_argument() -> Failure {
format!("missing argument\n\n{USAGE}").into()
}
/// Open a document for a subcommand. A password-protected Word file needs its password
/// ([`password_from`]). As in the app, a document opened with one is saved with it again, so
/// `convert` does not quietly write a copy the password no longer protects.
fn open(path: &Path, password: Option<&str>) -> Result<Session, String> {
// A file that gives no page size (HTML, Markdown, plain text) opens on the system region's
// paper, as in the app: A4 in Italy, Letter in the US (#1381).
let region = system_region();
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, &region)?;
let mut s = session(doc);
s.path = Some(path.into());
if encrypted {
s.password = password.map(wordcraft_engine::io::Password::new);
}
Ok(s)
}
/// The commands that open a file, and which of their arguments not starting with `--` it is (as
/// `pos` counts). `run` opens the value of `--file`.
const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0), ("inspect", 0), ("render", 0), ("zotero", 1)];
/// The arguments as text. Only the file the command opens may have a name that isn't valid Unicode,
/// such as a Latin-1 one (#1440): it comes back as the path to open, and lossy among the arguments.
/// Any other such argument is an error naming it.
fn text_args<S: AsRef<OsStr>>(raw: &[S]) -> Result<(Vec<String>, Option<PathBuf>), String> {
let args: Vec<String> = raw.iter().map(|a| a.as_ref().to_string_lossy().into_owned()).collect();
let cmd = args.first().map(String::as_str).unwrap_or("");
let file_at = match OPENS_FILE.iter().find(|(c, _)| *c == cmd) {
Some((_, n)) => args.iter().enumerate().skip(1).filter(|(_, a)| !a.starts_with("--")).nth(*n).map(|(i, _)| i),
None if cmd == "run" => args.iter().position(|a| a == "--file").map(|i| i + 1),
None => None,
};
// `run --script FILE` may also name its script with such a file name.
let script_at = if cmd == "run" { args.iter().position(|a| a == "--script").map(|i| i + 1) } else { None };
let mut file = None;
for (i, a) in raw.iter().map(AsRef::as_ref).enumerate() {
if a.to_str().is_none() {
if Some(i) != file_at && Some(i) != script_at {
return Err(format!("argument {} is not valid Unicode: {}", i + 1, a.to_string_lossy()));
}
if Some(i) == file_at {
file = Some(PathBuf::from(a));
}
}
}
Ok((args, file))
}
/// The password for a protected Word file: `--password`, `--password-file`, or
/// `WORDCRAFT_PASSWORD`, in that order. The file form exists because a password on a command line
/// is readable by every other process on the machine; a trailing newline is dropped so a file
/// written by `echo` works. Never logged, and never named in an error.
fn password_from(args: &[String]) -> Result<Option<String>, String> {
if let Some(password) = arg_value(args, "--password") {
return Ok(Some(password));
}
if let Some(path) = arg_value(args, "--password-file") {
let raw = std::fs::read_to_string(&path).map_err(|e| format!("--password-file {path}: {e}"))?;
return Ok(Some(raw.trim_end_matches(['\r', '\n']).to_string()));
}
Ok(std::env::var("WORDCRAFT_PASSWORD").ok().filter(|p| !p.is_empty()))
}
/// The positional arguments, skipping every option's value so an option value is never read as a
/// path. Mirrors [`validate_options`], which has already checked the options are known.
fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String> {
let mut out = Vec::new();
let mut i = 0;
while let Some(arg) = args.get(i) {
let takes_value =
arg.starts_with("--") && specs.iter().any(|s| s.name == arg && s.takes_value) && args.get(i + 1).is_some_and(|v| !v.starts_with("--"));
if takes_value {
i += 2;
} else {
if !arg.starts_with("--") {
out.push(arg);
}
i += 1;
}
}
out
}
/// A headless session in the system region: every way the CLI starts one gives `file.new` the
/// same paper (#1381).
fn session(doc: wordcraft_doc::Document) -> Session {
let mut s = Session::new(doc);
s.system_region = system_region();
s
}
/// The system region, read as the app reads it (locale variables, else the platform's locale).
fn system_region() -> wordcraft_engine::Region {
wordcraft_engine::Region::from_locale_vars(|name| std::env::var(name).ok(), sys_locale::get_locale)
}
/// The body text for `text`: the plain-text export, so list items keep their bullet or number label.
fn listed_text(doc: &wordcraft_doc::Document) -> Result<String, String> {
let bytes = wordcraft_engine::io::save_bytes("out.txt", doc)?;
@@ -67,6 +188,58 @@ fn arg_value(args: &[String], name: &str) -> Option<String> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned()
}
enum OptionArg<'a> {
Absent,
Duplicate,
MissingValue,
Value(&'a str),
}
fn find_option_arg<'a>(args: &'a [String], name: &str) -> OptionArg<'a> {
let mut matches = args.iter().enumerate().filter(|(_, a)| *a == name);
let Some((i, _)) = matches.next() else {
return OptionArg::Absent;
};
if matches.next().is_some() {
return OptionArg::Duplicate;
}
match args.get(i + 1) {
None => OptionArg::MissingValue,
Some(val) if val.starts_with("--") => OptionArg::MissingValue,
Some(val) => OptionArg::Value(val.as_str()),
}
}
fn parse_page_arg(command: &str, args: &[String]) -> Result<Option<u64>, String> {
match find_option_arg(args, "--page") {
OptionArg::Absent => Ok(None),
OptionArg::Duplicate => Err(format!("{command}: duplicate --page option")),
OptionArg::MissingValue => Err(format!("{command}: --page requires a value")),
OptionArg::Value(val) => {
let n = val.parse::<u64>().map_err(|_| format!("{command}: invalid --page `{val}`: not a valid integer"))?;
if n == 0 {
return Err(format!("{command}: invalid --page `0`: page numbers start at 1"));
}
Ok(Some(n))
}
}
}
fn parse_scale_arg(command: &str, args: &[String]) -> Result<Option<f64>, String> {
match find_option_arg(args, "--scale") {
OptionArg::Absent => Ok(None),
OptionArg::Duplicate => Err(format!("{command}: duplicate --scale option")),
OptionArg::MissingValue => Err(format!("{command}: --scale requires a value")),
OptionArg::Value(val) => {
let scale = val.parse::<f64>().map_err(|_| format!("{command}: invalid --scale `{val}`: not a number"))?;
if !scale.is_finite() || scale <= 0.0 {
return Err(format!("{command}: invalid --scale `{val}`: scale must be a positive number"));
}
Ok(Some(scale))
}
}
}
#[derive(Clone, Copy)]
struct OptionSpec {
name: &'static str,
@@ -112,13 +285,30 @@ fn automation_roots(
Ok((roots, left))
}
const NO_OPTIONS: &[OptionSpec] = &[];
const CONVERT_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--markup", takes_value: false }, OptionSpec { name: "--format", takes_value: true }];
const RENDER_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--page", takes_value: true }, OptionSpec { name: "--scale", takes_value: true }];
/// The password a protected Word file is opened with: `--password` (which any other process on
/// the machine can read from the command line), `--password-file`, or `WORDCRAFT_PASSWORD`.
const PASSWORD_OPTIONS: &[OptionSpec] =
&[OptionSpec { name: "--password", takes_value: true }, OptionSpec { name: "--password-file", takes_value: true }];
const CONVERT_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--markup", takes_value: false },
OptionSpec { name: "--format", takes_value: true },
OptionSpec { name: "--update-fields", takes_value: false },
OptionSpec { name: "--password", takes_value: true },
OptionSpec { name: "--password-file", takes_value: true },
];
const RENDER_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--page", takes_value: true },
OptionSpec { name: "--scale", takes_value: true },
OptionSpec { name: "--password", takes_value: true },
OptionSpec { name: "--password-file", takes_value: true },
];
const RUN_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--file", takes_value: true },
OptionSpec { name: "--password", takes_value: true },
OptionSpec { name: "--password-file", takes_value: true },
OptionSpec { name: "--template", takes_value: true },
OptionSpec { name: "--cmd", takes_value: true },
OptionSpec { name: "--script", takes_value: true },
OptionSpec { name: "--save", takes_value: true },
OptionSpec { name: "--page", takes_value: true },
OptionSpec { name: "--scale", takes_value: true },
@@ -181,25 +371,37 @@ fn write_stdout(write: impl FnOnce(&mut std::io::StdoutLock<'static>) -> std::io
Ok(())
}
fn run(args: &[String]) -> Result<(), Failure> {
/// The operand after `--script`, by its real name (it may not be valid Unicode).
fn script_operand<S: AsRef<OsStr>>(raw: &[S]) -> Option<PathBuf> {
let at = raw.iter().position(|a| a.as_ref() == OsStr::new("--script"))?;
raw.get(at + 1).map(|a| PathBuf::from(a.as_ref()))
}
fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let script_path = script_operand(args);
let (args, file) = text_args(args)?;
// Opens the file by its real name, which its text in `args` may have lost, and with the
// password of a protected document (`password_from`).
let open = |text: &str, password: Option<&str>| open(file.as_deref().unwrap_or(Path::new(text)), password);
let cmd = args.first().map(String::as_str).unwrap_or("");
let rest: Vec<String> = args.iter().skip(1).cloned().collect();
let pos = |i: usize| rest.iter().filter(|a| !a.starts_with("--")).nth(i).cloned().ok_or_else(|| format!("missing argument\n\n{USAGE}"));
// Before options are checked or files and folders opened.
if wants_help(cmd, &rest) {
return write_stdout(|o| write!(o, "{USAGE}"));
}
match cmd {
"convert" => {
validate_options("convert", &rest, CONVERT_OPTIONS)?;
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
let format = arg_value(&rest, "--format");
let paths: Vec<&String> = rest
.iter()
.enumerate()
.filter(|(n, a)| !a.starts_with("--") && !(*n > 0 && rest.get(n - 1).is_some_and(|p| p == "--format")))
.map(|(_, a)| a)
.collect();
let missing = || format!("missing argument\n\n{USAGE}");
let (i, o) = (paths.first().ok_or_else(missing)?.to_string(), paths.get(1).ok_or_else(missing)?.to_string());
let paths = positionals(&rest, CONVERT_OPTIONS);
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
let markup = rest.iter().any(|a| a == "--markup");
let mut s = open(&i)?;
let password = password_from(&rest)?;
let mut s = open(&i, password.as_deref())?;
if rest.iter().any(|a| a == "--update-fields") {
s.run("references.updateFields", &json!({})).map_err(|e| e.to_string())?;
}
let r = if o.to_ascii_lowercase().ends_with(".png") {
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup}))
} else {
@@ -214,46 +416,78 @@ fn run(args: &[String]) -> Result<(), Failure> {
Ok(())
}
"info" => {
validate_options("info", &rest, NO_OPTIONS)?;
let mut s = open(&pos(0)?)?;
validate_options("info", &rest, PASSWORD_OPTIONS)?;
let paths = positionals(&rest, PASSWORD_OPTIONS);
let password = password_from(&rest)?;
let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
let info = serde_json::to_string_pretty(&s.run("file.info", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default();
write_stdout(|o| writeln!(o, "{info}"))
}
"text" => {
validate_options("text", &rest, NO_OPTIONS)?;
let s = open(&pos(0)?)?;
validate_options("text", &rest, PASSWORD_OPTIONS)?;
let paths = positionals(&rest, PASSWORD_OPTIONS);
let password = password_from(&rest)?;
let s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
let text = listed_text(&s.doc)?;
write_stdout(|o| writeln!(o, "{text}"))
}
"inspect" => {
validate_options("inspect", &rest, NO_OPTIONS)?;
let mut s = open(&pos(0)?)?;
validate_options("inspect", &rest, PASSWORD_OPTIONS)?;
let paths = positionals(&rest, PASSWORD_OPTIONS);
let password = password_from(&rest)?;
let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
let tree = serde_json::to_string_pretty(&s.run("document.inspect", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default();
write_stdout(|o| writeln!(o, "{tree}"))
}
"render" => {
validate_options("render", &rest, RENDER_OPTIONS)?;
let (i, o) = (pos(0)?, pos(1)?);
let mut s = open(&i)?;
let page = arg_value(&rest, "--page").and_then(|p| p.parse::<u64>().ok()).unwrap_or(1);
let scale = arg_value(&rest, "--scale").and_then(|p| p.parse::<f64>().ok()).unwrap_or(2.0);
let paths = positionals(&rest, RENDER_OPTIONS);
let password = password_from(&rest)?;
let (Some(i), Some(o)) = (paths.first(), paths.get(1)) else {
return Err(missing_argument());
};
let (i, o) = (i.to_string(), o.to_string());
let page = parse_page_arg("render", &rest)?.unwrap_or(1);
let scale = parse_scale_arg("render", &rest)?.unwrap_or(2.0);
let mut s = open(&i, password.as_deref())?;
let r = s.run("file.exportPng", &json!({"path": o, "page": page, "scale": scale})).map_err(|e| e.to_string())?;
write_stdout(|o| writeln!(o, "{r}"))
}
"run" => {
validate_options("run", &rest, RUN_OPTIONS)?;
let mut s = match (arg_value(&rest, "--file"), arg_value(&rest, "--template")) {
(Some(f), _) => open(&f)?,
let page = parse_page_arg("run", &rest)?;
let scale = parse_scale_arg("run", &rest)?;
let password = password_from(&rest)?;
let script = match arg_value(&rest, "--script") {
Some(path) if !path.starts_with("--") => Some(script::load(script_path.as_deref().unwrap_or(Path::new(&path)))?),
_ if rest.iter().any(|a| a == "--script") => return Err("run: --script requires a value".into()),
_ => None,
};
// A source named on the command line wins over both of the script's.
let (mut from_file, mut from_template) = (arg_value(&rest, "--file"), arg_value(&rest, "--template"));
if from_file.is_none() && from_template.is_none() {
from_file = script.as_ref().and_then(|c| c.file.clone());
from_template = script.as_ref().and_then(|c| c.template.clone());
}
let mut s = match (from_file, from_template) {
(Some(f), _) => open(&f, password.as_deref())?,
(None, Some(t)) => {
let mut s = Session::new(wordcraft_doc::Document::new());
let mut s = session(wordcraft_doc::Document::new());
s.run("file.new", &json!({"template": t})).map_err(|e| e.to_string())?;
s
}
_ => Session::new(wordcraft_doc::Document::new()),
_ => session(wordcraft_doc::Document::new()),
};
if let Some(name) = arg_value(&rest, "--author") {
set_author(&mut s, &name)?;
}
let print = rest.iter().any(|a| a == "--print");
for (n, (id, params)) in script.iter().flat_map(|c| c.commands.iter()).enumerate() {
let r = s.run(id, params).map_err(|e| format!("command {} ({id}): {e}", n + 1))?;
if print {
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
}
}
let mut i = 0;
while i < rest.len() {
if rest.get(i).map(String::as_str) == Some("--cmd") {
@@ -263,7 +497,7 @@ fn run(args: &[String]) -> Result<(), Failure> {
None => (spec.clone(), json!({})),
};
let r = s.run(&id, &params).map_err(|e| format!("{id}: {e}"))?;
if rest.iter().any(|a| a == "--print") {
if print {
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
}
i += 2;
@@ -271,13 +505,27 @@ fn run(args: &[String]) -> Result<(), Failure> {
i += 1;
}
}
if let Some(out) = arg_value(&rest, "--save") {
let outputs: Vec<String> = script.iter().flat_map(|c| c.save.iter().cloned()).chain(arg_value(&rest, "--save")).collect();
// Check every output can be made before writing any, so a bad one leaves no half-done job.
for out in &outputs {
let parent = Path::new(out).parent().filter(|p| !p.as_os_str().is_empty());
if let Some(p) = parent.filter(|p| !p.is_dir()) {
return Err(format!("{out}: folder {} does not exist", p.display()).into());
}
if Path::new(out).is_dir() {
return Err(format!("{out}: is a folder, not a file").into());
}
if !out.to_ascii_lowercase().ends_with(".png") {
wordcraft_engine::io::save_bytes(out, &s.doc)?;
}
}
for out in outputs {
let r = if out.to_ascii_lowercase().ends_with(".png") {
let mut v = json!({"path": out});
if let Some(n) = arg_value(&rest, "--page").and_then(|p| p.parse::<u64>().ok()) {
if let Some(n) = page {
v["page"] = json!(n);
}
if let Some(k) = arg_value(&rest, "--scale").and_then(|p| p.parse::<f64>().ok()) {
if let Some(k) = scale {
v["scale"] = json!(k);
}
s.run("file.exportPng", &v)
@@ -291,7 +539,7 @@ fn run(args: &[String]) -> Result<(), Failure> {
}
"commands" => {
validate_options("commands", &rest, COMMANDS_OPTIONS)?;
let s = Session::new(wordcraft_doc::Document::new());
let s = session(wordcraft_doc::Document::new());
if rest.iter().any(|a| a == "--json") {
let list = serde_json::to_string_pretty(&s.registry.describe()).unwrap_or_default();
write_stdout(|o| writeln!(o, "{list}"))
@@ -306,7 +554,7 @@ fn run(args: &[String]) -> Result<(), Failure> {
}
"parity" => {
validate_options("parity", &rest, PARITY_OPTIONS)?;
let s = Session::new(wordcraft_doc::Document::new());
let s = session(wordcraft_doc::Document::new());
let p = wordcraft_engine::catalog::parity(&s.registry);
if rest.iter().any(|a| a == "--markdown") {
write_stdout(|o| write!(o, "{}", parity_markdown(&p)))
@@ -359,10 +607,12 @@ fn run(args: &[String]) -> Result<(), Failure> {
}
"zotero" => {
validate_options("zotero", &rest, ZOTERO_OPTIONS)?;
let name = pos(0)?;
let command = wordcraft_zotero::Command::from_name(&name).ok_or_else(|| format!("unknown Zotero command `{name}`\n\n{USAGE}"))?;
let file = pos(1)?;
let mut s = open(&file)?;
let paths = positionals(&rest, ZOTERO_OPTIONS);
let (Some(name), Some(file)) = (paths.first(), paths.get(1)) else {
return Err(missing_argument());
};
let command = wordcraft_zotero::Command::from_name(name).ok_or_else(|| format!("unknown Zotero command `{name}`\n\n{USAGE}"))?;
let mut s = open(file, None)?;
for spec in rest.iter().zip(rest.iter().skip(1)).filter(|(a, _)| *a == "--cmd").map(|(_, v)| v) {
let (id, params) = match spec.split_once('=') {
Some((id, p)) => (id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| format!("{id}: bad JSON params: {e}"))?),
@@ -437,7 +687,7 @@ fn parity_markdown(p: &Value) -> String {
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let args: Vec<OsString> = std::env::args_os().skip(1).collect();
match run(&args) {
Ok(()) | Err(Failure::StdoutClosed) => ExitCode::SUCCESS,
Err(Failure::Error(e)) => {
@@ -450,6 +700,7 @@ fn main() -> ExitCode {
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).into()).collect()
@@ -457,7 +708,7 @@ mod tests {
#[test]
fn validators_accept_only_the_documented_options() {
assert!(validate_options("info", &args(&["file.docx"]), NO_OPTIONS).is_ok());
assert!(validate_options("info", &args(&["file.docx"]), PASSWORD_OPTIONS).is_ok());
assert!(validate_options("render", &args(&["in.docx", "out.png", "--page", "2", "--scale", "1.5"]), RENDER_OPTIONS).is_ok());
assert!(validate_options("run", &args(&["--cmd", "text.insert={}", "--cmd", "edit.undo", "--print"]), RUN_OPTIONS).is_ok());
assert!(validate_options("commands", &args(&["--json"]), COMMANDS_OPTIONS).is_ok());
@@ -474,6 +725,41 @@ mod tests {
);
}
#[test]
fn an_option_value_is_never_read_as_a_path() {
// `--password`'s value sits between the paths and would be taken for one without
// `positionals`, so `render` would write its picture over a file called after the password.
assert_eq!(positionals(&args(&["in.docx", "--password", "secret", "out.png"]), RENDER_OPTIONS), vec!["in.docx", "out.png"]);
assert_eq!(positionals(&args(&["in.docx", "out.png", "--page", "2"]), RENDER_OPTIONS), vec!["in.docx", "out.png"]);
// A bare flag takes no value, so the next argument stays a path.
assert_eq!(positionals(&args(&["--print", "in.docx"]), RUN_OPTIONS), vec!["in.docx"]);
// `convert` is the one with a value-taking flag between the two paths.
assert_eq!(positionals(&args(&["in.docx", "--format", "flatopc", "out.docx"]), CONVERT_OPTIONS), vec!["in.docx", "out.docx"]);
}
#[test]
fn the_password_comes_from_an_option_a_file_or_the_environment() {
assert_eq!(password_from(&args(&["f.docx"])), Ok(None));
assert_eq!(password_from(&args(&["f.docx", "--password", "s3cret"])), Ok(Some("s3cret".into())));
// `--password` wins over the other two sources.
assert_eq!(password_from(&args(&["--password", "a", "--password-file", "/nonexistent"])), Ok(Some("a".into())));
}
#[test]
fn a_password_file_loses_only_its_trailing_newline() {
let path = std::env::temp_dir().join("wordcraft-cli-password-test.txt");
std::fs::write(&path, "s3cret\r\n").unwrap();
let got = password_from(&args(&["f.docx", "--password-file", &path.to_string_lossy()]));
std::fs::remove_file(&path).ok();
assert_eq!(got, Ok(Some("s3cret".into())));
}
#[test]
fn a_missing_password_file_is_named_without_the_password() {
let err = password_from(&args(&["f.docx", "--password-file", "/nonexistent/secret.txt"])).unwrap_err();
assert!(err.contains("--password-file"), "{err}");
}
#[test]
fn unknown_options_are_rejected_before_file_access() {
assert_eq!(run(&args(&["info", "missing.docx", "--passwrod", "x"])), Err("info: unknown option --passwrod".into()));
@@ -481,6 +767,26 @@ mod tests {
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
}
#[test]
fn help_or_h_after_a_subcommand_asks_for_the_usage() {
for cmd in SUBCOMMANDS {
assert!(wants_help(cmd, &args(&["in.docx", "--help"])), "{cmd}");
assert!(wants_help(cmd, &args(&["-h"])), "{cmd}");
assert!(!wants_help(cmd, &args(&["in.docx", "--hepl"])), "{cmd}");
}
assert!(!wants_help("frobnicate", &args(&["--help"])));
}
#[test]
fn usage_lists_exactly_the_subcommands() {
let listed: Vec<&str> = USAGE
.lines()
.filter_map(|line| line.strip_prefix(" wordcraft-cli ")?.split_whitespace().next())
.filter(|word| !word.starts_with(['-', '[']))
.collect();
assert_eq!(listed, SUBCOMMANDS);
}
#[test]
fn text_keeps_bullet_and_number_labels() {
let doc = wordcraft_engine::io::open_bytes("lists.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap();
@@ -547,10 +853,372 @@ mod tests {
assert!(run(&args(&["mcp", "--automation-write-root", "/definitely/not/a/folder"])).is_err());
}
#[cfg(unix)]
#[test]
fn only_the_opened_file_may_be_other_than_unicode() {
use std::os::unix::ffi::OsStringExt;
let latin1 = || OsString::from_vec(b"caf\xe9.docx".to_vec());
let ok = |raw: Vec<OsString>| text_args(&raw).unwrap().1;
assert_eq!(ok(vec!["zotero".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["render".into(), latin1(), "out.png".into(), "--page".into(), "2".into()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["run".into(), "--print".into(), "--file".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["info".into(), "in.docx".into()]), None);
// Every command that opens a file reaches the open with it (no such file here), so
// `OPENS_FILE` and the commands' own arguments agree.
let missing = || OsString::from_vec(b"/definitely/not/caf\xe9.docx".to_vec());
let mut opening: Vec<Vec<OsString>> = OPENS_FILE
.iter()
.map(|(cmd, n)| [vec![(*cmd).into()], vec![OsString::from("refresh"); *n], vec![missing(), "out.png".into()]].concat())
.collect();
opening.push(vec!["run".into(), "--file".into(), missing()]);
for raw in opening {
let e = run(&raw).unwrap_err().to_string();
assert!(e.contains("No such file"), "{raw:?}: {e}");
}
for (raw, n) in [
(vec!["zotero".into(), latin1(), "in.docx".into()], 2),
(vec!["render".into(), "in.docx".into(), latin1()], 3),
(vec!["run".into(), "--save".into(), latin1()], 3),
(vec!["commands".into(), latin1()], 2),
] {
assert_eq!(text_args(&raw).unwrap_err(), format!("argument {n} is not valid Unicode: caf\u{FFFD}.docx"));
}
}
#[test]
fn automation_roots_are_an_error_with_connect() {
let dir = std::env::temp_dir().to_string_lossy().into_owned();
let e = run(&args(&["mcp", "--connect", "127.0.0.1:9", "--automation-read-root", &dir])).unwrap_err().to_string();
assert!(e.contains("--automation-read-root") && e.contains("headless"), "{e}");
}
#[test]
fn render_rejects_missing_or_invalid_page_and_scale() {
// Missing operands
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page"])), Err("render: --page requires a value".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale"])), Err("render: --scale requires a value".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "--scale", "2"])), Err("render: --page requires a value".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale", "--page", "1"])), Err("render: --scale requires a value".into()));
// Non-numeric operands
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--page", "banana"])),
Err("render: invalid --page `banana`: not a valid integer".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "banana"])),
Err("render: invalid --scale `banana`: not a number".into())
);
// Out-of-range values
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--page", "0"])),
Err("render: invalid --page `0`: page numbers start at 1".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--page", "-1"])),
Err("render: invalid --page `-1`: not a valid integer".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "0"])),
Err("render: invalid --scale `0`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "-2"])),
Err("render: invalid --scale `-2`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "NaN"])),
Err("render: invalid --scale `NaN`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "inf"])),
Err("render: invalid --scale `inf`: scale must be a positive number".into())
);
// Duplicate options
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "1", "--page", "2"])), Err("render: duplicate --page option".into()));
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "1", "--scale", "2"])),
Err("render: duplicate --scale option".into())
);
}
#[test]
fn run_rejects_missing_or_invalid_page_and_scale() {
// Missing operands
assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--page"])), Err("run: --page requires a value".into()));
assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale"])), Err("run: --scale requires a value".into()));
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "--scale", "2"])),
Err("run: --page requires a value".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "--page", "1"])),
Err("run: --scale requires a value".into())
);
// Non-numeric operands
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "banana"])),
Err("run: invalid --page `banana`: not a valid integer".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "banana"])),
Err("run: invalid --scale `banana`: not a number".into())
);
// Out-of-range values
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "0"])),
Err("run: invalid --page `0`: page numbers start at 1".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "-1"])),
Err("run: invalid --page `-1`: not a valid integer".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "0"])),
Err("run: invalid --scale `0`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "-1"])),
Err("run: invalid --scale `-1`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "NaN"])),
Err("run: invalid --scale `NaN`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "inf"])),
Err("run: invalid --scale `inf`: scale must be a positive number".into())
);
// Duplicate options
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "1", "--page", "2"])),
Err("run: duplicate --page option".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "1", "--scale", "2"])),
Err("run: duplicate --scale option".into())
);
}
#[test]
fn render_and_run_valid_options_and_defaults_produce_files() {
let dir = std::env::temp_dir().join(format!("wc-cli-render-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let docx_path = dir.join("sample.docx");
// Create sample docx via `run --save sample.docx`
assert!(run(&args(&["run", "--template", "sample", "--save", &docx_path.to_string_lossy()])).is_ok());
assert!(docx_path.exists());
let docx_str = docx_path.to_string_lossy().into_owned();
// 1. render with defaults
let out_default = dir.join("out_default.png");
assert!(run(&args(&["render", &docx_str, &out_default.to_string_lossy()])).is_ok());
assert!(out_default.exists());
// 2. render with valid --page and --scale
let out_valid = dir.join("out_valid.png");
assert!(run(&args(&["render", &docx_str, &out_valid.to_string_lossy(), "--page", "1", "--scale", "2"])).is_ok());
assert!(out_valid.exists());
// 3. render with different flag order
let out_reordered = dir.join("out_reordered.png");
assert!(run(&args(&["render", &docx_str, &out_reordered.to_string_lossy(), "--scale", "1", "--page", "1"])).is_ok());
assert!(out_reordered.exists());
// 4. render failure leaves no output file
let out_invalid = dir.join("out_invalid.png");
assert!(run(&args(&["render", &docx_str, &out_invalid.to_string_lossy(), "--page", "banana"])).is_err());
assert!(!out_invalid.exists());
// 5. run --save out.png with defaults
let run_default = dir.join("run_default.png");
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_default.to_string_lossy()])).is_ok());
assert!(run_default.exists());
// 6. run --save out.png with --page and --scale
let run_valid = dir.join("run_valid.png");
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_valid.to_string_lossy(), "--page", "1", "--scale", "1"])).is_ok());
assert!(run_valid.exists());
// 7. run --save out.png with different flag order
let run_reordered = dir.join("run_reordered.png");
assert!(run(&args(&["run", "--file", &docx_str, "--scale", "1", "--page", "1", "--save", &run_reordered.to_string_lossy()])).is_ok());
assert!(run_reordered.exists());
// 8. run --save out.png failure leaves no output file
let run_invalid = dir.join("run_invalid.png");
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_invalid.to_string_lossy(), "--scale", "banana"])).is_err());
assert!(!run_invalid.exists());
let _ = std::fs::remove_dir_all(dir);
}
/// A scratch folder that removes itself, even when the test fails.
struct ScriptDir(PathBuf);
impl std::ops::Deref for ScriptDir {
type Target = Path;
fn deref(&self) -> &Path {
&self.0
}
}
impl Drop for ScriptDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn script_dir(tag: &str) -> ScriptDir {
let dir = std::env::temp_dir().join(format!("wc-cli-script-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
ScriptDir(dir)
}
fn write_script(dir: &Path, text: &str) -> String {
let p = dir.join("job.json");
std::fs::write(&p, text).unwrap();
p.to_string_lossy().into_owned()
}
#[test]
fn script_builds_a_document_and_saves_docx_and_pdf_relative_to_the_script() {
let dir = script_dir("two");
let script = write_script(
&dir,
r#"{"template":"blank","commands":[{"command":"text.insert","params":{"text":"Hello script"}},{"command":"format.bold"}],"save":["out.docx","out.pdf"]}"#,
);
run(&args(&["run", "--script", &script])).unwrap();
assert!(std::fs::read(dir.join("out.pdf")).unwrap().starts_with(b"%PDF"));
let doc = open(&dir.join("out.docx"), None).unwrap().doc;
let json = serde_json::to_string(&doc).unwrap();
assert!(json.contains("Hello script") && json.contains("\"bold\":true"));
}
#[test]
fn script_with_a_bad_output_writes_no_output() {
let dir = script_dir("badout");
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","b.xyz"]}"#);
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
assert_eq!(e.matches("b.xyz").count(), 1, "{e}");
assert!(!dir.join("a.docx").exists());
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","nope/b.docx"]}"#);
assert!(run(&args(&["run", "--script", &script])).is_err());
assert!(!dir.join("a.docx").exists());
}
#[test]
fn script_with_a_folder_as_output_writes_no_output() {
let dir = script_dir("folderout");
std::fs::create_dir(dir.join("b.pdf")).unwrap();
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","b.pdf"]}"#);
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
assert!(e.contains("b.pdf"), "{e}");
assert!(!dir.join("a.docx").exists());
}
/// APFS refuses file names that aren't valid UTF-8, so the fixture can't be made on macOS
/// (as in `tests/non_utf8_args.rs`).
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn script_file_name_need_not_be_unicode() {
use std::os::unix::ffi::OsStringExt;
let dir = script_dir("latin1");
let mut name = dir.as_os_str().to_os_string().into_vec();
name.extend_from_slice(b"/caf\xe9.json");
let path = PathBuf::from(OsString::from_vec(name));
std::fs::write(&path, r#"{"commands":["caret.docEnd"],"save":["o.txt"]}"#).unwrap();
let raw = vec![OsString::from("run"), OsString::from("--script"), path.into_os_string()];
run(&raw).unwrap();
assert!(dir.join("o.txt").exists());
}
#[test]
fn command_line_template_wins_over_the_scripts_file() {
let dir = script_dir("source");
let input = dir.join("in.docx");
run(&args(&["run", "--template", "blank", "--cmd", "text.insert={\"text\":\"FROM FILE\"}", "--save", &input.to_string_lossy()])).unwrap();
let script = write_script(&dir, r#"{"file":"in.docx","commands":["caret.docEnd"]}"#);
let (a, b) = (dir.join("a.txt"), dir.join("b.txt"));
run(&args(&["run", "--script", &script, "--template", "blank", "--save", &a.to_string_lossy()])).unwrap();
assert!(!std::fs::read_to_string(&a).unwrap().contains("FROM FILE"));
run(&args(&["run", "--script", &script, "--save", &b.to_string_lossy()])).unwrap();
assert!(std::fs::read_to_string(&b).unwrap().contains("FROM FILE"));
}
#[test]
fn script_value_missing_is_reported() {
let e = run(&args(&["run", "--script", "--cmd", "caret.docEnd"])).unwrap_err().to_string();
assert!(e.contains("--script requires a value"), "{e}");
}
#[test]
fn script_reads_from_a_stream_and_caps_its_size() {
let s = script::load_from(&br#"{"commands":["caret.docEnd"],"save":["o.docx"]}"#[..], "-", None).unwrap();
assert_eq!((s.commands.len(), s.save), (1, vec!["o.docx".to_string()]));
let big = std::io::repeat(b' ').take(script::MAX_SCRIPT_BYTES + 10);
let e = script::load_from(big, "-", None).unwrap_err();
assert!(e.contains("too large"), "{e}");
assert!(script::load_from(&b"{"[..], "-", None).is_err());
}
#[test]
fn script_commands_run_before_cmd_flags() {
let dir = script_dir("order");
let script = write_script(&dir, r#"{"commands":[{"command":"text.insert","params":{"text":"first"}}]}"#);
let out = dir.join("o.txt");
run(&args(&["run", "--script", &script, "--cmd", "text.insert={\"text\":\" second\"}", "--save", &out.to_string_lossy()])).unwrap();
assert!(std::fs::read_to_string(&out).unwrap().contains("first second"));
}
#[test]
fn script_failure_names_the_command_and_writes_nothing() {
let dir = script_dir("fail");
let script =
write_script(&dir, r#"{"commands":[{"command":"text.insert","params":{"text":"x"}},{"command":"no.such.command"}],"save":["out.docx"]}"#);
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
assert!(e.contains("command 2") && e.contains("no.such.command"), "{e}");
assert!(!dir.join("out.docx").exists());
}
#[test]
fn script_rejects_junk_empty_missing_and_huge_input() {
let dir = script_dir("junk");
for text in [
"not json",
"[]",
"{}",
r#"{"commands":[]}"#,
r#"{"commands":[{"params":{}}]}"#,
r#"{"commands":[1]}"#,
r#"{"commands":[{"command":"a"}],"save":[1]}"#,
] {
let script = write_script(&dir, text);
assert!(run(&args(&["run", "--script", &script])).is_err(), "{text}");
}
assert!(run(&args(&["run", "--script", &dir.join("missing.json").to_string_lossy()])).is_err());
let huge = write_script(&dir, &" ".repeat(5 * 1024 * 1024));
let e = run(&args(&["run", "--script", &huge])).unwrap_err().to_string();
assert!(e.contains("too large"), "{e}");
}
#[test]
fn script_parse_resolves_paths_against_the_base() {
let s = script::parse(r#"{"file":"in.docx","commands":[{"command":"edit.undo"}],"save":["/abs/o.pdf","o.docx"]}"#, Some(Path::new("/base")))
.unwrap();
let joined = |name: &str| Path::new("/base").join(name).to_string_lossy().into_owned();
assert_eq!(s.file, Some(joined("in.docx")));
assert_eq!(s.save, vec!["/abs/o.pdf".to_string(), joined("o.docx")]);
let s = script::parse(r#"{"commands":["caret.docEnd"],"save":["o.docx"]}"#, None).unwrap();
assert_eq!(s.save, vec!["o.docx".to_string()]);
assert_eq!(s.commands.len(), 1);
}
}
+112
View File
@@ -0,0 +1,112 @@
//! `run --script FILE`: commands read from a JSON file (`-` is stdin), so a job can be kept in a
//! file instead of a long chain of `--cmd` flags (#1963).
//!
//! ```json
//! {"file": "in.docx", "template": "sample",
//! "commands": [{"command": "text.insert", "params": {"text": "Hi"}}, "caret.docEnd"],
//! "save": ["out.docx", "out.pdf"]}
//! ```
//!
//! `file` and `template` are optional (`--file` / `--template` on the command line win), and a
//! command is an object with `command` and optional `params`, or just the id as a string. Relative
//! `file` and `save` paths resolve against the script's folder (the working folder for stdin).
use std::io::Read;
use std::path::Path;
use serde_json::Value;
/// The largest script read. A job is a list of commands; anything bigger is a mistake.
pub const MAX_SCRIPT_BYTES: u64 = 4 * 1024 * 1024;
/// The most commands a script may hold.
const MAX_COMMANDS: usize = 100_000;
/// The most outputs a script may save to.
const MAX_SAVES: usize = 64;
#[derive(Debug)]
pub struct Script {
pub file: Option<String>,
pub template: Option<String>,
pub commands: Vec<(String, Value)>,
pub save: Vec<String>,
}
/// Read the script at `arg` (`-` for stdin) and parse it.
pub fn load(arg: &Path) -> Result<Script, String> {
let name = arg.display().to_string();
if arg == Path::new("-") {
return load_from(std::io::stdin().lock(), &name, None);
}
let f = std::fs::File::open(arg).map_err(|e| format!("--script {name}: {e}"))?;
load_from(f, &name, arg.parent().filter(|p| !p.as_os_str().is_empty()))
}
/// Read and parse a script from `r`, called `name` in errors; relative paths resolve against `base`.
pub fn load_from(r: impl Read, name: &str, base: Option<&Path>) -> Result<Script, String> {
let text = read_capped(r, name)?;
parse(&text, base).map_err(|e| format!("--script {name}: {e}"))
}
fn read_capped(r: impl Read, name: &str) -> Result<String, String> {
let mut bytes = Vec::new();
r.take(MAX_SCRIPT_BYTES + 1).read_to_end(&mut bytes).map_err(|e| format!("--script {name}: {e}"))?;
if bytes.len() as u64 > MAX_SCRIPT_BYTES {
return Err(format!("--script {name}: script too large (over {} MiB)", MAX_SCRIPT_BYTES / 1024 / 1024));
}
String::from_utf8(bytes).map_err(|_| format!("--script {name}: not valid UTF-8"))
}
fn resolve(path: &str, base: Option<&Path>) -> String {
match base {
Some(b) if Path::new(path).is_relative() => b.join(path).to_string_lossy().into_owned(),
_ => path.to_string(),
}
}
fn opt_string(obj: &serde_json::Map<String, Value>, key: &str) -> Result<Option<String>, String> {
match obj.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.clone())),
Some(_) => Err(format!("`{key}` must be a string")),
}
}
pub fn parse(text: &str, base: Option<&Path>) -> Result<Script, String> {
let v: Value = serde_json::from_str(text).map_err(|e| format!("bad JSON: {e}"))?;
let obj = v.as_object().ok_or("a script is a JSON object with a `commands` list")?;
let list = match obj.get("commands") {
Some(Value::Array(a)) => a,
_ => return Err("`commands` must be a list".into()),
};
if list.is_empty() {
return Err("`commands` is empty".into());
}
if list.len() > MAX_COMMANDS {
return Err(format!("too many commands (over {MAX_COMMANDS})"));
}
let mut commands = Vec::with_capacity(list.len());
for (i, c) in list.iter().enumerate() {
let n = i + 1;
let entry = match c {
Value::String(id) => Some((id.clone(), Value::Object(Default::default()))),
Value::Object(o) => match o.get("command") {
Some(Value::String(id)) => Some((id.clone(), o.get("params").cloned().unwrap_or_else(|| Value::Object(Default::default())))),
_ => None,
},
_ => None,
};
match entry {
Some((id, _)) if id.is_empty() => return Err(format!("command {n}: empty command id")),
Some(e) => commands.push(e),
None => return Err(format!("command {n}: expected a command id or {{\"command\": id, \"params\": {{…}}}}")),
}
}
let save = match obj.get("save") {
None | Some(Value::Null) => Vec::new(),
Some(Value::String(s)) => vec![resolve(s, base)],
Some(Value::Array(a)) if a.len() <= MAX_SAVES => {
a.iter().map(|s| s.as_str().map(|s| resolve(s, base)).ok_or("`save` entries must be strings")).collect::<Result<_, _>>()?
}
Some(_) => return Err(format!("`save` must be a path or a list of at most {MAX_SAVES} paths")),
};
Ok(Script { file: opt_string(obj, "file")?.map(|f| resolve(&f, base)), template: opt_string(obj, "template")?, commands, save })
}
@@ -0,0 +1,49 @@
//! Explicit field refresh for headless conversions; ordinary conversion keeps cached results.
use std::process::Command;
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::{Document, Path, Pos, StoryRef};
#[test]
fn convert_refresh_is_opt_in() {
let dir = std::env::temp_dir().join(format!("wc-convert-fields-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut doc = Document::from_text("Contents\nTarget");
doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap().props.page_break_before = Some(true);
doc.insert_object(&Pos::body(1, 0), InlineObject::BookmarkStart { name: "target".into() }, &Default::default()).unwrap();
for locked in [true, false] {
doc.insert_object(
&Pos::body(0, 0),
InlineObject::Field { instr: "PAGEREF target".into(), result: "104".into(), locked, dirty: true, code: false, form: String::new() },
&Default::default(),
)
.unwrap();
}
let input = dir.join("in.json");
std::fs::write(&input, serde_json::to_vec(&doc).unwrap()).unwrap();
let docx = dir.join("in.docx");
let make = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("convert").arg(&input).arg(&docx).output().unwrap();
assert!(make.status.success(), "{}", String::from_utf8_lossy(&make.stderr));
for refresh in [false, true] {
let output = dir.join(if refresh { "refreshed.json" } else { "cached.json" });
let mut cmd = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
cmd.arg("convert").arg(&docx).arg(&output);
if refresh {
cmd.arg("--update-fields");
}
let run = cmd.output().unwrap();
assert!(run.status.success(), "{}", String::from_utf8_lossy(&run.stderr));
let out: Document = serde_json::from_slice(&std::fs::read(output).unwrap()).unwrap();
let results: Vec<_> = out
.para(StoryRef::Body, &Path::top(0))
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.as_str()),
_ => None,
})
.collect();
assert_eq!(results, if refresh { vec!["2", "104"] } else { vec!["104", "104"] });
}
std::fs::remove_dir_all(dir).unwrap();
}
@@ -0,0 +1,53 @@
//! `wordcraft-cli convert page.html out.docx` puts an HTML file, which gives no page size, on the
//! system region's paper, as the app does: A4 in Italy, Letter in the US (#1381).
use std::process::Command;
const HTML: &str = "<html><body><h2>Premessa</h2><table border=1><thead><tr><th>Indicatore</th><th>Valore</th></tr></thead>\
<tbody><tr><td>Audit</td><td style=\"text-align:right;\">4</td></tr></tbody></table></body></html>";
/// Run the CLI in `region` with `args` (`{src}` is the sample HTML, `{out}` a JSON file it
/// writes) and read back the page size of the JSON it saved.
fn page_size_of(region: &str, args: &[&str]) -> (f64, f64) {
let dir = std::env::temp_dir().join(format!("wordcraft-cli-region-{region}-{}-{}", args.len(), std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (src, out) = (dir.join("page.html"), dir.join("out.json"));
std::fs::write(&src, HTML).unwrap();
let args: Vec<String> = args.iter().map(|a| a.replace("{src}", &src.to_string_lossy()).replace("{out}", &out.to_string_lossy())).collect();
let run = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(&args).env("WORDCRAFT_REGION", region).env_remove("LC_ALL").output().unwrap();
assert!(run.status.success(), "{args:?}: {}", String::from_utf8_lossy(&run.stderr));
let doc: serde_json::Value = serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap();
let _ = std::fs::remove_dir_all(&dir);
let sp = &doc["lastSection"];
(sp["pageW"].as_f64().unwrap(), sp["pageH"].as_f64().unwrap())
}
/// Convert the sample to JSON in `region` and read back the page size.
fn page_size(region: &str) -> (f64, f64) {
page_size_of(region, &["convert", "{src}", "{out}"])
}
fn is_a4((w, h): (f64, f64)) -> bool {
(w - 595.28).abs() < 0.01 && (h - 841.89).abs() < 0.01
}
#[test]
fn html_converts_onto_the_region_paper() {
let (w, h) = page_size("it-IT");
assert!(is_a4((w, h)), "A4 in Italy: {w} x {h}");
assert_eq!(page_size("en-US"), (612.0, 792.0), "Letter in the US");
}
#[test]
fn every_run_session_follows_the_region() {
// `run --file`, `run --template` and a bare `run` all start a session in the system region,
// so `file.new` gives the same paper whichever way the session began.
for args in [
&["run", "--cmd", "file.new", "--save", "{out}"][..],
&["run", "--template", "blank", "--save", "{out}"][..],
&["run", "--file", "{src}", "--cmd", "file.new", "--save", "{out}"][..],
] {
assert!(is_a4(page_size_of("it-IT", args)), "A4 in Italy: {args:?} gave {:?}", page_size_of("it-IT", args));
assert_eq!(page_size_of("en-US", args), (612.0, 792.0), "Letter in the US: {args:?}");
}
}
+47
View File
@@ -0,0 +1,47 @@
//! `--help` or `-h` after any subcommand prints the usage and exits 0, before options are checked
//! or files and folders opened (#968).
use std::process::{Command, Output};
const SUBCOMMANDS: [&str; 10] = ["convert", "info", "text", "inspect", "render", "run", "commands", "parity", "mcp", "zotero"];
fn cli(args: &[&str]) -> Output {
// `output()` gives the child no stdin, so `mcp` can't wait for a client.
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap()
}
fn assert_prints_usage(args: &[&str]) {
let out = cli(args);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{args:?}: status {:?}, stderr: {stderr}", out.status);
assert!(String::from_utf8_lossy(&out.stdout).starts_with("wordcraft-cli — "), "{args:?}: stdout: {}", String::from_utf8_lossy(&out.stdout));
assert!(stderr.is_empty(), "{args:?}: stderr: {stderr}");
}
#[test]
fn every_subcommand_prints_usage_for_help_and_h() {
assert_prints_usage(&["--help"]);
assert_prints_usage(&["-h"]);
for sub in SUBCOMMANDS {
for flag in ["--help", "-h"] {
assert_prints_usage(&[sub, flag]);
}
}
}
#[test]
fn help_comes_before_opening_files_and_folders() {
assert_prints_usage(&["info", "missing.docx", "--help"]);
assert_prints_usage(&["convert", "missing.docx", "out.pdf", "-h"]);
assert_prints_usage(&["mcp", "--automation-read-root", "/definitely/not/a/folder", "--help"]);
}
#[test]
fn misspelled_options_and_unknown_commands_still_fail() {
let out = cli(&["info", "--hepl"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("info: unknown option --hepl"));
let out = cli(&["frobnicate", "--help"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("unknown command `frobnicate`"));
}
+76
View File
@@ -0,0 +1,76 @@
//! A file name that isn't valid Unicode (`caf\xe9.txt`, Latin-1) never panics the CLI (#1440): the
//! file a command opens opens, and any other such argument is an error naming it. Not on macOS:
//! APFS refuses file names that aren't valid UTF-8, so the fixture can't be made there.
#![cfg(all(unix, not(target_os = "macos")))]
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
use std::path::PathBuf;
use std::process::{Command, Output};
fn cli(args: &[OsString]) -> Output {
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap()
}
/// A temp folder holding `caf\xe9.txt`, whose text is "Bonjour".
fn folder(name: &str) -> (PathBuf, OsString) {
let dir = std::env::temp_dir().join(format!("wc-cli-non-utf8-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join(OsString::from_vec(b"caf\xe9.txt".to_vec()));
std::fs::write(&file, "Bonjour\n").unwrap();
(dir, file.into_os_string())
}
fn ok(out: &Output) -> String {
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
String::from_utf8_lossy(&out.stdout).into_owned()
}
#[test]
fn the_file_a_command_opens_may_have_any_name() {
let (dir, file) = folder("open");
assert_eq!(ok(&cli(&["text".into(), file.clone()])).trim(), "Bonjour");
assert!(ok(&cli(&["info".into(), file.clone()])).contains("\"words\""));
assert!(ok(&cli(&["inspect".into(), file.clone()])).contains("Bonjour"));
let md = dir.join("out.md");
ok(&cli(&["convert".into(), file.clone(), md.clone().into()]));
assert!(std::fs::read_to_string(&md).unwrap().contains("Bonjour"));
let saved = dir.join("run.txt");
ok(&cli(&["run".into(), "--file".into(), file.clone(), "--save".into(), saved.clone().into()]));
assert!(std::fs::read_to_string(&saved).unwrap().contains("Bonjour"));
// Saving without a path writes back to the file under its real name, not a lossy one.
let mut args: Vec<OsString> = vec!["run".into(), "--file".into(), file.clone()];
for cmd in ["caret.docEnd", "text.insert={\"text\":\" encore\"}", "file.save"] {
args.extend(["--cmd".into(), cmd.into()]);
}
ok(&cli(&args));
assert!(std::fs::read_to_string(&file).unwrap().contains("encore"));
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 3, "a second file was written");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn any_other_such_argument_is_an_error_naming_it() {
let (dir, file) = folder("error");
let out_name = dir.join(OsString::from_vec(b"caf\xe9.md".to_vec())).into_os_string();
let cmd = OsString::from_vec(b"text.insert={\"text\":\"\xe9\"}".to_vec());
for (args, n) in [
(vec!["convert".into(), file.clone(), out_name.clone()], 3),
(vec!["run".into(), "--file".into(), file.clone(), "--cmd".into(), cmd], 5),
(vec![OsString::from_vec(b"t\xe9xt".to_vec()), file.clone()], 1),
] {
let out = cli(&args);
let stderr = String::from_utf8_lossy(&out.stderr);
let shown = args[n - 1].to_string_lossy();
assert!(shown.contains('\u{FFFD}'));
assert_eq!(out.status.code(), Some(1), "{args:?}: stderr: {stderr}");
assert_eq!(stderr, format!("wordcraft-cli: argument {n} is not valid Unicode: {shown}\n"), "{args:?}");
}
assert!(!std::path::Path::new(&out_name).exists());
let _ = std::fs::remove_dir_all(dir);
}
@@ -0,0 +1,31 @@
//! `wordcraft-cli run`: a picture inserted by `--cmd insert.picture` stays in the saved file when
//! the next commands type (#965).
use std::process::Command;
/// A 1x1 PNG.
const PNG: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
#[test]
fn typing_after_insert_picture_keeps_it_in_the_saved_file() {
let dir = std::env::temp_dir().join(format!("wordcraft-cli-965-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (pic, out) = (dir.join("pic.png"), dir.join("p.docx"));
std::fs::write(&pic, PNG).unwrap();
let picture = format!(r#"insert.picture={{"path":{},"width":250}}"#, serde_json::json!(pic.to_string_lossy()));
let cmds = [r#"text.insert={"text":"x"}"#, "text.newParagraph", &picture, "text.newParagraph", r#"text.insert={"text":"cap"}"#];
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
cli.args(["run", "--template", "blank"]);
for c in cmds {
cli.args(["--cmd", c]);
}
let run = cli.arg("--save").arg(&out).output().unwrap();
let docx = std::fs::read(&out);
let _ = std::fs::remove_dir_all(&dir);
assert!(run.status.success(), "stderr: {}", String::from_utf8_lossy(&run.stderr));
assert!(docx.unwrap().windows(11).any(|w| w == b"word/media/"), "the saved file keeps the picture");
}
+42
View File
@@ -0,0 +1,42 @@
//! #866: `wordcraft-cli text` omits tracked deletions (it does not go through `document.text`).
use std::process::Command;
fn cli() -> Command {
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"))
}
#[test]
fn text_omits_tracked_deletions() {
let dir = std::env::temp_dir().join(format!("wc-cli-866-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("t.docx");
let path_s = path.to_str().unwrap();
let make = cli()
.args([
"run",
"--save",
path_s,
"--cmd",
r#"text.insert={"text":"Signed by Alice Smith, Partner."}"#,
"--cmd",
r#"review.trackChanges={"value":true}"#,
"--cmd",
r#"select.text={"text":"Alice Smith, Partner"}"#,
"--cmd",
r#"text.insert={"text":"Bob Jones, Director"}"#,
])
.output()
.expect("run");
assert!(make.status.success(), "run failed: {}", String::from_utf8_lossy(&make.stderr));
let out = cli().args(["text", path_s]).output().expect("text");
assert!(out.status.success(), "text failed: {}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
assert_eq!(stdout.trim_end(), "Signed by Bob Jones, Director.");
assert!(!stdout.contains("Alice Smith"), "{stdout}");
let _ = std::fs::remove_dir_all(&dir);
}
+1 -1
View File
@@ -29,4 +29,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage"] }
+6
View File
@@ -6,9 +6,15 @@
//!
//! Differences from the desktop app: no TCP control channel; Open and Insert › Pictures use the
//! browser file picker (bytes arrive through `Services::inbox`); Save and Export download.
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
mod prefs;
#[cfg(any(target_arch = "wasm32", test))]
mod save_fallback;
#[cfg(target_arch = "wasm32")]
mod web;
+88
View File
@@ -0,0 +1,88 @@
//! Preferences between visits (#2132): the same [`UiState`] the desktop app keeps in `ui.json`
//! (theme, interface size, recent symbols, editing options…), kept in the browser's
//! `localStorage` under [`KEY`]. Restored at startup; written back whenever it changes, so a
//! reload or a closed tab keeps the last setting (browsers don't reliably run exit code).
use wordcraft_ui_egui::{UiState, WordApp};
/// The `localStorage` key.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub const KEY: &str = "wordcraft.ui";
/// Largest stored value read back (`localStorage` holds about 5 MB per site).
const MAX_STORED: usize = 4 << 20;
/// Apply preferences stored on an earlier visit. Missing, oversized or unreadable values leave
/// the defaults.
pub fn restore(app: &mut WordApp, stored: Option<&str>) {
if let Some(s) = stored.filter(|s| s.len() <= MAX_STORED)
&& let Some(ui) = UiState::from_json(s.as_bytes())
{
app.apply_prefs(ui);
}
}
/// Tracks what was last written, so the store is written only when a preference changed.
#[derive(Default)]
pub struct PrefsSync {
last: Option<String>,
}
impl PrefsSync {
/// Starts from what is already stored, so restoring doesn't write it straight back.
pub fn new(stored: Option<String>) -> Self {
Self { last: stored }
}
/// The JSON to store when the app's preferences differ from what was last written.
pub fn changed(&mut self, app: &WordApp) -> Option<String> {
let json = match serde_json::to_string(&app.prefs()) {
Ok(j) => j,
Err(e) => {
log::warn!("preferences: cannot serialize: {e}");
return None;
}
};
if self.last.as_deref() == Some(json.as_str()) {
return None;
}
self.last = Some(json.clone());
Some(json)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use wordcraft_ui_egui::Services;
fn app() -> WordApp {
WordApp::new(wordcraft_engine::Session::new(wordcraft_engine::sample::sample_document()), Services::default())
}
/// #2132: dark mode chosen on one visit is there after a reload.
#[test]
fn dark_mode_survives_reload() {
let mut first = app();
let mut sync = PrefsSync::new(None);
let initial = sync.changed(&first);
assert!(initial.is_some(), "first frame stores the preferences");
assert_eq!(sync.changed(&first), None, "unchanged preferences are not rewritten");
first.run("ui.theme", json!({"value": "dark"})).unwrap();
let stored = sync.changed(&first).expect("the theme change is stored");
let mut reloaded = app();
restore(&mut reloaded, Some(&stored));
assert_eq!(reloaded.ui.theme, wordcraft_ui_egui::theme::Appearance::Dark);
assert_eq!(PrefsSync::new(Some(stored)).changed(&reloaded), None, "restoring doesn't write back");
}
#[test]
fn junk_storage_keeps_defaults() {
for junk in [None, Some(""), Some("not json"), Some("[1,2]"), Some("{\"theme\":42}")] {
let mut a = app();
restore(&mut a, junk);
assert_eq!(a.ui.theme, wordcraft_ui_egui::theme::Appearance::System, "junk keeps the default appearance");
}
}
}
+46
View File
@@ -0,0 +1,46 @@
//! Save-picker rejection policy, shared with native tests of the browser fallback.
use wordcraft_ui_egui::async_saves::SaveOutcome;
/// A cancelled picker ends the save. Other picker failures try the browser download path.
/// Write/close errors do not pass through this function: they retain their own failure.
pub(crate) fn picker_rejected(name: &str, download: impl FnOnce() -> Result<(), String>) -> SaveOutcome {
if name == "AbortError" {
SaveOutcome::Cancelled
} else {
match download() {
Ok(()) => SaveOutcome::Saved,
Err(error) => SaveOutcome::Failed(error),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unavailable_picker_downloads_once() {
for name in ["SecurityError", "NotSupportedError", "TypeError", ""] {
let mut calls = 0;
let outcome = picker_rejected(name, || {
calls += 1;
Ok(())
});
assert_eq!(outcome, SaveOutcome::Saved);
assert_eq!(calls, 1);
}
}
#[test]
fn cancelled_picker_never_downloads() {
let outcome = picker_rejected("AbortError", || panic!("cancel must not download"));
assert_eq!(outcome, SaveOutcome::Cancelled);
}
#[test]
fn failed_fallback_remains_a_failed_save() {
let outcome = picker_rejected("SecurityError", || Err("download unavailable".into()));
assert_eq!(outcome, SaveOutcome::Failed("download unavailable".into()));
}
}
+186 -62
View File
@@ -6,11 +6,12 @@ use std::rc::Rc;
use wasm_bindgen::JsCast as _;
use wordcraft_engine::Session;
use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome};
use wordcraft_ui_egui::{Inbox, Services, WordApp};
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"];
const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS;
mod font_store;
const CANVAS_ID: &str = "wordcraft_canvas";
const LOADING_ID: &str = "wordcraft_loading";
@@ -27,7 +28,7 @@ pub fn start() {
return;
};
let dirty = Rc::new(Cell::new(false));
let saves = Rc::new(PickerSaves::default());
let saves = AsyncSaves::default();
if let Err(e) = guard_unload(dirty.clone(), saves.clone()) {
log::error!("no unsaved-changes guard: {e}");
}
@@ -48,7 +49,8 @@ pub fn start() {
// Fonts the user added on an earlier visit (#582); the app notices them arrive.
font_store::load_all(cc.egui_ctx.clone());
let inbox: Inbox = Inbox::default();
if let Err(e) = listen_for_pasted_pictures(inbox.clone(), cc.egui_ctx.clone()) {
let pasted: Inbox = Inbox::default();
if let Err(e) = listen_for_pasted_pictures(pasted.clone(), cc.egui_ctx.clone()) {
log::error!("pasting pictures is unavailable: {e}");
}
// The browser's locale picks the new document's paper, the units and the
@@ -58,13 +60,26 @@ pub fn start() {
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_engine::blank_document(&region) };
let mut session = Session::new(doc);
session.system_region = region;
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone()));
let messages = Rc::new(RefCell::new(Vec::new()));
let mut app = WordApp::new(session, services(inbox.clone(), cc.egui_ctx.clone(), dirty, saves.clone(), messages.clone()));
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
app.services.pasted = Some(pasted);
app.autosave = false;
// Preferences from an earlier visit (#2132), as the desktop app reads `ui.json`.
let stored = stored_prefs();
crate::prefs::restore(&mut app, stored.as_deref());
// A region chosen in File › Options replaces the browser's for the first blank
// document too (#838).
if !sample && app.session.prefs.region.is_some() {
let doc = app.session.blank_document();
app.session.set_document(doc);
}
let prefs = crate::prefs::PrefsSync::new(stored);
// No document to show: start on File › Home, as the desktop app does (#766).
if !sample {
app.start_screen();
}
Ok(Box::new(WebShell { app, inbox, saves }))
Ok(Box::new(WebShell { app, inbox, messages, prefs }))
}),
)
.await;
@@ -86,38 +101,17 @@ fn query() -> String {
struct WebShell {
app: WordApp,
inbox: Inbox,
saves: Rc<PickerSaves>,
messages: Rc<RefCell<Vec<String>>>,
prefs: crate::prefs::PrefsSync,
}
/// Saves the browser's save picker finishes after `download` has returned (#728). The app marks
/// the document saved as soon as `download` returns, so a cancelled or failed picker save must
/// mark it unsaved again, and the `beforeunload` guard keeps warning while one is in flight.
#[derive(Default)]
struct PickerSaves {
/// Picker saves started and not yet written and closed.
pending: Cell<u32>,
/// Picker saves that didn't write the file: (name, error, or `None` when cancelled).
unsaved: RefCell<Vec<(String, Option<String>)>>,
fn local_storage() -> Option<web_sys::Storage> {
web_sys::window()?.local_storage().ok()?
}
impl WebShell {
/// A picker save that didn't happen leaves the document with unsaved changes.
fn settle_picker_saves(&mut self) {
let unsaved = std::mem::take(&mut *self.saves.unsaved.borrow_mut());
for (name, error) in unsaved {
// Only a save in a format that keeps everything marked the document saved.
if wordcraft_ui_egui::keeps_everything(&name) {
self.app.session.dirty = true;
}
if let Some(error) = error {
let msg = wordcraft_ui_egui::i18n::fmt(
wordcraft_ui_egui::i18n::t("Couldn't download {name}: {error}"),
&[("name", &name), ("error", &error)],
);
self.app.status(msg);
}
}
}
/// The preferences stored on an earlier visit, if the browser allows storage.
fn stored_prefs() -> Option<String> {
local_storage()?.get_item(crate::prefs::KEY).ok()?
}
impl eframe::App for WebShell {
@@ -137,8 +131,22 @@ impl eframe::App for WebShell {
}
});
}
self.settle_picker_saves();
let msgs = std::mem::take(&mut *self.messages.borrow_mut());
for msg in msgs {
self.app.status(msg);
}
self.app.logic(ctx);
// Store changed preferences straight away: a reload or closed tab runs no exit code (#2132).
if let Some(json) = self.prefs.changed(&self.app) {
match local_storage() {
Some(store) => {
if let Err(e) = store.set_item(crate::prefs::KEY, &json) {
log::warn!("preferences: cannot save: {e:?}");
}
}
None => log::warn!("preferences: browser storage is unavailable"),
}
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
@@ -157,10 +165,11 @@ const MAX_PASTED_PICTURES: u32 = 20;
/// Pasting a picture (#45): eframe's paste handler only reads text, so a clipboard holding just
/// pictures (a screenshot, a browser's Copy Image) is read here and the pictures arrive through
/// the inbox like dropped ones. A clipboard with text is left to eframe.
/// the app's `pasted` queue, which inserts them only when the page is the edit target (#1349). A clipboard with text is left to eframe.
fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), String> {
let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
let on_paste = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::ClipboardEvent)>::new(move |e: web_sys::ClipboardEvent| {
// The target is always eframe's hidden text input while the page has focus; the app decides (#1349).
let Some(data) = e.clipboard_data() else { return };
if !data.get_data("text").unwrap_or_default().is_empty() {
return;
@@ -181,7 +190,7 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St
match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
Ok(buf) => {
let bytes = js_sys::Uint8Array::new(&buf).to_vec();
// The inbox inserts files named like pictures; the engine reads the format from the bytes.
// The engine reads the format from the bytes; the name is ignored.
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(("pasted.png".into(), bytes));
ctx.request_repaint();
}
@@ -197,9 +206,11 @@ fn listen_for_pasted_pictures(inbox: Inbox, ctx: egui::Context) -> Result<(), St
}
/// `dirty` is the flag the `beforeunload` guard reads ([`guard_unload`]).
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Rc<PickerSaves>) -> Services {
fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: AsyncSaves, messages: Rc<RefCell<Vec<String>>>) -> Services {
let open_inbox = inbox.clone();
let save_ctx = ctx.clone();
let font_ctx = ctx.clone();
let font_messages = messages.clone();
Services {
open_async: Some(Box::new(move |purpose: &str| {
let inbox = open_inbox.clone();
@@ -213,17 +224,28 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Rc<P
} else {
rfd::AsyncFileDialog::new().add_filter("Documents", DOC_EXTS)
};
let many = purpose == "font";
wasm_bindgen_futures::spawn_local(async move {
let Some(file) = dialog.pick_file().await else {
return;
};
let bytes = file.read().await;
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
ctx.request_repaint();
// Add Font takes every selected file (#1789); the others one.
let files = if many { dialog.pick_files().await.unwrap_or_default() } else { dialog.pick_file().await.into_iter().collect() };
for file in files.into_iter().take(MAX_PICKED_FILES) {
let mut bytes = file.read().await;
let mut pending = inbox.lock().unwrap_or_else(|e| e.into_inner());
// Fonts waiting for the app to drain them stay under a byte cap. A file
// over it is queued empty, so Add Font reports that file on its own.
let held: usize = pending.iter().map(|(_, b)| b.len()).fold(0, usize::saturating_add);
if many && held.saturating_add(bytes.len()) > MAX_PENDING_PICKED_BYTES {
bytes = Vec::new();
}
pending.push((file.file_name(), bytes));
drop(pending);
ctx.request_repaint();
}
});
})),
// Exports ask for a name; the browser decides where the download goes.
pick_save: Some(Box::new(|name: &str| Some(name.to_string()))),
async_saves: Some(saves.clone()),
download: Some(Box::new(move |path: &str, bytes: &[u8]| download(&saves, &save_ctx, path, bytes))),
print: Some(Box::new(print_pdf)),
inbox: Some(inbox),
@@ -231,10 +253,116 @@ fn services(inbox: Inbox, ctx: egui::Context, dirty: Rc<Cell<bool>>, saves: Rc<P
animations_enabled: Some(animations_enabled()),
store_font: Some(Box::new(font_store::store)),
forget_font: Some(Box::new(font_store::forget)),
load_system_fonts: Some(Box::new(move || {
load_system_fonts(font_ctx.clone(), font_messages.clone());
})),
..Default::default()
}
}
/// Most files one multi-select pick (Add Font) reads.
const MAX_PICKED_FILES: usize = 2000;
/// Most bytes of picked fonts held in the inbox before the app drains them.
const MAX_PENDING_PICKED_BYTES: usize = 256 << 20;
/// Most local fonts one Load System Installed Fonts installs (a designer's machine has thousands).
const MAX_SYSTEM_FONTS: usize = 500;
/// Most font bytes one Load System Installed Fonts reads into memory (wasm memory is small).
const MAX_SYSTEM_FONT_BYTES: usize = 256 << 20;
/// Most local fonts looked at, installed or not (already available, unreadable, too big).
const MAX_SYSTEM_FONTS_TRIED: u32 = 4000;
/// Query the browser's Local Font Access API (`window.queryLocalFonts()`) and install the faces
/// into the global font database, up to [`MAX_SYSTEM_FONTS`] faces and [`MAX_SYSTEM_FONT_BYTES`].
fn load_system_fonts(ctx: egui::Context, messages: Rc<RefCell<Vec<String>>>) {
wasm_bindgen_futures::spawn_local(async move {
let Some(window) = web_sys::window() else {
return;
};
let query_fn = match js_sys::Reflect::get(&window, &wasm_bindgen::JsValue::from_str("queryLocalFonts")) {
Ok(val) if val.is_function() => js_sys::Function::from(val),
_ => {
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access is not supported by your browser.").to_string());
ctx.request_repaint();
return;
}
};
let fonts_promise = match query_fn.call0(&window) {
Ok(p) => js_sys::Promise::from(p),
Err(e) => {
log::warn!("queryLocalFonts call failed: {e:?}");
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access was denied.").to_string());
ctx.request_repaint();
return;
}
};
let fonts_val = match wasm_bindgen_futures::JsFuture::from(fonts_promise).await {
Ok(val) => val,
Err(e) => {
log::warn!("queryLocalFonts promise rejected: {e:?}");
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("Local font access was denied.").to_string());
ctx.request_repaint();
return;
}
};
let font_list = js_sys::Array::from(&fonts_val);
let len = font_list.length();
let mut loaded = 0usize;
let mut total_bytes = 0usize;
for i in 0..len.min(MAX_SYSTEM_FONTS_TRIED) {
if loaded >= MAX_SYSTEM_FONTS || total_bytes >= MAX_SYSTEM_FONT_BYTES {
log::warn!("local fonts: stopped after {loaded} fonts ({total_bytes} bytes)");
break;
}
let font_data = font_list.get(i);
let Ok(blob_fn) = js_sys::Reflect::get(&font_data, &wasm_bindgen::JsValue::from_str("blob")) else {
continue;
};
if !blob_fn.is_function() {
continue;
}
let Ok(blob_val) = js_sys::Function::from(blob_fn).call0(&font_data) else {
continue;
};
let Ok(blob_js) = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(blob_val)).await else {
continue;
};
let Ok(blob) = blob_js.dyn_into::<web_sys::Blob>() else {
continue;
};
// Skip a face too big on its own or for what is left of the budget, before reading it.
let size = blob.size().clamp(0.0, usize::MAX as f64) as usize;
if size > wordcraft_fonts::MAX_FONT_FILE_BYTES || total_bytes.saturating_add(size) > MAX_SYSTEM_FONT_BYTES {
continue;
}
let Ok(array_buffer) = wasm_bindgen_futures::JsFuture::from(blob.array_buffer()).await else {
continue;
};
let bytes = js_sys::Uint8Array::new(&array_buffer).to_vec();
if bytes.len() > wordcraft_fonts::MAX_FONT_FILE_BYTES {
continue;
}
total_bytes = total_bytes.saturating_add(bytes.len());
let ps_name = js_sys::Reflect::get(&font_data, &wasm_bindgen::JsValue::from_str("postscriptName"))
.ok()
.and_then(|v| v.as_string())
.unwrap_or_else(|| format!("local-font-{i}"));
let name = format!("{ps_name}.ttf");
match wordcraft_fonts::FontDb::global().install_font(&name, &bytes) {
Ok(_) => loaded += 1,
Err(wordcraft_fonts::UserFontError::AlreadyAvailable(_)) => {}
Err(e) => log::warn!("local font {name} skipped: {e}"),
}
}
if loaded > 0 {
let msg = wordcraft_ui_egui::i18n::fmt(wordcraft_ui_egui::i18n::t("Loaded {count} system fonts."), &[("count", &loaded.to_string())]);
messages.borrow_mut().push(msg);
} else {
messages.borrow_mut().push(wordcraft_ui_egui::i18n::t("All system fonts are already available.").to_string());
}
ctx.request_repaint();
});
}
/// Whether the system has animation effects on, as the browser passes it on: off while the
/// `prefers-reduced-motion: reduce` media query matches. `None` when the browser can't say. The
/// query is made once and asked every frame; it follows the system's setting by itself.
@@ -245,11 +373,11 @@ fn animations_enabled() -> Box<dyn Fn() -> Option<bool>> {
/// Closing, reloading or leaving the tab with unsaved changes asks first. Browsers show their own
/// confirmation (a page can only ask for it), so there is no Save button: Cancel, then File › Save.
fn guard_unload(dirty: Rc<Cell<bool>>, saves: Rc<PickerSaves>) -> Result<(), String> {
fn guard_unload(dirty: Rc<Cell<bool>>, saves: AsyncSaves) -> Result<(), String> {
let window = web_sys::window().ok_or("no window")?;
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(move |e: web_sys::BeforeUnloadEvent| {
// A save still in the browser's picker hasn't written anything yet.
if dirty.get() || saves.pending.get() > 0 {
if dirty.get() || saves.pending() > 0 {
e.prevent_default();
// Older browsers ask only when `returnValue` is set.
e.set_return_value("unsaved changes");
@@ -262,7 +390,7 @@ fn guard_unload(dirty: Rc<Cell<bool>>, saves: Rc<PickerSaves>) -> Result<(), Str
}
/// Trigger a browser save dialog or download of `bytes` named after the last component of `path`.
fn download(saves: &Rc<PickerSaves>, ctx: &egui::Context, path: &str, bytes: &[u8]) -> Result<(), String> {
fn download(saves: &AsyncSaves, ctx: &egui::Context, path: &str, bytes: &[u8]) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "document".into());
let window = web_sys::window().ok_or("no window")?;
@@ -284,7 +412,7 @@ fn download(saves: &Rc<PickerSaves>, ctx: &egui::Context, path: &str, bytes: &[u
}
fn trigger_save_file_picker(
saves: &Rc<PickerSaves>,
saves: &AsyncSaves,
ctx: &egui::Context,
window: &web_sys::Window,
picker_fn: &js_sys::Function,
@@ -325,39 +453,35 @@ fn trigger_save_file_picker(
let prom: js_sys::Promise = prom_val.dyn_into().map_err(|_| "not a promise".to_string())?;
let bytes_vec = bytes.to_vec();
let window = window.clone();
let saves = saves.clone();
let ctx = ctx.clone();
let name = name.to_string();
saves.pending.set(saves.pending.get().saturating_add(1));
// The app holds back a Save Changes prompt's New/Open until this ticket ends (#1315).
let ticket = saves.start();
wasm_bindgen_futures::spawn_local(async move {
// The document counts as saved only once the file is written and closed.
let unsaved = match wasm_bindgen_futures::JsFuture::from(prom).await {
let outcome = match wasm_bindgen_futures::JsFuture::from(prom).await {
Ok(handle_val) => match write_file_handle(&handle_val, &bytes_vec).await {
Ok(()) => {
log::info!("saved {name} through the save picker");
None
SaveOutcome::Saved
}
Err(e) => {
log::error!("couldn't write {name}: {e}");
Some(Some(e))
SaveOutcome::Failed(e)
}
},
Err(e) => {
let err_name =
js_sys::Reflect::get(&e, &wasm_bindgen::JsValue::from_str("name")).ok().and_then(|v| v.as_string()).unwrap_or_default();
if err_name == "AbortError" {
log::debug!("save picker cancelled");
Some(None)
} else {
log::error!("showSaveFilePicker error: {e:?}");
Some(Some(format!("{e:?}")))
}
// A browser can expose the picker but reject its promise (for example in
// a cross-origin iframe). Use the same download path as an absent API;
// cancelling a picker must never start an unwanted download (#1246).
crate::save_fallback::picker_rejected(&err_name, || trigger_anchor_download(&window, &name, &bytes_vec))
}
};
if let Some(error) = unsaved {
saves.unsaved.borrow_mut().push((name, error));
}
saves.pending.set(saves.pending.get().saturating_sub(1));
saves.finish(ticket, outcome);
ctx.request_repaint();
});
Ok(())
+1 -1
View File
@@ -36,7 +36,7 @@ async fn open() -> Result<IdbDatabase, String> {
let req = factory.open_with_u32(DB_NAME, 1).map_err(js)?;
let upgrade_req = req.clone();
let upgrade = Closure::<dyn FnMut()>::new(move || {
if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::<IdbDatabase>().map_err(JsValue::from))
if let Ok(db) = upgrade_req.result().and_then(|r| r.dyn_into::<IdbDatabase>())
&& !db.object_store_names().contains(STORE)
&& let Err(e) = db.create_object_store(STORE)
{
+7 -2
View File
@@ -36,9 +36,14 @@ winresource = "0.1"
fmv-macos-events = "=0.1.0"
# The system's Reduce motion setting (System Settings › Accessibility › Display), read through
# NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard,
# at this version; this adds only the `NSAccessibility` bindings.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication"] }
# at this version; this adds the `NSAccessibility` bindings and, to fit the title bar around the
# traffic lights, the window, button and view ones.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder", "NSPasteboard"] }
objc2 = "0.6"
# A copied equation's MathML goes on the pasteboard as `public.mathml.presentation` beside
# arboard's HTML and text (#1297), through NSPasteboard's safe `setString:forType:`. Already in
# the build through arboard, at this version; this adds only the NSString bindings.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString"] }
# Closing the window must quit through NSApplication rather than by dropping the NSWindow
# inside the AppKit run loop (see `schedule_macos_terminate`).
dispatch2 = "0.3"
+89 -9
View File
@@ -9,10 +9,15 @@
//!
//! Everything read here comes from other programs: sizes are capped as it is read.
//!
//! Written here: a copy holding an equation puts HTML (the equation as MathML) beside its text
//! (#816), for programs that read MathML (pasting into Word and LibreOffice not yet verified). arboard sets HTML with a
//! text alternative on every desktop platform; formats of other names (Word's "Office Open XML"
//! with OMML, #1297) it can't, so they aren't offered.
//! Written here: a copy puts HTML beside its text, so other programs keep its formatting (#1523),
//! with equations as MathML (#816). arboard sets HTML with a text alternative on every desktop
//! platform. A copy of one equation alone also goes in the platform's MathML formats (#1297),
//! added beside arboard's: on Windows "MathML" and "MathML Presentation" (UTF-16LE), on macOS
//! `public.mathml.presentation` and `public.mathml` — the names W3C MathML 3 §6.2.4 registers
//! for transferring MathML, and the ones Microsoft documents its Office apps reading first when
//! they paste math (learn.microsoft.com/office/math/mathml, "Clipboard support"). Pasting into
//! Word and LibreOffice is not yet verified. Linux and the BSDs have no Word, and arboard can't
//! add MIME types of other names, so there the HTML carries the MathML alone.
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
@@ -26,13 +31,24 @@ thread_local! {
static WRITER: std::cell::RefCell<Option<arboard::Clipboard>> = const { std::cell::RefCell::new(None) };
}
/// Put `html` on the clipboard with `text` as its plain-text alternative. `false` when it couldn't
/// (the caller copies the text alone), including a Wayland session without the data-control
/// protocol, where arboard would fall back to X11 and native Wayland programs might not see it.
pub fn set_html(html: &str, text: &str) -> bool {
/// Put `html` on the clipboard with `text` as its plain-text alternative, and `mathml` (a copy of
/// one equation alone; empty for any other copy) in the platform's MathML formats. `false` when
/// the HTML and text couldn't be set (the caller copies the text alone), including a Wayland
/// session without the data-control protocol, where arboard would fall back to X11 and native
/// Wayland programs might not see it. MathML that can't be added is logged: the HTML still
/// carries it.
pub fn set_html(html: &str, text: &str, mathml: &str) -> bool {
if !platform::can_set() {
return false;
}
let set = set_html_text(html, text);
if set && !mathml.is_empty() && mathml.len() <= MAX_PASTE_BYTES {
platform::add_mathml(mathml);
}
set
}
fn set_html_text(html: &str, text: &str) -> bool {
WRITER.with(|w| {
let Ok(mut slot) = w.try_borrow_mut() else { return false };
if slot.is_none() {
@@ -146,6 +162,9 @@ mod platform {
)
}
/// arboard serves its own MIME types only; Word, which reads the MathML formats, isn't here.
pub fn add_mathml(_mathml: &str) {}
pub fn rtf() -> Option<Vec<u8>> {
if !wayland() {
return None;
@@ -193,9 +212,68 @@ mod platform {
pub fn can_set() -> bool {
true
}
/// Add the MathML to what arboard just set, as "MathML" and "MathML Presentation": UTF-16LE
/// text (with its terminating NUL, as Windows text formats have).
pub fn add_mathml(mathml: &str) {
let Ok(_open) = Clipboard::new_attempts(10) else {
log::warn!("clipboard: couldn't open it to add MathML");
return;
};
let bytes: Vec<u8> = mathml.encode_utf16().chain(std::iter::once(0)).flat_map(u16::to_le_bytes).collect();
for name in super::MATHML_FORMATS {
let Some(format) = raw::register_format(name) else { continue };
if let Err(e) = raw::set_without_clear(format.get(), &bytes) {
log::warn!("clipboard: {name}: {e}");
}
}
}
}
#[cfg(not(any(target_os = "windows", all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))))]
/// The clipboard format names for MathML that Windows programs read (W3C MathML 3 §6.2.4; Office
/// reads and writes both).
#[cfg(target_os = "windows")]
const MATHML_FORMATS: [&str; 2] = ["MathML", "MathML Presentation"];
#[cfg(target_os = "macos")]
mod platform {
use objc2_app_kit::NSPasteboard;
use objc2_foundation::NSString;
pub fn formats() -> Vec<String> {
Vec::new()
}
pub fn rtf() -> Option<Vec<u8>> {
None
}
pub fn encoded_picture() -> Option<Vec<u8>> {
None
}
pub fn can_set() -> bool {
true
}
/// Add the MathML to the pasteboard item arboard just wrote, as the MathML types (W3C MathML 3
/// §6.2.4): presentation markup, and the generic type for readers that only know that one.
pub fn add_mathml(mathml: &str) {
let board = NSPasteboard::generalPasteboard();
let text = NSString::from_str(mathml);
for name in ["public.mathml.presentation", "public.mathml"] {
if !board.setString_forType(&text, &NSString::from_str(name)) {
log::warn!("clipboard: couldn't add {name}");
}
}
}
}
#[cfg(not(any(
target_os = "windows",
target_os = "macos",
all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten")))
)))]
mod platform {
pub fn formats() -> Vec<String> {
Vec::new()
@@ -212,4 +290,6 @@ mod platform {
pub fn can_set() -> bool {
true
}
pub fn add_mathml(_mathml: &str) {}
}
+10 -1
View File
@@ -484,7 +484,16 @@ mod tests {
assert!(!dir.exists() || std::fs::read_dir(&dir).unwrap().next().is_none(), "no key file after a failed bind");
drop(taken);
// Bound: the key file holds the key that opens the port.
let (rx, key_file) = start(port, egui::Context::default(), Some(&dir)).unwrap();
let mut free_port = port.wrapping_add(1).max(1024);
let mut started = None;
for _ in 0..100 {
if let Some(res) = start(free_port, egui::Context::default(), Some(&dir)) {
started = Some((res, free_port));
break;
}
free_port = free_port.wrapping_add(1).max(1024);
}
let ((rx, key_file), port) = started.unwrap();
let key_file = key_file.unwrap();
assert_eq!(key_file.path(), wordcraft_control_key::key_path(&dir, port));
let key = std::fs::read_to_string(key_file.path()).unwrap();
+135 -12
View File
@@ -12,6 +12,12 @@
//! thread first checks that one of them is there and otherwise answers with an error, and the app
//! shows its built-in file browser. `WORDCRAFT_FILE_DIALOG=builtin` always uses the built-in one.
//!
//! On Linux and the BSDs, rfd reports a portal that failed to show its dialog as a cancel (#2999).
//! There a cancel quicker than anyone could dismiss a dialog is taken as that failure: the dialog
//! is asked for once more without a parent window, and if that also comes back at once the
//! built-in browser shows. Windows and macOS always show their dialog, so a quick cancel there is
//! the person's and is answered as one.
//!
//! [`Services::file_dialog`]: wordcraft_ui_egui::Services::file_dialog
use std::future::Future;
@@ -27,7 +33,7 @@ type Request = (FileDialogRequest, Sender<FileDialogAnswer>);
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<FileDialogAnswer>>;
/// A picker's answer, waited for off the UI thread.
type Picking = Pin<Box<dyn Future<Output = Option<rfd::FileHandle>> + Send>>;
type Picking = Pin<Box<dyn Future<Output = Vec<rfd::FileHandle>> + Send>>;
/// Shows the dialogs the app queued; lives in the eframe app, which has the window.
pub struct Launcher {
@@ -54,20 +60,22 @@ impl Launcher {
pub fn show(&self, parent: &eframe::Frame) {
while let Ok((req, answer)) = self.queue.try_recv() {
// Built here, on the UI thread: macOS shows the panel as a sheet on the window now.
let dialog = rfd::AsyncFileDialog::new().set_parent(parent);
let picking: Picking = match req {
FileDialogRequest::Open { purpose } => Box::pin(open_filters(dialog, &purpose).pick_file()),
FileDialogRequest::Save { name } => {
let exts = save_exts(&name);
Box::pin(dialog.set_file_name(name).add_filter("Documents", &exts).save_file())
}
};
let picking = picker(rfd::AsyncFileDialog::new().set_parent(parent), &req);
#[cfg(all(unix, not(target_os = "macos")))]
let unparented = picker(rfd::AsyncFileDialog::new(), &req);
let ctx = self.ctx.clone();
let waiting = std::thread::Builder::new().name("file dialog".into()).spawn(move || {
// The picker only starts when first polled: without a system dialog it never does.
// The pickers only start when first polled: without a system dialog they never do.
let picked = match no_system_dialog() {
Some(reason) => Err(reason),
None => Ok(pollster::block_on(picking).map(|f| f.path().to_string_lossy().to_string())),
None => {
// Only the portal folds a failure into "cancelled" (#2999).
#[cfg(all(unix, not(target_os = "macos")))]
let picked = pick(picking, unparented, TOO_QUICK);
#[cfg(not(all(unix, not(target_os = "macos"))))]
let picked: Result<_, String> = Ok(pollster::block_on(picking));
picked.map(|f| f.iter().map(|f| f.path().to_string_lossy().to_string()).collect())
}
};
let _ = answer.send(picked);
ctx.request_repaint();
@@ -80,6 +88,53 @@ impl Launcher {
}
}
/// The picker for `req` on `dialog`.
fn picker(dialog: rfd::AsyncFileDialog, req: &FileDialogRequest) -> Picking {
match req.clone() {
FileDialogRequest::Open { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_file().await.into_iter().collect() }),
FileDialogRequest::OpenMany { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_files().await.unwrap_or_default() }),
FileDialogRequest::Save { name } => {
let mut dialog = dialog.set_file_name(name.clone());
for (label, exts) in save_filters(&name) {
dialog = dialog.add_filter(label, &exts);
}
Box::pin(async move { dialog.save_file().await.into_iter().collect() })
}
}
}
/// Quicker than anyone can see a dialog and dismiss it.
// ponytail: a timing heuristic, as rfd folds the portal's failure code into "cancelled". A portal
// failing slower than this still reads as a cancel; ask rfd for the response code if it matters.
#[cfg(all(unix, not(target_os = "macos")))]
const TOO_QUICK: std::time::Duration = std::time::Duration::from_millis(300);
/// Wait for `picking`. Nothing picked within `too_quick` is a portal that failed to show its
/// dialog (#2999), not a person cancelling: try `unparented`, as a portal may fail on the parent
/// window; if that too comes back at once, an error, so the app shows its built-in browser.
/// Linux and the BSDs only (built everywhere for its test).
#[cfg(any(test, all(unix, not(target_os = "macos"))))]
fn pick<T>(
picking: impl Future<Output = Vec<T>>,
unparented: impl Future<Output = Vec<T>>,
too_quick: std::time::Duration,
) -> Result<Vec<T>, String> {
fn timed<T>(f: impl Future<Output = Vec<T>>, too_quick: std::time::Duration) -> (Vec<T>, bool) {
let start = std::time::Instant::now();
let picked = pollster::block_on(f);
let quick = picked.is_empty() && start.elapsed() < too_quick;
(picked, quick)
}
match timed(picking, too_quick) {
(_, true) => log::warn!("the system file dialog closed at once; asking again without a parent window"),
(picked, false) => return Ok(picked),
}
match timed(unparented, too_quick) {
(_, true) => Err("the system file dialog closed at once".into()),
(picked, false) => Ok(picked),
}
}
/// The file types Open offers (shared with the built-in browser).
fn open_filters(mut d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog {
for (name, exts) in wordcraft_ui_egui::open_filters(purpose) {
@@ -158,9 +213,77 @@ fn save_exts(name: &str) -> Vec<String> {
exts
}
/// The "Save as type" list (#1399): one named entry per format, the suggested one first (see
/// [`save_exts`] for why the order matters).
///
/// rfd doesn't report which entry the user picked, so if they choose "PDF" but leave "name.docx"
/// in the name box, the extension in the name wins, as the format is chosen by extension.
fn save_filters(name: &str) -> Vec<(String, Vec<String>)> {
save_exts(name).into_iter().map(|e| (format!("{} (*.{e})", format_name(&e)), vec![e])).collect()
}
/// A readable name for a save format's extension.
fn format_name(ext: &str) -> String {
match ext {
"docx" => "Word Document".into(),
"docm" => "Word Macro-Enabled Document".into(),
"dotx" => "Word Template".into(),
"dotm" => "Word Macro-Enabled Template".into(),
"pdf" => "PDF".into(),
"txt" => "Plain Text".into(),
"md" => "Markdown".into(),
"html" => "Web Page".into(),
"rtf" => "Rich Text Format".into(),
"odt" => "OpenDocument Text".into(),
"tex" => "LaTeX".into(),
"png" => "PNG Image".into(),
"json" => "JSON".into(),
"xml" => "XML".into(),
other => other.to_ascii_uppercase(),
}
}
#[cfg(test)]
mod tests {
use super::save_exts;
use super::{pick, save_exts, save_filters};
use std::time::Duration;
/// #2999: a dialog that "cancels" at once is a portal that failed: asked again without a
/// parent, then the built-in browser. A pick, or a cancel after a while, is the answer.
#[test]
fn an_instant_cancel_retries_then_falls_back() {
let quick = Duration::from_millis(200);
let slow_cancel = || async {
std::thread::sleep(Duration::from_millis(250));
Vec::<u8>::new()
};
assert_eq!(pick(async { vec![1u8] }, async { vec![2] }, quick), Ok(vec![1]));
assert_eq!(pick(async { vec![] }, async { vec![2u8] }, quick), Ok(vec![2]), "asked again without a parent");
assert!(pick(async { Vec::<u8>::new() }, async { vec![] }, quick).is_err(), "the built-in browser");
assert_eq!(pick(slow_cancel(), async { vec![2u8] }, quick), Ok(vec![]), "a real cancel isn't asked again");
assert_eq!(pick(async { vec![] }, slow_cancel(), quick), Ok(vec![]), "the second dialog cancelled for real");
}
/// #1399: the Save dialog's type list has one named entry per format, the suggested one first,
/// every savable extension exactly once.
#[test]
fn save_dialog_lists_one_named_type_per_format() {
let filters = save_filters("report.odt");
assert_eq!(filters.len(), wordcraft_engine::io::SAVE_EXTS.len());
assert_eq!(filters[0].1, vec!["odt".to_string()]);
assert!(filters[0].0.contains("OpenDocument"), "{}", filters[0].0);
let all: Vec<&String> = filters.iter().flat_map(|(_, e)| e).collect();
for e in wordcraft_engine::io::SAVE_EXTS {
assert_eq!(all.iter().filter(|x| x.as_str() == *e).count(), 1, "{e}");
}
assert!(filters.iter().all(|(l, e)| !l.trim().is_empty() && e.len() == 1));
let mut labels: Vec<&String> = filters.iter().map(|(l, _)| l).collect();
labels.sort();
labels.dedup();
assert_eq!(labels.len(), filters.len(), "labels are distinct");
// Unknown names fall back to Word's format first.
assert_eq!(save_filters("no extension")[0].1, vec!["docx".to_string()]);
}
/// #689: the Save dialog's default extension is the suggested name's own format, so a file
/// name the user types without one is saved in the document's format instead of coming back
+55 -3
View File
@@ -1,12 +1,37 @@
//! Windows graphics instance configuration (backends and the DirectX 12 shader compiler), applied
//! before wgpu can initialize any driver.
//! Graphics instance configuration applied before wgpu can initialize any driver: Windows
//! backends and the DirectX 12 shader compiler, and the Intel Mac indirect-validation skip.
// On macOS only the indirect-validation items are used outside tests. Elsewhere it is the other
// way round, so those two carry their own `allow`.
#![cfg_attr(target_os = "macos", allow(dead_code))]
use eframe::{
NativeOptions,
egui_wgpu::WgpuSetup,
wgpu::{Backends, DeviceType, Dx12Compiler},
wgpu::{Backends, DeviceType, Dx12Compiler, InstanceFlags},
};
/// wgpu compiles its indirect-call validation compute shaders inside `request_device`. On old
/// Intel Macs (HD Graphics 5000 on Big Sur, #2652) Metal's Intel
/// compiler aborts on them, the device is lost and eframe exits before the window opens. WordCraft
/// issues no indirect draws or dispatches, so Intel Macs skip them; every other GPU keeps wgpu's
/// default. Same fix as PhotoCraft's storytold/PhotoCraft#3171.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool {
macos && intel
}
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
/// `WGPU_VALIDATION_INDIRECT_CALL=1` still turns it back on.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub fn configure_indirect_validation(options: &mut NativeOptions, skip: bool) {
if !skip {
return;
}
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
create.instance_descriptor.flags = create.instance_descriptor.flags.difference(InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
}
}
/// What the start-up probe learned about one DirectX 12 adapter.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Dx12Adapter {
@@ -153,6 +178,33 @@ fn probe_dx12(options: &NativeOptions) -> Vec<Dx12Adapter> {
mod tests {
use super::*;
#[test]
fn intel_macs_skip_the_indirect_validation_shaders() {
// #2652: Metal's Intel compiler aborted on them, losing the device before the window opened.
assert!(skips_indirect_validation(true, true));
// Apple silicon, Windows and Linux keep wgpu's validation.
for (macos, intel) in [(true, false), (false, true), (false, false)] {
assert!(!skips_indirect_validation(macos, intel), "macos={macos} intel={intel}");
}
let mut options = NativeOptions::default();
let WgpuSetup::CreateNew(before) = &options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
let before = before.instance_descriptor.flags;
configure_indirect_validation(&mut options, false);
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert_eq!(create.instance_descriptor.flags, before, "flags untouched when not skipping");
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
configure_indirect_validation(&mut options, true);
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert!(!create.instance_descriptor.flags.contains(InstanceFlags::VALIDATION_INDIRECT_CALL));
}
}
fn configured_backends(backends: Backends) -> Backends {
let mut options = NativeOptions::default();
configure_backends(&mut options, backends);
+188 -13
View File
@@ -1,6 +1,6 @@
//! WordCraft desktop app.
//!
//! Usage: `wordcraft [--control <port>] [--sample] [files…]`
//! Usage: `wordcraft [--control <port>] [--sample] [files…]`; `--help` prints `USAGE`.
//!
//! `--control <port>` (or `WORDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
//! `{"id":1,"key":"…","method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hi"}}}`.
@@ -16,11 +16,13 @@ mod clipboard;
mod control_server;
mod default_apps;
mod file_dialogs;
#[cfg(any(target_os = "windows", test))]
#[cfg(any(target_os = "windows", target_os = "macos", test))]
mod graphics;
mod logging;
mod print;
mod region;
#[cfg(target_os = "macos")]
mod traffic_lights;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{
@@ -28,9 +30,9 @@ use wordcraft_ui_egui::{
window_geometry::{WindowGeometry, take_rescue},
};
/// The app, the restored window geometry until the first frame has checked it, the control
/// server's key file (removed on exit), the file dialogs the app asked for and, on macOS, the
/// documents opened from Finder.
/// The app, the restored window geometry (or, with none, the default: no position) until the
/// first frame that knows the monitor has checked it, the control server's key file (removed on
/// exit), the file dialogs the app asked for and, on macOS, the documents opened from Finder.
struct App(
WordApp,
Option<WindowGeometry>,
@@ -45,11 +47,24 @@ impl eframe::App for App {
apple_events::poll(&self.4, &mut self.0, ctx);
self.0.logic(ctx);
self.3.show(frame);
// A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit
// there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first
// knows the monitor: the off-screen check below takes the pending geometry then.
if let Some((pos, size)) = ctx.input(|i| self.1.and_then(|g| g.fit_on_monitor(i.viewport()))) {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
}
let prev = self.0.ui.window;
self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size()));
// The first frame, once the monitor is known (as `take_rescue` waits for): maximize a
// window that opened at its normal size so it never flashed on screen half-opened.
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| ctx.input(|i| i.viewport().monitor_size.is_some()));
if let Some(pos) = ctx.input(|i| take_rescue(&mut self.1, i.viewport())) {
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
}
if let Some(command) = maximize {
ctx.send_viewport_cmd(command);
}
// The window's close button: with unsaved changes, ask first (`WordApp::close_requested`).
let closing = ctx.input(|i| i.viewport().close_requested());
if closing && !self.0.close_requested() {
@@ -189,15 +204,35 @@ fn save_prefs(app: &WordApp) {
return;
}
if let Some(p) = prefs_path() {
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
if let Some(dir) = p.parent()
&& let Err(e) = std::fs::create_dir_all(dir)
{
log::warn!("preferences: cannot create {}: {e}", dir.display());
return;
}
if let Ok(bytes) = serde_json::to_vec_pretty(&app.prefs()) {
let _ = std::fs::write(&p, bytes);
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}"),
}
}
}
/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or
/// interrupted write leaves the previous preferences in place.
fn write_prefs(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "ui.json".into());
let tmp = path.with_file_name(format!(".{name}.{}.tmp", std::process::id()));
let written = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path));
if written.is_err() {
let _ = std::fs::remove_file(&tmp);
}
written
}
/// Native file dialogs, shown without blocking the window (`file_dialogs`, #94), File › Print
/// through the system spooler (`print`), the system clipboard's text, formatted copies,
/// pictures and format names (`clipboard`, #45, #584, #687, #833), the system's animation setting
@@ -228,11 +263,59 @@ fn app_icon() -> Option<egui::IconData> {
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
}
const USAGE: &str = "\
wordcraft — the WordCraft desktop app
USAGE:
wordcraft [--sample] [--control PORT] [FILE…]
OPTIONS:
--sample open the sample document
--control PORT start the localhost control server on PORT, for agents and scripts
(or set WORDCRAFT_CONTROL_PORT); see docs/control-protocol.md
--version print the version
-h, --help print this help
";
/// The commit the build came from (short), or `dev`.
fn build_sha() -> &'static str {
option_env!("WORDCRAFT_BUILD_SHA").map(|s| s.get(..8).unwrap_or(s)).unwrap_or("dev")
}
/// The arguments as text, and apart the files whose names aren't valid Unicode, such as a Latin-1
/// one (#1440). A `--control` port that isn't is an error.
fn text_args() -> (Vec<String>, Vec<std::path::PathBuf>) {
let (mut args, mut files) = (Vec::new(), Vec::new());
for (i, a) in std::env::args_os().skip(1).enumerate() {
match a.into_string() {
Ok(a) => args.push(a),
Err(a) if args.last().is_some_and(|p| p == "--control") => {
// Unlike `eprintln!`, never panics.
let (n, lossy) = (i + 1, a.to_string_lossy());
let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("wordcraft: argument {n} is not valid Unicode: {lossy}\n"));
std::process::exit(2);
}
Err(a) => files.push(a.into()),
}
}
(args, files)
}
/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it
/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.
fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
let name = path.to_string_lossy();
let opened = std::fs::read(path)
.map_err(|e| e.to_string())
.and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})));
match opened {
// Not when it waits for a password: that open names the document itself.
Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()),
Ok(_) => {}
Err(e) => log::warn!("{name}: {e}"),
}
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
@@ -240,18 +323,38 @@ fn main() -> eframe::Result {
let mut control_port: Option<u16> = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
let mut args = std::env::args().skip(1);
let (args, other_files) = text_args();
let mut bad_control: Option<Option<String>> = None;
let mut args = args.into_iter().peekable();
while let Some(a) = args.next() {
match a.as_str() {
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
// A flag after `--control` stays a flag; a bad or missing port is an error (#1426).
"--control" => {
let port = args.next_if(|p| p.strip_prefix('-').is_none_or(|n| n.starts_with(|c: char| c.is_ascii_digit())));
match port.as_deref().and_then(|p| p.parse::<std::num::NonZeroU16>().ok()) {
Some(p) => control_port = Some(p.get()),
None => bad_control = Some(port),
}
}
"--sample" => sample = true,
"--version" => {
println!("wordcraft {} ({})", env!("CARGO_PKG_VERSION"), build_sha());
return Ok(());
}
// Before the window opens (#968). Unlike `print!`, a closed stdout can't panic.
"--help" | "-h" => {
let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());
return Ok(());
}
_ => files.push(a),
}
}
// After the loop, so `--version` later on the line still wins. Unlike `eprintln!`, never panics.
if let Some(port) = bad_control {
let got = port.map(|p| format!(", got {p:?}")).unwrap_or_default();
let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("wordcraft: --control needs a port number from 1 to 65535{got}\n"));
std::process::exit(2);
}
// The log file lives in the settings directory, next to the preferences; opened after the
// arguments, so `--version` leaves no file behind. Records logged until now are written to it
// first. Runs without preferences (agents' test runs) log to standard error only, so they
@@ -286,6 +389,9 @@ fn main() -> eframe::Result {
// FXC, which needs no DLL of ours (#603; see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652).
#[cfg(target_os = "macos")]
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
load_user_fonts();
let restored = saved_window();
if let Some(window) = restored {
@@ -328,8 +434,21 @@ fn main() -> eframe::Result {
app.session.set_document(doc);
}
app.watch_desktop_theme(&cc.egui_ctx);
if prefs_enabled()
&& let Some(dir) = wordcraft_control_key::settings_dir()
{
let ctx = cc.egui_ctx.clone();
if let Err(e) = app.session.enable_recovery(dir.join("recovery"), std::sync::Arc::new(move || ctx.request_repaint())) {
log::warn!("local recovery unavailable: {e}");
}
}
app.ui.window = restored;
app.integrated_titlebar = cfg!(target_os = "macos");
#[cfg(target_os = "macos")]
if let Some(bar) = traffic_lights::title_bar() {
log::info!("title bar: {bar:?}");
app.mac_titlebar = bar;
}
let mut key_file = None;
if let Some(port) = control_port
&& let Some((rx, file)) = control_server::start(port, cc.egui_ctx.clone(), wordcraft_control_key::settings_dir().as_deref())
@@ -340,7 +459,7 @@ fn main() -> eframe::Result {
// Nothing to open (no files, no sample, no control channel driving the editor): start
// on File › Home, as Word does (#766), unless the Start screen option is off. Files
// macOS hands over later open on top of it and close it (`apple_events`).
if files.is_empty() && !sample && control_port.is_none() {
if files.is_empty() && other_files.is_empty() && !sample && control_port.is_none() {
app.start_screen();
}
for f in files {
@@ -348,9 +467,13 @@ fn main() -> eframe::Result {
log::warn!("{f}: {e}");
}
}
for f in other_files {
open_non_unicode(&mut app, &f);
}
Ok(Box::new(App(
app,
restored,
// With nothing restored, the window the system placed is checked all the same.
Some(restored.unwrap_or_default()),
key_file,
dialogs,
#[cfg(target_os = "macos")]
@@ -359,3 +482,55 @@ fn main() -> eframe::Result {
}),
)
}
// Not on macOS: APFS refuses file names that aren't valid UTF-8, so no such file can exist there.
#[cfg(all(test, unix, not(target_os = "macos")))]
mod tests {
use super::*;
use std::os::unix::ffi::OsStringExt;
#[test]
fn a_file_whose_name_isnt_unicode_opens_and_saves_back_to_itself() {
let dir = std::env::temp_dir().join(format!("wc-app-non-utf8-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(std::ffi::OsString::from_vec(b"caf\xe9.txt".to_vec()));
std::fs::write(&path, "Bonjour").unwrap();
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
open_non_unicode(&mut app, &path);
assert_eq!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Bonjour");
assert_eq!(app.session.path.as_deref(), Some(path.as_path()));
app.run("caret.docEnd", serde_json::json!({})).unwrap();
app.run("text.insert", serde_json::json!({"text": " encore"})).unwrap();
app.run("file.save", serde_json::json!({})).unwrap();
assert!(std::fs::read_to_string(&path).unwrap().contains("Bonjour encore"));
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "a second file was written");
// A file that can't be read is a warning, as one that fails to open by name is.
open_non_unicode(&mut app, &dir.join(std::ffi::OsString::from_vec(b"gon\xe9.txt".to_vec())));
assert_eq!(app.session.path.as_deref(), Some(path.as_path()));
let _ = std::fs::remove_dir_all(dir);
}
}
#[cfg(test)]
mod prefs_tests {
use super::write_prefs;
#[test]
fn failed_preferences_write_keeps_the_old_file_and_reports_the_error() {
let dir = std::env::temp_dir().join(format!("wordcraft-prefs-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("ui.json");
std::fs::write(&path, b"old").unwrap();
write_prefs(&path, b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
// A directory squatting on the temporary name makes the preparation step fail.
std::fs::create_dir(dir.join(format!(".ui.json.{}.tmp", std::process::id()))).unwrap();
assert!(write_prefs(&path, b"newer").is_err());
assert_eq!(std::fs::read(&path).unwrap(), b"new");
let _ = std::fs::remove_dir_all(&dir);
}
}
+2 -2
View File
@@ -26,7 +26,7 @@ pub fn finish_pending_jobs() {
}
pub fn submit() -> Submit {
Box::new(|bytes, settings: Settings, collate| {
Box::new(|bytes, settings: Settings, collate, options| {
let (sender, receiver) = std::sync::mpsc::channel();
let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || {
let job = craft_print::Job {
@@ -41,7 +41,7 @@ pub fn submit() -> Submit {
grayscale: settings.grayscale,
scaling: craft_print::Scaling::ShrinkToFit,
title: "WordCraft document".into(),
..Default::default()
options,
};
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
});
+26
View File
@@ -0,0 +1,26 @@
//! Where AppKit puts the traffic lights, so the integrated title bar fits around them (macOS).
//!
//! The lights' size and position change between macOS versions (they grew in macOS 26), so the
//! title bar measures the real buttons instead of assuming the old 28 pt layout. These calls are
//! safe in the `objc2-app-kit` bindings (our workspace forbids unsafe).
use objc2::MainThreadMarker;
use objc2_app_kit::{NSApplication, NSWindowButton};
use wordcraft_ui_egui::chrome::MacTitleBar;
/// The title bar around the app window's traffic lights, or `None` before the window exists or
/// when AppKit has no zoom button to measure (the caller keeps the standard layout).
pub fn title_bar() -> Option<MacTitleBar> {
let mtm = MainThreadMarker::new()?;
let window = NSApplication::sharedApplication(mtm).windows().firstObject()?;
let zoom = window.standardWindowButton(NSWindowButton::ZoomButton)?;
// In window coordinates, which count up from the bottom.
let frame = zoom.convertRect_toView(zoom.bounds(), None);
let window_height = window.frame().size.height;
let right = frame.origin.x + frame.size.width;
let centre_from_top = window_height - (frame.origin.y + frame.size.height / 2.0);
if !(right.is_finite() && centre_from_top.is_finite() && right > 0.0 && centre_from_top > 0.0) {
return None;
}
Some(MacTitleBar::around_lights(right as f32, centre_from_top as f32, frame.size.height as f32))
}
+58
View File
@@ -0,0 +1,58 @@
//! `wordcraft --control` checks its port before the window opens (#1426).
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
fn app(args: &[&str]) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
.args(args)
// With no display, an app that tries to open a window fails at once instead.
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
// A bad `--control` must not fall back to this port either.
.env("WORDCRAFT_CONTROL_PORT", "7981")
.env("WORDCRAFT_NO_PREFS", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(30);
while child.try_wait().unwrap().is_none() {
if Instant::now() > deadline {
let _ = child.kill();
panic!("wordcraft {args:?} was still running after 30 s: it started the app");
}
std::thread::sleep(Duration::from_millis(50));
}
child.wait_with_output().unwrap()
}
#[test]
fn a_bad_port_is_an_error_naming_it() {
let cases: [(&[&str], &str); 8] = [
(&["--control", "-99999999999999999999"], ", got \"-99999999999999999999\""),
(&["--control", "abc"], ", got \"abc\""),
(&["--control", "79811"], ", got \"79811\""),
(&["--control", "0"], ", got \"0\""),
(&["--control", "-5"], ", got \"-5\""),
(&["--control", "abc", "--control", "7981"], ", got \"abc\""),
(&["--control"], ""),
(&["--control", "--sample"], ""),
];
for (args, got) in cases {
let out = app(args);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "{args:?}: stderr: {stderr}");
assert_eq!(stderr, format!("wordcraft: --control needs a port number from 1 to 65535{got}\n"), "{args:?}");
}
}
#[test]
fn a_flag_after_control_is_read_as_a_flag() {
let out = app(&["--control", "--version"]);
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}");
}
+42
View File
@@ -0,0 +1,42 @@
//! `wordcraft --help` and `-h` print the options and exit without opening a window (#968).
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
fn app(flag: &str) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
.arg(flag)
// With no display, an app that tries to open a window fails at once instead.
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WORDCRAFT_CONTROL_PORT")
.env("WORDCRAFT_NO_PREFS", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(30);
while child.try_wait().unwrap().is_none() {
if Instant::now() > deadline {
let _ = child.kill();
panic!("wordcraft {flag} was still running after 30 s: it started the app");
}
std::thread::sleep(Duration::from_millis(50));
}
child.wait_with_output().unwrap()
}
#[test]
fn help_and_h_print_the_options_and_exit() {
for flag in ["--help", "-h"] {
let out = app(flag);
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert!(out.status.success(), "{flag}: status {:?}, stderr: {stderr}", out.status);
assert!(stdout.starts_with("wordcraft — "), "{flag}: stdout: {stdout}");
for option in ["--sample", "--control PORT", "--version", "-h, --help"] {
assert!(stdout.contains(option), "{flag}: {option} missing from: {stdout}");
}
assert!(stderr.is_empty(), "{flag}: stderr: {stderr}");
}
}
+53
View File
@@ -0,0 +1,53 @@
//! An argument that isn't valid Unicode, such as a Latin-1 file name, never panics the app (#1440).
#![cfg(unix)]
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
fn app(args: &[OsString]) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
.args(args)
// With no display, an app that tries to open a window fails at once instead.
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.env_remove("WORDCRAFT_CONTROL_PORT")
.env("WORDCRAFT_NO_PREFS", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(30);
while child.try_wait().unwrap().is_none() {
if Instant::now() > deadline {
let _ = child.kill();
panic!("wordcraft {args:?} was still running after 30 s: it started the app");
}
std::thread::sleep(Duration::from_millis(50));
}
child.wait_with_output().unwrap()
}
/// The file opens once the window is up (`open_non_unicode`, unit-tested in `main.rs`). Not on
/// macOS, where the app opens its window without a display variable and so keeps running.
#[cfg(not(target_os = "macos"))]
#[test]
fn a_file_with_such_a_name_starts_the_app() {
let out = app(&["--sample".into(), OsString::from_vec(b"caf\xe9.docx".to_vec())]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!stderr.contains("panic"), "stderr: {stderr}");
// It went on to open the window, which fails without a display.
assert!(stderr.contains("WinitEventLoop"), "stderr: {stderr}");
assert_eq!(out.status.code(), Some(1), "stderr: {stderr}");
}
#[test]
fn a_control_port_that_isnt_unicode_is_an_error_naming_it() {
let out = app(&["--sample".into(), "--control".into(), OsString::from_vec(b"79\xe981".to_vec())]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "stderr: {stderr}");
assert_eq!(stderr, "wordcraft: argument 3 is not valid Unicode: 79\u{FFFD}81\n");
}
+17
View File
@@ -26,7 +26,12 @@ chatroom
checkbox
checklist
childcare
clamour
clickable
colourful
colourisation
colourise
colourless
configurable
copyedit
coronavirus
@@ -54,6 +59,7 @@ ecommerce
email
emoji
emoticon
endeavour
esports
facebook
favicon
@@ -71,6 +77,7 @@ geolocation
gigabit
gigabyte
github
glamour
globalization
google
groupthink
@@ -80,6 +87,9 @@ hashtag
healthcare
homepage
homeschool
honour
honourable
honourably
hotspot
hyperlink
hypertext
@@ -106,6 +116,7 @@ logout
lowercase
makeover
malware
manoeuvrable
megabyte
megapixel
meme
@@ -127,6 +138,7 @@ onboard
online
onscreen
outsource
paediatric
paralegal
passcode
petabyte
@@ -139,10 +151,13 @@ podcast
popup
prequel
printout
prioritise
prioritization
prioritize
privatise
ransomware
recyclable
rigour
ringtone
roadmap
rollout
@@ -171,6 +186,7 @@ subfolder
sunscreen
superfood
superuser
synthesise
tagline
takeout
techie
@@ -188,6 +204,7 @@ touchpad
touchscreen
trackball
trackpad
travelogue
upcycle
upload
uppercase
+214
View File
@@ -0,0 +1,214 @@
# Russian spelling supplement: common words older Russian Hunspell dictionaries lack.
# Written by WordCraft contributors (MIT OR Apache-2.0); see ATTRIBUTION.md.
# One word per line: the base form, a TAB, then its declension, from which
# crates/proof/src/ru/extra.rs makes every form:
# m masculine noun ending in a consonant (маркетплейс, -а, -у, -ом, -е, -ы/-и, -ов/-ей…)
# f feminine noun in -а (инфографика, -и, -е, -у, -ой…; genitive plural without ending)
# fia feminine noun in -ия (интеграция, -ии, -ию, -ией, -ий…)
# fs feminine noun in -ь (соцсеть, -и, -ью, -ей, -ям…)
# nie neuter noun in -ие (согласие, -ия, -ию, -ием, -ий…)
# no neuter noun in -о (юрлицо, -а, -у, -ом, -е; genitive plural without ending)
# adj adjective or participle in -ый, -ий, -ой (бонусный, газоиспользующий)
# ovat verb in -овать with its conjugation and participles (колеровать, администрировать)
# = the forms themselves, space-separated (verbs, nouns with a fleeting vowel)
# A word alone on its line doesn't decline (онлайн, медиа). Capitalized words are names and match
# only when written with a capital.
# Commerce, marketing and the web
маркетплейс m
селлер m
фулфилмент m
ритейл m
ритейлер m
дистрибьютор m
дропшиппинг m
мерчандайзер m
эквайринг m
промокод m
кешбэк m
кэшбэк m
лендинг m
лид m
тренд m
бренд m
брендбук m
ребрендинг m
таргетинг m
геотаргетинг m
ретаргетинг m
таргетолог m
маркетолог m
копирайтер m
рерайтер m
контент m
блогер m
инфлюенсер m
хештег m
хэштег m
репост m
лайк m
подкаст m
стриминг m
вебинар m
чат m
бот m
чат-бот m
стартап m
франчайзинг m
аутсорсинг m
аутстаффинг m
коворкинг m
дедлайн m
фидбэк m
фидбек m
кейс m
скриншот m
скринкаст m
баннер m
виджет m
дашборд m
сервис m
сайт m
скан m
токен m
аккаунт m
логин m
пароль m
смартфон m
гаджет m
планшет m
ноутбук m
апгрейд m
апдейт m
бэкап m
бэкенд m
фронтенд m
датасет m
промпт m
интерфейс m
хостинг m
домен m
сниппет m
трафик m
юзер m
стикер m
чекбокс m
инпут m
плейсхолдер m
тултип m
гипсокартон m
пеноплекс m
праймер m
колер m
колорант m
Яндекс m
Битрикс m
Вебвизор m
Дзен m
инфографика f
аналитика f
оплата f
предоплата f
постоплата f
доплата f
переплата f
недоплата f
маркировка = маркировка маркировки маркировке маркировку маркировкой маркировкою маркировок маркировкам маркировками маркировках
отгрузка = отгрузка отгрузки отгрузке отгрузку отгрузкой отгрузкою отгрузок отгрузкам отгрузками отгрузках
колеровка = колеровка колеровки колеровке колеровку колеровкой колеровкою колеровок колеровкам колеровками колеровках
рассылка = рассылка рассылки рассылке рассылку рассылкой рассылкою рассылок рассылкам рассылками рассылках
выгрузка = выгрузка выгрузки выгрузке выгрузку выгрузкой выгрузкою выгрузок выгрузкам выгрузками выгрузках
интеграция fia
пагинация fia
госрегистрация fia
геолокация fia
монетизация fia
кастомизация fia
персонализация fia
сегментация fia
верификация fia
валидация fia
авторизация fia
аутентификация fia
оптимизация fia
визуализация fia
соцсеть fs
согласие nie
домовладение nie
рождение nie
паллетирование nie
неисполнение nie
непредоставление nie
бонусный adj
маркетинговый adj
мотивационный adj
гипоаллергенный adj
тиксотропный adj
сополимерный adj
покупочный adj
таргетированный adj
брендированный adj
кастомный adj
онлайновый adj
офлайновый adj
колеруемый adj
колерованный adj
заколерованный adj
газоиспользующий adj
колеровать ovat
заколеровать ovat
администрировать ovat
модерировать ovat
отмодерировать ovat
таргетировать ovat
монетизировать ovat
брендировать ovat
кастомизировать ovat
детектировать ovat
верифицировать ovat
валидировать ovat
генерировать ovat
сгенерировать ovat
мониторить = мониторить мониторю мониторишь мониторит мониторим мониторите мониторят мониторил мониторила мониторили мониторя мониторинг
гуглить = гуглить гуглю гуглишь гуглит гуглим гуглите гуглят гуглил гуглила гуглили
скачивать = скачивать скачиваю скачиваешь скачивает скачиваем скачиваете скачивают скачивал скачивала скачивали скачивается скачиваются
оценивание nie
медучреждение nie
юрлицо no
учётка = учётка учётки учётке учётку учёткой учёток учёткам учётками учётках учетка учетки учетке учетку учеткой учеток учеткам учетками учетках
перелинковка = перелинковка перелинковки перелинковке перелинковку перелинковкой перелинковок
поклейка = поклейка поклейки поклейке поклейку поклейкой поклеек
детекция fia
досудебный adj
категорийный adj
оркестратор m
оффер m
трекер m
бриф m
гайд m
монтажёр m
тенант m
мерчант m
амбассадор m
Роскомнадзор m
Директ m
Маркет m
вовлечённость fs
вовлеченность fs
укрывистость fs
# Words that don't decline
онлайн
офлайн
оффлайн
медиа
демо
франчайзи
киноа
смс
пн
вт
ср
чт
пт
сб
вс
+87
View File
@@ -17,6 +17,10 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.divyanshu2074]
real_name = "Divyanshu Gupta"
sync_github_name = true
[people.lokinmodar]
sync_github_name = true
@@ -60,6 +64,8 @@ display_name = "Ufuk Efe"
sync_github_name = true
[people.GhostMitra]
sync_github_name = true
[people.DevCoderMax]
real_name = "Gleyson do Nascimento Carvalho"
display_name = "Gleyson Carvalho"
@@ -76,6 +82,87 @@ real_name = "Manuel Carmona"
display_name = "manucarcor"
[people.odedigo]
[people.neupaneprashant]
real_name = "Prashant Neupane"
[people.cyril265]
display_name = "cyril265"
[people.webdevtodayjason]
display_name = "Jason Brashear"
sync_github_name = false
[people.lupant]
sync_github_name = true
[people.Nael-Nathanael]
[people.Losminek]
[people.Nghiathan13]
[people.leoncral]
sync_github_name = true
[people.ms-youssef]
sync_github_name = true
[people.finwarman]
real_name = "Fin Warman"
[people.albayazid]
real_name = "Sazid Al Bayazid"
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
[people.tlomts-dev]
display_name = "The Legend Of MTS Dev"
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.rbr48]
real_name = "M S Rana"
display_name = "Izhaan Intellect"
[people.holmbom]
real_name = "Erik Holmbom"
display_name = "Erik Holmbom"
sync_github_name = false
[people.andrejskvorc]
real_name = "Andrej Škvorc"
display_name = "Andrej Škvorc"
sync_github_name = false
[people.olekkut]
real_name = "Aleksander Kutycki"
display_name = "Aleksander Kutycki"
sync_github_name = true
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
[people.rem5411]
real_name = "Vivien Janny"
sync_github_name = true
[people.C00kieMonsta]
real_name = "Antoine Boxho"
sync_github_name = true
[people.matteo-andreuzza]
sync_github_name = true
[people.lucAmbr0]
sync_github_name = true
[people.maxm11]
real_name = "Max Murphy"
[people.SlavenDj]
sync_github_name = true
+4
View File
@@ -6,7 +6,11 @@ use std::path::{Path, PathBuf};
/// The app's settings folder (preferences, logs, key files): macOS
/// `~/Library/Application Support/WordCraft`, Windows `%APPDATA%\WordCraft`, otherwise
/// `$XDG_CONFIG_HOME/wordcraft` or `~/.config/wordcraft`.
/// `WORDCRAFT_PROFILE_DIR` overrides the entire folder for isolated runs.
pub fn settings_dir() -> Option<PathBuf> {
if let Some(path) = std::env::var_os("WORDCRAFT_PROFILE_DIR").filter(|p| !p.is_empty()) {
return Some(PathBuf::from(path));
}
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/WordCraft"))
} else if cfg!(windows) {
+35 -1
View File
@@ -1,5 +1,5 @@
//! Connectors and the shape properties beyond fill and outline: a shape's drawing id, its text
//! warp, and a connector's ends.
//! warp, a rounded rectangle's corner, and a connector's ends.
//!
//! A connector (DrawingML `straightConnector1`, `bentConnector3`, `curvedConnector3`) is a line
//! from its frame's top-left to its bottom-right corner (flips swap the corners). Each end may be
@@ -34,6 +34,13 @@ pub struct ShapeExtra {
pub arrow_start: bool,
#[serde(skip_serializing_if = "is_false")]
pub arrow_end: bool,
/// A rounded rectangle's corner radius (`roundRect`'s `adj`, ECMA-376 §20.1.10.56): in
/// 100000ths of the shorter side, at most [`MAX_CORNER`]; `None` draws [`DEFAULT_CORNER`].
#[serde(skip_serializing_if = "Option::is_none")]
pub corner: Option<u32>,
/// The outline dash pattern; absent in older saved documents means solid.
#[serde(skip_serializing_if = "crate::line_dash::LineDash::is_solid")]
pub dash: crate::line_dash::LineDash,
}
fn is_zero(v: &u32) -> bool {
@@ -49,6 +56,19 @@ impl ShapeExtra {
}
}
/// The largest rounded-rectangle corner: half the shorter side, which makes its ends round.
pub const MAX_CORNER: u32 = 50_000;
/// A rounded rectangle's corner radius without its own: 16% of the shorter side.
pub const DEFAULT_CORNER: f32 = 0.16;
/// The corner radius, in points, of a `w` × `h` rounded rectangle with `corner` (see
/// [`ShapeExtra::corner`]).
pub fn corner_radius(corner: Option<u32>, w: f32, h: f32) -> f32 {
let side = wordcraft_geom::finite(w).min(wordcraft_geom::finite(h)).max(0.0);
side * corner.map_or(DEFAULT_CORNER, |c| c.min(MAX_CORNER) as f32 / 100_000.0)
}
/// One glued end of a connector: the drawing id of the shape and its connection site index.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -83,6 +103,8 @@ pub fn sites(kind: ShapeKind, w: f32, h: f32) -> Vec<(f32, f32)> {
vec![(cx, 0.0), (cx - dx, cy - dy), (0.0, cy), (cx - dx, cy + dy), (cx, h), (cx + dx, cy + dy), (w, cy), (cx + dx, cy - dy)]
}
ShapeKind::Triangle => vec![(cx, 0.0), (cx / 2.0, cy), (0.0, h), (cx, h), (w, h), (cx + cx / 2.0, cy)],
// The corners, the legs' midpoints and the hypotenuse's.
ShapeKind::RightTriangle => vec![(0.0, 0.0), (0.0, cy), (0.0, h), (cx, h), (w, h), (cx, cy)],
ShapeKind::Line | ShapeKind::StraightConnector | ShapeKind::ElbowConnector | ShapeKind::CurvedConnector => vec![(0.0, 0.0), (w, h)],
_ => vec![(cx, 0.0), (0.0, cy), (cx, h), (w, cy)],
}
@@ -237,6 +259,18 @@ mod tests {
}
}
#[test]
fn corner_radius_follows_the_shorter_side() {
let near = |a: f32, b: f32| (a - b).abs() < 1e-4;
assert!(near(corner_radius(None, 200.0, 100.0), 16.0));
assert!(near(corner_radius(Some(0), 200.0, 100.0), 0.0));
assert!(near(corner_radius(Some(3_565), 100.0, 200.0), 3.565));
assert!(near(corner_radius(Some(MAX_CORNER), 200.0, 100.0), 50.0));
assert!(near(corner_radius(Some(u32::MAX), 200.0, 100.0), 50.0), "capped at a half-circle end");
assert!(near(corner_radius(Some(10_000), f32::NAN, 100.0), 0.0));
assert!(near(corner_radius(Some(10_000), -5.0, 100.0), 0.0));
}
#[test]
fn group_connectors_follow_their_shapes() {
let a = ShapeExtra { id: 7, ..Default::default() };
+10
View File
@@ -242,6 +242,10 @@ pub struct ContentControl {
/// The run properties of `w:sdtPr` (`w:rPr`), kept verbatim (XML).
#[serde(skip_serializing_if = "String::is_empty")]
pub rpr_xml: String,
/// The same run properties, read: the formatting text typed in place of the placeholder
/// takes (ECMA-376 §17.5.2.27). `None` when the control names none.
#[serde(skip_serializing_if = "Option::is_none")]
pub run_props: Option<crate::props::CharProps>,
/// Other `w:sdtPr` children we don't model, kept verbatim (XML) in file order.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub extra: Vec<String>,
@@ -490,6 +494,12 @@ impl Document {
out
}
/// How many content controls allow form filling (`w:lock` content editing) across every
/// story; 0 means a `w:edit="forms"` document has nothing left editable.
pub fn fillable_controls(&self) -> usize {
self.control_stories().into_iter().map(|story| self.control_ranges(story).iter().filter(|r| r.control.lock.can_edit()).count()).sum()
}
/// The innermost content control holding `p`.
pub fn control_at(&self, p: &Pos) -> Option<ControlRange> {
let para = self.para_at(p)?;
+463 -33
View File
@@ -40,11 +40,76 @@ impl Fragment {
}
found
}
/// The equation (its linear format and whether it is a display equation) when the fragment
/// is that one equation and nothing else but white space and empty paragraphs: a copy of it
/// also goes on the system clipboard in the MathML formats that equation editors read (#1297).
pub fn sole_equation(&self) -> Option<(&str, bool)> {
let mut found = None;
for b in &self.blocks {
let Block::Para(p) = b else { return None };
for c in p.text.chars().filter(|c| !c.is_whitespace()) {
if c != crate::para::OBJ || found.is_some() {
return None;
}
// The paragraph's only visible character, so its first object.
match p.objects.first() {
Some(InlineObject::Equation { linear, display, .. }) => found = Some((linear.as_str(), *display)),
_ => return None,
}
}
}
found
}
/// The text a copy puts on the clipboard: [`Self::plain_text`] with symbol-font characters
/// (Wingdings…) as their Unicode equivalents.
pub fn clipboard_text(&self) -> String {
self.to_document().shown_text(crate::StoryRef::Body)
}
/// Add `piece` at the end, followed by a paragraph mark unless it already ends with one: how
/// Word's Spike collects what it cuts. The piece's text box stories get ids after this
/// fragment's, so pieces copied from different documents (or after an id was freed and reused)
/// never share one.
pub fn append_piece(&mut self, mut piece: Fragment) {
let Some(last) = piece.blocks.last() else { return };
let ends_with_mark = piece.blocks.len() >= 2 && matches!(last, Block::Para(p) if p.is_empty());
if !ends_with_mark {
let mut mark = Paragraph::new();
if let Block::Para(p) = last {
mark.props = p.props.clone();
}
piece.blocks.push(Block::Para(mark));
}
let next = self.parts.keys().next_back().map_or(1, |k| k.saturating_add(1));
let ids: BTreeMap<u32, u32> = piece.parts.keys().zip(next..=u32::MAX).map(|(old, new)| (*old, new)).collect();
let renumber = |b: &mut Block| {
each_para_mut(b, 0, &mut |p| {
let mut changed = false;
for o in p.objects.iter_mut() {
for slot in o.text_box_slots() {
if let Some(new) = slot.and_then(|old| ids.get(&old)) {
*slot = Some(*new);
changed = true;
}
}
}
if changed {
p.touch();
}
})
};
piece.blocks.iter_mut().for_each(renumber);
for (old, mut part) in std::mem::take(&mut piece.parts) {
part.blocks.iter_mut().map(Arc::make_mut).for_each(renumber);
if let Some(new) = ids.get(&old) {
self.parts.insert(*new, part);
}
}
// The empty paragraph after the last mark is where the next piece starts.
if self.blocks.len() >= 2 && matches!(self.blocks.last(), Some(Block::Para(p)) if p.is_empty()) {
self.blocks.pop();
}
self.blocks.append(&mut piece.blocks);
}
pub fn from_text(s: &str) -> Fragment {
let lines: Vec<&str> = s.split('\n').collect();
Fragment {
@@ -54,6 +119,19 @@ impl Fragment {
}
}
/// Whether block `b`, in a block list the paragraph walk reaches at `depth`, has cells the walk
/// skips ([`crate::PARA_WALK_DEPTH`]). Bounded by that depth.
fn deeper_than_walk(b: &Block, depth: usize) -> bool {
match b {
Block::Para(_) => false,
Block::Table(t) => t
.rows
.iter()
.flat_map(|r| &r.cells)
.any(|c| depth + 1 > crate::PARA_WALK_DEPTH || c.blocks.iter().any(|x| deeper_than_walk(x, depth + 1))),
}
}
fn order(a: &Pos, b: &Pos) -> (Pos, Pos) {
if a <= b { (a.clone(), b.clone()) } else { (b.clone(), a.clone()) }
}
@@ -63,37 +141,46 @@ const MAX_BOX_DEPTH: usize = 8;
/// Most text box stories one paste may create (hostile documents can fan out).
const MAX_PASTED_BOXES: usize = 4096;
/// Call `f` on every paragraph of a block (tables descended, depth-limited).
pub(crate) fn each_para<'a>(b: &'a Block, depth: usize, f: &mut dyn FnMut(&'a Paragraph)) {
match b {
Block::Para(p) => f(p),
Block::Table(t) if depth < 16 => {
for cell in t.rows.iter().flat_map(|r| r.cells.iter()) {
for cb in &cell.blocks {
each_para(cb, depth + 1, f);
}
/// Call `f` on every paragraph of a block, in document order, however deep its tables nest. The
/// walk keeps its own stack instead of recursing, so it is complete: a text box it doesn't reach
/// is shown nowhere, which pruning relies on (#1339). `_depth` is unused (callers keep passing it).
pub(crate) fn each_para<'a>(b: &'a Block, _depth: usize, f: &mut dyn FnMut(&'a Paragraph)) {
// Most blocks are paragraphs, which need no stack.
if let Block::Para(p) = b {
return f(p);
}
let mut todo = vec![b];
while let Some(b) = todo.pop() {
match b {
Block::Para(p) => f(p),
Block::Table(t) => {
// Last first, so they come off the stack in document order.
let cells = t.rows.iter().rev().flat_map(|r| r.cells.iter().rev());
todo.extend(cells.flat_map(|c| c.blocks.iter().rev()).map(|cb| &**cb));
}
}
Block::Table(_) => {}
}
}
pub(crate) fn each_para_mut(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
match b {
Block::Para(p) => f(p),
Block::Table(t) if depth < 16 => {
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
for cb in &mut cell.blocks {
each_para_mut(Arc::make_mut(cb), depth + 1, f);
}
/// [`each_para`], changing the paragraphs.
pub(crate) fn each_para_mut(b: &mut Block, _depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
if let Block::Para(p) = b {
return f(p);
}
let mut todo = vec![b];
while let Some(b) = todo.pop() {
match b {
Block::Para(p) => f(p),
Block::Table(t) => {
let cells = t.rows.iter_mut().rev().flat_map(|r| r.cells.iter_mut().rev());
todo.extend(cells.flat_map(|c| c.blocks.iter_mut().rev()).map(Arc::make_mut));
}
}
Block::Table(_) => {}
}
}
/// Whether a paragraph anywhere in `b` (also inside tables) uses paragraph or character style
/// `id` (depth-limited like `each_para`). Table styles are the engine's `table.deleteStyle`'s job.
/// `id` (tables to 16 levels deep). Table styles are the engine's `table.deleteStyle`'s job.
fn uses_style(b: &Block, id: &str, depth: usize) -> bool {
let is = |s: &Option<String>| s.as_deref() == Some(id);
match b {
@@ -305,10 +392,16 @@ impl Document {
if a.story != b.story {
return Vec::new();
}
self.para_paths(a.story).into_iter().filter(|p| *p >= a.path && *p <= b.path).collect()
// Only the blocks the range reaches are walked (not every paragraph of the story).
let mut out = Vec::new();
if let Some(bl) = self.story(a.story) {
crate::walk_between(bl, &mut Vec::new(), &mut out, 0, (&a.path.0, &b.path.0));
}
out
}
/// Delete `a..b`. Returns the position where the deletion collapsed to.
/// Delete `a..b`. Returns the position where the deletion collapsed to. On error the document
/// is unchanged: both ends are checked before anything is deleted.
pub fn delete_range(&mut self, a: &Pos, b: &Pos) -> Result<Pos> {
let (a, b) = order(a, b);
if a == b {
@@ -317,6 +410,14 @@ impl Document {
if a.story != b.story {
return Err(DocError::Invalid("range spans stories".into()));
}
// Check both ends before the first edit, so an error never leaves the document half-deleted.
for p in [&a, &b] {
match self.para(p.story, &p.path) {
Some(para) if para.clamp(p.off) == p.off => {}
Some(_) => return Err(DocError::BadOffset(p.off)),
None => return Err(DocError::BadPath(p.path.to_string())),
}
}
// Content control markers whose partner is outside the range stay (the control is
// emptied, not broken).
let kept = self.kept_control_markers(&a, &b);
@@ -354,18 +455,42 @@ impl Document {
pb.clone()
};
let bl = self.container_mut(a.story, &a.path)?;
// Section breaks on the marks in between go with their paragraphs.
let between: Vec<_> = bl
.get(ia + 1..ib)
.unwrap_or_default()
.iter()
.filter_map(|b| match &**b {
Block::Para(p) => p.section.clone(),
Block::Table(_) => None,
})
.collect();
if ib < bl.len() && ia < ib {
bl.drain(ia + 1..=ib);
}
tail.props = ParaProps::default();
// The deleted marks include `a`'s, so a section break there goes too: the joined
// paragraph ends where `b`'s mark did, and its text takes the following section's
// setup, as in Word (#2127).
let sect = tail.section.take();
let pa = self.para_mut(a.story, &a.path)?;
let keep_props = pa.props.clone();
pa.append(tail);
pa.props = keep_props;
let before = std::mem::replace(&mut pa.section, sect);
if a.story == crate::StoryRef::Body && a.path.0.len() == 1 {
// Nearest first, as "link to previous" resolved them before the delete.
let following = self.section_mut(a.path.last());
for s in between.iter().rev().chain(before.iter()) {
following.absorb(s);
}
}
return Ok(a);
}
// Different containers (range enters or leaves a table): clear text in every paragraph
// inside; remove whole tables that are entirely covered when at the same level as `a`.
// inside, then remove the tables it covers whole (below).
// Whether `b` is at its paragraph's end, before that paragraph is trimmed.
let b_at_end = self.para(b.story, &b.path).is_some_and(|p| p.len() == b.off);
let paths = self.paths_between(&a, &b);
for p in paths.iter().rev() {
let (from, to) = {
@@ -376,20 +501,68 @@ impl Document {
};
self.para_mut(a.story, p)?.delete_except(from, to, &kept_in(p))?;
}
// Drop top-level tables strictly between a and b at a's level.
if a.path.depth() == 0 && b.path.depth() > 0 {
let first_b = b.path.0.first().copied().unwrap_or(0) as usize;
let ia = a.path.last();
let bl = self.container_mut(a.story, &a.path)?;
let mut i = first_b;
while i > ia + 1 {
i -= 1;
// Tables the range covers whole, in the innermost container holding both ends, go (#1198,
// #1844): those strictly between the ends, the one `a` is in when it starts at that
// table's very start, and the one `b` is in when it ends at that table's very end. A range
// that is one table and nothing else only empties it (Word's Delete keeps the table).
let common = a.path.0.iter().zip(&b.path.0).take_while(|(x, y)| x == y).count();
let level = common - common % 3;
let (Some(&ia), Some(&ib)) = (a.path.0.get(level), b.path.0.get(level)) else { return Ok(a) };
let (ia, ib) = (ia as usize, ib as usize);
if ia >= ib {
return Ok(a);
}
let prefix = a.path.0.get(..level).unwrap_or_default().to_vec();
let block_path = |i: usize| {
let mut v = prefix.clone();
v.push(i as u32);
Path(v)
};
// Paragraphs inside the block at index `i` of the container, in document order.
let paths = self.para_paths(a.story);
let inside = |i: usize| -> Vec<&Path> {
let pre = block_path(i);
paths.iter().filter(|q| q.0.len() > pre.0.len() && q.0.starts_with(&pre.0)).collect()
};
// A table with cells deeper than the paragraph walk goes has paragraphs `inside` never
// lists, so where it starts or ends isn't known: it is never taken as covered.
let walked = |i: usize| self.container(a.story, &block_path(i)).and_then(|bl| bl.get(i)).is_some_and(|b| !deeper_than_walk(b, level / 3));
let a_covers = a.path.0.len() > level + 1 && a.off == 0 && walked(ia) && inside(ia).first() == Some(&&a.path);
let b_covers = b.path.0.len() > level + 1 && walked(ib) && inside(ib).last() == Some(&&b.path) && b_at_end;
let first = if a_covers { ia } else { ia + 1 };
let last = if b_covers { ib } else { ib - 1 };
let mut removed = 0;
{
let bl = self.container_mut(a.story, &block_path(ia))?;
for i in (first..=last.min(bl.len().saturating_sub(1))).rev() {
if matches!(bl.get(i).map(|b| &**b), Some(Block::Table(_))) {
bl.remove(i);
removed += 1;
}
}
if removed == 0 {
return Ok(a);
}
// A story or cell always ends with a paragraph.
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
}
Ok(a)
// The paragraphs left between the ends join, as in a range within one container.
let at = if a_covers { Pos { story: a.story, path: block_path(ia), off: 0 } } else { a.clone() };
let end = if b_covers {
Pos { story: a.story, path: block_path((ib + 1).saturating_sub(removed)), off: 0 }
} else {
Pos { story: a.story, path: block_path(ib.saturating_sub(removed)), off: 0 }
};
let paras = |p: &Pos| p.path.0.len() == level + 1 && self.para(p.story, &p.path).is_some();
if paras(&at) && paras(&end) && at.path <= end.path {
return self.delete_range(&at, &end);
}
if self.para(at.story, &at.path).is_some() {
return Ok(at);
}
Ok(self.clamp(&at))
}
/// Apply `f` to the character formatting of `a..b`. A collapsed range changes nothing (the
@@ -554,7 +727,7 @@ impl Document {
let body = crate::TextBody { next: None, ..src.body };
*budget -= 1;
self.adopt_text_boxes(&mut blocks, parts, depth + 1, budget);
let id = self.add_part(PartKind::TextBox, blocks.into_iter().map(Arc::new).collect());
let id = self.try_add_part(PartKind::TextBox, blocks.into_iter().map(Arc::new).collect()).ok()?;
if let Some(p) = self.parts.get_mut(&id) {
p.body = body;
}
@@ -655,6 +828,27 @@ mod tests {
Document::from_text(&lines.join("\n"))
}
/// Paths between two positions are those of all the story's paragraphs between them.
#[test]
fn paths_between_matches_every_path_filtered() {
let mut d = doc(&["a", "b", "c", "d", "e"]);
let mut inner = Table::new(2, 2, 100.0);
inner.rows[1].cells[0].blocks.push(crate::para_block(crate::Paragraph::new()));
let mut t = Table::new(3, 2, 200.0);
t.rows[1].cells[1].blocks.insert(0, Arc::new(Block::Table(inner)));
d.insert_block(StoryRef::Body, &Path::top(2), Block::Table(t)).unwrap();
d.insert_block(StoryRef::Body, &Path::top(4), Block::Table(Table::new(1, 3, 200.0))).unwrap();
let all = d.para_paths(StoryRef::Body);
for a in &all {
for b in &all {
let (pa, pb) = (Pos { story: StoryRef::Body, path: a.clone(), off: 0 }, Pos { story: StoryRef::Body, path: b.clone(), off: 0 });
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
let want: Vec<Path> = all.iter().filter(|p| *p >= lo && *p <= hi).cloned().collect();
assert_eq!(d.paths_between(&pa, &pb), want, "{a:?} {b:?}");
}
}
}
#[test]
fn split_and_join() {
let mut d = doc(&["Hello world"]);
@@ -666,6 +860,30 @@ mod tests {
assert_eq!(d.plain_text(StoryRef::Body), "Hello world");
}
#[test]
fn delete_range_with_bad_end_leaves_document_unchanged() {
let mut d = doc(&["one", "two", "three"]);
let before = d.plain_text(StoryRef::Body);
// Missing paragraph as the second end.
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(9, 0)).is_err());
assert_eq!(d.plain_text(StoryRef::Body), before);
// Offset past the end of the second paragraph.
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 99)).is_err());
assert_eq!(d.plain_text(StoryRef::Body), before);
// Offset inside a multi-byte character.
let mut d = doc(&["abc", "\u{e9}x"]);
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 1)).is_err());
assert_eq!(d.plain_text(StoryRef::Body), "abc\n\u{e9}x");
// Range entering a table with a bad offset at its start: the cell text stays.
let mut d = doc(&["before", "after"]);
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 1, 200.0))).unwrap();
d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "x", &CharProps::default()).unwrap();
let before = d.plain_text(StoryRef::Body);
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 1, 0, 0]), off: 0 };
assert!(d.delete_range(&Pos::body(0, 99), &cell).is_err());
assert_eq!(d.plain_text(StoryRef::Body), before);
}
#[test]
fn delete_multi() {
let mut d = doc(&["one", "two", "three"]);
@@ -674,6 +892,29 @@ mod tests {
assert_eq!(d.body.len(), 1);
}
/// #2127: a range over two section breaks joins three sections into the last; the headers
/// and footers it showed by linking to earlier ones stay, each from the nearest section that
/// named it, as they resolved before the delete.
#[test]
fn delete_over_two_section_breaks_keeps_the_linked_headers() {
let mut d = doc(&["A", "B", "C"]);
let set = |default| crate::section::HeaderSet { default: Some(default), ..Default::default() };
let s1 = crate::section::SectionProps { headers: set(1), footers: set(5), ..Default::default() };
let s2 = crate::section::SectionProps { headers: set(2), ..Default::default() };
let s3 = crate::section::SectionProps { margin_left: 99.0, ..Default::default() };
for (i, s) in [(0, s1), (1, s2), (2, s3.clone())] {
if let Some(Block::Para(p)) = d.body.get_mut(i).map(Arc::make_mut) {
p.section = Some(Box::new(s));
}
}
assert_eq!(d.sections().len(), 4);
d.delete_range(&Pos::body(0, 1), &Pos::body(2, 0)).unwrap();
assert_eq!(d.plain_text(StoryRef::Body), "AC");
let sections = d.sections();
assert_eq!(sections.len(), 2);
assert_eq!(*sections[0].1, crate::section::SectionProps { headers: set(2), footers: set(5), ..s3 });
}
#[test]
fn format_across() {
let mut d = doc(&["abc", "def"]);
@@ -851,6 +1092,42 @@ mod tests {
assert_eq!(Path(vec![1, 0, 1, 0]).cell(), Some((Path::top(1), 0, 1)));
}
fn para(text: &str) -> Block {
Block::Para(Paragraph::with_text(text, CharProps::default()))
}
#[test]
fn appended_pieces_are_each_followed_by_one_paragraph_mark() {
let mut spike = Fragment::default();
spike.append_piece(Fragment { blocks: vec![para("a")], ..Default::default() });
spike.append_piece(Fragment { blocks: vec![para("b")], ..Default::default() });
assert_eq!(spike.plain_text(), "a\nb\n");
// A piece that already ends with its paragraph mark gets no second one.
let mut spike = Fragment::default();
spike.append_piece(Fragment { blocks: vec![para("a"), para("")], ..Default::default() });
assert_eq!(spike.plain_text(), "a\n");
spike.append_piece(Fragment { blocks: vec![para("b"), para("c")], ..Default::default() });
assert_eq!(spike.plain_text(), "a\nb\nc\n");
}
#[test]
fn appended_pieces_keep_their_own_text_boxes() {
// Two documents give their first text box the same id.
let mut one = doc(&["x"]);
let id1 = text_box(&mut one, &Pos::body(0, 1), "One");
let mut two = doc(&["y"]);
let id2 = text_box(&mut two, &Pos::body(0, 1), "Two");
assert_eq!(id1, id2);
let mut spike = Fragment::default();
spike.append_piece(one.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3)));
spike.append_piece(two.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3)));
let mut dst = doc(&[""]);
dst.insert_fragment(&Pos::body(0, 0), &spike).unwrap();
let texts: Vec<String> =
[0, 1].iter().flat_map(|i| boxes_in(&dst, &Path::top(*i))).map(|b| dst.plain_text(StoryRef::Part(b.unwrap()))).collect();
assert_eq!(texts, ["One", "Two"]);
}
#[test]
fn bad_positions_error() {
let mut d = doc(&["é"]);
@@ -861,4 +1138,157 @@ mod tests {
assert_eq!(c, Pos::body(0, 2));
assert!(d.delete_range(&Pos { story: StoryRef::Part(3), path: Path::top(0), off: 0 }, &Pos::body(0, 0)).is_err());
}
// delete_range over whole tables (#1844), in the body, a header and a footnote.
/// A document whose story (the body, or a header or footnote part) holds `lines`.
fn story_doc(kind: Option<PartKind>, lines: &[&str]) -> (Document, StoryRef) {
match kind {
None => (doc(lines), StoryRef::Body),
Some(k) => {
let mut d = doc(&["body"]);
let blocks = lines.iter().map(|l| para_block(Paragraph::with_text(l, CharProps::default()))).collect();
let id = d.add_part(k, blocks);
(d, StoryRef::Part(id))
}
}
}
const STORIES: [Option<PartKind>; 3] = [None, Some(PartKind::Header), Some(PartKind::Footnote)];
fn at(story: StoryRef, path: &[u32], off: usize) -> Pos {
Pos { story, path: Path(path.to_vec()), off }
}
/// Insert an `rows`×`cols` table at `path` whose cells hold `texts`, row by row.
fn put_table(d: &mut Document, story: StoryRef, path: &[u32], rows: usize, cols: usize, texts: &[&str]) {
d.insert_block(story, &Path(path.to_vec()), Block::Table(Table::new(rows, cols, 200.0))).unwrap();
for (k, t) in texts.iter().enumerate() {
let mut p = path.to_vec();
p.extend([(k / cols) as u32, (k % cols) as u32, 0]);
d.insert_text(&at(story, &p, 0), t, &CharProps::default()).unwrap();
}
}
/// The story's top-level blocks: a paragraph's text, or `[table]`.
fn shape(d: &Document, story: StoryRef) -> Vec<String> {
d.story(story).unwrap().iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect()
}
fn cell_text(d: &Document, story: StoryRef, table: &[u32], r: u32, c: u32) -> String {
let mut p = table.to_vec();
p.extend([r, c, 0]);
d.para(story, &Path(p)).map(|p| p.text.clone()).unwrap_or_else(|| "<none>".into())
}
#[test]
fn a_range_ending_at_the_end_of_a_cell_that_isnt_the_last_keeps_the_table() {
for kind in STORIES {
for (r, c) in [(0, 1), (1, 0)] {
let (mut d, st) = story_doc(kind, &["before", "after"]);
put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]);
d.delete_range(&at(st, &[0], 0), &at(st, &[1, r, c, 0], 1)).unwrap();
assert_eq!(shape(&d, st), ["", "[table]", "after"], "{kind:?} ({r},{c})");
assert_eq!(cell_text(&d, st, &[1], 1, 1), "d", "{kind:?} ({r},{c})");
}
}
}
#[test]
fn a_range_from_the_start_of_one_table_into_the_middle_of_the_next_removes_only_the_first() {
for kind in STORIES {
let (mut d, st) = story_doc(kind, &["x", "mid", "y"]);
put_table(&mut d, st, &[2], 2, 2, &["e", "f", "g", "h"]);
put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]);
assert_eq!(shape(&d, st), ["x", "[table]", "mid", "[table]", "y"]);
let to = d.delete_range(&at(st, &[1, 0, 0, 0], 0), &at(st, &[3, 0, 1, 0], 1)).unwrap();
assert_eq!(shape(&d, st), ["x", "", "[table]", "y"], "{kind:?}");
assert_eq!([cell_text(&d, st, &[2], 0, 0), cell_text(&d, st, &[2], 0, 1), cell_text(&d, st, &[2], 1, 0)], ["", "", "g"], "{kind:?}");
assert!(d.para(st, &to.path).is_some(), "{kind:?}: {to:?}");
}
}
#[test]
fn a_range_from_a_cell_paragraph_to_the_end_of_a_table_nested_in_that_cell_removes_only_the_nested_table() {
for kind in STORIES {
let (mut d, st) = story_doc(kind, &["before", "after"]);
put_table(&mut d, st, &[1], 1, 1, &["p"]);
put_table(&mut d, st, &[1, 0, 0, 1], 1, 1, &["n"]);
d.insert_block(st, &Path(vec![1, 0, 0, 2]), Block::Para(Paragraph::with_text("q", CharProps::default()))).unwrap();
let to = d.delete_range(&at(st, &[1, 0, 0, 0], 1), &at(st, &[1, 0, 0, 1, 0, 0, 0], 1)).unwrap();
assert_eq!(shape(&d, st), ["before", "[table]", "after"], "{kind:?}");
let cell = d.container(st, &Path(vec![1, 0, 0, 0])).unwrap();
let cell: Vec<String> = cell.iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect();
assert_eq!(cell, ["pq"], "{kind:?}");
assert_eq!(to, at(st, &[1, 0, 0, 0], 1), "{kind:?}");
}
}
#[test]
fn two_whole_tables_at_the_end_of_a_story_leave_one_empty_paragraph() {
for kind in STORIES {
for trailing in [true, false] {
let (mut d, st) = story_doc(kind, &[""]);
put_table(&mut d, st, &[0], 1, 2, &["a", "b"]);
put_table(&mut d, st, &[1], 2, 1, &["c", "d"]);
if !trailing {
// A story that ends in a table (a hostile file).
d.story_mut(st).unwrap().pop();
}
let to = d.delete_range(&at(st, &[0, 0, 0, 0], 0), &at(st, &[1, 1, 0, 0], 1)).unwrap();
assert_eq!(shape(&d, st), [""], "{kind:?} trailing {trailing}");
assert_eq!(to, at(st, &[0], 0), "{kind:?} trailing {trailing}");
}
}
}
/// Content controls pair their markers within one block list (a story, or a cell), so a
/// marker in a table never has its partner outside it: a control inside a table removed whole
/// goes with it, and one the range only enters keeps both markers (the control is emptied).
#[test]
fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() {
use crate::control::ContentControl;
let start = || InlineObject::ControlStart { control: Box::new(ContentControl::default()) };
for kind in STORIES {
let (mut d, st) = story_doc(kind, &["before", "after"]);
put_table(&mut d, st, &[1], 1, 1, &["c"]);
// One control around "efo" in "before", one around the cell's text.
d.insert_object(&at(st, &[0], 4), InlineObject::ControlEnd, &CharProps::default()).unwrap();
let in_before = d.insert_object(&at(st, &[0], 1), start(), &CharProps::default()).unwrap();
d.insert_object(&at(st, &[1, 0, 0, 0], 1), InlineObject::ControlEnd, &CharProps::default()).unwrap();
d.insert_object(&at(st, &[1, 0, 0, 0], 0), start(), &CharProps::default()).unwrap();
assert_eq!(d.control_ranges(st).len(), 2, "setup");
let end = d.para(st, &Path(vec![1, 0, 0, 0])).unwrap().len();
// From inside the first control to the end of the table.
d.delete_range(&Pos { off: in_before.off + 1, ..in_before }, &at(st, &[1, 0, 0, 0], end)).unwrap();
assert_eq!(shape(&d, st), ["b\u{FFFC}e\u{FFFC}after"], "{kind:?}: the table goes, the paragraphs join");
let ranges = d.control_ranges(st);
assert_eq!(ranges.len(), 1, "{kind:?}: {ranges:?}");
assert!(ranges.iter().all(|r| r.start.path == Path::top(0) && r.end.path == Path::top(0)), "{kind:?}");
let objects = d.para(st, &Path::top(0)).map_or(0, |p| p.objects.len());
assert_eq!(objects, 2, "{kind:?}: no orphan markers left");
}
}
#[test]
fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() {
// 20 tables, each in the first cell of the one around it, ahead of that cell's paragraph:
// the innermost paragraphs come first in the document but the paragraph walk stops short.
let mut t = Table::new(1, 1, 200.0);
if let Some(cell) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
cell.blocks = vec![para_block(Paragraph::with_text("deep", CharProps::default()))];
}
for _ in 0..20 {
let mut outer = Table::new(1, 1, 200.0);
if let Some(cell) = outer.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
cell.blocks.insert(0, Arc::new(Block::Table(t)));
}
t = outer;
}
let mut d = doc(&["before", "after"]);
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
let first = d.para_paths(StoryRef::Body).into_iter().find(|p| p.0.first() == Some(&1)).unwrap();
d.delete_range(&Pos { story: StoryRef::Body, path: first, off: 0 }, &Pos::body(2, 0)).unwrap();
assert!(d.body.get(1).is_some_and(|b| b.as_table().is_some()), "a table with paragraphs the walk never saw was removed");
}
}
+181 -5
View File
@@ -1,6 +1,7 @@
//! Windows-1252 (the byte encoding legacy Word text pieces use) and the other single-byte Windows
//! code pages RTF files name with `\ansicpg` or a font's `\fcharset` (Cyrillic 1251, Central
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai).
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai), and Mac OS Roman
//! (10000, RTF `\mac`).
/// Windows-1252 code points for 0x80..=0x9F.
const CP1252: [u16; 32] = [
@@ -125,11 +126,134 @@ const CP1258: [u16; 128] = [
0x20AB, 0x00FF,
];
/// Mac OS Roman (code page 10000, RTF `\mac`) code points for 0x80..=0xFF, with the euro sign at
/// 0xDB (Mac OS 8.5 and later).
const MAC_ROMAN: [u16; 128] = [
0x00C4, 0x00C5, 0x00C7, 0x00C9, 0x00D1, 0x00D6, 0x00DC, 0x00E1, 0x00E0, 0x00E2, 0x00E4, 0x00E3, 0x00E5, 0x00E7, 0x00E9, 0x00E8, 0x00EA, 0x00EB,
0x00ED, 0x00EC, 0x00EE, 0x00EF, 0x00F1, 0x00F3, 0x00F2, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x00F9, 0x00FB, 0x00FC, 0x2020, 0x00B0, 0x00A2, 0x00A3,
0x00A7, 0x2022, 0x00B6, 0x00DF, 0x00AE, 0x00A9, 0x2122, 0x00B4, 0x00A8, 0x2260, 0x00C6, 0x00D8, 0x221E, 0x00B1, 0x2264, 0x2265, 0x00A5, 0x00B5,
0x2202, 0x2211, 0x220F, 0x03C0, 0x222B, 0x00AA, 0x00BA, 0x03A9, 0x00E6, 0x00F8, 0x00BF, 0x00A1, 0x00AC, 0x221A, 0x0192, 0x2248, 0x2206, 0x00AB,
0x00BB, 0x2026, 0x00A0, 0x00C0, 0x00C3, 0x00D5, 0x0152, 0x0153, 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA, 0x00FF, 0x0178,
0x2044, 0x20AC, 0x2039, 0x203A, 0xFB01, 0xFB02, 0x2021, 0x00B7, 0x201A, 0x201E, 0x2030, 0x00C2, 0x00CA, 0x00C1, 0x00CB, 0x00C8, 0x00CD, 0x00CE,
0x00CF, 0x00CC, 0x00D3, 0x00D4, 0xF8FF, 0x00D2, 0x00DA, 0x00DB, 0x00D9, 0x0131, 0x02C6, 0x02DC, 0x00AF, 0x02D8, 0x02D9, 0x02DA, 0x00B8, 0x02DD,
0x02DB, 0x02C7,
];
/// IBM PC (OEM) code page 437 code points for 0x80..=0xFF (the Unicode Consortium's CP437
/// mapping file).
const CP437: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, 0x03A6, 0x0398, 0x03A9, 0x03B4,
0x221E, 0x03C6, 0x03B5, 0x2229, 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2,
0x25A0, 0x00A0,
];
/// OEM code page 850 (DOS Latin-1, Western European) code points for 0x80..=0xFF (the Unicode
/// Consortium's CP850 mapping file).
const CP850: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x00D7, 0x0192, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x00F0, 0x00D0, 0x00CA, 0x00CB, 0x00C8, 0x0131, 0x00CD, 0x00CE, 0x00CF, 0x2518,
0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0x00FE, 0x00DE, 0x00DA, 0x00DB, 0x00D9,
0x00FD, 0x00DD, 0x00AF, 0x00B4, 0x00AD, 0x00B1, 0x2017, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2,
0x25A0, 0x00A0,
];
/// OEM code page 852 (DOS Latin-2, Central European) code points for 0x80..=0xFF (the Unicode Consortium's CP852
/// mapping file).
const CP852: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x016F, 0x0107, 0x00E7, 0x0142, 0x00EB, 0x0150, 0x0151, 0x00EE, 0x0179, 0x00C4, 0x0106, 0x00C9, 0x0139,
0x013A, 0x00F4, 0x00F6, 0x013D, 0x013E, 0x015A, 0x015B, 0x00D6, 0x00DC, 0x0164, 0x0165, 0x0141, 0x00D7, 0x010D, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x0104, 0x0105, 0x017D, 0x017E, 0x0118, 0x0119, 0x00AC, 0x017A, 0x010C, 0x015F, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
0x00C2, 0x011A, 0x015E, 0x2563, 0x2551, 0x2557, 0x255D, 0x017B, 0x017C, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x0102, 0x0103,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x0111, 0x0110, 0x010E, 0x00CB, 0x010F, 0x0147, 0x00CD, 0x00CE, 0x011B, 0x2518,
0x250C, 0x2588, 0x2584, 0x0162, 0x016E, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x0143, 0x0144, 0x0148, 0x0160, 0x0161, 0x0154, 0x00DA, 0x0155, 0x0170,
0x00FD, 0x00DD, 0x0163, 0x00B4, 0x00AD, 0x02DD, 0x02DB, 0x02C7, 0x02D8, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x02D9, 0x0171, 0x0158, 0x0159,
0x25A0, 0x00A0,
];
/// OEM code page 866 (DOS Cyrillic) code points for 0x80..=0xFF (the Unicode Consortium's CP866
/// mapping file).
const CP866: [u16; 128] = [
0x0410, 0x0411, 0x0412, 0x0413, 0x0414, 0x0415, 0x0416, 0x0417, 0x0418, 0x0419, 0x041A, 0x041B, 0x041C, 0x041D, 0x041E, 0x041F, 0x0420, 0x0421,
0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0427, 0x0428, 0x0429, 0x042A, 0x042B, 0x042C, 0x042D, 0x042E, 0x042F, 0x0430, 0x0431, 0x0432, 0x0433,
0x0434, 0x0435, 0x0436, 0x0437, 0x0438, 0x0439, 0x043A, 0x043B, 0x043C, 0x043D, 0x043E, 0x043F, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x0440, 0x0441, 0x0442, 0x0443, 0x0444, 0x0445, 0x0446, 0x0447, 0x0448, 0x0449, 0x044A, 0x044B,
0x044C, 0x044D, 0x044E, 0x044F, 0x0401, 0x0451, 0x0404, 0x0454, 0x0407, 0x0457, 0x040E, 0x045E, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x2116, 0x00A4,
0x25A0, 0x00A0,
];
/// OEM code page 737 (DOS Greek) code points for 0x80..=0xFF (the Unicode Consortium's CP737
/// mapping file).
const CP737: [u16; 128] = [
0x0391, 0x0392, 0x0393, 0x0394, 0x0395, 0x0396, 0x0397, 0x0398, 0x0399, 0x039A, 0x039B, 0x039C, 0x039D, 0x039E, 0x039F, 0x03A0, 0x03A1, 0x03A3,
0x03A4, 0x03A5, 0x03A6, 0x03A7, 0x03A8, 0x03A9, 0x03B1, 0x03B2, 0x03B3, 0x03B4, 0x03B5, 0x03B6, 0x03B7, 0x03B8, 0x03B9, 0x03BA, 0x03BB, 0x03BC,
0x03BD, 0x03BE, 0x03BF, 0x03C0, 0x03C1, 0x03C3, 0x03C2, 0x03C4, 0x03C5, 0x03C6, 0x03C7, 0x03C8, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561,
0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518,
0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, 0x03C9, 0x03AC, 0x03AD, 0x03AE, 0x03CA, 0x03AF, 0x03CC, 0x03CD, 0x03CB, 0x03CE, 0x0386, 0x0388,
0x0389, 0x038A, 0x038C, 0x038E, 0x038F, 0x00B1, 0x2265, 0x2264, 0x03AA, 0x03AB, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2,
0x25A0, 0x00A0,
];
/// OEM code page 857 (DOS Turkish; U+FFFD where the byte is unassigned) code points for 0x80..=0xFF (the Unicode Consortium's CP857
/// mapping file).
const CP857: [u16; 128] = [
0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x0131, 0x00C4, 0x00C5, 0x00C9, 0x00E6,
0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x0130, 0x00D6, 0x00DC, 0x00F8, 0x00A3, 0x00D8, 0x015E, 0x015F, 0x00E1, 0x00ED, 0x00F3, 0x00FA,
0x00F1, 0x00D1, 0x011E, 0x011F, 0x00BF, 0x00AE, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x00C1,
0x00C2, 0x00C0, 0x00A9, 0x2563, 0x2551, 0x2557, 0x255D, 0x00A2, 0x00A5, 0x2510, 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x00E3, 0x00C3,
0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x00A4, 0x00BA, 0x00AA, 0x00CA, 0x00CB, 0x00C8, 0xFFFD, 0x00CD, 0x00CE, 0x00CF, 0x2518,
0x250C, 0x2588, 0x2584, 0x00A6, 0x00CC, 0x2580, 0x00D3, 0x00DF, 0x00D4, 0x00D2, 0x00F5, 0x00D5, 0x00B5, 0xFFFD, 0x00D7, 0x00DA, 0x00DB, 0x00D9,
0x00EC, 0x00FF, 0x00AF, 0x00B4, 0x00AD, 0x00B1, 0xFFFD, 0x00BE, 0x00B6, 0x00A7, 0x00F7, 0x00B8, 0x00B0, 0x00A8, 0x00B7, 0x00B9, 0x00B3, 0x00B2,
0x25A0, 0x00A0,
];
/// The glyphs the OEM code pages show for 0x01..=0x1F (smileys, card suits, arrows), as
/// Windows' Alt+numpad entry produces them rather than the control characters.
const OEM_GLYPHS: [u16; 31] = [
0x263A, 0x263B, 0x2665, 0x2666, 0x2663, 0x2660, 0x2022, 0x25D8, 0x25CB, 0x25D9, 0x2642, 0x2640, 0x266A, 0x266B, 0x263C, 0x25BA, 0x25C4, 0x2195,
0x203C, 0x00B6, 0x00A7, 0x25AC, 0x21A8, 0x2191, 0x2193, 0x2192, 0x2190, 0x221F, 0x2194, 0x25B2, 0x25BC,
];
/// The OEM code pages [`oem_byte`] decodes.
pub const OEM_CODE_PAGES: [u16; 6] = [437, 737, 850, 852, 857, 866];
/// One byte of an OEM code page in [`OEM_CODE_PAGES`] (any other code page reads as 437) as a
/// char, with the graphic glyphs for 0x01..=0x1F and 0x7F (⌂); 0x00 stays NUL.
pub fn oem_byte(code_page: u16, b: u8) -> char {
let table: &[u16; 128] = match code_page {
737 => &CP737,
850 => &CP850,
852 => &CP852,
857 => &CP857,
866 => &CP866,
_ => &CP437,
};
let u = match b {
0x01..=0x1F => OEM_GLYPHS.get(usize::from(b - 1)).copied(),
0x7F => Some(0x2302),
0x80..=0xFF => table.get(usize::from(b - 0x80)).copied(),
_ => Some(u16::from(b)),
};
u.and_then(|u| char::from_u32(u32::from(u))).unwrap_or('\u{FFFD}')
}
/// The single-byte Windows code pages [`windows_byte`] decodes (1252 included).
pub const SINGLE_BYTE_CODE_PAGES: [u16; 10] = [874, 1250, 1251, 1252, 1253, 1254, 1255, 1256, 1257, 1258];
/// One byte of a single-byte Windows code page as a char; a code page outside
/// [`SINGLE_BYTE_CODE_PAGES`] reads as Windows-1252.
/// Mac OS Roman, RTF's `\mac` character set.
pub const MAC_ROMAN_CODE_PAGE: u16 = 10000;
/// One byte of a single-byte code page as a char: a Windows one in [`SINGLE_BYTE_CODE_PAGES`] or
/// Mac OS Roman ([`MAC_ROMAN_CODE_PAGE`]); any other code page reads as Windows-1252.
pub fn windows_byte(code_page: u16, b: u8) -> char {
let table: &[u16; 128] = match code_page {
874 => &CP874,
@@ -141,6 +265,7 @@ pub fn windows_byte(code_page: u16, b: u8) -> char {
1256 => &CP1256,
1257 => &CP1257,
1258 => &CP1258,
MAC_ROMAN_CODE_PAGE => &MAC_ROMAN,
_ => return cp1252(b),
};
if b < 0x80 {
@@ -149,11 +274,25 @@ pub fn windows_byte(code_page: u16, b: u8) -> char {
table.get(usize::from(b - 0x80)).and_then(|u| char::from_u32(u32::from(*u))).unwrap_or('\u{FFFD}')
}
/// Whether [`code_page_byte`] decodes `code_page`: a Windows one ([`SINGLE_BYTE_CODE_PAGES`]), an
/// OEM (DOS) one ([`OEM_CODE_PAGES`]) or Mac OS Roman. Document formats (RTF) name all three.
pub fn is_byte_code_page(code_page: u16) -> bool {
SINGLE_BYTE_CODE_PAGES.contains(&code_page) || OEM_CODE_PAGES.contains(&code_page) || code_page == MAC_ROMAN_CODE_PAGE
}
/// A byte of 8-bit document text in `code_page` (see [`is_byte_code_page`]) as a char. Below 0x80
/// it is ASCII in every code page (text, so an OEM page's glyphs for control codes don't apply).
pub fn code_page_byte(code_page: u16, b: u8) -> char {
if b >= 0x80 && OEM_CODE_PAGES.contains(&code_page) { oem_byte(code_page, b) } else { windows_byte(code_page, b) }
}
/// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern
/// European → 1250… `None` for the ANSI and default charsets (the document's code page applies),
/// symbol fonts and the double-byte East Asian charsets.
/// European → 1250, 77 Mac → 10000, 254 PC / 255 OEM → 437… `None` for the ANSI and default charsets (the
/// document's code page applies), symbol fonts and the double-byte East Asian charsets.
pub fn charset_code_page(charset: i32) -> Option<u16> {
Some(match charset {
77 => MAC_ROMAN_CODE_PAGE,
254 | 255 => 437,
161 => 1253,
162 => 1254,
163 => 1258,
@@ -180,6 +319,11 @@ mod tests {
assert_eq!(decode(1253, &[0xC1, 0xE2]), "Αβ");
assert_eq!(decode(1252, &[0x80, 0xE9]), "ێ");
assert_eq!(decode(9999, &[0xE9]), "é");
// Mac OS Roman (#1653): accents, the euro sign at 0xDB, typographic quotes.
assert_eq!(decode(10000, &[0x80, 0x8E, 0xDB, 0xF1, 0xF2, 0xF3, 0xD2, 0xD3, 0x41]), "Äé€ÒÚÛ“”A");
assert_eq!(charset_code_page(77), Some(10000));
assert_eq!(charset_code_page(254), Some(437));
assert_eq!(charset_code_page(255), Some(437));
assert_eq!(charset_code_page(204), Some(1251));
assert_eq!(charset_code_page(0), None);
for cp in SINGLE_BYTE_CODE_PAGES {
@@ -188,4 +332,36 @@ mod tests {
}
}
}
/// #1653: one decoder for document text in any code page RTF names: Windows, DOS or Mac.
#[test]
fn document_text_in_windows_dos_and_mac_code_pages() {
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| code_page_byte(cp, b)).collect::<String>();
assert_eq!(decode(437, &[0x82, 0xA0, 0x41, 0x01]), "éáA\u{1}", "DOS text; ASCII stays ASCII");
assert_eq!(decode(850, &[0x9D, 0xE9]), "ØÚ");
assert_eq!(decode(10000, &[0x8E]), "é");
assert_eq!(decode(1251, &[0xC4]), "Д");
for cp in [437, 850, 866, 1250, 1252, 10000] {
assert!(is_byte_code_page(cp), "{cp}");
}
assert!(!is_byte_code_page(932) && !is_byte_code_page(0) && !SINGLE_BYTE_CODE_PAGES.contains(&10000));
}
#[test]
fn oem_code_pages() {
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| oem_byte(cp, b)).collect::<String>();
assert_eq!(decode(437, &[0x82, 0xE9, 0x01, 0x7F]), "éΘ☺⌂");
assert_eq!(decode(850, &[0x9B, 0xE9]), "øÚ");
assert_eq!(decode(852, &[0xA5, 0x88, 0xBE, 0xA9]), "ąłżę");
assert_eq!(decode(866, &[0x8F, 0xE0, 0xA8, 0xA2, 0xA5, 0xE2, 0xF0, 0xFC]), "ПриветЁ№");
assert_eq!(decode(737, &[0x80, 0x99, 0xE0]), "Αβω");
assert_eq!(decode(857, &[0x8D, 0x98, 0xA6, 0x9F]), "ıİĞş");
assert_eq!(oem_byte(857, 0xD5), '\u{FFFD}', "unassigned in 857");
assert_eq!(decode(9999, &[0xE9]), "Θ", "unknown pages read as 437");
for cp in OEM_CODE_PAGES {
for b in 0..=255u8 {
let _ = oem_byte(cp, b);
}
}
}
}
+13 -1
View File
@@ -45,7 +45,7 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out:
let path = Path(prefix.clone());
for off in p.object_offsets() {
match p.object_at(off) {
Some(InlineObject::FieldStart { instr, locked }) => {
Some(InlineObject::FieldStart { instr, locked, .. }) => {
open.push((instr.clone(), *locked, Pos::new(story, path.clone(), off)));
}
Some(InlineObject::FieldEnd) => match open.pop() {
@@ -93,6 +93,18 @@ impl Document {
self.story(story).map(|bl| scan_story(story, bl).ranges).unwrap_or_default()
}
/// `p` moved past the range-field ends right after it. A click just after a field's result
/// lands before its end marker (the marker takes no room); typing or inserting there belongs
/// after the field, as in Word, not in its result.
pub fn past_field_ends(&self, p: &Pos) -> Pos {
let Some(para) = self.para_at(p) else { return p.clone() };
let mut off = p.off;
while matches!(para.object_at(off), Some(InlineObject::FieldEnd)) {
off += OBJ.len_utf8();
}
Pos { off, ..p.clone() }
}
/// Whether any story holds a range-field marker without its partner.
pub fn has_unbalanced_field_ranges(&self) -> bool {
self.story_refs().into_iter().any(|s| self.story(s).is_some_and(|bl| !scan_story(s, bl).orphans.is_empty()))
+3 -3
View File
@@ -24,7 +24,7 @@ pub struct Graphic {
pub h: f32,
/// Where the graphic was read from, so saving writes the original drawing itself back (not
/// only these items). `None` for graphics made some other way.
#[serde(skip)]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<std::sync::Arc<Embedded>>,
/// A SmartArt graphic WordCraft can edit (one it made, or one read back exactly as it wrote
/// it): saving writes the diagram parts from it, and the items are drawn from it. See
@@ -41,7 +41,7 @@ pub struct Graphic {
/// source format and the package parts that markup refers to. Never edited; WordCraft only moves
/// and resizes the object around it. The parts themselves (bytes, content type and their own
/// relationships) are kept in [`crate::Document::passthrough`] under their part names.
#[derive(Clone, Debug, Default, PartialEq)]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Embedded {
/// The object's markup as a standalone XML element with its namespace declarations (DOCX:
/// the `a:graphic` of a chart, SmartArt diagram or native ink, an ink `mc:AlternateContent`,
@@ -52,7 +52,7 @@ pub struct Embedded {
}
/// One relationship an [`Embedded`] object uses.
#[derive(Clone, Debug, Default, PartialEq)]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct EmbeddedRef {
/// The relationship id the markup uses.
pub id: String,
+163
View File
@@ -0,0 +1,163 @@
//! Legacy form fields (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`): what their `w:ffData` says,
//! read from the raw XML a [`crate::InlineObject::Field`] keeps in its `form` (ECMA-376 Part 1
//! §17.16.17). Only what layout and PDF export need; the XML itself stays the source of truth
//! for saving.
/// Most drop-down entries kept (Word allows 25).
pub const MAX_ENTRIES: usize = 64;
/// A legacy form field's kind and state.
#[derive(Clone, Debug, PartialEq)]
pub enum LegacyForm {
/// A check box: `checked` is its state (`w:checked`, else `w:default`), `size` its explicit
/// size in points (`w:size`), `None` when it follows the text (`w:sizeAuto`).
CheckBox { name: String, checked: bool, size: Option<f32> },
/// A text field; its text is the field's result.
Text { name: String, max_len: Option<u32> },
/// A drop-down list showing entry `selected` (`w:result`, else `w:default`, else the first).
DropDown { name: String, entries: Vec<String>, selected: usize },
}
impl LegacyForm {
/// The field's definition, or `None` when `ff_data` holds none of the three.
pub fn parse(ff_data: &str) -> Option<LegacyForm> {
let name = attr_of(ff_data, "w:name").unwrap_or_default();
if let Some(cb) = element(ff_data, "w:checkBox") {
let default = attr_of(cb, "w:default").is_some_and(|v| on(&v));
// `<w:checked/>` with no value means checked.
let checked = match tag(cb, "w:checked") {
Some(t) => attr_in(t).is_none_or(|v| on(&v)),
None => default,
};
let size =
attr_of(cb, "w:size").and_then(|v| v.trim().parse::<f32>().ok()).filter(|v| v.is_finite() && *v > 0.0).map(|v| (v / 2.0).min(1584.0));
return Some(LegacyForm::CheckBox { name, checked, size });
}
if let Some(dd) = element(ff_data, "w:ddList") {
let entries: Vec<String> = tags(dd, "w:listEntry").filter_map(attr_in).take(MAX_ENTRIES).collect();
let pick = |n: &str| attr_of(dd, n).and_then(|v| v.trim().parse::<usize>().ok());
let selected = pick("w:result").or_else(|| pick("w:default")).filter(|i| *i < entries.len()).unwrap_or(0);
return Some(LegacyForm::DropDown { name, entries, selected });
}
if ff_data.contains("<w:textInput") {
let max_len =
element(ff_data, "w:textInput").and_then(|t| attr_of(t, "w:maxLength")).and_then(|v| v.trim().parse::<u32>().ok()).filter(|n| *n > 0);
return Some(LegacyForm::Text { name, max_len });
}
None
}
/// The text a drop-down shows (its selected entry); `None` for other kinds or an empty list.
pub fn shown_text(&self) -> Option<&str> {
match self {
LegacyForm::DropDown { entries, selected, .. } => entries.get(*selected).map(String::as_str),
_ => None,
}
}
/// The field's bookmark-style name (`w:name`), possibly empty.
pub fn name(&self) -> &str {
match self {
LegacyForm::CheckBox { name, .. } | LegacyForm::Text { name, .. } | LegacyForm::DropDown { name, .. } => name,
}
}
}
fn on(v: &str) -> bool {
!matches!(v.trim(), "0" | "false" | "off")
}
/// The element `name` in `xml` with its content: from its start tag to its end tag, or the
/// self-closing tag alone.
fn element<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
let open = tag(xml, name)?;
let start = xml.find(open)?;
if open.ends_with("/>") {
return Some(open);
}
let close = format!("</{name}>");
let end = xml.get(start..)?.find(&close)? + start + close.len();
xml.get(start..end)
}
/// The first start (or empty) tag `<name ...>` in `xml`.
fn tag<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
tags(xml, name).next()
}
/// Every start (or empty) tag `<name ...>` in `xml`, in order.
fn tags<'a>(xml: &'a str, name: &str) -> impl Iterator<Item = &'a str> + use<'a> {
let pat = format!("<{name}");
let mut from = 0;
std::iter::from_fn(move || {
loop {
let at = from + xml.get(from..)?.find(&pat)?;
let after = at + pat.len();
from = after;
// `<w:checkBox` must not match `<w:checkBoxes`: the name ends at a space, `/` or `>`.
if xml.get(after..).and_then(|s| s.chars().next()).is_some_and(|c| c == ' ' || c == '/' || c == '>') {
let end = after + xml.get(after..)?.find('>')? + 1;
return xml.get(at..end);
}
}
})
}
/// The `w:val` of the first tag `name` in `xml`.
fn attr_of(xml: &str, name: &str) -> Option<String> {
tag(xml, name).and_then(attr_in)
}
/// The `w:val` of a single tag, XML entities resolved.
fn attr_in(tag: &str) -> Option<String> {
let at = tag.find("w:val=")? + "w:val=".len();
let quote = tag.get(at..)?.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let rest = tag.get(at + 1..)?;
let end = rest.find(quote)?;
Some(unescape(rest.get(..end)?))
}
fn unescape(s: &str) -> String {
s.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_boxes_text_fields_and_drop_downs() {
let cb = |inner: &str| format!(r#"<w:ffData><w:name w:val="Box1"/><w:enabled/><w:checkBox>{inner}</w:checkBox></w:ffData>"#);
assert_eq!(
LegacyForm::parse(&cb(r#"<w:sizeAuto/><w:default w:val="0"/>"#)),
Some(LegacyForm::CheckBox { name: "Box1".into(), checked: false, size: None })
);
assert_eq!(
LegacyForm::parse(&cb(r#"<w:sizeAuto/><w:default w:val="1"/>"#)).map(|f| matches!(f, LegacyForm::CheckBox { checked: true, .. })),
Some(true)
);
// `w:checked` wins over `w:default`; without a value it means checked.
assert!(matches!(
LegacyForm::parse(&cb(r#"<w:default w:val="1"/><w:checked w:val="0"/>"#)),
Some(LegacyForm::CheckBox { checked: false, .. })
));
assert!(matches!(LegacyForm::parse(&cb(r#"<w:default w:val="0"/><w:checked/>"#)), Some(LegacyForm::CheckBox { checked: true, .. })));
assert!(matches!(
LegacyForm::parse(&cb(r#"<w:size w:val="30"/><w:default w:val="0"/>"#)),
Some(LegacyForm::CheckBox { size: Some(15.0), .. })
));
let dd = r#"<w:ffData><w:name w:val="List1"/><w:ddList><w:result w:val="1"/><w:listEntry w:val="Alpha"/><w:listEntry w:val="B &amp; C"/></w:ddList></w:ffData>"#;
let f = LegacyForm::parse(dd).unwrap();
assert_eq!(f.shown_text(), Some("B & C"));
assert_eq!(f.name(), "List1");
let dd0 = r#"<w:ffData><w:ddList><w:listEntry w:val="Only"/></w:ddList></w:ffData>"#;
assert_eq!(LegacyForm::parse(dd0).and_then(|f| f.shown_text().map(str::to_string)).as_deref(), Some("Only"));
let tx = r#"<w:ffData><w:name w:val="Name1"/><w:textInput><w:maxLength w:val="40"/></w:textInput></w:ffData>"#;
assert_eq!(LegacyForm::parse(tx), Some(LegacyForm::Text { name: "Name1".into(), max_len: Some(40) }));
assert_eq!(LegacyForm::parse(""), None);
assert_eq!(LegacyForm::parse("<w:ffData><w:name w:val=\"x\"/></w:ffData>"), None);
}
}
+240 -13
View File
@@ -24,6 +24,8 @@ pub mod eq;
pub mod fields;
pub mod freeform;
pub mod graphic;
pub mod legacy_form;
pub mod line_dash;
pub mod link;
pub mod locale_number;
pub mod math;
@@ -71,6 +73,27 @@ pub enum DocError {
pub type Result<T> = std::result::Result<T, DocError>;
/// A free id, `min` or more, for something new (a part, a list, a comment): one past `largest`, the
/// largest id in use, so ids keep growing; once that would pass `u32::MAX` (a file can use any id),
/// the lowest one `used` (every id in use) doesn't hold. `None` when every id from `min` up is taken.
pub fn free_id(largest: Option<u32>, used: impl Iterator<Item = u32>, min: u32) -> Option<u32> {
let Some(largest) = largest else { return Some(min) };
if let Some(next) = largest.checked_add(1) {
return Some(next.max(min));
}
let mut ids: Vec<u32> = used.filter(|&i| i >= min).collect();
ids.sort_unstable();
ids.dedup();
let mut want = min;
for i in ids {
if i > want {
break;
}
want = want.checked_add(1)?;
}
Some(want)
}
/// A block in a story. (Blocks are always behind an `Arc`, so the size difference is fine.)
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -289,6 +312,11 @@ pub struct TextBody {
/// Resize shape to fit text (`a:spAutoFit`, VML `mso-fit-shape-to-text`): Word grows the
/// box to its text, so none of the text is cut.
pub auto_fit: bool,
/// The colour the shape's style gives its text (`wps:style/a:fontRef`): what automatic text
/// colour means inside this box, instead of black. Text whose colour comes from its runs,
/// styles or the document defaults keeps it (Word, observed with synthetic documents).
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_color: Option<props::Rgb>,
}
/// Word's default text box margins (left, top, right, bottom), EMU: 0.1" and 0.05".
@@ -322,6 +350,13 @@ pub struct Comment {
pub resolved: bool,
/// `Document::parts` id of the comment text.
pub part: u32,
/// Word's durable id for the comment (`w16cid:durableId`, hex), kept across saves so Word
/// keeps tracking the same comment. `None` for comments made here: the writer assigns one.
#[serde(skip_serializing_if = "Option::is_none")]
pub durable_id: Option<String>,
/// The comment's UTC timestamp from the file (`w16cex:dateUtc`); `date` is local time there.
#[serde(skip_serializing_if = "Option::is_none")]
pub date_utc: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
@@ -359,6 +394,40 @@ impl Document {
}
}
/// A building block (Insert › Quick Parts, AutoText…) kept in the document's glossary
/// (ECMA-376 §17.12: `w:docPart`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct BuildingBlock {
/// The entry's name (`w:docPartPr/w:name`).
pub name: String,
/// Its gallery (`w:category/w:gallery`: `docParts` for Quick Parts, `autoTxt` for AutoText…).
pub gallery: String,
/// Its category within the gallery (`w:category/w:name`, e.g. "General").
pub category: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub description: String,
/// The entry's identity (`w:guid`), kept as read.
#[serde(skip_serializing_if = "String::is_empty")]
pub guid: String,
/// The paragraph style it's associated with (`w:style`), kept as read.
#[serde(skip_serializing_if = "String::is_empty")]
pub style: String,
/// Its types (`w:types/w:type`: `normal`, `bbPlcHdr` for a content control's placeholder…).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub types: Vec<String>,
/// How it's inserted (`w:behaviors/w:behavior`: `content`, `p`, `pg`).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub behaviors: Vec<String>,
/// The content (`w:docPartBody`).
pub blocks: Blocks,
}
/// The gallery Quick Parts saves to (`w:gallery w:val="docParts"`).
pub const QUICK_PARTS_GALLERY: &str = "docParts";
/// The AutoText gallery (`w:gallery w:val="autoTxt"`).
pub const AUTO_TEXT_GALLERY: &str = "autoTxt";
/// A bibliography source.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
@@ -420,6 +489,10 @@ pub struct CoreProps {
#[serde(default, rename_all = "camelCase")]
pub struct Settings {
pub track_changes: bool,
/// Record a tracked cut and paste of a sentence or paragraph as a move (Track moves); off
/// when the file has `w:doNotTrackMoves` (ECMA-376 §17.15.1.29), and then it is an
/// ordinary deletion and insertion.
pub track_moves: bool,
/// Default tab stop interval, points.
pub default_tab: f32,
pub even_odd_headers: bool,
@@ -433,6 +506,8 @@ pub struct Settings {
/// Most consecutive lines that may end with a hyphen (`w:consecutiveHyphenLimit`); 0 = no limit.
pub consecutive_hyphen_limit: u32,
pub page_color: Option<Rgb>,
/// Picture or texture page background (Design › Page Color › Fill Effects), drawn over the colour.
pub page_picture: Option<PagePicture>,
pub watermark: Option<Watermark>,
/// Theme fonts (major = headings, minor = body).
pub major_font: String,
@@ -464,6 +539,17 @@ pub struct Settings {
/// 2013 and later, which places a table's border at the margin rather than its text and lets
/// justified lines shrink their spaces to fit more text.
pub compat_mode: u32,
/// Fonts the document's font table classes as roman (serif, `w:family w:val="roman"`): Word
/// draws one that isn't installed in a serif face.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub roman_fonts: Vec<String>,
/// `w:doNotUseHTMLParagraphAutoSpacing`: the space between two paragraphs is the first's space
/// after plus the second's space before, not the larger of the two.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub sum_paragraph_spacing: bool,
/// Keep contextual paragraph spacing inside legacy table cells (`allowSpaceOfSameStyleInTable`).
/// Word ignores the layout override in compatibility mode 15, but the flag still round-trips.
pub allow_space_of_same_style_in_table: bool,
/// Drawing grid spacing in points, across and down (View › Gridlines); ECMA-376's default is 1/8 inch.
pub grid_h: f32,
pub grid_v: f32,
@@ -480,6 +566,10 @@ pub struct Settings {
pub attached_template: Option<String>,
/// Update the document's styles from the attached template when it is opened (`w:linkStyles`).
pub link_styles: bool,
/// The zoom the document was saved at (`w:zoom`), restored when it is opened; `None` for 100%
/// without a fit mode.
#[serde(skip_serializing_if = "Option::is_none")]
pub zoom: Option<Zoom>,
/// Remove personal information when saving (`w:removePersonalInformation`, ECMA-376
/// §17.15.1.70): the saved file names comment and revision authors "Author" and leaves out
/// the document's creator and last-modified-by. The open document keeps its real values.
@@ -491,6 +581,24 @@ pub struct Settings {
pub remove_date_time: bool,
}
/// A saved zoom: a percentage, or a fit mode (the percentage it last came to).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Zoom {
/// 10–500.
pub percent: u32,
pub fit: Option<ZoomFit>,
}
/// View › Zoom's fit modes a document can be saved in.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ZoomFit {
OnePage,
PageWidth,
TextWidth,
}
/// Word's compatibility mode for documents that don't state one.
pub const LEGACY_COMPAT_MODE: u32 = 12;
@@ -503,6 +611,18 @@ pub const DEFAULT_HYPHENATION_ZONE: f32 = 18.0;
/// ECMA-376 §17.15.1.44/45: 180 twentieths of a point when a document doesn't say.
pub const DEFAULT_GRID: f32 = 9.0;
/// A picture fill for the page background (`v:background/v:fill` with an image).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PagePicture {
/// Key into [`Document::media`].
pub media: String,
/// `v:fill/@type` (`tile`, `frame`, `tile`...); tiled when `tile`, stretched to the page otherwise.
pub fill_type: String,
/// `o:title` of the fill (texture name), kept for round trip.
pub title: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Watermark {
@@ -539,6 +659,7 @@ impl Default for Settings {
fn default() -> Self {
Settings {
track_changes: false,
track_moves: true,
default_tab: 36.0,
even_odd_headers: false,
mirror_margins: false,
@@ -547,6 +668,7 @@ impl Default for Settings {
hyphenate_caps: true,
consecutive_hyphen_limit: 0,
page_color: None,
page_picture: None,
watermark: None,
major_font: styles::HEADING_FONT.into(),
minor_font: styles::BODY_FONT.into(),
@@ -560,12 +682,16 @@ impl Default for Settings {
protect_formatting: false,
protect_hash: Vec::new(),
compat_mode: COMPAT_MODE_CURRENT,
roman_fonts: Vec::new(),
sum_paragraph_spacing: false,
allow_space_of_same_style_in_table: false,
grid_h: DEFAULT_GRID,
grid_v: DEFAULT_GRID,
math: None,
bib_style: None,
attached_template: None,
link_styles: false,
zoom: None,
remove_personal_info: false,
remove_date_time: false,
}
@@ -597,11 +723,14 @@ pub struct Document {
#[serde(skip_serializing_if = "NoteSeparators::is_empty")]
pub endnote_separators: NoteSeparators,
/// Embedded media (images) by key.
#[serde(skip)]
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub media: BTreeMap<String, Arc<Vec<u8>>>,
/// Source-format parts we keep verbatim for round-trip (e.g. custom XML), by part name.
#[serde(skip)]
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub passthrough: BTreeMap<String, Arc<Vec<u8>>>,
/// The document's own building blocks (its glossary document), in file order.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub glossary: Vec<BuildingBlock>,
}
impl Default for Document {
@@ -629,6 +758,7 @@ impl Document {
endnote_separators: NoteSeparators::default(),
media: BTreeMap::new(),
passthrough: BTreeMap::new(),
glossary: Vec::new(),
}
}
@@ -814,19 +944,24 @@ impl Document {
/// The whole text of a story, paragraphs separated by `\n` (tables: cells by `\t`).
pub fn plain_text(&self, s: StoryRef) -> String {
self.story_text(s, false)
self.story_text(s, TextMode::Plain)
}
/// [`Self::plain_text`] as it reads on screen: symbol-font characters (Wingdings…) as their
/// Unicode equivalents ([`Paragraph::shown_text`]).
pub fn shown_text(&self, s: StoryRef) -> String {
self.story_text(s, true)
self.story_text(s, TextMode::Shown)
}
fn story_text(&self, s: StoryRef, shown: bool) -> String {
/// [`Self::plain_text`] without tracked-deleted runs. A deleted paragraph mark still yields `\n`.
pub fn final_text(&self, s: StoryRef) -> String {
self.story_text(s, TextMode::Final)
}
fn story_text(&self, s: StoryRef, mode: TextMode) -> String {
let mut out = String::new();
if let Some(bl) = self.story(s) {
text_of(bl, &mut out, 0, shown);
text_of(bl, &mut out, 0, mode);
}
if out.ends_with('\n') {
out.pop();
@@ -893,15 +1028,34 @@ impl Document {
&mut self.last_section
}
/// Allocate a new part id and insert the part.
/// Remove the section break on the mark of top-level body paragraph `block`: its section
/// joins the following one ([`SectionProps::absorb`]). False when there is no break there.
pub fn remove_section_break(&mut self, block: usize) -> bool {
if !matches!(self.body.get(block).map(|b| &**b), Some(Block::Para(p)) if p.section.is_some()) {
return false;
}
let Some(Block::Para(p)) = self.body.get_mut(block).map(Arc::make_mut) else { return false };
let Some(before) = p.section.take() else { return false };
self.section_mut(block).absorb(&before);
true
}
/// Allocate a new part id and insert the part. Part ids run out only past `u32::MAX` parts, more
/// than memory holds; then nothing is added and the id is 0, which [`free_id`] never gives a part.
pub fn add_part(&mut self, kind: PartKind, blocks: Blocks) -> u32 {
let id = self.parts.keys().next_back().map(|k| k + 1).unwrap_or(1);
self.try_add_part(kind, blocks).unwrap_or(0)
}
/// [`Document::add_part`], or an error, adding nothing, when every part id is taken.
pub fn try_add_part(&mut self, kind: PartKind, blocks: Blocks) -> Result<u32> {
let id = free_id(self.parts.keys().next_back().copied(), self.parts.keys().copied(), 1)
.ok_or_else(|| DocError::Invalid("no free part id".into()))?;
let mut part = Part { kind, blocks, body: TextBody::default() };
if part.blocks.is_empty() {
part.blocks.push(para_block(Paragraph::new()));
}
self.parts.insert(id, part);
id
Ok(id)
}
/// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit,
@@ -1158,6 +1312,18 @@ impl Document {
}
}
/// Repair every paragraph's runs (see [`Paragraph::normalize`]); for models from untrusted
/// JSON, whose run lengths may overflow, fall short or split a character.
pub fn normalize_runs(&mut self) {
let mut stories: Vec<&mut Blocks> = vec![&mut self.body];
stories.extend(self.parts.values_mut().map(|p| &mut p.blocks));
for blocks in stories {
for b in blocks.iter_mut() {
edit::each_para_mut(Arc::make_mut(b), 0, &mut Paragraph::normalize);
}
}
}
/// Number of paragraphs in the body (all depths).
pub fn paragraph_count(&self) -> usize {
self.para_paths(StoryRef::Body).len()
@@ -1283,8 +1449,11 @@ pub fn count_words(s: &str) -> usize {
s.split(|c: char| c.is_whitespace() || c == para::OBJ).filter(|w| w.chars().any(char::is_alphanumeric)).count()
}
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
pub(crate) const PARA_WALK_DEPTH: usize = 16;
fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
if depth > 16 {
if depth > PARA_WALK_DEPTH {
return;
}
for (i, b) in bl.iter().enumerate() {
@@ -1307,14 +1476,72 @@ fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
}
}
fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) {
/// [`walk`] for the paragraphs from path `a` to `b` inclusive, skipping the blocks outside.
pub(crate) fn walk_between(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize, (a, b): (&[u32], &[u32])) {
use std::cmp::Ordering::{Greater, Less};
if depth > PARA_WALK_DEPTH {
return;
}
// Where the paths starting with `q` lie against `x`: all before, all after, or either.
let side = |q: &[u32], x: &[u32]| {
let m = q.len().min(x.len());
q[..m].cmp(&x[..m])
};
for (i, blk) in bl.iter().enumerate() {
prefix.push(i as u32);
if side(prefix, b) == Greater {
prefix.pop();
break;
}
if side(prefix, a) != Less {
match &**blk {
Block::Para(_) => {
if prefix.as_slice() >= a && prefix.as_slice() <= b {
out.push(Path(prefix.clone()));
}
}
Block::Table(t) => {
for (r, row) in t.rows.iter().enumerate() {
for (c, cell) in row.cells.iter().enumerate() {
prefix.push(r as u32);
prefix.push(c as u32);
if side(prefix, a) != Less && side(prefix, b) != Greater {
walk_between(&cell.blocks, prefix, out, depth + 1, (a, b));
}
prefix.pop();
prefix.pop();
}
}
}
}
}
prefix.pop();
}
}
/// Which text of a paragraph [`text_of`] collects.
#[derive(Clone, Copy)]
enum TextMode {
/// Every run, tracked deletions included ([`Paragraph::plain_text`]).
Plain,
/// As shown on screen ([`Paragraph::shown_text`]).
Shown,
/// Without tracked deletions ([`Paragraph::final_text`]).
Final,
}
fn text_of(bl: &Blocks, out: &mut String, depth: usize, mode: TextMode) {
if depth > 16 {
return;
}
for b in bl {
match &**b {
Block::Para(p) => {
out.push_str(&if shown { p.shown_text() } else { p.plain_text() });
out.push_str(&match mode {
TextMode::Plain => p.plain_text(),
TextMode::Shown => p.shown_text(),
TextMode::Final => p.final_text(),
});
out.push('\n');
}
Block::Table(t) => {
@@ -1322,7 +1549,7 @@ fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) {
let mut cells = Vec::new();
for c in &row.cells {
let mut s = String::new();
text_of(&c.blocks, &mut s, depth + 1, shown);
text_of(&c.blocks, &mut s, depth + 1, mode);
cells.push(s.trim_end_matches('\n').replace('\n', " "));
}
out.push_str(&cells.join("\t"));
+124
View File
@@ -0,0 +1,124 @@
//! DrawingML outline dashes, measured in multiples of the stroke width.
use serde::{Deserialize, Serialize};
pub const DASH_PERCENT: u32 = 100_000;
pub const MAX_DASH_STOPS: usize = 64;
const MIN_DASH_POINTS: f32 = 0.01;
const MAX_DASH_POINTS: f32 = 20_000.0;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LineDash {
#[default]
Solid,
Dot,
Dash,
LongDash,
DashDot,
LongDashDot,
LongDashDotDot,
SystemDash,
SystemDot,
SystemDashDot,
SystemDashDotDot,
/// Dash/space pairs in 100000ths of the line width (`a:ds/@d`, `@sp`).
Custom(Vec<(u32, u32)>),
}
impl LineDash {
pub fn from_ooxml(value: &str) -> Self {
match value {
"dot" => Self::Dot,
"dash" => Self::Dash,
"lgDash" => Self::LongDash,
"dashDot" => Self::DashDot,
"lgDashDot" => Self::LongDashDot,
"lgDashDotDot" => Self::LongDashDotDot,
"sysDash" => Self::SystemDash,
"sysDot" => Self::SystemDot,
"sysDashDot" => Self::SystemDashDot,
"sysDashDotDot" => Self::SystemDashDotDot,
_ => Self::Solid,
}
}
pub fn ooxml(&self) -> Option<&'static str> {
Some(match self {
Self::Solid => "solid",
Self::Dot => "dot",
Self::Dash => "dash",
Self::LongDash => "lgDash",
Self::DashDot => "dashDot",
Self::LongDashDot => "lgDashDot",
Self::LongDashDotDot => "lgDashDotDot",
Self::SystemDash => "sysDash",
Self::SystemDot => "sysDot",
Self::SystemDashDot => "sysDashDot",
Self::SystemDashDotDot => "sysDashDotDot",
Self::Custom(_) => return None,
})
}
pub fn is_solid(&self) -> bool {
*self == Self::Solid
}
/// Page-point lengths for the painters. Bound hostile custom lists and tiny spans.
pub fn pattern(&self, width: f32) -> Vec<f32> {
let preset: &[f32] = match self {
Self::Solid => return Vec::new(),
Self::Dot => &[1.0, 3.0],
Self::Dash => &[4.0, 3.0],
Self::LongDash => &[8.0, 3.0],
Self::DashDot => &[4.0, 3.0, 1.0, 3.0],
Self::LongDashDot => &[8.0, 3.0, 1.0, 3.0],
Self::LongDashDotDot => &[8.0, 3.0, 1.0, 3.0, 1.0, 3.0],
Self::SystemDash => &[3.0, 1.0],
Self::SystemDot => &[1.0, 1.0],
Self::SystemDashDot => &[3.0, 1.0, 1.0, 1.0],
Self::SystemDashDotDot => &[3.0, 1.0, 1.0, 1.0, 1.0, 1.0],
Self::Custom(stops) => {
return stops
.iter()
.take(MAX_DASH_STOPS)
.flat_map(|&(d, sp)| [d, sp])
.map(|v| dash_points(v as f32 / DASH_PERCENT as f32, width))
.collect();
}
};
preset.iter().map(|v| dash_points(*v, width)).collect()
}
}
fn dash_points(ratio: f32, width: f32) -> f32 {
let width = if width.is_finite() { width.clamp(0.25, 200.0) } else { 0.75 };
(ratio * width).clamp(MIN_DASH_POINTS, MAX_DASH_POINTS)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dash_units_follow_line_width() {
assert_eq!(LineDash::Dash.pattern(1.0), [4.0, 3.0]);
assert_eq!(LineDash::Dash.pattern(2.0), [8.0, 6.0]);
assert_eq!(LineDash::Custom(vec![(200_000, 150_000)]).pattern(2.0), [4.0, 3.0]);
assert!(LineDash::Solid.pattern(2.0).is_empty());
}
#[test]
fn old_shapes_default_to_solid() {
let extra: crate::connector::ShapeExtra = serde_json::from_str("{}").unwrap();
assert!(extra.dash.is_solid());
assert!(!serde_json::to_string(&extra).unwrap().contains("dash"));
}
#[test]
fn custom_dashes_are_bounded() {
let pattern = LineDash::Custom(vec![(0, u32::MAX); MAX_DASH_STOPS + 1]).pattern(f32::NAN);
assert_eq!(pattern.len(), MAX_DASH_STOPS * 2);
assert!(pattern.iter().all(|v| v.is_finite() && *v >= MIN_DASH_POINTS && *v <= MAX_DASH_POINTS));
}
}
+19 -5
View File
@@ -9,14 +9,23 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
["http", "https", "mailto"].iter().any(|s| scheme.eq_ignore_ascii_case(s))
}
/// Whether a hyperlink may be written as an `href` (or Markdown destination) in an exported file:
/// a web or email link, a bookmark (`#name`) or a relative path. Scripts (`javascript:`,
/// `vbscript:`), `data:`, `file:` and every other scheme are refused, read the way a browser reads
/// a URL: ASCII control characters and spaces are dropped first, so `java\nscript:` is a script.
/// Whether a hyperlink may be written as an `href` (or Markdown destination, RTF `HYPERLINK`
/// field, ODT `xlink:href`, LaTeX `\href`) in an exported file: a web or email link, a bookmark
/// (`#name`) or a relative path. Scripts (`javascript:`, `vbscript:`), `data:`, `file:` and every
/// other scheme are refused, read the way a browser reads a URL: ASCII control characters and
/// spaces are dropped first, so `java\nscript:` is a script. A protocol-relative reference
/// (`//host/…`) or a UNC path (`\\host\…`, or `//host` once backslashes are read as separators)
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
/// refused too.
pub fn is_safe_href(url: &str) -> bool {
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
if cleaned.replace('\\', "/").starts_with("//") {
return false;
}
match scheme(url) {
Some(s) => matches!(s.as_str(), "http" | "https" | "mailto"),
None => !url.trim().is_empty(),
None => !cleaned.is_empty(),
}
}
@@ -73,6 +82,11 @@ mod tests {
"data:text/html,<script>alert(1)</script>",
"file:///etc/passwd",
"ms-msdt:/id",
// Protocol-relative and UNC references name another machine (NTLM leak on Windows).
"//attacker.example/share/x",
"\\\\attacker.example\\share\\x",
"/\\attacker.example\\x",
" //attacker.example/x",
"",
] {
assert!(!is_safe_href(bad), "{bad:?}");
+14 -1
View File
@@ -111,10 +111,11 @@ pub fn format_number(v: f64, lang: Option<&str>) -> String {
if !v.is_finite() {
return "0".into();
}
if v.fract() == 0.0 && v.abs() < 1e15 {
if v.fract() == 0.0 && v.abs() < 9.2e18 {
return format!("{}", v as i64);
}
let s = format!("{v:.2}");
let s = if s == "-0.00" { "0.00".to_string() } else { s };
if decimal_separator(lang) == ',' { s.replace('.', ",") } else { s }
}
@@ -147,6 +148,7 @@ mod tests {
assert_eq!(parse_number(".", pl), None);
assert_eq!(format_number(5.75, pl), "5,75");
assert_eq!(format_number(1350.0, pl), "1350");
assert_eq!(format_number(-0.001, pl), "0,00", "a rounded zero has no sign");
}
#[test]
@@ -161,6 +163,8 @@ mod tests {
assert_eq!(parse_number("1.", en), Some(1.0));
assert_eq!(parse_number("0,125", en), None, "never 125");
assert_eq!(format_number(5.75, en), "5.75");
assert_eq!(format_number(-0.001, en), "0.00", "a rounded zero has no sign");
assert_eq!(format_number(-0.0, None), "0");
assert_eq!(decimal_separator(Some("de-CH")), '.');
assert_eq!(decimal_separator(Some("de-DE")), ',');
}
@@ -174,4 +178,13 @@ mod tests {
}
assert_eq!(format_number(f64::NAN, None), "0");
}
#[test]
fn large_whole_numbers_have_no_decimals() {
assert_eq!(format_number(1e15, None), "1000000000000000");
assert_eq!(format_number(2.5e15, None), "2500000000000000");
assert_eq!(format_number(-1e15, None), "-1000000000000000");
assert_eq!(format_number(9.0e18, None), "9000000000000000000");
assert_eq!(format_number(1e15 + 0.5, None), "1000000000000000.50");
}
}
+299 -21
View File
@@ -340,7 +340,7 @@ fn yes() -> bool {
/// A row holds an equation-number mark (`#`) in a top-level run.
pub fn has_number_mark(row: &[MNode]) -> bool {
row.iter().any(|n| matches!(n, MNode::Run(r) if !r.lit && r.text.contains('#')))
row.iter().any(|n| matches!(n, MNode::Run(r) if !r.lit && !r.nor && r.text.contains('#')))
}
/// Integral signs (their limits default to sub/sup positions).
@@ -470,8 +470,27 @@ fn lin_arg(a: &[MNode], s: &mut String, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for n in a {
for (i, n) in a.iter().enumerate() {
lin_node(n, s, depth + 1);
// A bare script or limit ends at a space: a letter, digit or period after it is its own item.
if matches!(n, MNode::Script { kind, .. } if *kind != ScriptKind::Pre) || matches!(n, MNode::Lim { .. }) {
let ends_bare = s.chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '.');
if ends_bare && a.get(i + 1).and_then(first_lin_char).is_some_and(|c| c.is_alphanumeric() || c == '.') {
s.push(' ');
}
}
}
}
/// The first character `n` is written with, when that is cheap to tell (letters and digits).
fn first_lin_char(n: &MNode) -> Option<char> {
match n {
MNode::Run(r) => r.text.chars().next(),
MNode::Func { name, .. } => name.first().and_then(first_lin_char),
MNode::Script { kind, base, .. } if *kind != ScriptKind::Pre => base.first().and_then(first_lin_char),
MNode::Lim { e, .. } => e.first().and_then(first_lin_char),
MNode::Frac { num, .. } => num.first().and_then(first_lin_char),
_ => None,
}
}
@@ -499,6 +518,27 @@ fn lin_operand(a: &[MNode], s: &mut String, depth: usize) {
}
}
/// A script or limit operand. The parser reads a run of letters and digits up to the next space or
/// operator, so `V_dd` is written bare as Word's Linear format shows it (`V_(dd)` reads back the same).
fn lin_script(a: &[MNode], s: &mut String, depth: usize) {
// Bare only when the parser reads the whole run back, in the same style.
if let [MNode::Run(r)] = a
&& r.text.chars().nth(1).is_some()
{
let upright_name = r.sty == Some(MSty::Plain) && crate::math_linear::FUNCTION_NAMES.contains(&r.text.as_str());
let number = r.text.starts_with(|c: char| c.is_ascii_digit()) && r.text.chars().all(|c| c.is_alphanumeric() || c == '.');
let word = r.text.starts_with(char::is_alphabetic) && r.text.chars().all(char::is_alphanumeric);
let upright_other = r.sty == Some(MSty::Plain) && !upright_name;
if upright_other || (!number && !word) {
s.push('(');
s.push_str(&r.text);
s.push(')');
return;
}
}
lin_operand(a, s, depth);
}
fn lin_node(n: &MNode, s: &mut String, depth: usize) {
match n {
MNode::Run(r) => s.push_str(&r.text),
@@ -515,9 +555,9 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
MNode::Script { kind, base, sub, sup } => {
if *kind == ScriptKind::Pre {
s.push('_');
lin_operand(sub, s, depth);
lin_script(sub, s, depth);
s.push('^');
lin_operand(sup, s, depth);
lin_script(sup, s, depth);
s.push('▒');
lin_operand(base, s, depth);
return;
@@ -525,11 +565,11 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
lin_operand(base, s, depth);
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
s.push('_');
lin_operand(sub, s, depth);
lin_script(sub, s, depth);
}
if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
s.push('^');
lin_operand(sup, s, depth);
lin_script(sup, s, depth);
}
}
MNode::Rad { deg, deg_hide, e } => {
@@ -548,11 +588,11 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
s.push(*chr);
if !sub_hide && !sub.is_empty() {
s.push('_');
lin_operand(sub, s, depth);
lin_script(sub, s, depth);
}
if !sup_hide && !sup.is_empty() {
s.push('^');
lin_operand(sup, s, depth);
lin_script(sup, s, depth);
}
s.push('▒');
lin_operand(e, s, depth);
@@ -592,7 +632,7 @@ fn lin_node(n: &MNode, s: &mut String, depth: usize) {
MNode::Lim { upper, e, lim } => {
lin_operand(e, s, depth);
s.push(if *upper { '┴' } else { '┬' });
lin_operand(lim, s, depth);
lin_script(lim, s, depth);
}
MNode::Acc { chr, e } => {
lin_operand(e, s, depth);
@@ -701,7 +741,7 @@ pub fn math_class(c: char) -> MClass {
}
}
/// Merge adjacent runs with the same formatting; recurse into structures.
/// Merge adjacent runs with the same formatting; recurse into every structure.
pub fn merge_runs(a: Arg) -> Arg {
let mut out: Arg = Vec::with_capacity(a.len());
for n in a {
@@ -722,23 +762,79 @@ pub fn merge_runs(a: Arg) -> Arg {
}
MNode::Run(r)
}
MNode::Frac { kind, num, den } => MNode::Frac { kind, num: merge_runs(num), den: merge_runs(den) },
MNode::Script { kind, base, sub, sup } => MNode::Script { kind, base: merge_runs(base), sub: merge_runs(sub), sup: merge_runs(sup) },
MNode::Rad { deg, deg_hide, e } => MNode::Rad { deg: merge_runs(deg), deg_hide, e: merge_runs(e) },
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } => {
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub: merge_runs(sub), sup: merge_runs(sup), e: merge_runs(e) }
}
MNode::Delim { beg, end, sep, grow, shp_match, elems } => {
MNode::Delim { beg, end, sep, grow, shp_match, elems: elems.into_iter().map(merge_runs).collect() }
}
MNode::Func { name, e } => MNode::Func { name: merge_runs(name), e: merge_runs(e) },
other => other,
other => crate::math_linear::map_args(other, &merge_runs),
};
out.push(n);
}
out
}
/// Deepest node (a leaf counts 1) the parsers wrap further. Half of [`MAX_DEPTH`]: serde_json
/// stops reading at 128 nested containers and a node costs two (object and array).
pub(crate) const WRAP_DEPTH: usize = MAX_DEPTH / 2;
/// Calls `f` with every argument of `n`.
fn each_arg<'a>(n: &'a MNode, f: &mut impl FnMut(&'a [MNode])) {
match n {
MNode::Run(_) => {}
MNode::Frac { num, den, .. } => {
f(num);
f(den);
}
MNode::Script { base, sub, sup, .. } => {
f(base);
f(sub);
f(sup);
}
MNode::Rad { deg, e, .. } => {
f(deg);
f(e);
}
MNode::Nary { sub, sup, e, .. } => {
f(sub);
f(sup);
f(e);
}
MNode::Delim { elems, .. } => elems.iter().for_each(|a| f(a)),
MNode::Func { name, e } => {
f(name);
f(e);
}
MNode::Lim { e, lim, .. } => {
f(e);
f(lim);
}
MNode::Acc { e, .. }
| MNode::Bar { e, .. }
| MNode::BorderBox { e, .. }
| MNode::Boxed { e }
| MNode::GroupChr { e, .. }
| MNode::Phant { e, .. } => f(e),
MNode::EqArr { rows } => rows.iter().for_each(|a| f(a)),
MNode::Matrix { rows, .. } => rows.iter().flatten().for_each(|a| f(a)),
}
}
/// `n` is already nested too deeply to be wrapped in another structure. The parsers use this so
/// `x^1^1^1…` stays shallow (a left-nested tree 10 000 deep overflows the stack in `Clone`,
/// `Drop`, `PartialEq` and serde). Iterative, stops at the first too-deep path; costs at most the
/// size of `n`, which the parsers' input caps bound.
pub(crate) fn too_deep(n: &MNode) -> bool {
let mut stack: Vec<(&MNode, usize)> = vec![(n, 1)];
while let Some((n, d)) = stack.pop() {
if d >= WRAP_DEPTH {
return true;
}
each_arg(n, &mut |a| stack.extend(a.iter().map(|c| (c, d + 1))));
}
false
}
/// Any of `a` is too deep to be wrapped ([`too_deep`]).
pub(crate) fn any_too_deep(a: &[MNode]) -> bool {
a.iter().any(too_deep)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -807,6 +903,188 @@ mod tests {
}
}
//T1
/// Deepest nesting of `a` (a leaf counts 1), without recursion.
fn depth_of(a: &[MNode]) -> usize {
let mut best = 0;
let mut stack: Vec<(&MNode, usize)> = a.iter().map(|n| (n, 1)).collect();
while let Some((n, d)) = stack.pop() {
best = best.max(d);
each_arg(n, &mut |a| stack.extend(a.iter().map(|c| (c, d + 1))));
}
best
}
/// Run `f` on a thread with a stack far below the 8 MB of a main thread (#1456).
fn on_small_stack(f: impl FnOnce() + Send + 'static) {
std::thread::Builder::new().stack_size(512 * 1024).spawn(f).unwrap().join().unwrap();
}
#[test]
fn deeply_chained_input_stays_shallow() {
on_small_stack(|| {
let cases: Vec<(&str, Arg)> = vec![
("x^1^1…", parse_linear(&format!("x{}", "^1".repeat(9999)))),
("a/a/a…", parse_linear(&format!("a{}", "/a".repeat(9999)))),
("accents", parse_linear(&format!("x{}", "\u{302}".repeat(9999)))),
("limits", parse_linear(&format!("x{}", "┬a".repeat(9000)))),
("(a b)^1^1…", parse_linear(&format!("(a b){}", "^1".repeat(9000)))),
("latex x^1^1…", crate::math_latex::parse_latex(&format!("x{}", "^1".repeat(9999)))),
("latex \\sum…", crate::math_latex::parse_latex(&"\\sum".repeat(5000))),
("latex \\sin x^1…", crate::math_latex::parse_latex(&format!("\\sin x{}", "^1".repeat(9000)))),
];
for (name, n) in cases {
assert!(!n.is_empty(), "{name}");
assert!(depth_of(&n) <= 70, "{name}: depth {}", depth_of(&n));
let copy = n.clone();
assert_eq!(copy, n, "{name}");
let _ = to_linear(&n);
let _ = crate::math_latex::to_latex(&n);
let json = serde_json::to_string(&n).unwrap();
let back: Arg = serde_json::from_str(&json).unwrap();
assert_eq!(back, n, "{name}");
}
});
}
#[test]
fn ordinary_nesting_is_still_built() {
// Well below the cap: scripts, fractions and operators still wrap.
let n = parse_linear(&format!("x{}", "^1".repeat(10)));
assert_eq!(depth_of(&n), 11, "{n:#?}");
let n = crate::math_latex::parse_latex(r"\sum_{i}\sum_{j}a_{ij}");
assert!(matches!(&n[0], MNode::Nary { e, .. } if matches!(e.first(), Some(MNode::Nary { .. }))), "{n:#?}");
}
//T2
#[test]
fn latex_text_escapes_what_latex_reads_as_markup() {
for text in ["a}b\\c", "{x}", "50% of #1", "a_b&c$d~e^f", "\\", "\\alpha", "\\{"] {
let n = vec![MNode::Run(MRun { text: text.into(), nor: true, sty: Some(MSty::Plain), ..Default::default() })];
let tex = crate::math_latex::to_latex(&n);
assert_eq!(crate::math_latex::parse_latex(&tex), n, "{text:?} → {tex}");
}
let n = vec![MNode::Run(MRun::new("a\\b"))];
assert_eq!(crate::math_latex::parse_latex(&crate::math_latex::to_latex(&n)), n);
}
//T3
#[test]
fn latex_runs_merge_inside_every_structure() {
use crate::math_latex::parse_latex;
for (tex, lin) in [(r"\overline{ab}", "¯(ab)"), (r"\underline{ab}", "▁(ab)"), (r"\boxed{ab}", "▭(ab)"), (r"\phantom{ab}", "⟡(ab)")] {
assert_eq!(parse_latex(tex), parse_linear(lin), "{tex}");
}
let n = parse_latex(r"\begin{matrix}a b&c d\\e f&g\end{matrix}");
let MNode::Matrix { rows, .. } = &n[0] else { panic!("{n:#?}") };
assert!(rows.iter().flatten().all(|c| c.len() == 1), "{rows:#?}");
let n = parse_latex(r"\hat{ab}\underbrace{ab}\underset{ab}{cd}");
let runs = |a: &Arg| a.iter().filter(|n| matches!(n, MNode::Run(_))).count();
assert!(matches!(&n[0], MNode::Acc { e, .. } if runs(e) == 1), "{n:#?}");
assert!(matches!(&n[1], MNode::GroupChr { e, .. } if runs(e) == 1), "{n:#?}");
assert!(matches!(&n[2], MNode::Lim { e, lim, .. } if runs(e) == 1 && runs(lim) == 1), "{n:#?}");
}
#[test]
fn multi_letter_scripts_round_trip() {
// Word for Mac 16.113.4 (#1609): a script is the whole run up to a space or operator, and Word's
// Linear format shows it bare (`V_dd`); the parenthesised form reads back identically.
let script = |kind, sub: &str, sup: &str| MNode::Script { kind, base: vec![run("V")], sub: parse_linear(sub), sup: parse_linear(sup) };
for n in [
vec![script(ScriptKind::Sub, "dd", "")],
vec![script(ScriptKind::Sup, "", "ab")],
vec![script(ScriptKind::SubSup, "in", "2")],
vec![MNode::Nary {
chr: '∑',
lim_loc: None,
grow: false,
sub_hide: false,
sup_hide: false,
sub: vec![run("ij")],
sup: vec![run("n")],
e: vec![run("x")],
}],
vec![MNode::Lim { upper: false, e: vec![run("⟶")], lim: vec![run("yields")] }],
] {
assert_eq!(parse_linear(&to_linear(&n)), n, "{}", to_linear(&n));
}
// Numbers and single letters stay bare.
assert_eq!(to_linear(&[script(ScriptKind::SubSup, "10", "2.5")]), "V_10^2.5");
assert_eq!(to_linear(&[script(ScriptKind::Sub, "d", "")]), "V_d");
assert_eq!(to_linear(&[script(ScriptKind::Sub, "dd", "")]), "V_dd");
assert_eq!(to_linear(&[script(ScriptKind::Sup, "", "ab")]), "V^ab");
}
#[test]
fn multi_letter_script_operands_follow_word() {
// #1609, Word for Mac 16.113.4: `V_dd` and `V_(dd)` build up the same; `dd` is all subscript.
let sub_dd = MNode::Script { kind: ScriptKind::Sub, base: vec![run("V")], sub: vec![run("dd")], sup: vec![] };
assert_eq!(parse_linear("V_dd"), vec![sub_dd.clone()]);
assert_eq!(parse_linear("V_(dd)"), vec![sub_dd]);
let sup_ab = MNode::Script { kind: ScriptKind::Sup, base: vec![run("x")], sub: vec![], sup: vec![run("ab")] };
assert_eq!(parse_linear("x^ab"), vec![sup_ab.clone()]);
assert_eq!(parse_linear("x^(ab)"), vec![sup_ab]);
// Delimiters still end the operand.
let both = MNode::Script { kind: ScriptKind::SubSup, base: vec![run("x")], sub: vec![run("i")], sup: vec![run("2")] };
assert_eq!(parse_linear("x_i^2"), vec![both]);
let a_i = MNode::Script { kind: ScriptKind::Sub, base: vec![run("a")], sub: vec![run("i")], sup: vec![] };
assert_eq!(parse_linear("a_i b"), vec![a_i, run("b")]);
let both2 = MNode::Script { kind: ScriptKind::SubSup, base: vec![run("x")], sub: vec![run("ij")], sup: vec![run("kl")] };
assert_eq!(parse_linear("x_ij^kl"), vec![both2]);
// Round trip is Word's bare form.
assert_eq!(to_linear(&parse_linear("V_(dd)")), "V_dd");
}
#[test]
fn a_bare_script_does_not_absorb_what_follows() {
// #1609 review: `x_i` then `y` must not read back as x with subscript `iy`.
let sc =
|kind, base: &str, sub: &str, sup: &str| MNode::Script { kind, base: vec![run(base)], sub: parse_linear(sub), sup: parse_linear(sup) };
let sin_x = MNode::Func { name: vec![MNode::Run(MRun::plain("sin"))], e: vec![run("x")] };
let lim = MNode::Lim { upper: false, e: vec![run("a")], lim: vec![run("n")] };
for n in [
vec![sc(ScriptKind::Sub, "x", "i", ""), run("y")],
vec![sc(ScriptKind::Sub, "a", "i", ""), sc(ScriptKind::Sub, "b", "j", "")],
vec![sc(ScriptKind::Sup, "x", "", "2"), run("y")],
vec![sc(ScriptKind::Sup, "x", "", "2"), run("3")],
vec![sc(ScriptKind::Sub, "x", "i", ""), sin_x],
vec![lim.clone(), run("y")],
vec![sc(ScriptKind::Sub, "x", "ab", ""), run("y")],
vec![sc(ScriptKind::Sub, "x", "i", ""), run(".5")],
] {
let lin = to_linear(&n);
assert_eq!(parse_linear(&lin), n, "{lin}");
}
assert_eq!(to_linear(&[sc(ScriptKind::Sub, "x", "i", ""), run("y")]), "x_i y");
// Nothing extra when the script already ends in a bracket or a symbol follows.
assert_eq!(to_linear(&[sc(ScriptKind::Sub, "x", "i", ""), run("+")]), "x_i+");
}
#[test]
fn a_function_name_in_a_script_stays_upright() {
// Word for Mac (#1609): `x_max` and `x_(max)` both give an upright function name.
let up = vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![MNode::Run(MRun::plain("max"))], sup: vec![] }];
assert_eq!(parse_linear("x_max"), up);
assert_eq!(parse_linear("x_(max)"), up);
assert_eq!(parse_linear(&to_linear(&up)), up, "{}", to_linear(&up));
// Other upright text is not a function name: it keeps its parentheses.
let other = vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![MNode::Run(MRun::plain("ab"))], sup: vec![] }];
assert_eq!(to_linear(&other), "x_(ab)");
}
#[test]
fn digits_then_letters_continue_a_script() {
// Word for Mac (#1609): a script runs to a space or operator, so `x_2a` is subscript "2a".
let sub = |t: &str| vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![run(t)], sup: vec![] }];
assert_eq!(parse_linear("x_2a"), sub("2a"));
assert_eq!(parse_linear("x_a2"), sub("a2"));
assert_eq!(parse_linear("x_αβ"), sub("αβ"));
assert_eq!(parse_linear("x_10"), sub("10"));
assert_eq!(parse_linear("x_2.5"), sub("2.5"));
assert_eq!(to_linear(&sub("2a")), "x_2a");
assert_eq!(parse_linear(&to_linear(&sub("2a"))), sub("2a"));
}
#[test]
fn hostile_linear_never_panics() {
for s in ["", "/", "^", "_", "√", "∑", "((((", "))))", "■(", "█(@@&&", "a/", "/b", "^^^__", "√(&)", "∫_^▒", "lim_", "sin"] {
+35
View File
@@ -362,6 +362,26 @@ pub fn tidy(root: &mut Arg) {
go(root, 0);
}
/// Call `f` on every run in the tree.
pub fn each_run_mut(root: &mut Arg, f: &mut dyn FnMut(&mut MRun)) {
fn go(a: &mut Arg, f: &mut dyn FnMut(&mut MRun), depth: usize) {
if depth > MAX_DEPTH {
return;
}
for n in a.iter_mut() {
if let MNode::Run(r) = n {
f(r);
}
for c in 0..child_count(n) {
if let Some(ch) = child_mut(n, c) {
go(ch, f, depth + 1);
}
}
}
}
go(root, f, 0);
}
fn merge_shallow(a: Arg) -> Arg {
let mut out: Arg = Vec::with_capacity(a.len());
for n in a {
@@ -1179,6 +1199,21 @@ mod tests {
assert!(matches!(eq[0], MNode::Frac { .. }));
}
#[test]
fn build_up_keeps_a_multi_letter_subscript() {
// #1609: `dd` typed in a subscript box split into V_d and d on the next space.
let mut eq: Arg = Vec::new();
let p = insert_nodes(&mut eq, &MathPos::default(), parse_linear("⬚_⬚"));
let p = insert_text(&mut eq, &p, "V", TypeStyle::default());
let p = move_right(&eq, &p).unwrap();
let p = insert_text(&mut eq, &p, "dd", TypeStyle::default());
let p = move_right(&eq, &p).unwrap();
assert_eq!(p, MathPos::new(vec![], 1));
let built = eq.clone();
assert_eq!(build_up(&mut eq, &p, false), None);
assert_eq!(eq, built, "{}", lin(&eq));
}
#[test]
fn backspace_dissolves_and_removes() {
let mut eq = parse_linear("√x");
+163 -22
View File
@@ -5,7 +5,9 @@
//! accents, `\overbrace`/`\underbrace`, `\text`/`\mathrm`/`\mathbf`/`\mathbb`…, function names,
//! spacing and every named symbol in [`crate::math_symbols`]. Unknown commands stay as text.
use crate::math::{Arg, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, is_nary_char, math_class, merge_runs};
use crate::math::{
Arg, ColJc, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, WRAP_DEPTH, any_too_deep, is_nary_char, math_class, merge_runs, too_deep,
};
use crate::math_linear::{FUNCTION_NAMES, map_args};
use crate::math_symbols::{name_of, symbol};
@@ -109,7 +111,7 @@ pub fn parse_latex(s: &str) -> Arg {
p.i += 1;
}
let out = merge_runs(out);
if out.iter().any(|n| matches!(n, MNode::Run(r) if r.text.contains('#'))) {
if out.iter().any(|n| matches!(n, MNode::Run(r) if !r.nor && r.text.contains('#'))) {
return vec![MNode::EqArr { rows: vec![out] }];
}
out
@@ -147,8 +149,13 @@ impl Lp {
Tok::Sup | Tok::Sub => {
self.i += 1;
let s = self.arg();
let base = out.pop();
out.push(script(base, tok == Tok::Sup, s));
if out.last().is_some_and(too_deep) {
// Too deep to wrap (`x^1^1^1…`): the operator is dropped, the script kept.
out.extend(s);
} else {
let base = out.pop();
out.push(script(base, tok == Tok::Sup, s));
}
}
Tok::Cmd(ref c) if c == "limits" || c == "nolimits" || c == "displaystyle" || c == "textstyle" => {
self.i += 1;
@@ -171,7 +178,7 @@ impl Lp {
}
self.depth -= 1;
// Big operators take the following term as their operand.
nary_operands(out)
nary_operands(out, self.depth)
}
/// A command argument: a `{group}` or one token.
@@ -224,10 +231,14 @@ impl Lp {
}
self.i += 1;
let mut level = 0i32;
// The previous token was a control word (`\backslash{}`: the empty braces end its name).
let mut after_word = false;
while let Some(t) = self.peek().cloned() {
self.i += 1;
let was_word = std::mem::replace(&mut after_word, matches!(&t, Tok::Cmd(c) if c.chars().all(|c| c.is_ascii_alphabetic())));
match t {
Tok::Close if level == 0 => break,
Tok::Open if was_word && self.peek() == Some(&Tok::Close) => self.i += 1,
Tok::Open => {
level += 1;
s.push('{');
@@ -369,9 +380,30 @@ impl Lp {
one(MNode::Run(MRun { text: t, nor: true, sty: Some(MSty::Italic), ..Default::default() }))
}
"operatorname" => {
let starred = self.peek() == Some(&Tok::Ch('*'));
if starred {
self.i += 1;
}
let t = self.raw_arg();
let name = vec![MNode::Run(MRun::plain(t))];
self.apply(name)
if starred && self.peek() == Some(&Tok::Sub) {
self.i += 1;
let lim = self.arg();
vec![MNode::Lim { upper: false, e: name, lim }]
} else {
self.apply(name)
}
}
"tag" => {
let starred = self.peek() == Some(&Tok::Ch('*'));
if starred {
self.i += 1;
}
let t = self.raw_arg();
let num_str = if starred { format!("#{t}") } else { format!("# ({t})") };
let mut r = MRun::new(num_str);
r.lit = false;
vec![MNode::Run(r)]
}
"mathrm" | "rm" => {
let a = self.arg();
@@ -413,13 +445,15 @@ impl Lp {
"underline" => one(MNode::Bar { top: false, e: self.arg() }),
"boxed" | "fbox" => one(MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: self.arg() }),
"phantom" => one(MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: false, e: self.arg() }),
"overbrace" | "underbrace" | "overparen" | "underparen" => {
"overbrace" | "underbrace" | "overparen" | "underparen" | "overbracket" | "underbracket" => {
let top = c.starts_with("over");
let chr = match c {
"overbrace" => '⏞',
"underbrace" => '⏟',
"overparen" => '⏜',
_ => '⏝',
"underparen" => '⏝',
"overbracket" => '⎴',
_ => '⎵',
};
let e = self.arg();
let g = MNode::GroupChr { chr, top, e };
@@ -515,6 +549,10 @@ impl Lp {
while let Some(t @ (Tok::Sup | Tok::Sub)) = self.peek().cloned() {
self.i += 1;
let s = self.arg();
if any_too_deep(&e) {
e.extend(s);
continue;
}
let base = if e.len() == 1 { e.pop() } else { Some(group(std::mem::take(&mut e))) };
e.push(script(base, t == Tok::Sup, s));
}
@@ -525,6 +563,22 @@ impl Lp {
}
fn environment(&mut self, env: &str) -> Arg {
let base = env.trim_end_matches('*');
let mut col_jc = Vec::new();
if base == "array" || base == "tabular" {
let _ = self.opt();
let spec = self.raw_arg();
for ch in spec.chars() {
match ch {
'l' | 'L' => col_jc.push(ColJc::Left),
'r' | 'R' => col_jc.push(ColJc::Right),
'c' | 'C' => col_jc.push(ColJc::Center),
_ => {}
}
}
} else if base == "aligned" {
let _ = self.opt();
}
// Rows of `&`-separated cells up to `\end{…}`.
let mut rows: Vec<Vec<Arg>> = vec![vec![]];
loop {
@@ -552,7 +606,6 @@ impl Lp {
if rows.len() > 1 && rows.last().is_some_and(|r| r.iter().all(|c| c.is_empty())) {
rows.pop();
}
let base = env.trim_end_matches('*');
let delims = match base {
"pmatrix" => Some((Some('('), Some(')'))),
"bmatrix" => Some((Some('['), Some(']'))),
@@ -570,8 +623,8 @@ impl Lp {
elems: vec![vec![inner]],
};
match base {
"matrix" | "pmatrix" | "bmatrix" | "Bmatrix" | "vmatrix" | "Vmatrix" | "smallmatrix" | "array" => {
let m = MNode::Matrix { rows, col_jc: Vec::new() };
"matrix" | "pmatrix" | "bmatrix" | "Bmatrix" | "vmatrix" | "Vmatrix" | "smallmatrix" | "array" | "tabular" => {
let m = MNode::Matrix { rows, col_jc };
match delims {
Some((b, e)) => vec![wrap(m, b, e)],
None => vec![m],
@@ -629,12 +682,14 @@ fn script(base: Option<MNode>, sup: bool, s: Arg) -> MNode {
}
/// Big operators without an operand take the atoms after them up to a relation or binary operator.
fn nary_operands(a: Arg) -> Arg {
/// `depth` is how deep the operators already sit; past [`WRAP_DEPTH`] they stay without operand
/// (`\sum\sum\sum…` must not nest once per operator).
fn nary_operands(a: Arg, depth: usize) -> Arg {
let mut out: Arg = Vec::with_capacity(a.len());
let mut it = a.into_iter().peekable();
while let Some(n) = it.next() {
match n {
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } if e.is_empty() => {
MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } if e.is_empty() && depth < WRAP_DEPTH => {
let mut body = Vec::new();
while let Some(next) = it.peek() {
let stop = match next {
@@ -650,8 +705,14 @@ fn nary_operands(a: Arg) -> Arg {
body.push(x);
}
}
let body = nary_operands(body);
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e: merge_runs(body) });
let body = merge_runs(nary_operands(body, depth + 1));
if any_too_deep(&body) {
// No room to wrap the operand: it follows the operator.
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e });
out.extend(body);
} else {
out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e: body });
}
}
other => out.push(other),
}
@@ -725,6 +786,21 @@ fn chars(text: &str, s: &mut String) {
}
}
/// Text inside `\text{…}`: only what LaTeX would read as markup is escaped.
fn text_chars(text: &str, s: &mut String) {
for c in text.chars() {
match c {
'{' | '}' | '%' | '$' | '#' | '&' | '_' | '~' => {
s.push('\\');
s.push(c);
}
// `{}` ends the command name without adding a space (spaces count in text).
'\\' => s.push_str("\\backslash{}"),
c => s.push(c),
}
}
}
fn delim_tex(c: Option<char>) -> String {
match c {
None => ".".into(),
@@ -736,6 +812,17 @@ fn delim_tex(c: Option<char>) -> String {
}
}
fn group_chr_tex(chr: char, top: bool) -> &'static str {
match chr {
'⏜' => "\\overparen",
'⏝' => "\\underparen",
'⎴' => "\\overbracket",
'⎵' => "\\underbracket",
_ if top => "\\overbrace",
_ => "\\underbrace",
}
}
fn node(n: &MNode, s: &mut String, depth: usize) {
match n {
MNode::Run(r) => {
@@ -764,7 +851,7 @@ fn node(n: &MNode, s: &mut String, depth: usize) {
};
s.push_str(open);
if r.nor {
s.push_str(&r.text);
text_chars(&r.text, s);
} else {
chars(&r.text, s);
}
@@ -839,12 +926,16 @@ fn node(n: &MNode, s: &mut String, depth: usize) {
s.push(' ');
braced(e, s, depth);
}
MNode::Delim { beg, end, elems, .. } => {
MNode::Delim { beg, end, sep, elems, .. } => {
s.push_str("\\left");
s.push_str(&delim_tex(*beg));
let mid = match sep {
Some(c) => format!("\\middle{}", delim_tex(Some(*c))),
None => "\\middle|".to_string(),
};
for (i, e) in elems.iter().enumerate() {
if i > 0 {
s.push_str("\\middle|");
s.push_str(&mid);
}
arg(e, s, depth);
}
@@ -863,11 +954,10 @@ fn node(n: &MNode, s: &mut String, depth: usize) {
s.push('_');
braced(lim, s, depth);
} else if let [MNode::GroupChr { chr, top, e: inner }] = e.as_slice() {
s.push_str(if *top { "\\overbrace" } else { "\\underbrace" });
s.push_str(group_chr_tex(*chr, *top));
braced(inner, s, depth);
s.push(if *upper { '^' } else { '_' });
braced(lim, s, depth);
let _ = chr;
} else {
s.push_str(if *upper { "\\overset" } else { "\\underset" });
braced(lim, s, depth);
@@ -903,8 +993,8 @@ fn node(n: &MNode, s: &mut String, depth: usize) {
braced(e, s, depth);
}
MNode::Boxed { e } => braced(e, s, depth),
MNode::GroupChr { top, e, .. } => {
s.push_str(if *top { "\\overbrace" } else { "\\underbrace" });
MNode::GroupChr { chr, top, e } => {
s.push_str(group_chr_tex(*chr, *top));
braced(e, s, depth);
}
MNode::EqArr { rows } => {
@@ -996,6 +1086,15 @@ mod tests {
}
}
#[test]
fn group_character_latex_roundtrip() {
for cmd in ["\\overparen{a+b}", "\\underparen{a+b}", "\\overbracket{x}", "\\underbracket{x}"] {
let n = parse_latex(cmd);
let tex = to_latex(&n);
assert_eq!(tex, cmd);
}
}
#[test]
fn hostile_latex_never_panics() {
for s in ["", "\\", "{", "}", "^", "_", "\\frac", "\\sqrt[", "\\left(", "\\right)", "\\begin{matrix", "&&\\\\", "{{{{", "\\end{x}", "a^^b__c"]
@@ -1011,4 +1110,46 @@ mod tests {
let deep = "\\hat".repeat(5000) + "x";
let _ = parse_latex(&deep);
}
#[test]
fn array_column_specs_and_aligned_optional_args() {
let n = parse_latex(r"\begin{array}{lcr} a & b & c \\ d & e & f \end{array}");
let MNode::Matrix { rows, col_jc } = &n[0] else { panic!("{n:#?}") };
assert_eq!(col_jc, &[ColJc::Left, ColJc::Center, ColJc::Right]);
assert_eq!(rows.len(), 2);
assert_eq!(crate::math::to_linear(&rows[0][0]), "a");
assert_eq!(crate::math::to_linear(&rows[0][1]), "b");
assert_eq!(crate::math::to_linear(&rows[0][2]), "c");
let n = parse_latex(r"\begin{aligned}[t] x &= 1 \\ y &= 2 \end{aligned}");
assert!(matches!(&n[0], MNode::EqArr { rows } if rows.len() == 2));
}
#[test]
fn operatorname_starred_and_tag() {
let n = parse_latex(r"\operatorname*{argmin}_{x} f(x)");
assert!(matches!(&n[0], MNode::Lim { upper: false, .. }));
let n = parse_latex(r"E=mc^2 \tag{1}");
let MNode::EqArr { rows } = &n[0] else { panic!("{n:#?}") };
assert!(crate::math::has_number_mark(&rows[0]));
let n = parse_latex(r"E=mc^2 \tag*{(A)}");
let MNode::EqArr { rows } = &n[0] else { panic!("{n:#?}") };
assert!(crate::math::has_number_mark(&rows[0]));
}
#[test]
fn delim_preserves_custom_separator() {
let delim = MNode::Delim {
beg: Some('{'),
end: Some('}'),
sep: Some('‖'),
grow: true,
shp_match: false,
elems: vec![vec![MNode::Run(MRun::new("x"))], vec![MNode::Run(MRun::new("y"))]],
};
let tex = to_latex(&[delim]);
assert!(tex.contains(r"\middle\|"), "Expected \\middle\\|, got: {tex}");
}
}
+53 -20
View File
@@ -9,7 +9,7 @@
//! an operator or space; a parenthesised operand loses its parentheses). `^`/`_` with nothing
//! before them are pre-scripts. A top-level `#` numbers the equation (`E=mc^2#(1)`).
use crate::math::{Arg, FracKind, MAX_DEPTH, MClass, MNode, MRun, ScriptKind, is_nary_char, math_class, merge_runs};
use crate::math::{Arg, FracKind, MAX_DEPTH, MClass, MNode, MRun, ScriptKind, any_too_deep, is_nary_char, math_class, merge_runs, too_deep};
/// Function names set upright and applied to the following operand.
pub const FUNCTION_NAMES: &[&str] = &[
@@ -135,6 +135,11 @@ impl P {
'/' | '⁄' | '∕' | '¦' => {
self.i += 1;
let start = operand_start(&out);
// A numerator that is already deep stays as it is: `/` is text then.
if out.get(start..).is_some_and(any_too_deep) {
out.push(text(c));
continue;
}
let num = strip_parens(out.drain(start..).collect());
let den = self.operand(stops);
let kind = match c {
@@ -156,20 +161,34 @@ impl P {
} else {
self.i += 1;
let s = self.script_operand(stops);
let base = out.pop();
out.push(attach_script(base, c == '^', s));
if out.last().is_some_and(too_deep) {
out.push(text(c));
out.extend(s);
} else {
let base = out.pop();
out.push(attach_script(base, c == '^', s));
}
}
}
'┬' | '┴' => {
self.i += 1;
let lim = self.script_operand(stops);
let e = out.pop().map(|n| vec![n]).unwrap_or_default();
out.push(MNode::Lim { upper: c == '┴', e: strip_parens(e), lim });
if out.last().is_some_and(too_deep) {
out.push(text(c));
out.extend(lim);
} else {
let e = out.pop().map(|n| vec![n]).unwrap_or_default();
out.push(MNode::Lim { upper: c == '┴', e: strip_parens(e), lim });
}
}
c if is_combining(c) => {
self.i += 1;
let e = out.pop().map(|n| strip_parens(vec![n])).unwrap_or_default();
out.push(MNode::Acc { chr: c, e });
if out.last().is_some_and(too_deep) {
out.push(text(c));
} else {
let e = out.pop().map(|n| strip_parens(vec![n])).unwrap_or_default();
out.push(MNode::Acc { chr: c, e });
}
}
'▒' | '\u{2061}' => self.i += 1,
' ' => {
@@ -537,31 +556,39 @@ impl P {
}
out.pop()
};
if !matches!(c, '^' | '_') && !(matches!(c, '┬' | '┴') && self.no_limits == 0) && !is_combining(c) {
break;
}
// The base is already deep: the operator is text (see `too_deep`).
let deep = if whole { any_too_deep(out) } else { out.last().is_some_and(too_deep) };
self.i += 1;
if deep {
let s = if matches!(c, '^' | '_' | '┬' | '┴') { self.script_operand(stops) } else { Vec::new() };
out.push(text(c));
out.extend(s);
continue;
}
match c {
'^' | '_' => {
self.i += 1;
let s = self.script_operand(stops);
let b = base(out);
out.push(attach_script(b, c == '^', s));
}
'┬' | '┴' if self.no_limits == 0 => {
self.i += 1;
'┬' | '┴' => {
let lim = self.script_operand(stops);
let b = base(out);
out.push(MNode::Lim { upper: c == '┴', e: b.map(|n| vec![n]).unwrap_or_default(), lim });
}
c if is_combining(c) => {
self.i += 1;
_ => {
let b = base(out);
out.push(MNode::Acc { chr: c, e: b.map(|n| strip_parens(vec![n])).unwrap_or_default() });
}
_ => break,
}
}
}
/// A script or limit: a parenthesised group, a structure, a number, a signed operand or one
/// character.
/// A script or limit: a parenthesised group, a structure, a number, a signed operand, a run of
/// letters (and digits) or one other character.
fn script_operand(&mut self, stops: &[char]) -> Arg {
if self.tired() {
return self.rest();
@@ -585,7 +612,7 @@ impl P {
Some(c) if is_prefix(c) || closer(c).is_some() => self.atom(stops),
Some(c) if c.is_ascii_digit() => {
let start = self.i;
while self.peek().is_some_and(|k| k.is_ascii_digit() || k == '.') {
while self.peek().is_some_and(|k| k.is_alphanumeric() || k == '.') {
self.i += 1;
}
let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
@@ -597,10 +624,16 @@ impl P {
v.extend(self.script_operand(stops));
v
}
Some(c) if c.is_ascii_alphabetic() => {
// A function name as a script (`e^sin x` is rare): one letter.
self.i += 1;
vec![text(c)]
Some(c) if c.is_alphabetic() => {
// Word (#1609): the whole run of letters and digits up to a space or operator, so
// `V_dd` is V with a subscript `dd`, exactly like `V_(dd)`.
let start = self.i;
while self.peek().is_some_and(|k| k.is_alphanumeric()) {
self.i += 1;
}
let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
// A known function name stays upright, as Word builds it.
vec![MNode::Run(if FUNCTION_NAMES.contains(&t.as_str()) { MRun::plain(t) } else { MRun::new(t) })]
}
Some(c) if !stops.contains(&c) && c != ' ' && !is_closer(c) => {
self.i += 1;
+1
View File
@@ -85,6 +85,7 @@ pub const SYMBOLS: &[(&str, char)] = &[
("vee", '∨'),
("wedge", '∧'),
("setminus", '∖'),
("backslash", '\\'),
("wr", '≀'),
("diamond", '⋄'),
("dagger", '†'),
+71 -13
View File
@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
use crate::props::{Align, CharProps};
use crate::section::NumFormat;
use crate::{DocError, free_id};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -75,6 +76,17 @@ pub struct AbstractNum {
pub id: u32,
pub name: Option<String>,
pub levels: Vec<Level>,
/// Word's list identity (`w:nsid`, 8 hex digits), which it uses to match lists across
/// documents (paste, compare); kept from the file.
#[serde(skip_serializing_if = "Option::is_none")]
pub nsid: Option<String>,
/// The list template the definition came from (`w:tmpl`); kept from the file.
#[serde(skip_serializing_if = "Option::is_none")]
pub tmpl: Option<String>,
/// `w:multiLevelType` from the file (`singleLevel`, `multilevel`, `hybridMultilevel`);
/// `None`: derived from the levels.
#[serde(skip_serializing_if = "Option::is_none")]
pub multi_level_type: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
@@ -144,6 +156,11 @@ impl ListKind {
}
}
/// The bullet a level of a plain bulleted list shows: • ○ ▪ repeating (`level` is zero-based).
pub fn default_bullet(level: usize) -> char {
['•', '○', '▪'][level % 3]
}
fn bullet_level(i: usize, c: char) -> Level {
Level {
format: NumFormat::Bullet,
@@ -176,8 +193,8 @@ pub fn levels_for(kind: ListKind) -> Vec<Level> {
let lvl = |format: NumFormat, text: String| Level { format, text, indent: ind, hanging: 18.0, ..Level::default() };
let n = i + 1;
match kind {
ListKind::Bullet => bullet_level(i, ['•', '○', '▪'][i % 3]),
ListKind::BulletChar(c) => bullet_level(i, if i == 0 { c } else { ['•', '○', '▪'][i % 3] }),
ListKind::Bullet => bullet_level(i, default_bullet(i)),
ListKind::BulletChar(c) => bullet_level(i, if i == 0 { c } else { default_bullet(i) }),
ListKind::Numbered => lvl(cyc(NumFormat::Decimal, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n}.")),
ListKind::NumberedParen => lvl(cyc(NumFormat::Decimal, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n})")),
ListKind::UpperLetter => lvl(cyc(NumFormat::UpperLetter, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n}.")),
@@ -219,20 +236,40 @@ impl Numbering {
}
self.abstract_of(num)?.levels.get(level as usize)
}
/// Create a new list of `kind`; returns the `num` id.
/// Create a new list of `kind`; returns the `num` id. List ids run out only past `u32::MAX`
/// lists, more than memory holds; then nothing is added and the id is 0, "no list".
pub fn add_list(&mut self, kind: ListKind) -> u32 {
let aid = self.abstracts.iter().map(|a| a.id + 1).max().unwrap_or(0);
self.abstracts.push(AbstractNum { id: aid, name: None, levels: levels_for(kind) });
let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
self.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
nid
self.try_add_list(kind).unwrap_or(0)
}
/// A new `num` that restarts numbering of an existing list.
/// [`Numbering::add_list`], or an error, adding nothing, when list ids are all taken.
pub fn try_add_list(&mut self, kind: ListKind) -> crate::Result<u32> {
self.try_add_levels(None, levels_for(kind))
}
/// A new list definition (`name`, `levels`) and a `num` for it; returns the `num` id. Both ids
/// are [`free_id`]s, so the largest ids a file can hold don't make them collide with its lists;
/// an error, adding nothing, when they are all taken.
pub fn try_add_levels(&mut self, name: Option<String>, levels: Vec<Level>) -> crate::Result<u32> {
let aid = free_id(self.abstracts.iter().map(|a| a.id).max(), self.abstracts.iter().map(|a| a.id), 0);
let (Some(aid), Some(nid)) = (aid, self.free_num_id()) else { return Err(DocError::Invalid("no free list id".into())) };
self.abstracts.push(AbstractNum { id: aid, name, levels, ..Default::default() });
self.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
Ok(nid)
}
/// A `num` id no list has (`num` 0 means "no list"), `None` when they are all taken.
fn free_num_id(&self) -> Option<u32> {
free_id(self.nums.iter().map(|n| n.id).max(), self.nums.iter().map(|n| n.id), 1)
}
/// A new `num` that restarts numbering of an existing list (`None`: no such list, or list ids
/// are all taken).
pub fn restart(&mut self, num: u32) -> Option<u32> {
let a = self.num(num)?.abstract_id;
let nid = self.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
let starts = self.abstracts.iter().find(|x| x.id == a).map(|x| x.levels.iter().enumerate().map(|(i, l)| (i as u8, l.start)).collect());
self.nums.push(Num { id: nid, abstract_id: a, start_overrides: starts.unwrap_or_default(), ..Default::default() });
let source = self.num(num)?;
let a = source.abstract_id;
let level_overrides = source.level_overrides.clone();
let nid = self.free_num_id()?;
// Every level starts again at its own effective start, so a level this list defines itself keeps its start.
let levels = self.abstracts.iter().find(|x| x.id == a).map_or(0, |x| x.levels.len());
let start_overrides = (0..levels).filter_map(|i| self.level(num, i as u8).map(|l| (i as u8, l.start))).collect();
self.nums.push(Num { id: nid, abstract_id: a, start_overrides, level_overrides });
Some(nid)
}
/// Find an existing list whose first level matches `kind`, so consecutive bullet clicks reuse it.
@@ -256,6 +293,12 @@ pub struct Counters {
}
impl Counters {
/// The current value of each level of `num`'s list (0 for a level not counted yet).
pub fn values(&self, numbering: &Numbering, num: u32) -> Option<[u32; 9]> {
let abs = numbering.abstract_of(num)?;
Some(self.state.get(&abs.id).copied().unwrap_or([0; 9]))
}
/// Advance the counter for `num`/`level` and return the label (`"1."`, `"a)"`, `"•"`).
pub fn next_label(&mut self, numbering: &Numbering, num: u32, level: u8) -> Option<(String, Level)> {
let n = numbering.num(num)?;
@@ -432,4 +475,19 @@ mod tests {
assert_eq!(c.next_label(&n, b, 0).unwrap().0, "•");
assert_eq!(n.find_kind(ListKind::Bullet), Some(b));
}
#[test]
fn restart_keeps_the_list_s_own_level_definitions() {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
let own = Level { format: NumFormat::UpperRoman, text: "Part %1".into(), indent: 90.0, hanging: 40.0, start: 5, ..Level::default() };
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.level_overrides.push((0, own.clone()));
}
let r = n.restart(id).unwrap();
assert_eq!(n.num(r).unwrap().level_overrides, vec![(0, own.clone())]);
assert_eq!(n.level(r, 0), Some(&own));
let mut c = Counters::default();
assert_eq!(c.next_label(&n, r, 0).unwrap().0, "Part V");
}
}
+143 -26
View File
@@ -216,6 +216,10 @@ pub struct Float {
/// laid out, as Word for Windows does (Word for Mac draws hidden VML shapes).
#[serde(skip_serializing_if = "is_false")]
pub hidden: bool,
/// Anchored in a table cell but placed on the page, not in the cell (`wp:anchor/@layoutInCell`
/// off, any VML shape in a header or footer): a picture watermark in a header that starts with a table.
#[serde(skip_serializing_if = "is_false")]
pub outside_cell: bool,
}
/// Longest alternative text kept (characters): hostile files and commands are capped.
@@ -288,7 +292,11 @@ pub enum ShapeKind {
Rectangle,
RoundedRectangle,
Ellipse,
/// Flowchart OR preset: an ellipse with horizontal and vertical spokes.
FlowchartOr,
Triangle,
/// A right triangle (DrawingML's `rtTriangle`): the right angle at the bottom left.
RightTriangle,
Diamond,
Line,
Arrow,
@@ -312,6 +320,60 @@ pub enum NoteKind {
Endnote,
}
/// Where a positional tab ([`InlineObject::PositionalTab`]) lands on its line, and how the text
/// after it lines up there (ECMA-376 `ST_PTabAlignment`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum PTabAlign {
#[default]
Left,
Center,
Right,
}
impl PTabAlign {
pub const ALL: [PTabAlign; 3] = [PTabAlign::Left, PTabAlign::Center, PTabAlign::Right];
/// The OOXML name (`left`, `center`, `right`).
pub fn ooxml(self) -> &'static str {
match self {
PTabAlign::Left => "left",
PTabAlign::Center => "center",
PTabAlign::Right => "right",
}
}
pub fn from_ooxml(v: &str) -> Option<PTabAlign> {
PTabAlign::ALL.into_iter().find(|a| a.ooxml() == v)
}
}
/// What a positional tab's place is measured against (ECMA-376 `ST_PTabRelativeTo`): the text
/// margins, or the paragraph's indents.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum PTabBase {
#[default]
Margin,
Indent,
}
impl PTabBase {
pub const ALL: [PTabBase; 2] = [PTabBase::Margin, PTabBase::Indent];
/// The OOXML name (`margin`, `indent`).
pub fn ooxml(self) -> &'static str {
match self {
PTabBase::Margin => "margin",
PTabBase::Indent => "indent",
}
}
pub fn from_ooxml(v: &str) -> Option<PTabBase> {
PTabBase::ALL.into_iter().find(|a| a.ooxml() == v)
}
}
/// How ruby (phonetic guide) text lines up over its base text (ECMA-376 `ST_RubyAlign`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -380,7 +442,7 @@ pub enum InlineObject {
crop: [f32; 4],
/// The OLE object (embedded or linked file) this picture shows, as read from a file:
/// saving writes the object back, not only its picture. See [`crate::graphic::Embedded`].
#[serde(skip)]
#[serde(default, skip_serializing_if = "Option::is_none")]
ole: Option<std::sync::Arc<crate::graphic::Embedded>>,
},
/// A chart or SmartArt diagram (see [`crate::graphic`]).
@@ -444,11 +506,20 @@ pub enum InlineObject {
result: String,
#[serde(default)]
locked: bool,
/// `w:dirty` (ECMA-376 §17.16.5.35): the cached result is stale — another program that
/// couldn't compute it set the flag so Word refreshes the field on open. Kept through
/// saves; cleared when WordCraft computes a result, like Word's F9.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
dirty: bool,
/// Shows its code (`{ PAGE }`) instead of its result, the other way round from the
/// document-wide View › Field Codes setting (`fields.toggleCode`, Shift+F9). A view
/// toggle: never written to files.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
code: bool,
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`): the
/// source's `w:ffData`, raw OOXML kept for round trip. Empty for other fields.
#[serde(default, skip_serializing_if = "String::is_empty")]
form: String,
},
/// The start of a field whose result is ordinary content — formatted, possibly spanning
/// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Imported TOCs and citation
@@ -458,6 +529,9 @@ pub enum InlineObject {
instr: String,
#[serde(default)]
locked: bool,
/// `w:dirty` on the begin marker, as [`InlineObject::Field::dirty`].
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
dirty: bool,
},
/// The end of the innermost open [`InlineObject::FieldStart`].
FieldEnd,
@@ -533,6 +607,17 @@ pub enum InlineObject {
#[serde(default, skip_serializing_if = "CharProps::is_empty")]
props: Box<CharProps>,
},
/// A positional tab (Word's alignment tab, `w:ptab`): it goes to the left edge, centre or
/// right edge of the margins or of the indents, whatever the paragraph's tab stops, and the
/// text after it lines up there. Text already past that place sends it to the next line.
PositionalTab {
#[serde(default)]
align: PTabAlign,
#[serde(default)]
relative_to: PTabBase,
#[serde(default)]
leader: crate::props::TabLeader,
},
/// Something we don't model, kept for round-trip (raw XML of the source format).
Opaque {
format: String,
@@ -662,6 +747,14 @@ impl InlineObject {
})
.collect()
}
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
/// the `w:ffData` it keeps; `None` for anything else.
pub fn legacy_form(&self) -> Option<crate::legacy_form::LegacyForm> {
match self {
InlineObject::Field { form, .. } if !form.is_empty() => crate::legacy_form::LegacyForm::parse(form),
_ => None,
}
}
/// Zero-width markers don't take part in layout.
pub fn is_marker(&self) -> bool {
matches!(
@@ -740,6 +833,7 @@ impl InlineObject {
InlineObject::Field { result, .. } => result,
InlineObject::Equation { linear, .. } => linear,
InlineObject::Ruby { base, .. } => base,
InlineObject::PositionalTab { .. } => "\t",
InlineObject::Opaque { text, .. } => text,
_ => "",
}
@@ -854,7 +948,7 @@ impl Paragraph {
k += 1;
continue;
}
let font = runs.peek().filter(|(r, _)| r.contains(&i)).and_then(|(_, p)| p.font.as_deref());
let font = runs.peek().filter(|(r, _)| r.contains(&i)).and_then(|(_, p)| p.shown_font());
out.push(font.and_then(|f| crate::symbol_fonts::symbol_char(f, c)).unwrap_or(c));
}
out
@@ -1029,32 +1123,32 @@ impl Paragraph {
/// Merge adjacent equal runs and drop empty ones.
pub fn normalize(&mut self) {
// Runs must end on character boundaries and cover the text exactly (never expected, but
// files lie): clamp each end to the text, push it past a split character, pad the tail.
let n = self.text.len();
let mut end = 0usize;
let mut out: Vec<Run> = Vec::with_capacity(self.runs.len());
for r in self.runs.drain(..) {
if r.len == 0 {
let mut e = end.saturating_add(r.len).min(n);
while !self.text.is_char_boundary(e) {
e += 1;
}
if e == end {
continue;
}
match out.last_mut() {
Some(l) if l.props == r.props => l.len += r.len,
_ => out.push(r),
Some(l) if l.props == r.props => l.len += e - end,
_ => out.push(Run { len: e - end, props: r.props }),
}
end = e;
}
if end < n {
match out.last_mut() {
Some(l) => l.len += n - end,
None => out.push(Run { len: n, props: self.mark.clone() }),
}
}
self.runs = out;
// Repair a run list that doesn't cover the text exactly (never expected, but files lie).
let total: usize = self.runs.iter().map(|r| r.len).sum();
if total != self.text.len() {
if total < self.text.len() {
let p = self.runs.last().map(|r| r.props.clone()).unwrap_or_else(|| self.mark.clone());
self.runs.push(Run { len: self.text.len() - total, props: p });
} else {
let mut keep = self.text.len();
for r in &mut self.runs {
r.len = r.len.min(keep);
keep -= r.len;
}
self.runs.retain(|r| r.len > 0);
}
}
}
/// Delete `a..b` (bytes). Objects inside are removed.
@@ -1306,7 +1400,7 @@ impl Paragraph {
let mut a = 0;
let mut prev_end = false;
for (i, c) in t.char_indices() {
if i >= off {
if i > off {
break;
}
if prev_end && c != ' ' {
@@ -1319,7 +1413,9 @@ impl Paragraph {
}
}
let mut b = t.len();
let mut ended = false;
// A caret on the space right after a sentence's end belongs to that sentence; a terminator
// directly followed by text ("Hello.World") doesn't end the sentence the caret is in.
let mut ended = t.get(..off).unwrap_or("").ends_with(['.', '!', '?']) && t.get(off..).unwrap_or("").starts_with(' ');
for (i, c) in t.get(off..).unwrap_or("").char_indices() {
if ended && c != ' ' {
b = off + i;
@@ -1337,6 +1433,19 @@ impl Paragraph {
mod tests {
use super::*;
#[test]
fn sentence_at_sentence_start() {
let p = Paragraph::with_text("Hello. World", CharProps::default());
assert_eq!(p.sentence_at(0), (0, 7));
assert_eq!(p.sentence_at(6), (0, 7));
assert_eq!(p.sentence_at(7), (7, 12));
assert_eq!(p.sentence_at(8), (7, 12));
assert_eq!(p.sentence_at(99), (7, 12));
let p = Paragraph::with_text("Hello.World", CharProps::default());
assert_eq!(p.sentence_at(6), (6, 11));
assert_eq!(p.sentence_at(8), (6, 11));
}
fn bold() -> CharProps {
CharProps { bold: Some(true), ..Default::default() }
}
@@ -1345,8 +1454,12 @@ mod tests {
fn text_without_takes_ranges_in_any_order() {
// Multi-byte chars at range edges, an object, and ranges unsorted, overlapping, nested and empty.
let mut p = Paragraph::with_text("añb€c", CharProps::default());
p.insert_object(3, InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, code: false }, &CharProps::default())
.unwrap();
p.insert_object(
3,
InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps::default(),
)
.unwrap();
let text = p.text.clone();
let naive = |dropped: &[std::ops::Range<usize>]| {
let mut out = String::new();
@@ -1415,8 +1528,12 @@ mod tests {
#[test]
fn objects_track_text() {
let mut p = Paragraph::with_text("ab", CharProps::default());
p.insert_object(1, InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, code: false }, &CharProps::default())
.unwrap();
p.insert_object(
1,
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps::default(),
)
.unwrap();
p.insert_object(0, InlineObject::BookmarkStart { name: "x".into() }, &CharProps::default()).unwrap();
assert_eq!(p.objects.len(), 2);
assert!(matches!(p.object_at(0), Some(InlineObject::BookmarkStart { .. })));
+233 -7
View File
@@ -155,6 +155,46 @@ impl Underline {
}
}
/// The OpenType ligatures a run asks for ([MS-DOCX] `w14:ligatures`): standard (`liga`),
/// contextual (`clig`), historical (`hlig`) and discretional (`dlig`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Ligatures {
pub standard: bool,
pub contextual: bool,
pub historical: bool,
pub discretional: bool,
}
impl Ligatures {
/// Every combination of the four flags.
pub fn all() -> impl Iterator<Item = Ligatures> {
(0u8..16).map(|b| Ligatures { standard: b & 1 != 0, contextual: b & 2 != 0, historical: b & 4 != 0, discretional: b & 8 != 0 })
}
/// The `w14:ligatures/@w14:val` name: `none`, `all`, or the set flags joined in camelCase
/// (`standardContextual`).
pub fn ooxml(self) -> String {
let on: Vec<&str> =
[(self.standard, "standard"), (self.contextual, "contextual"), (self.historical, "historical"), (self.discretional, "discretional")]
.into_iter()
.filter_map(|(on, n)| on.then_some(n))
.collect();
match on.len() {
0 => "none".into(),
4 => "all".into(),
_ => on
.iter()
.enumerate()
.flat_map(|(i, n)| n.chars().enumerate().map(move |(j, c)| if i > 0 && j == 0 { c.to_ascii_uppercase() } else { c }))
.collect(),
}
}
/// `None` for a name [MS-DOCX] doesn't define.
pub fn from_ooxml(s: &str) -> Option<Ligatures> {
Ligatures::all().find(|l| l.ooxml() == s)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum VertAlign {
@@ -286,6 +326,11 @@ pub struct CharProps {
pub style: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub font: Option<String>,
/// The symbol's own font (OOXML `w:sym/@w:font`, ECMA-376 §17.3.3.30), set on a symbol
/// character from a `w:sym` or Insert › Symbol: it is shown in this font whatever `font` is,
/// so changing the text's font keeps the symbol. `None` = the run's font.
#[serde(skip_serializing_if = "Option::is_none")]
pub sym_font: Option<String>,
/// Points.
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<f32>,
@@ -331,6 +376,10 @@ pub struct CharProps {
/// Kerning for fonts at or above this size (points); 0 = off.
#[serde(skip_serializing_if = "Option::is_none")]
pub kern: Option<f32>,
/// OpenType ligatures (`w14:ligatures`). `None` leaves WordCraft's default: standard and
/// contextual ligatures.
#[serde(skip_serializing_if = "Option::is_none")]
pub ligatures: Option<Ligatures>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outline: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -372,6 +421,10 @@ pub struct CharProps {
/// BCP 47 language of complex-script text (`w:lang/@w:bidi`, e.g. `fa-IR`).
#[serde(skip_serializing_if = "Option::is_none")]
pub lang_bidi: Option<String>,
/// East Asian typography: two lines in one and horizontal in vertical (`w:eastAsianLayout`).
/// Kept for round trip; layout doesn't apply it yet.
#[serde(skip_serializing_if = "Option::is_none")]
pub east_asian_layout: Option<EastAsianLayout>,
/// Hyperlink target: a URL, or `#bookmark` for an internal link.
#[serde(skip_serializing_if = "Option::is_none")]
pub link: Option<String>,
@@ -386,6 +439,60 @@ pub struct CharProps {
pub fmt_change: Option<Box<PropChange<CharProps>>>,
}
/// East Asian typography settings of a run (ISO/IEC 29500-1 §17.3.2.10, `w:eastAsianLayout`):
/// *two lines in one* sets the run's text on two sub-lines inside one line (warichu), *horizontal
/// in vertical* turns it 90° to read across a vertical line (tate-chu-yoko). Each attribute is
/// `None` when the file doesn't say.
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct EastAsianLayout {
/// East Asian typography run id (`w:id`).
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<i32>,
/// Two lines in one (`w:combine`).
#[serde(skip_serializing_if = "Option::is_none")]
pub combine: Option<bool>,
/// Brackets around two lines in one (`w:combineBrackets`).
#[serde(skip_serializing_if = "Option::is_none")]
pub combine_brackets: Option<CombineBrackets>,
/// Horizontal in vertical (`w:vert`).
#[serde(skip_serializing_if = "Option::is_none")]
pub vert: Option<bool>,
/// Compress horizontal-in-vertical text to the line height (`w:vertCompress`).
#[serde(skip_serializing_if = "Option::is_none")]
pub vert_compress: Option<bool>,
}
/// Brackets around two lines in one (`ST_CombineBrackets`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CombineBrackets {
None,
Round,
Square,
Angle,
Curly,
}
impl CombineBrackets {
pub const ALL: [CombineBrackets; 5] =
[CombineBrackets::None, CombineBrackets::Round, CombineBrackets::Square, CombineBrackets::Angle, CombineBrackets::Curly];
/// OOXML `w:combineBrackets` value.
pub fn ooxml(self) -> &'static str {
match self {
CombineBrackets::None => "none",
CombineBrackets::Round => "round",
CombineBrackets::Square => "square",
CombineBrackets::Angle => "angle",
CombineBrackets::Curly => "curly",
}
}
/// The value for `s`, `None` for one the schema doesn't have.
pub fn from_ooxml(s: &str) -> Option<CombineBrackets> {
CombineBrackets::ALL.into_iter().find(|b| b.ooxml() == s)
}
}
/// A tracked formatting change (ECMA-376 §17.13.5: `w:rPrChange`, `w:pPrChange`,
/// `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:sectPrChange`): the properties as they
/// were before the change, and the revision (author, date) that made it.
@@ -440,9 +547,9 @@ impl CharProps {
/// The fields of `self` that differ from `base` (equal ones unset): what `self` adds when
/// overlaid on `base`.
pub fn minus(mut self, base: &CharProps) -> CharProps {
drop_same_fields!(self, base; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, link, ins, del);
cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del, ligatures);
self
}
@@ -451,9 +558,9 @@ impl CharProps {
/// Plain and complex-script properties inherit independently. Commands that intend to
/// format both scripts must set both values explicitly.
pub fn overlay(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, link, ins, del, fmt_change);
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del, fmt_change, ligatures);
if let Some(p) = &patch.text_effects {
self.text_effects.get_or_insert_with(Default::default).overlay(p);
}
@@ -465,10 +572,29 @@ impl CharProps {
pub fn is_empty(&self) -> bool {
*self == CharProps::default()
}
/// The font a character of this run is shown in: the symbol's own font when it has one
/// ([`Self::sym_font`]), else the run's font.
pub fn shown_font(&self) -> Option<&str> {
self.sym_font.as_deref().filter(|f| !f.trim().is_empty()).or(self.font.as_deref())
}
/// The same formatting for text typed or painted next to a symbol: without the symbol's own
/// font.
pub fn without_symbol(mut self) -> CharProps {
self.sym_font = None;
self
}
/// Clear direct formatting but keep what isn't "formatting" (links, revisions, character style
/// is cleared too, as Word's Clear Formatting does).
pub fn cleared(&self) -> CharProps {
CharProps { link: self.link.clone(), ins: self.ins, del: self.del, fmt_change: self.fmt_change.clone(), ..Default::default() }
CharProps {
link: self.link.clone(),
ins: self.ins,
del: self.del,
fmt_change: self.fmt_change.clone(),
// A symbol stays the symbol it is.
sym_font: self.sym_font.clone(),
..Default::default()
}
}
/// Just the formatting: without the link, revision marks and tracked formatting change.
pub fn formatting(&self) -> CharProps {
@@ -554,6 +680,10 @@ impl TabLeader {
}
}
/// Two tab positions closer than this (points) are the same stop: `para.tabs` replaces at this
/// tolerance, and the Tabs dialog and the ruler merge and detect no-ops with it.
pub const TAB_POS_EPS: f32 = 0.5;
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct TabStop {
/// Points from the paragraph's left indent origin (the text column's left edge).
@@ -789,6 +919,16 @@ pub struct ParaProps {
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap: Option<u8>,
/// Drop cap "In margin" (`w:dropCap="margin"`): the letter hangs in the left margin and the
/// text keeps its indent. Unset = dropped into the text.
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap_margin: Option<bool>,
/// The drop cap's distance from the text, in points (`w:hSpace`). Unset = 0.
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap_distance: Option<f32>,
/// The drop cap letter's font family (the cap run's `w:rFonts`). Unset = the text's font.
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap_font: Option<String>,
/// A text frame (`w:framePr` other than a drop cap): the paragraph is placed apart from the
/// text, which may wrap around it — a footer's page number in a frame, for instance.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -829,7 +969,7 @@ impl ParaProps {
pub fn overlay(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars, space_before, space_after, space_before_auto, space_after_auto, line_spacing,
contextual_spacing, mirror_indents, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders,
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, frame, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn,
suppress_hyphens, suppress_line_numbers, bidi, drop_cap, drop_cap_margin, drop_cap_distance, drop_cap_font, frame, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn,
snap_to_grid);
}
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
@@ -1074,11 +1214,31 @@ pub struct RowProps {
pub header: bool,
/// Don't let the row break across pages.
pub cant_split: bool,
/// Table properties this row overrides (`w:tblPrEx`, ECMA-376 §17.4.61): width, alignment,
/// indent, borders, shading, layout and cell margins. `Some` when the file has the element.
#[serde(skip_serializing_if = "Option::is_none")]
pub exceptions: Option<Box<TableProps>>,
/// Grid columns skipped before the row's first cell (`w:gridBefore`), and the preferred
/// width of that gap in points (`w:wBefore`).
#[serde(skip_serializing_if = "is_zero")]
pub grid_before: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub w_before: Option<f32>,
/// Grid columns left after the row's last cell (`w:gridAfter`), and their width in points
/// (`w:wAfter`).
#[serde(skip_serializing_if = "is_zero")]
pub grid_after: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub w_after: Option<f32>,
/// Tracked formatting change (`w:trPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<RowProps>>>,
}
fn is_zero(v: &u32) -> bool {
*v == 0
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum VMerge {
@@ -1103,11 +1263,20 @@ pub struct CellProps {
pub vmerge: VMerge,
pub shading: Option<Rgb>,
pub borders: Option<Borders>,
pub valign: VAlign,
/// Vertical alignment (`w:vAlign`); `None` follows the table style, else top. An explicit
/// `Top` beats the style.
#[serde(skip_serializing_if = "Option::is_none")]
pub valign: Option<VAlign>,
/// Cell margins override (top, left, bottom, right), points.
pub margins: Option<[f32; 4]>,
/// Per-side overrides when `margins` is absent; omitted sides inherit table defaults.
#[serde(skip_serializing_if = "Option::is_none")]
pub partial_margins: Option<[Option<f32>; 4]>,
/// Which way the cell's text runs.
pub text_direction: TextDirection,
/// The file states the horizontal direction (`lrTb`) outright; saving writes it back.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub text_direction_set: bool,
pub no_wrap: bool,
/// The cell's last paragraph takes no room when it is empty (`w:hideMark`): a row of such
/// empty cells shrinks to its borders.
@@ -1118,6 +1287,17 @@ pub struct CellProps {
pub fmt_change: Option<Box<PropChange<CellProps>>>,
}
impl CellProps {
/// Resolve top, left, bottom and right margins against the current table defaults.
/// A complete override takes precedence over per-side overrides.
pub fn resolved_margins(&self, defaults: [f32; 4]) -> [f32; 4] {
if let Some(margins) = self.margins {
return margins;
}
self.partial_margins.map_or(defaults, |sides| std::array::from_fn(|i| sides[i].unwrap_or(defaults[i])))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1166,6 +1346,37 @@ mod tests {
}
}
/// The 16 `w14:ligatures` values of [MS-DOCX], each to its flags and back.
#[test]
fn ligature_names() {
let l = |standard, contextual, historical, discretional| Ligatures { standard, contextual, historical, discretional };
for (name, flags) in [
("none", l(false, false, false, false)),
("standard", l(true, false, false, false)),
("contextual", l(false, true, false, false)),
("historical", l(false, false, true, false)),
("discretional", l(false, false, false, true)),
("standardContextual", l(true, true, false, false)),
("standardHistorical", l(true, false, true, false)),
("contextualHistorical", l(false, true, true, false)),
("standardDiscretional", l(true, false, false, true)),
("contextualDiscretional", l(false, true, false, true)),
("historicalDiscretional", l(false, false, true, true)),
("standardContextualHistorical", l(true, true, true, false)),
("standardContextualDiscretional", l(true, true, false, true)),
("standardHistoricalDiscretional", l(true, false, true, true)),
("contextualHistoricalDiscretional", l(false, true, true, true)),
("all", l(true, true, true, true)),
] {
assert_eq!(Ligatures::from_ooxml(name), Some(flags), "{name}");
assert_eq!(flags.ooxml(), name);
}
assert_eq!(Ligatures::all().count(), 16);
for bad in ["", "Standard", "contextualStandard", "standardstandard", "allx"] {
assert_eq!(Ligatures::from_ooxml(bad), None, "{bad:?}");
}
}
#[test]
fn text_direction_names() {
let mut d = TextDirection::Horizontal;
@@ -1227,4 +1438,19 @@ mod tests {
let back: CharProps = serde_json::from_str(&s).unwrap();
assert_eq!(back, p);
}
#[test]
fn symbol_font_is_optional_in_json_and_survives_font_changes() {
// Older documents (no `symFont`) still load.
let old: CharProps = serde_json::from_str(r#"{"font":"Arial"}"#).unwrap();
assert_eq!(old.sym_font, None);
let p = CharProps { font: Some("Arial".into()), sym_font: Some("Wingdings".into()), ..Default::default() };
let s = serde_json::to_string(&p).unwrap();
assert!(s.contains(r#""symFont":"Wingdings""#), "{s}");
assert_eq!(serde_json::from_str::<CharProps>(&s).unwrap(), p);
// A font change keeps the symbol's own font; Clear Formatting too.
let changed = p.clone().overlaid(&CharProps { font: Some("Times New Roman".into()), ..Default::default() });
assert_eq!(changed.sym_font.as_deref(), Some("Wingdings"));
assert_eq!(p.cleared().sym_font.as_deref(), Some("Wingdings"));
}
}
+36 -1
View File
@@ -11,6 +11,8 @@ use crate::styles::{StyleKind, StyleSheet};
/// Automatic (HTML-style) paragraph spacing, points: what Word for Windows gives `w:beforeAutospacing`
/// and `w:afterAutospacing` at any font size (measured).
pub const AUTO_SPACING: f32 = 14.0;
/// The largest drop cap distance from the text, in points (22", as Word's dialog allows).
pub const MAX_DROP_CAP_DISTANCE: f32 = 1584.0;
/// Concrete character formatting.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -39,6 +41,10 @@ pub struct ResolvedChar {
/// Kerning: the smallest font size, in points, that is kerned (`w:kern`); 0 = never kerned.
#[serde(default)]
pub kern: f32,
/// OpenType ligatures (`w14:ligatures`); `None` = the shaper's default (standard and
/// contextual).
#[serde(default)]
pub ligatures: Option<crate::props::Ligatures>,
pub outline: bool,
pub shadow: bool,
pub emboss: bool,
@@ -70,7 +76,8 @@ pub struct ResolvedChar {
impl ResolvedChar {
fn from(c: &CharProps) -> ResolvedChar {
ResolvedChar {
font: c.font.clone().unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
// A symbol character is shown in its own font (`w:sym`), whatever the run's font.
font: c.shown_font().map(str::to_string).unwrap_or_else(|| crate::styles::BODY_FONT.to_string()),
size: c.size.unwrap_or(11.0).clamp(1.0, 1638.0),
bold: c.bold.unwrap_or(false),
italic: c.italic.unwrap_or(false),
@@ -90,6 +97,7 @@ impl ResolvedChar {
scale: c.scale.unwrap_or(100.0).clamp(1.0, 600.0),
position: c.position.unwrap_or(0.0).clamp(-800.0, 800.0),
kern: c.kern.filter(|k| k.is_finite()).unwrap_or(0.0).clamp(0.0, 1638.0),
ligatures: c.ligatures,
outline: c.outline.unwrap_or(false),
shadow: c.shadow.unwrap_or(false),
emboss: c.emboss.unwrap_or(false),
@@ -172,6 +180,15 @@ pub struct ResolvedPara {
pub borders: Option<Borders>,
pub bidi: bool,
pub drop_cap: u8,
/// The drop cap hangs in the margin (see `ParaProps::drop_cap_margin`).
#[serde(default)]
pub drop_cap_margin: bool,
/// The drop cap's distance from the text, in points.
#[serde(default)]
pub drop_cap_distance: f32,
/// The drop cap letter's font family; `None` = the text's.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub drop_cap_font: Option<String>,
/// A text frame the paragraph sits in (see `ParaProps::frame`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frame: Option<crate::props::TextFrame>,
@@ -250,6 +267,9 @@ impl ResolvedPara {
borders: p.borders.filter(Borders::any_visible),
bidi: p.bidi.unwrap_or(false),
drop_cap: p.drop_cap.unwrap_or(0).min(10),
drop_cap_margin: p.drop_cap_margin.unwrap_or(false),
drop_cap_distance: p.drop_cap_distance.filter(|d| d.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_DROP_CAP_DISTANCE),
drop_cap_font: p.drop_cap_font.clone().filter(|f| !f.trim().is_empty()),
frame: p.frame.clone().filter(|_| p.drop_cap.unwrap_or(0) == 0),
suppress_hyphens: p.suppress_hyphens.unwrap_or(false),
suppress_line_numbers: p.suppress_line_numbers.unwrap_or(false),
@@ -349,6 +369,8 @@ impl StyleSheet {
}
}
}
// Only a character itself has a symbol font of its own (`w:sym`); a style never gives one.
cp.sym_font = None;
let mut direct = c.clone();
direct.style = None;
cp.overlay(&direct);
@@ -360,6 +382,19 @@ impl StyleSheet {
mod tests {
use super::*;
/// #2036: only a character itself (direct formatting) has a symbol font of its own; a
/// style never gives one to its text.
#[test]
fn a_style_never_gives_text_a_symbol_font() {
let mut s = StyleSheet::builtin();
if let Some(n) = s.get_mut("Normal") {
n.chr.sym_font = Some("Wingdings".into());
}
assert_ne!(s.resolve_char(None, &CharProps::default()).font, "Wingdings");
let direct = CharProps { sym_font: Some("Wingdings".into()), ..Default::default() };
assert_eq!(s.resolve_char(None, &direct).font, "Wingdings");
}
#[test]
fn heading_resolves() {
let s = StyleSheet::builtin();
+88 -6
View File
@@ -2,7 +2,36 @@
use serde::{Deserialize, Serialize};
use crate::props::{Borders, VAlign};
use crate::props::Borders;
/// How a section's text sits on its pages, top to bottom (`w:vAlign`, ECMA-376 §17.6.23,
/// ST_VerticalJc). Table cells and text boxes use [`crate::props::VAlign`], which has no
/// Justified.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum PageVAlign {
#[default]
Top,
Center,
/// Justified (`both`): the room left on each page is shared out equally between the page's
/// paragraphs and tables, so the text runs from the top margin to the bottom one.
#[serde(alias = "both", alias = "justified")]
Justify,
Bottom,
}
impl PageVAlign {
/// From a command or file value: `top|center|justify|bottom` (also `both`, `justified`,
/// `middle`); anything else is Top.
pub fn parse(v: &str) -> PageVAlign {
match v {
"center" | "middle" => PageVAlign::Center,
"justify" | "justified" | "both" => PageVAlign::Justify,
"bottom" => PageVAlign::Bottom,
_ => PageVAlign::Top,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -227,7 +256,7 @@ impl NumFormat {
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
}
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Arabic, Hindi, Thai,
/// Vietnamese, Russian, text amounts) show decimal, as do values past a sequence's end
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
/// (the 21st item of ①…⑳).
pub fn format(self, n: u32) -> String {
use crate::cjk_numbers as cjk;
@@ -237,6 +266,8 @@ impl NumFormat {
NumFormat::LowerRoman => roman(n),
NumFormat::UpperLetter => letters(n).to_uppercase(),
NumFormat::LowerLetter => letters(n),
NumFormat::RussianUpper => russian_letters(n).to_uppercase(),
NumFormat::RussianLower => russian_letters(n),
NumFormat::Ordinal => format!("{n}{}", ordinal_suffix(n)),
NumFormat::CardinalText => cardinal_text(n),
NumFormat::OrdinalText => ordinal_text(n),
@@ -275,8 +306,6 @@ impl NumFormat {
| NumFormat::DecimalHalfWidth
| NumFormat::Hex
| NumFormat::VietnameseCounting
| NumFormat::RussianLower
| NumFormat::RussianUpper
| NumFormat::Hebrew1
| NumFormat::Hebrew2
| NumFormat::ArabicAlpha
@@ -354,6 +383,22 @@ pub fn letters(n: u32) -> String {
std::iter::repeat_n(c, k as usize).collect()
}
/// The 29 letters Word counts Russian lists with: а..я without ё, й, ъ and ь (ы stays).
const RUSSIAN_LETTERS: [char; 29] =
['а', 'б', 'в', 'г', 'д', 'е', 'ж', 'з', 'и', 'к', 'л', 'м', 'н', 'о', 'п', 'р', 'с', 'т', 'у', 'ф', 'х', 'ц', 'ч', 'ш', 'щ', 'ы', 'э', 'ю', 'я'];
/// Word-style Russian letters: а..я, аа..яя, ааа.. (repeated letter, as [`letters`]).
pub fn russian_letters(n: u32) -> String {
if n == 0 {
return String::new();
}
let len = RUSSIAN_LETTERS.len() as u32;
let n = n.min(len * 30);
let k = (n - 1) / len + 1;
let c = RUSSIAN_LETTERS.get(((n - 1) % len) as usize).copied().unwrap_or('а');
std::iter::repeat_n(c, k as usize).collect()
}
const ONES: [&str; 20] = [
"zero",
"one",
@@ -676,8 +721,16 @@ pub struct SectionProps {
pub title_page: bool,
pub page_num_start: Option<u32>,
pub page_num_format: NumFormat,
/// The file carries a `w:pgNumType` element that sets nothing else (no start, decimal
/// format), so saving writes it back rather than dropping it.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub page_num_set: bool,
pub line_numbers: Option<LineNumbering>,
pub valign: VAlign,
pub valign: PageVAlign,
/// Form protection of the section (`w:formProt`, ECMA-376 §17.6.6): `Some` when the file
/// states it. Kept so saving writes it back; editing does not act on it.
#[serde(skip_serializing_if = "Option::is_none")]
pub form_prot: Option<bool>,
pub page_borders: Option<Borders>,
/// Text direction / bidi section.
pub rtl: bool,
@@ -685,6 +738,9 @@ pub struct SectionProps {
/// sections turn the page's text area (headers and footers stay horizontal).
#[serde(skip_serializing_if = "is_horizontal")]
pub text_direction: crate::props::TextDirection,
/// The file states the horizontal direction (`lrTb`) outright; saving writes it back.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub text_direction_set: bool,
/// Tracked change of the section's properties (`w:sectPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<crate::props::PropChange<SectionProps>>>,
@@ -718,11 +774,14 @@ impl Default for SectionProps {
title_page: false,
page_num_start: None,
page_num_format: NumFormat::Decimal,
page_num_set: false,
line_numbers: None,
valign: VAlign::Top,
valign: PageVAlign::Top,
form_prot: None,
page_borders: None,
rtl: false,
text_direction: crate::props::TextDirection::Horizontal,
text_direction_set: false,
fmt_change: None,
doc_grid: None,
footnote_pr: NoteProps::default(),
@@ -736,6 +795,16 @@ fn is_horizontal(d: &crate::props::TextDirection) -> bool {
}
impl SectionProps {
/// Join the section before this one into it, as when the break between them is deleted:
/// this section's setup stays, as in Word, and the headers and footers it leaves out (linked
/// to the previous section) are taken from `before`, so its text keeps showing them.
pub fn absorb(&mut self, before: &SectionProps) {
for (own, prev) in [(&mut self.headers, &before.headers), (&mut self.footers, &before.footers)] {
own.default = own.default.or(prev.default);
own.first = own.first.or(prev.first);
own.even = own.even.or(prev.even);
}
}
/// Width of the text area, points.
pub fn text_width(&self) -> f32 {
(self.page_w - self.margin_left - self.margin_right - self.gutter).max(18.0)
@@ -831,6 +900,8 @@ mod tests {
assert_eq!(NumFormat::ChineseCountingThousand.format(101), "一百零一");
assert_eq!(NumFormat::DecimalEnclosedCircle.format(3), "③");
assert_eq!(NumFormat::Hebrew1.format(3), "3", "formats not drawn yet show decimal");
assert_eq!(NumFormat::RussianLower.format(0), "");
assert_eq!(NumFormat::RussianUpper.format(u32::MAX).chars().count(), 30);
for f in NumFormat::ALL {
assert_eq!(NumFormat::from_ooxml(f.ooxml()), f, "{f:?} round-trips");
assert!(f == NumFormat::Bullet || f == NumFormat::None || !f.format(u32::MAX).is_empty(), "{f:?}");
@@ -838,6 +909,17 @@ mod tests {
assert_eq!(NumFormat::from_ooxml("klingon"), NumFormat::Decimal, "unknown formats are decimal");
}
#[test]
fn russian_letters_follow_word() {
// Word's own labels for a russianLower list: no ё, й, ъ or ь, then doubled letters.
let labels: Vec<String> = (1..=31).map(|n| NumFormat::RussianLower.format(n)).collect();
assert_eq!(labels.join(" "), "а б в г д е ж з и к л м н о п р с т у ф х ц ч ш щ ы э ю я аа бб");
assert_eq!(NumFormat::RussianLower.format(59), "ааа");
assert_eq!(NumFormat::RussianLower.format(100), "нннн");
assert_eq!(NumFormat::RussianUpper.format(26), "Ы");
assert_eq!(NumFormat::RussianUpper.format(36), "ЖЖ");
}
#[test]
fn landscape_swaps() {
let mut s = SectionProps { margin_left: 50.0, ..Default::default() };
+17 -7
View File
@@ -68,13 +68,14 @@ impl SmartArtLayout {
}
}
/// The layout an id names (any ASCII case; `list` is the basic list).
/// The layout an id names (any ASCII case, ignoring hyphens, underscores and spaces;
/// `list` is the basic list; `basic process` or `process` is process, etc.).
pub fn from_id(id: &str) -> Option<SmartArtLayout> {
let id = id.trim();
if id.eq_ignore_ascii_case("list") {
let norm = id.chars().filter(|c| !c.is_whitespace() && *c != '-' && *c != '_').collect::<String>();
if norm.eq_ignore_ascii_case("list") {
return Some(SmartArtLayout::BasicList);
}
SmartArtLayout::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(id))
SmartArtLayout::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(&norm) || l.label().replace(' ', "").eq_ignore_ascii_case(&norm))
}
/// The name shown for it.
@@ -159,7 +160,8 @@ impl SmartArtColors {
}
pub fn from_id(id: &str) -> Option<SmartArtColors> {
SmartArtColors::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(id.trim()))
let norm = id.chars().filter(|c| !c.is_whitespace() && *c != '-' && *c != '_').collect::<String>();
SmartArtColors::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(&norm) || c.label().replace(' ', "").eq_ignore_ascii_case(&norm))
}
/// The name shown for it.
@@ -287,8 +289,9 @@ impl SmartArtSpec {
/// `s` as item text: control characters as spaces, ends trimmed, at most [`MAX_TEXT`] characters.
pub fn clean_text(s: &str) -> String {
let s: String = s.chars().take(MAX_TEXT * 4).map(|c| if c.is_control() { ' ' } else { c }).collect();
s.trim().chars().take(MAX_TEXT).collect::<String>().trim_end().to_string()
let trimmed = s.trim_start_matches(|c: char| c.is_whitespace() || c.is_control());
let s: String = trimmed.chars().take(MAX_TEXT).map(|c| if c.is_control() { ' ' } else { c }).collect();
s.trim_end().to_string()
}
#[cfg(test)]
@@ -319,6 +322,13 @@ mod tests {
assert_eq!(s.descendants(5), 6..6);
assert_eq!(s.descendants(99), 0..0);
assert_eq!(SmartArtLayout::from_id("VENN"), Some(SmartArtLayout::Venn));
assert_eq!(SmartArtLayout::from_id("basic-list"), Some(SmartArtLayout::BasicList));
assert_eq!(SmartArtLayout::from_id("Basic Process"), Some(SmartArtLayout::Process));
assert_eq!(SmartArtLayout::from_id("basic_cycle"), Some(SmartArtLayout::Cycle));
assert_eq!(SmartArtLayout::from_id("list"), Some(SmartArtLayout::BasicList));
assert_eq!(SmartArtColors::from_id("colorful"), Some(SmartArtColors::Colorful));
assert_eq!(SmartArtColors::from_id("Accent 2"), Some(SmartArtColors::Accent2));
assert_eq!(SmartArtColors::from_id("accent-4"), Some(SmartArtColors::Accent4));
assert_eq!(clean_text(&format!("{}Valid Text", " ".repeat(1500))), "Valid Text");
}
}
+354 -2
View File
@@ -3,7 +3,12 @@
use serde::{Deserialize, Serialize};
use crate::props::{Align, Border, BorderStyle, Borders, CharProps, LineSpacing, ParaProps, Rgb, TabAlign, TabLeader, TabStop, TextColor, Underline};
use std::collections::BTreeMap;
use crate::props::{
Align, Border, BorderStyle, Borders, CharProps, LineSpacing, ParaProps, Rgb, TabAlign, TabLeader, TabStop, TableLook, TextColor, Underline,
VAlign,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -41,6 +46,10 @@ pub struct Style {
/// (`w:locked`).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub locked: bool,
/// The default style of its kind (`w:default`), whatever its id: localized documents call
/// the default character, table and list styles something other than the English ids.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub default: bool,
/// Table style: conditional formatting for header row, banding etc.
pub table: Option<TableStyleParts>,
}
@@ -84,6 +93,166 @@ pub struct TableStyleParts {
pub col_band_fill: Option<Rgb>,
pub col_band_chr: CharProps,
pub col_band_borders: Option<Borders>,
/// Per-part formatting the named fields above don't carry: every field of the parts without
/// one (even bands, corner cells), and the cell text alignment of every part. Read and write
/// parts through [`TableStyleParts::part`] / [`TableStyleParts::set_part`].
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub regions: BTreeMap<TablePart, PartFormat>,
}
/// A conditional formatting part of a table style (`w:tblStylePr/@w:type`, ECMA-376 §17.7.6).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TablePart {
WholeTable,
FirstRow,
LastRow,
FirstCol,
LastCol,
Band1Vert,
Band2Vert,
Band1Horz,
Band2Horz,
NeCell,
NwCell,
SeCell,
SwCell,
}
impl TablePart {
/// Every part, lowest precedence first (ECMA-376 §17.7.6: whole table, bands, first/last
/// column, first/last row, corner cells).
pub const ALL: [TablePart; 13] = [
TablePart::WholeTable,
TablePart::Band1Vert,
TablePart::Band2Vert,
TablePart::Band1Horz,
TablePart::Band2Horz,
TablePart::FirstCol,
TablePart::LastCol,
TablePart::FirstRow,
TablePart::LastRow,
TablePart::NeCell,
TablePart::NwCell,
TablePart::SeCell,
TablePart::SwCell,
];
/// The `w:type` value.
pub fn ooxml(self) -> &'static str {
match self {
TablePart::WholeTable => "wholeTable",
TablePart::FirstRow => "firstRow",
TablePart::LastRow => "lastRow",
TablePart::FirstCol => "firstCol",
TablePart::LastCol => "lastCol",
TablePart::Band1Vert => "band1Vert",
TablePart::Band2Vert => "band2Vert",
TablePart::Band1Horz => "band1Horz",
TablePart::Band2Horz => "band2Horz",
TablePart::NeCell => "neCell",
TablePart::NwCell => "nwCell",
TablePart::SeCell => "seCell",
TablePart::SwCell => "swCell",
}
}
pub fn from_ooxml(s: &str) -> Option<TablePart> {
TablePart::ALL.into_iter().find(|p| p.ooxml() == s)
}
/// A part spanning whole rows (its left/right edges are the table's sides, `inside_v` the
/// edges between its cells).
pub fn is_row(self) -> bool {
matches!(self, TablePart::FirstRow | TablePart::LastRow | TablePart::Band1Horz | TablePart::Band2Horz)
}
/// A part spanning whole columns (its top/bottom edges are the table's, `between` the edges
/// between its cells).
pub fn is_column(self) -> bool {
matches!(self, TablePart::FirstCol | TablePart::LastCol | TablePart::Band1Vert | TablePart::Band2Vert)
}
fn bit(self) -> u16 {
1 << (TablePart::ALL.iter().position(|p| *p == self).unwrap_or(0) as u16)
}
}
/// The table style parts one cell is in (always the whole table).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct PartSet(u16);
impl PartSet {
/// The parts of the cell at row `ri` of `nrows`, grid column `g` spanning `span` of `ncols`,
/// in a table with options `look` and row bands of `band_size` rows.
pub fn of_cell(look: &TableLook, band_size: u32, (ri, nrows): (usize, usize), (g, span, ncols): (usize, usize, usize)) -> PartSet {
let header = look.header_row && ri == 0;
let total = look.total_row && ri + 1 == nrows && nrows > 1;
let first_col = look.first_column && g == 0;
let last_col = look.last_column && g.saturating_add(span) >= ncols && ncols > 1;
let size = band_size.clamp(1, 1000) as usize;
let row_band = look.banded_rows && !header;
let odd_row = (ri.saturating_sub(usize::from(look.header_row)) / size).is_multiple_of(2);
let col_band = look.banded_columns && !first_col;
let odd_col = g.saturating_sub(usize::from(look.first_column)).is_multiple_of(2);
let mut bits = TablePart::WholeTable.bit();
for (on, p) in [
(col_band && odd_col, TablePart::Band1Vert),
(col_band && !odd_col, TablePart::Band2Vert),
(row_band && odd_row, TablePart::Band1Horz),
(row_band && !odd_row, TablePart::Band2Horz),
(first_col, TablePart::FirstCol),
(last_col, TablePart::LastCol),
(header, TablePart::FirstRow),
(total, TablePart::LastRow),
(header && last_col, TablePart::NeCell),
(header && first_col, TablePart::NwCell),
(total && last_col, TablePart::SeCell),
(total && first_col, TablePart::SwCell),
] {
if on {
bits |= p.bit();
}
}
PartSet(bits)
}
pub fn contains(self, p: TablePart) -> bool {
self.0 & p.bit() != 0
}
/// The parts, lowest precedence first.
pub fn iter(self) -> impl Iterator<Item = TablePart> {
TablePart::ALL.into_iter().filter(move |p| self.contains(*p))
}
}
/// What one table style part gives its cells.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct PartFormat {
pub chr: CharProps,
/// Cell shading.
pub fill: Option<Rgb>,
/// Cell borders (the whole table's are the table borders).
pub borders: Option<Borders>,
/// Horizontal alignment of the cells' paragraphs (`w:pPr/w:jc`).
pub align: Option<Align>,
/// Vertical alignment in the cells (`w:tcPr/w:vAlign`).
pub v_align: Option<VAlign>,
}
impl PartFormat {
pub fn is_empty(&self) -> bool {
*self == PartFormat::default()
}
/// What `patch` sets wins; borders merge edge by edge.
pub fn overlay(&mut self, patch: &PartFormat) {
self.chr.overlay(&patch.chr);
self.fill = patch.fill.or(self.fill);
self.borders = match (self.borders, patch.borders) {
(Some(mut b), Some(p)) => {
b.overlay(&p);
Some(b)
}
(b, p) => p.or(b),
};
self.align = patch.align.or(self.align);
self.v_align = patch.v_align.or(self.v_align);
}
}
impl TableStyleParts {
@@ -120,6 +289,125 @@ impl TableStyleParts {
self.col_band_fill = patch.col_band_fill.or(self.col_band_fill);
self.col_band_chr.overlay(&patch.col_band_chr);
self.col_band_borders = merge(self.col_band_borders, patch.col_band_borders);
for (k, f) in &patch.regions {
self.regions.entry(*k).or_default().overlay(f);
}
}
/// Part `p`'s formatting. The whole table's text formatting is the style's own
/// ([`Style::table_part`] adds it); its borders are the table borders.
pub fn part(&self, p: TablePart) -> PartFormat {
let extra = self.regions.get(&p).cloned().unwrap_or_default();
let named = |chr: &CharProps, fill: Option<Rgb>, borders: Option<Borders>| PartFormat {
chr: chr.clone(),
fill,
borders,
align: extra.align,
v_align: extra.v_align,
};
match p {
TablePart::WholeTable => {
PartFormat { chr: CharProps::default(), fill: self.fill, borders: self.borders, align: None, v_align: extra.v_align }
}
TablePart::FirstRow => named(&self.header_chr, self.header_fill, self.header_borders),
TablePart::LastRow => {
// The built-in top rule is the region's top edge unless its borders set one.
let borders = match (self.total_borders, self.total_border_top) {
(Some(mut b), top) => {
b.top = b.top.or(top);
Some(b)
}
(None, top) => top.map(|t| Borders { top: Some(t), ..Borders::default() }),
};
named(&self.total_chr, self.total_fill, borders)
}
TablePart::FirstCol => named(&self.first_col_chr, self.first_col_fill, self.first_col_borders),
TablePart::LastCol => named(&self.last_col_chr, self.last_col_fill, self.last_col_borders),
TablePart::Band1Vert => named(&self.col_band_chr, self.col_band_fill, self.col_band_borders),
TablePart::Band1Horz => named(&self.band_chr, self.band_fill, self.band_borders),
_ => extra,
}
}
/// Replace part `p`'s formatting (for the whole table only its fill, borders and vertical
/// alignment: [`Style::set_table_part`] stores the rest).
pub fn set_part(&mut self, p: TablePart, f: PartFormat) {
let align_only = PartFormat { align: f.align, v_align: f.v_align, ..PartFormat::default() };
let PartFormat { chr, fill, borders, .. } = f.clone();
let rest = match p {
TablePart::WholeTable => {
self.fill = fill;
self.borders = borders;
PartFormat { v_align: f.v_align, ..PartFormat::default() }
}
TablePart::FirstRow => {
(self.header_chr, self.header_fill, self.header_borders) = (chr, fill, borders);
align_only
}
TablePart::LastRow => {
(self.total_chr, self.total_fill) = (chr, fill);
self.total_border_top = borders.and_then(|b| b.top);
self.total_borders = borders.map(|b| Borders { top: None, ..b }).filter(|b| *b != Borders::default());
align_only
}
TablePart::FirstCol => {
(self.first_col_chr, self.first_col_fill, self.first_col_borders) = (chr, fill, borders);
align_only
}
TablePart::LastCol => {
(self.last_col_chr, self.last_col_fill, self.last_col_borders) = (chr, fill, borders);
align_only
}
TablePart::Band1Vert => {
(self.col_band_chr, self.col_band_fill, self.col_band_borders) = (chr, fill, borders);
align_only
}
TablePart::Band1Horz => {
(self.band_chr, self.band_fill, self.band_borders) = (chr, fill, borders);
align_only
}
_ => f,
};
if rest.is_empty() {
self.regions.remove(&p);
} else {
self.regions.insert(p, rest);
}
}
}
impl Style {
/// Table style part `p` as this style alone sets it (the whole table's text formatting and
/// paragraph alignment are the style's own).
pub fn table_part(&self, p: TablePart) -> PartFormat {
let mut f = self.table.as_ref().map(|t| t.part(p)).unwrap_or_default();
if p == TablePart::WholeTable {
f.chr = self.chr.clone();
f.align = self.para.align;
}
f
}
/// Replace part `p` of this table style.
pub fn set_table_part(&mut self, p: TablePart, f: PartFormat) {
if p == TablePart::WholeTable {
self.chr = f.chr.clone();
self.para.align = f.align;
}
let mut t = self.table.take().unwrap_or_default();
t.set_part(p, f);
self.table = (t != TableStyleParts::default()).then_some(t);
}
}
impl TableStyleProps {
/// Part `p` with the based-on chain merged (see [`Style::table_part`]).
pub fn part(&self, p: TablePart) -> PartFormat {
let mut f = self.parts.part(p);
if p == TablePart::WholeTable {
f.chr = self.chr.clone();
f.align = self.para.align;
}
f
}
}
@@ -142,6 +430,10 @@ pub struct StyleSheet {
/// the application; nonempty `default_para` is always written after spacing edits.
pub default_para_present: bool,
pub styles: Vec<Style>,
/// The file's `w:latentStyles` (behaviour of built-in styles not defined in the document:
/// gallery, priority, visibility), as XML, written back as is.
#[serde(skip_serializing_if = "Option::is_none")]
pub latent_styles: Option<String>,
}
impl Default for StyleSheet {
@@ -168,7 +460,13 @@ fn color(c: Rgb) -> Option<TextColor> {
impl StyleSheet {
pub fn empty() -> Self {
StyleSheet { default_chr: CharProps::default(), default_para: ParaProps::default(), default_para_present: true, styles: Vec::new() }
StyleSheet {
default_chr: CharProps::default(),
default_para: ParaProps::default(),
default_para_present: true,
styles: Vec::new(),
latent_styles: None,
}
}
/// The built-in style set used for new documents.
@@ -184,6 +482,7 @@ impl StyleSheet {
},
default_para_present: true,
styles: Vec::new(),
latent_styles: None,
};
let mut add = |st: Style| s.styles.push(st);
add(Style { id: "Normal".into(), name: "Normal".into(), priority: Some(0), quick: true, ..p_style() });
@@ -483,6 +782,33 @@ impl StyleSheet {
None => self.styles.push(st),
}
}
/// For a file from a producer that never writes `w:qFormat` (LibreOffice): give it Word's
/// usual Quick Styles gallery. Each built-in style whose id or name matches a quick style of
/// [`StyleSheet::builtin`] becomes quick (taking the built-in's priority when it has none), and
/// the quick built-ins the file lacks are added with their linked styles. Existing ids and
/// names are never overwritten. Callers use it only when no style in the file is quick.
pub fn add_default_gallery(&mut self) {
let builtin = StyleSheet::builtin();
for q in builtin.styles.iter().filter(|s| s.quick && s.kind != StyleKind::Table) {
let found =
self.styles.iter().position(|s| s.id == q.id).or_else(|| self.styles.iter().position(|s| s.name.eq_ignore_ascii_case(&q.name)));
if let Some(st) = found.and_then(|i| self.styles.get_mut(i)) {
if st.builtin && st.kind == q.kind {
st.quick = true;
st.priority = st.priority.or(q.priority);
}
continue;
}
for sid in std::iter::once(q.id.as_str()).chain(q.linked.as_deref()) {
if let Some(b) = builtin.get(sid)
&& self.get(sid).is_none()
&& self.find(&b.name).is_none()
{
self.styles.push(b.clone());
}
}
}
}
/// Remove style `id` and return it. Styles based on it are rebased onto its own base, and
/// `next` / `linked` references to it are dropped. Text that uses it is the document's to
/// retarget ([`crate::Document::restyle`]).
@@ -605,6 +931,32 @@ mod tests {
assert!(g.iter().any(|s| s.id == "Heading1"));
}
#[test]
fn default_gallery_marks_and_adds_quick_builtins() {
let b = StyleSheet::builtin();
let mut s = StyleSheet::empty();
let plain = |id: &str, name: &str| Style { id: id.into(), name: name.into(), builtin: true, ..Default::default() };
s.styles.push(plain("Normal", "Normal"));
s.styles.push(plain("Heading1", "heading 1"));
s.styles.push(Style { priority: Some(7), ..plain("Heading2", "Heading 2") });
s.styles.push(Style { builtin: false, ..plain("Heading3", "Heading 3") });
s.styles.push(Style { para: ParaProps { space_after: Some(99.0), ..Default::default() }, ..plain("Custom1", "Subtitle") });
s.add_default_gallery();
let ids: Vec<&str> = s.gallery().iter().map(|x| x.id.as_str()).collect();
for id in ["Normal", "NoSpacing", "Heading1", "Heading2", "Title"] {
assert!(ids.contains(&id), "{id} in {ids:?}");
}
let mut bare = StyleSheet::empty();
bare.add_default_gallery();
assert!(bare.get("Heading1").is_some_and(|x| x.quick) && bare.get("Title").is_some(), "missing quick builtins added");
assert_eq!(s.get("Heading2").and_then(|x| x.priority), Some(7), "file priority kept");
assert_eq!(s.get("Heading1").and_then(|x| x.priority), b.get("Heading1").and_then(|x| x.priority));
assert!(!s.get("Heading3").is_some_and(|x| x.quick), "custom style untouched");
assert!(s.get("Subtitle").is_none(), "a style with that name exists: not duplicated");
assert!(s.get("Custom1").is_some_and(|x| x.quick), "found by name only, built in: quick");
assert_eq!(s.get("Custom1").and_then(|x| x.para.space_after), Some(99.0));
}
#[test]
fn older_serialized_styles_keep_explicit_defaults() {
let mut value = serde_json::to_value(StyleSheet::empty()).unwrap();
+16 -1
View File
@@ -22,7 +22,7 @@
/// The symbol fonts this module knows, by lowercase family name.
fn table(family: &str) -> Option<&'static [u32; 224]> {
let f = family.trim();
if f.eq_ignore_ascii_case("symbol") {
if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") {
Some(&SYMBOL)
} else if f.eq_ignore_ascii_case("wingdings") {
Some(&WINGDINGS)
@@ -243,6 +243,21 @@ mod tests {
}
}
#[test]
fn pcl6_symbol_encoding() {
for family in ["Symbol (PCL6)", " symbol (pcl6) "] {
assert!(is_symbol_font(family));
for code in 0x20..=0xFF {
let c = char::from_u32(code).unwrap();
assert_eq!(symbol_char(family, c), symbol_char("Symbol", c));
let pua = char::from_u32(0xF000 + code).unwrap();
assert_eq!(symbol_char(family, pua), symbol_char("Symbol", pua));
}
assert_eq!(symbol_char(family, '\u{F0B3}'), Some('≥'));
}
assert!(!is_symbol_font("Symbol (unknown)"));
}
#[test]
fn symbol_uses_the_adobe_symbol_encoding() {
assert_eq!(symbol_char("Symbol", '\u{F061}'), Some('α'));
+144 -23
View File
@@ -127,9 +127,27 @@ impl Table {
}
pub fn delete_row(&mut self, r: usize) {
if r < self.rows.len() {
self.rows.remove(r);
if r >= self.rows.len() {
return;
}
// A vertical merge that starts in this row now starts in the row below.
let starts: Vec<usize> = (0..self.rows.get(r).map_or(0, |row| row.cells.len()))
.filter(|&c| self.rows.get(r).and_then(|row| row.cells.get(c)).is_some_and(|x| x.props.vmerge == VMerge::Restart))
.map(|c| self.grid_col(r, c))
.collect();
for g in starts {
let continues = |t: &Self, row: usize| {
t.cell_at_grid(row, g).and_then(|i| t.rows.get(row)?.cells.get(i)).is_some_and(|x| x.props.vmerge == VMerge::Continue)
};
if !continues(self, r + 1) {
continue;
}
let more = continues(self, r + 2);
if let Some(c) = self.cell_at_grid(r + 1, g).and_then(|i| self.rows.get_mut(r + 1)?.cells.get_mut(i)) {
c.props.vmerge = if more { VMerge::Restart } else { VMerge::None };
}
}
self.rows.remove(r);
}
/// Insert a grid column at grid index `g` (cells spanning it widen instead).
@@ -256,28 +274,15 @@ impl Table {
}
}
/// Split cell `c` of row `r` into `n` cells horizontally. When the cell spans fewer grid
/// columns than that, the grid is refined inside the cell only: the new borders divide it
/// Split cell `c` of row `r` into `n` cells horizontally. Where the cell's grid lines don't
/// fall on the equal parts, the grid is refined inside the cell only: the new borders divide it
/// into equal parts, and the cells of other rows widen over the new grid columns, so their
/// extent and the table's width stay as they were.
pub fn split_cell(&mut self, r: usize, c: usize, n: usize) {
/// extent and the table's width stay as they were. Returns the parts' spans, left to right.
pub fn split_cell(&mut self, r: usize, c: usize, n: usize) -> Vec<usize> {
let n = n.clamp(1, MAX_COLS);
let Some(span) = self.rows.get(r).and_then(|row| row.cells.get(c)).map(Cell::span) else { return };
let Some(span) = self.rows.get(r).and_then(|row| row.cells.get(c)).map(Cell::span) else { return Vec::new() };
if n <= 1 {
return;
}
if span >= n {
let Some(row) = self.rows.get_mut(r) else { return };
let Some(cell) = row.cells.get_mut(c) else { return };
let w = cell.props.width.map(|w| w / n as f32);
cell.props.width = w;
cell.props.span = (span - (n - 1)) as u32;
for k in 1..n {
let mut nc = Cell::empty();
nc.props.width = w;
row.cells.insert(c + k, nc);
}
return;
return vec![span];
}
let g = self.grid_col(r, c);
let mut end = g.saturating_add(span);
@@ -286,7 +291,7 @@ impl Table {
self.grid.push(72.0);
}
if self.grid.len() < end {
return;
return vec![span];
}
for w in self.grid.get_mut(g..end).into_iter().flatten() {
if !(w.is_finite() && *w > 0.0) {
@@ -341,7 +346,7 @@ impl Table {
// The cell now spans `g..end`: cut it at the new lines.
let grid = self.grid.clone();
let width = |a: usize, b: usize| grid.get(a..b).map(|x| x.iter().sum::<f32>());
let Some(row) = self.rows.get_mut(r) else { return };
let Some(row) = self.rows.get_mut(r) else { return Vec::new() };
let mut a = g;
let mut parts = Vec::with_capacity(lines.len() + 1);
for &b in lines.iter().chain(std::iter::once(&end)) {
@@ -363,6 +368,122 @@ impl Table {
row.cells.insert((c + k).min(row.cells.len()), nc);
}
}
parts.iter().map(|&(a, b)| b - a).collect()
}
/// The rows cell `c` of row `r` covers: (first row, count). A vertically merged cell runs from
/// its `Restart` cell down through the `Continue` cells that start at the same grid column.
pub fn vertical_extent(&self, r: usize, c: usize) -> Option<(usize, usize)> {
let g = self.grid_col(r, c);
let at = |rr: usize| -> Option<VMerge> {
let ci = self.cell_at_grid(rr, g)?;
if self.grid_col(rr, ci) != g {
return None;
}
Some(self.rows.get(rr)?.cells.get(ci)?.props.vmerge)
};
let mut top = r;
if self.rows.get(r)?.cells.get(c)?.props.vmerge == VMerge::Continue {
while let Some(up) = top.checked_sub(1).and_then(at) {
if up == VMerge::None {
break;
}
top -= 1;
if up == VMerge::Restart {
break;
}
}
}
let mut k = 1;
if at(top) != Some(VMerge::None) {
while at(top + k) == Some(VMerge::Continue) {
k += 1;
}
}
Some((top, k))
}
/// Split Cells: cut cell `c` of row `r` into `cols` × `rows` cells. A cell one row high gets
/// `rows - 1` new rows under it, over which the row's other cells merge down; a vertically
/// merged cell is regrouped instead, so `rows` must divide its height. The content stays in
/// the first part. Returns false (with the table unchanged) when the split can't be made.
pub fn split_cell_grid(&mut self, r: usize, c: usize, cols: usize, rows: usize) -> bool {
let (cols, rows) = (cols.clamp(1, MAX_COLS), rows.clamp(1, MAX_ROWS));
let Some((top, k)) = self.vertical_extent(r, c) else { return false };
let g = self.grid_col(r, c);
let Some(ci) = self.cell_at_grid(top, g) else { return false };
if (k > 1 && k % rows != 0) || (k == 1 && self.rows.len().saturating_add(rows - 1) > MAX_ROWS) {
return false;
}
let height = if k > 1 { k } else { rows };
if k > 1 && rows > 1 {
let q = k / rows;
for i in 0..k {
if let Some(cj) = self.cell_at_grid(top + i, g)
&& let Some(cell) = self.rows.get_mut(top + i).and_then(|row| row.cells.get_mut(cj))
{
cell.props.vmerge = if q == 1 {
VMerge::None
} else if i % q == 0 {
VMerge::Restart
} else {
VMerge::Continue
};
}
}
} else if k == 1 && rows > 1 {
let Some(src) = self.rows.get_mut(top) else { return false };
let mut like = Row { props: src.props.clone(), cells: Vec::with_capacity(src.cells.len()), controls: ControlWrap::default() };
like.props.header = false;
for (i, cell) in src.cells.iter_mut().enumerate() {
let mut nc = Cell::empty();
nc.props = cell.props.clone();
if i == ci {
cell.props.vmerge = VMerge::None;
nc.props.vmerge = VMerge::None;
} else {
// The row's other cells merge down over the new rows.
if cell.props.vmerge == VMerge::None {
cell.props.vmerge = VMerge::Restart;
}
nc.props.vmerge = VMerge::Continue;
}
like.cells.push(nc);
}
for _ in 1..rows {
self.rows.insert(top + 1, like.clone());
}
}
if cols > 1 {
let spans = self.split_cell(top, ci, cols);
let total: usize = spans.iter().sum();
let vmerge = self.rows.get(top).and_then(|row| row.cells.get(ci)).map(|cell| cell.props.vmerge).unwrap_or_default();
if let Some(row) = self.rows.get_mut(top) {
for cell in row.cells.iter_mut().skip(ci + 1).take(spans.len().saturating_sub(1)) {
cell.props.vmerge = vmerge;
}
}
// The cells under it, which the new grid lines widened, are cut the same way.
let grid = self.grid.clone();
let width = |a: usize, b: usize| grid.get(a..b).map(|x| x.iter().sum::<f32>());
for rr in top + 1..top + height {
let Some(cj) = self.cell_at_grid(rr, g).filter(|&cj| self.grid_col(rr, cj) == g) else { continue };
let Some(row) = self.rows.get_mut(rr) else { continue };
let Some(cell) = row.cells.get_mut(cj).filter(|cell| cell.span() == total) else { continue };
let mut a = g;
let mut parts = Vec::with_capacity(spans.len());
for (n, &span) in spans.iter().enumerate() {
let mut part = if n == 0 { cell.clone() } else { Cell::empty() };
part.props.vmerge = cell.props.vmerge;
part.props.span = span as u32;
part.props.width = width(a, a + span);
a += span;
parts.push(part);
}
row.cells.splice(cj..=cj, parts);
}
}
true
}
pub fn total_width(&self) -> f32 {
+119 -1
View File
@@ -90,6 +90,46 @@ fn parts_and_media() {
assert_ne!(k1, k3);
}
#[test]
fn free_ids_skip_taken_ones_instead_of_overflowing() {
assert_eq!(free_id(None, std::iter::empty(), 1), Some(1));
assert_eq!(free_id(Some(7), [3, 7].into_iter(), 1), Some(8));
assert_eq!(free_id(Some(0), [0].into_iter(), 1), Some(1));
// A file used the largest id: the lowest free one, not a wrapped 0 or a taken id.
assert_eq!(free_id(Some(u32::MAX), [1, 2, 4, u32::MAX].into_iter(), 1), Some(3));
assert_eq!(free_id(Some(u32::MAX), [u32::MAX, 1, 0, 1].into_iter(), 0), Some(2));
// Every id from `min` up taken: none, rather than a duplicate.
assert_eq!(free_id(Some(u32::MAX), [u32::MAX, u32::MAX - 1, u32::MAX - 2].into_iter(), u32::MAX - 2), None);
}
#[test]
fn parts_and_lists_after_the_largest_ids_take_free_ones() {
// A file can carry any ids, 0 and u32::MAX included; new ones must not overflow or replace them.
let mut d = Document::new();
for id in [0, 1, u32::MAX] {
let blocks = vec![para_block(Paragraph::with_text(&id.to_string(), CharProps::default()))];
d.parts.insert(id, Part { kind: PartKind::Header, blocks, ..Default::default() });
}
let parts = d.parts.clone();
assert_eq!(d.try_add_part(PartKind::Comment, Vec::new()), Ok(2));
assert_eq!(d.add_part(PartKind::Footer, Vec::new()), 3);
assert!(parts.iter().all(|(k, p)| d.parts.get(k) == Some(p)));
let n = &mut d.numbering;
n.add_list(ListKind::Bullet);
n.add_list(ListKind::Bullet);
n.abstracts[1].id = u32::MAX;
n.nums[1].id = u32::MAX;
n.nums[1].abstract_id = u32::MAX;
let lists = n.clone();
let num = n.try_add_list(ListKind::Numbered).unwrap();
assert_eq!((num, n.num(num).map(|x| x.abstract_id)), (2, Some(1)));
assert_eq!(n.abstract_of(num).map(|a| &a.levels), Some(&numbering::levels_for(ListKind::Numbered)));
assert_eq!(n.restart(num), Some(3));
assert_eq!(n.num(3).map(|x| x.abstract_id), Some(1));
assert_eq!((&n.abstracts[..2], &n.nums[..2]), (&lists.abstracts[..], &lists.nums[..]));
}
#[test]
fn clone_is_shallow() {
let d = Document::from_text("a\nb");
@@ -241,6 +281,84 @@ fn prune_text_boxes_drops_only_unshown_ones() {
assert_eq!(d.prune_text_boxes(), 0);
}
/// `blocks` in the only cell of `levels` tables, each nested in the next.
fn nested_tables(levels: usize, blocks: Blocks) -> Blocks {
(0..levels).fold(blocks, |inner, _| {
let mut t = Table::new(1, 1, 100.0);
t.rows[0].cells[0].blocks = inner;
vec![Arc::new(Block::Table(t))]
})
}
/// A paragraph showing text box story `story` (drawing id `id`).
fn box_para(story: u32, id: u32) -> Blocks {
let shape = InlineObject::Shape {
kind: para::ShapeKind::TextBox,
w: 50.0,
h: 20.0,
fill: None,
stroke: None,
stroke_width: 0.0,
float: Default::default(),
story: Some(story),
freeform: None,
effects: Default::default(),
extra: connector::ShapeExtra { id, ..Default::default() },
};
let mut p = Paragraph::with_text("cell", CharProps::default());
p.insert_object(0, shape, &CharProps::default()).unwrap();
vec![para_block(p)]
}
#[test]
fn text_boxes_shown_from_deeply_nested_tables_are_kept() {
// #1339: the paragraph walk stopped 16 tables down, so a box shown only from deeper was taken
// for unshown and the next edit pruned its story.
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
let mut d = Document::from_text("body");
let at_17 = d.add_part(PartKind::TextBox, para_of("17 tables down"));
let at_40 = d.add_part(PartKind::TextBox, para_of("40 tables down"));
let inner = d.add_part(PartKind::TextBox, para_of("deep in a box"));
let outer = d.add_part(PartKind::TextBox, nested_tables(18, box_para(inner, 0)));
let in_header = d.add_part(PartKind::TextBox, para_of("deep in a header"));
let hdr = d.add_part(PartKind::Header, nested_tables(20, box_para(in_header, 0)));
let unshown = d.add_part(PartKind::TextBox, para_of("shown nowhere"));
d.body.extend(nested_tables(17, box_para(at_17, 0)));
d.body.extend(nested_tables(40, box_para(at_40, 7)));
d.body.extend(box_para(outer, 0));
assert_eq!(d.prune_text_boxes(), 1);
assert!(!d.parts.contains_key(&unshown));
let kept: Vec<u32> = d.parts.keys().copied().collect();
assert_eq!(kept, vec![at_17, at_40, inner, outer, in_header, hdr]);
assert_eq!(d.prune_text_boxes(), 0);
// Counted in reading order, the nested box after its own; new drawing ids above the deep one.
let part = StoryRef::Part;
assert_eq!(d.counted_stories(), vec![StoryRef::Body, part(at_17), part(at_40), part(outer), part(inner)]);
assert_eq!(d.next_shape_id(), 8);
}
#[test]
fn a_deeply_nested_table_is_copied_with_its_text_boxes() {
// A copy takes the box 17 tables down along, so pasting it into another document shows its
// text, not the other document's box that happens to have the same id.
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
let mut d = Document::from_text("body");
let deep = d.add_part(PartKind::TextBox, para_of("deep box"));
d.body.extend(nested_tables(17, box_para(deep, 0)));
let frag = d.body_fragment();
assert_eq!(frag.parts.keys().copied().collect::<Vec<_>>(), vec![deep]);
let mut e = Document::new();
let own = e.add_part(PartKind::TextBox, para_of("its own box"));
assert_eq!(own, deep, "the same id in both documents");
e.body = box_para(own, 0);
let end = e.end_of(StoryRef::Body);
e.insert_fragment(&end, &frag).unwrap();
let mut shown = Vec::new();
e.objects_in_reading_order(&mut |o| shown.extend(o.text_box()));
let texts: Vec<String> = shown.iter().map(|id| e.plain_text(StoryRef::Part(*id))).collect();
assert_eq!(texts, vec!["its own box", "deep box"]);
}
#[test]
fn notes_in_text_box_follow_nested_boxes_once() {
let shape = |story| InlineObject::Shape {
@@ -273,7 +391,7 @@ fn notes_in_text_box_follow_nested_boxes_once() {
}
fn fs(instr: &str) -> InlineObject {
InlineObject::FieldStart { instr: instr.into(), locked: false }
InlineObject::FieldStart { instr: instr.into(), locked: false, dirty: false }
}
#[test]
+20 -9
View File
@@ -59,8 +59,8 @@ impl WrapPolygon {
/// A shape's outline in unit coordinates (0..1 across a `w` × `h` frame), flattened to straight
/// lines. `None` for shapes whose outline is their rectangle (rectangles, text boxes, lines,
/// freeforms).
pub fn shape_outline(kind: ShapeKind, w: f32, h: f32) -> Option<Vec<(f32, f32)>> {
/// freeforms). `corner` is a rounded rectangle's (see [`crate::connector::ShapeExtra::corner`]).
pub fn shape_outline(kind: ShapeKind, w: f32, h: f32, corner: Option<u32>) -> Option<Vec<(f32, f32)>> {
use std::f32::consts::{FRAC_PI_2, PI, TAU};
let pos = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0e6) } else { 0.0 };
let (w, h) = (pos(w), pos(h));
@@ -73,10 +73,12 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32) -> Option<Vec<(f32, f32)>>
| ShapeKind::StraightConnector
| ShapeKind::ElbowConnector
| ShapeKind::CurvedConnector => return None,
ShapeKind::Ellipse => (0..48).map(|i| (i as f32) * TAU / 48.0).map(|a| (0.5 + 0.5 * a.cos(), 0.5 + 0.5 * a.sin())).collect(),
ShapeKind::Ellipse | ShapeKind::FlowchartOr => {
(0..48).map(|i| (i as f32) * TAU / 48.0).map(|a| (0.5 + 0.5 * a.cos(), 0.5 + 0.5 * a.sin())).collect()
}
ShapeKind::RoundedRectangle => {
// Corner radius as drawn: 16% of the shorter side.
let r = 0.16 * w.min(h);
// Corner radius as drawn.
let r = crate::connector::corner_radius(corner, w, h);
let (rx, ry) = if w > 0.0 && h > 0.0 { (r / w, r / h) } else { (0.0, 0.0) };
let mut pts = Vec::new();
for (cx, cy, a0) in [(1.0 - rx, 1.0 - ry, 0.0), (rx, 1.0 - ry, FRAC_PI_2), (rx, ry, PI), (1.0 - rx, ry, PI + FRAC_PI_2)] {
@@ -88,6 +90,7 @@ pub fn shape_outline(kind: ShapeKind, w: f32, h: f32) -> Option<Vec<(f32, f32)>>
pts
}
ShapeKind::Triangle => vec![(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)],
ShapeKind::RightTriangle => vec![(0.0, 0.0), (1.0, 1.0), (0.0, 1.0)],
ShapeKind::Diamond => vec![(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)],
ShapeKind::Arrow => {
// As drawn: the head is half the height long, the shaft 40% of the height thick.
@@ -217,18 +220,26 @@ mod tests {
ShapeKind::Ellipse,
ShapeKind::RoundedRectangle,
ShapeKind::Triangle,
ShapeKind::RightTriangle,
ShapeKind::Diamond,
ShapeKind::Arrow,
ShapeKind::Star,
ShapeKind::Heart,
] {
for (w, h) in [(100.0, 50.0), (0.0, 0.0), (f32::NAN, 10.0)] {
let pts = shape_outline(kind, w, h).unwrap();
assert!(pts.len() >= 3 && pts.len() <= MAX_DERIVED_POINTS, "{kind:?}");
assert!(pts.iter().all(|(x, y)| (-0.001..=1.001).contains(x) && (-0.001..=1.001).contains(y)), "{kind:?}");
for corner in [None, Some(0), Some(3_565), Some(u32::MAX)] {
let pts = shape_outline(kind, w, h, corner).unwrap();
assert!(pts.len() >= 3 && pts.len() <= MAX_DERIVED_POINTS, "{kind:?}");
assert!(pts.iter().all(|(x, y)| (-0.001..=1.001).contains(x) && (-0.001..=1.001).contains(y)), "{kind:?}");
}
}
}
assert!(shape_outline(ShapeKind::Rectangle, 10.0, 10.0).is_none());
assert!(shape_outline(ShapeKind::Rectangle, 10.0, 10.0, None).is_none());
// The end of the bottom-right corner: in the frame's corner when the corner is square,
// 16% of the height (8 of 100 wide) in from it by default.
let corner_pt = |corner| shape_outline(ShapeKind::RoundedRectangle, 100.0, 50.0, corner).unwrap()[6];
assert!(corner_pt(Some(0)).0 > 0.999 && corner_pt(Some(0)).1 > 0.999, "{:?}", corner_pt(Some(0)));
assert!((corner_pt(None).0 - 0.92).abs() < 0.001, "{:?}", corner_pt(None));
}
#[test]
+1
View File
@@ -14,6 +14,7 @@ wordcraft-geom = { workspace = true }
cfb = { workspace = true }
image = { workspace = true }
miniz_oxide = { workspace = true }
sha1 = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
+447
View File
@@ -0,0 +1,447 @@
//! Password-protected Word 97-2003 files ([MS-DOC] §2.2.6, [MS-OFFCRYPTO] §2.3.5-2.3.7).
//!
//! Three schemes exist for `.doc`:
//!
//! * **XOR obfuscation** (`fObfuscated`): a 32-bit password verifier sits in `FibBase.lKey` and
//! the streams are XORed with a 16-byte array derived from the password (method 2).
//! * **RC4** (header version 1.1): `EncryptionHeader` at the start of the Table stream; the key
//! per 512-byte block comes from MD5.
//! * **RC4 CryptoAPI** (header version 2/3/4 . 2): like RC4 but with a SHA-1 key derivation, a
//! key size of 40-128 bits and a variable-size header.
//!
//! In all three the WordDocument stream (past its first 68 bytes), the Table stream (past its
//! header, for RC4) and the whole Data stream are transformed. The password is checked against
//! the stored verifier before anything is decrypted, so a wrong password is reported and never
//! yields a garbled document. Everything is written from the public specifications.
use sha1::{Digest, Sha1};
use crate::DocbinError;
/// Bytes at the start of the WordDocument stream that are stored untransformed.
pub(crate) const FIB_PLAIN: usize = 68;
/// RC4 re-keys every this many bytes of a stream.
const BLOCK: usize = 512;
/// Longest password considered (the specifications' limit for the RC4 schemes).
const MAX_PASSWORD_CHARS: usize = 255;
/// XOR obfuscation uses at most this many single-byte password characters.
const MAX_XOR_PASSWORD: usize = 15;
/// `FibBase.flags`: the document is encrypted (`fEncrypted`) / obfuscated (`fObfuscated`).
pub(crate) const F_ENCRYPTED: u16 = 0x0100;
pub(crate) const F_OBFUSCATED: u16 = 0x8000;
/// `EncryptionHeaderFlags`: `fCryptoAPI`, `fExternal`, `fAES`.
const H_CRYPTO_API: u32 = 0x04;
const H_EXTERNAL: u32 = 0x10;
const H_AES: u32 = 0x20;
fn unsupported(what: &str) -> DocbinError {
DocbinError::Encryption(what.into())
}
// ---------------------------------------------------------------------------------------------
// Primitives
/// MD5 ([RFC 1321]); the RC4 scheme is defined on it.
pub(crate) fn md5(data: &[u8]) -> [u8; 16] {
const S: [u32; 64] = [
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16,
23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
];
// K[i] = floor(2^32 * abs(sin(i + 1))), as the RFC defines it.
let k: Vec<u32> = (0..64u32).map(|i| (f64::from(i + 1).sin().abs() * 4_294_967_296.0) as u32).collect();
let (mut a0, mut b0, mut c0, mut d0) = (0x6745_2301u32, 0xefcd_ab89u32, 0x98ba_dcfeu32, 0x1032_5476u32);
let mut msg = Vec::with_capacity(data.len() + 72);
msg.extend_from_slice(data);
msg.push(0x80);
while msg.len() % 64 != 56 {
msg.push(0);
}
msg.extend_from_slice(&(data.len() as u64).wrapping_mul(8).to_le_bytes());
for chunk in msg.as_chunks::<64>().0 {
let mut m = [0u32; 16];
let words = chunk.as_chunks::<4>().0;
for (w, b) in m.iter_mut().zip(words) {
*w = u32::from_le_bytes(*b);
}
let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0);
for i in 0..64usize {
let (f, g) = match i / 16 {
0 => ((b & c) | (!b & d), i),
1 => ((d & b) | (!d & c), (5 * i + 1) % 16),
2 => (b ^ c ^ d, (3 * i + 5) % 16),
_ => (c ^ (b | !d), (7 * i) % 16),
};
let f = f.wrapping_add(a).wrapping_add(k[i]).wrapping_add(m[g]);
a = d;
d = c;
c = b;
b = b.wrapping_add(f.rotate_left(S[i]));
}
a0 = a0.wrapping_add(a);
b0 = b0.wrapping_add(b);
c0 = c0.wrapping_add(c);
d0 = d0.wrapping_add(d);
}
let mut out = [0u8; 16];
let (cells, _) = out.as_chunks_mut::<4>();
for (o, v) in cells.iter_mut().zip([a0, b0, c0, d0]) {
*o = v.to_le_bytes();
}
out
}
fn sha1(parts: &[&[u8]]) -> Vec<u8> {
let mut h = Sha1::new();
for p in parts {
h.update(p);
}
h.finalize().to_vec()
}
/// The RC4 stream cipher.
pub(crate) struct Rc4 {
s: [u8; 256],
i: u8,
j: u8,
}
impl Rc4 {
pub(crate) fn new(key: &[u8]) -> Rc4 {
let mut s = [0u8; 256];
for (n, v) in s.iter_mut().enumerate() {
*v = n as u8;
}
let kl = key.len().max(1);
let mut j = 0u8;
for i in 0..256usize {
let k = key.get(i % kl).copied().unwrap_or(0);
j = j.wrapping_add(s[i]).wrapping_add(k);
s.swap(i, usize::from(j));
}
Rc4 { s, i: 0, j: 0 }
}
fn next(&mut self) -> u8 {
self.i = self.i.wrapping_add(1);
self.j = self.j.wrapping_add(self.s[usize::from(self.i)]);
self.s.swap(usize::from(self.i), usize::from(self.j));
self.s[usize::from(self.s[usize::from(self.i)].wrapping_add(self.s[usize::from(self.j)]))]
}
pub(crate) fn apply(&mut self, data: &mut [u8]) {
for b in data {
*b ^= self.next();
}
}
}
fn utf16le(password: &str) -> Vec<u8> {
password.encode_utf16().take(MAX_PASSWORD_CHARS).flat_map(u16::to_le_bytes).collect()
}
fn u16_at(d: &[u8], at: usize) -> Option<u16> {
Some(u16::from_le_bytes(d.get(at..at.checked_add(2)?)?.try_into().ok()?))
}
fn u32_at(d: &[u8], at: usize) -> Option<u32> {
Some(u32::from_le_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?))
}
// ---------------------------------------------------------------------------------------------
// XOR obfuscation (method 1 key/verifier pieces, method 2 array and transform)
const PAD_ARRAY: [u8; 15] = [0xBB, 0xFF, 0xFF, 0xBA, 0xFF, 0xFF, 0xB9, 0x80, 0x00, 0xBE, 0x0F, 0x00, 0xBF, 0x0F, 0x00];
const INITIAL_CODE: [u16; 15] =
[0xE1F0, 0x1D0F, 0xCC9C, 0x84C0, 0x110C, 0x0E10, 0xF1CE, 0x313E, 0x1872, 0xE139, 0xD40F, 0x84F9, 0x280C, 0xA96A, 0x4EC3];
const XOR_MATRIX: [u16; 105] = [
0xAEFC, 0x4DD9, 0x9BB2, 0x2745, 0x4E8A, 0x9D14, 0x2A09, 0x7B61, 0xF6C2, 0xFDA5, 0xEB6B, 0xC6F7, 0x9DCF, 0x2BBF, 0x4563, 0x8AC6, 0x05AD, 0x0B5A,
0x16B4, 0x2D68, 0x5AD0, 0x0375, 0x06EA, 0x0DD4, 0x1BA8, 0x3750, 0x6EA0, 0xDD40, 0xD849, 0xA0B3, 0x5147, 0xA28E, 0x553D, 0xAA7A, 0x44D5, 0x6F45,
0xDE8A, 0xAD35, 0x4A4B, 0x9496, 0x390D, 0x721A, 0xEB23, 0xC667, 0x9CEF, 0x29FF, 0x53FE, 0xA7FC, 0x5FD9, 0x47D3, 0x8FA6, 0x0F6D, 0x1EDA, 0x3DB4,
0x7B68, 0xF6D0, 0xB861, 0x60E3, 0xC1C6, 0x93AD, 0x377B, 0x6EF6, 0xDDEC, 0x45A0, 0x8B40, 0x06A1, 0x0D42, 0x1A84, 0x3508, 0x6A10, 0xAA51, 0x4483,
0x8906, 0x022D, 0x045A, 0x08B4, 0x1168, 0x76B4, 0xED68, 0xCAF1, 0x85C3, 0x1BA7, 0x374E, 0x6E9C, 0x3730, 0x6E60, 0xDCC0, 0xA9A1, 0x4363, 0x86C6,
0x1DAD, 0x3331, 0x6662, 0xCCC4, 0x89A9, 0x0373, 0x06E6, 0x0DCC, 0x1021, 0x2042, 0x4084, 0x8108, 0x1231, 0x2462, 0x48C4,
];
/// `CreateXorKey_Method1`. `pw` is 1 to 15 bytes.
fn xor_key(pw: &[u8]) -> u16 {
let mut key = INITIAL_CODE.get(pw.len().wrapping_sub(1)).copied().unwrap_or(0);
let mut current = 0x68i32;
for &ch in pw.iter().rev() {
let mut ch = ch;
for _ in 0..7 {
if ch & 0x40 != 0
&& let Some(m) = usize::try_from(current).ok().and_then(|c| XOR_MATRIX.get(c))
{
key ^= m;
}
ch <<= 1;
current -= 1;
}
}
key
}
/// `CreatePasswordVerifier_Method1`.
fn verifier_method1(pw: &[u8]) -> u16 {
let mut v = 0u16;
for &b in pw.iter().rev().chain(std::iter::once(&(pw.len() as u8))) {
let i1 = u16::from(v & 0x4000 != 0);
let i2 = v.wrapping_mul(2) & 0x7FFF;
v = (i1 | i2) ^ u16::from(b);
}
v ^ 0xCE4B
}
/// `CreatePasswordVerifier_Method2`: the 32-bit value stored in `FibBase.lKey`.
pub(crate) fn xor_verifier(pw: &[u8]) -> u32 {
(u32::from(xor_key(pw)) << 16) | u32::from(verifier_method1(pw))
}
/// `CreateXorArray_Method2`.
pub(crate) fn xor_array(pw: &[u8]) -> [u8; 16] {
let key = (xor_verifier(pw) >> 16) as u16;
let (high, low) = ((key >> 8) as u8, key as u8);
let mut arr = [0u8; 16];
for (i, slot) in arr.iter_mut().enumerate() {
*slot = pw.get(i).copied().unwrap_or_else(|| PAD_ARRAY.get(i.saturating_sub(pw.len())).copied().unwrap_or(0));
}
let (pairs, _) = arr.as_chunks_mut::<2>();
for pair in pairs {
pair[0] = (pair[0] ^ low).rotate_right(1);
pair[1] = (pair[1] ^ high).rotate_right(1);
}
arr
}
/// The single-byte forms of a password the XOR scheme tries: the system-codepage form (Latin-1
/// here) and the "low byte, or high byte when the low one is zero" form. Each is cut to 15.
fn xor_candidates(password: &str) -> Vec<Vec<u8>> {
let mut out: Vec<Vec<u8>> = Vec::new();
let latin: Option<Vec<u8>> = password.chars().take(MAX_XOR_PASSWORD).map(|c| u8::try_from(u32::from(c)).ok()).collect();
if let Some(l) = latin {
out.push(l);
}
let folded: Vec<u8> = password.encode_utf16().take(MAX_XOR_PASSWORD).map(|u| if u & 0xFF == 0 { (u >> 8) as u8 } else { u as u8 }).collect();
if !out.contains(&folded) {
out.push(folded);
}
out.retain(|p| !p.is_empty());
out
}
// ---------------------------------------------------------------------------------------------
// The cipher
/// How the streams of one file are transformed, once the password has been verified.
pub(crate) enum Cipher {
Xor { array: [u8; 16] },
Rc4 { base: Vec<u8>, crypto_api: bool, key_len: usize, table_skip: usize },
}
impl Cipher {
/// Check `password` against the file's verifier and set up the cipher. `flags` and `lkey` come
/// from the FIB base, `table` is the (still encrypted) Table stream.
pub(crate) fn open(flags: u16, lkey: u32, table: &[u8], password: &str) -> Result<Cipher, DocbinError> {
if flags & F_OBFUSCATED != 0 {
for pw in xor_candidates(password) {
if xor_verifier(&pw) == lkey {
return Ok(Cipher::Xor { array: xor_array(&pw) });
}
}
return Err(DocbinError::WrongPassword);
}
let short = || DocbinError::Malformed("the encryption header is truncated".into());
let major = u16_at(table, 0).ok_or_else(short)?;
let minor = u16_at(table, 2).ok_or_else(short)?;
match (major, minor) {
(1, 1) => Self::open_rc4(table, password),
(2..=4, 2) => Self::open_crypto_api(table, password),
_ => Err(unsupported(&format!("unknown encryption version {major}.{minor}"))),
}
}
fn open_rc4(table: &[u8], password: &str) -> Result<Cipher, DocbinError> {
let short = || DocbinError::Malformed("the encryption header is truncated".into());
let salt = table.get(4..20).ok_or_else(short)?;
let enc_verifier = table.get(20..36).ok_or_else(short)?;
let enc_hash = table.get(36..52).ok_or_else(short)?;
let h0 = md5(&utf16le(password));
let mut buf = Vec::with_capacity(336);
for _ in 0..16 {
buf.extend_from_slice(&h0[..5]);
buf.extend_from_slice(salt);
}
let base = md5(&buf)[..5].to_vec();
let cipher = Cipher::Rc4 { base, crypto_api: false, key_len: 16, table_skip: 52 };
let mut rc4 = Rc4::new(&cipher.block_key(0));
let mut verifier = [0u8; 16];
verifier.copy_from_slice(enc_verifier);
let mut hash = [0u8; 16];
hash.copy_from_slice(enc_hash);
rc4.apply(&mut verifier);
rc4.apply(&mut hash);
if md5(&verifier) != hash {
return Err(DocbinError::WrongPassword);
}
Ok(cipher)
}
fn open_crypto_api(table: &[u8], password: &str) -> Result<Cipher, DocbinError> {
let short = || DocbinError::Malformed("the encryption header is truncated".into());
let hflags = u32_at(table, 4).ok_or_else(short)?;
if hflags & H_EXTERNAL != 0 {
return Err(unsupported("the document uses an external encryption provider"));
}
if hflags & H_AES != 0 {
return Err(unsupported("AES-encrypted .doc files are not supported"));
}
if hflags & H_CRYPTO_API == 0 {
return Err(unsupported("unknown encryption header"));
}
let hsize = usize::try_from(u32_at(table, 8).ok_or_else(short)?).unwrap_or(usize::MAX);
// EncryptionHeader: flags, sizeExtra, algId, algIdHash, keySize, providerType, 2 reserved
// words and the CSP name; it starts at byte 12.
if hsize < 32 || hsize > table.len() {
return Err(DocbinError::Malformed("the encryption header has a bad size".into()));
}
let alg_id = u32_at(table, 20).ok_or_else(short)?;
let alg_hash = u32_at(table, 24).ok_or_else(short)?;
if !matches!(alg_id, 0 | 0x6801) {
return Err(unsupported(&format!("encryption algorithm 0x{alg_id:X}")));
}
if !matches!(alg_hash, 0 | 0x8004) {
return Err(unsupported(&format!("hash algorithm 0x{alg_hash:X}")));
}
let mut bits = u32_at(table, 28).ok_or_else(short)?;
if bits == 0 {
bits = 40;
}
if !(40..=128).contains(&bits) || bits % 8 != 0 {
return Err(unsupported(&format!("a {bits}-bit key")));
}
let key_len = (bits / 8) as usize;
// EncryptionVerifier: saltSize, salt[16], encryptedVerifier[16], verifierHashSize,
// encryptedVerifierHash[20].
let v = 12 + hsize;
if u32_at(table, v) != Some(16) {
return Err(DocbinError::Malformed("the encryption salt has a bad size".into()));
}
let salt = table.get(v + 4..v + 20).ok_or_else(short)?;
let enc_verifier = table.get(v + 20..v + 36).ok_or_else(short)?;
if u32_at(table, v + 36) != Some(20) {
return Err(unsupported("a verifier hash that is not SHA-1"));
}
let enc_hash = table.get(v + 40..v + 60).ok_or_else(short)?;
let mut pw = utf16le(password);
let base = sha1(&[salt, &pw]);
pw.fill(0);
let cipher = Cipher::Rc4 { base, crypto_api: true, key_len, table_skip: v + 60 };
let mut rc4 = Rc4::new(&cipher.block_key(0));
let mut verifier = enc_verifier.to_vec();
let mut hash = enc_hash.to_vec();
rc4.apply(&mut verifier);
rc4.apply(&mut hash);
if sha1(&[&verifier]) != hash {
return Err(DocbinError::WrongPassword);
}
Ok(cipher)
}
/// The RC4 key of stream block `block`.
fn block_key(&self, block: u32) -> Vec<u8> {
match self {
Cipher::Xor { .. } => Vec::new(),
Cipher::Rc4 { base, crypto_api: false, .. } => md5(&[base.as_slice(), &block.to_le_bytes()].concat()).to_vec(),
Cipher::Rc4 { base, crypto_api: true, key_len, .. } => {
let mut k = sha1(&[base, &block.to_le_bytes()]);
k.truncate(*key_len);
// A 40-bit key is zero-padded to 128 bits.
if *key_len == 5 {
k.resize(16, 0);
}
k
}
}
}
/// Decrypt `data` in place; its first `skip` bytes are stored untransformed.
fn decrypt(&self, data: &mut [u8], skip: usize) {
match self {
Cipher::Xor { array } => {
for (i, b) in data.iter_mut().enumerate().skip(skip) {
let a = array[i % 16];
// Zero bytes, and bytes equal to the array element, were left as they were.
if *b != 0 && *b != a {
*b ^= a;
}
}
}
Cipher::Rc4 { .. } => {
for (n, chunk) in data.chunks_mut(BLOCK).enumerate() {
let Ok(block) = u32::try_from(n) else { return };
let mut rc4 = Rc4::new(&self.block_key(block));
let start = n * BLOCK;
// The keystream runs over the untransformed bytes too.
let plain = skip.saturating_sub(start).min(chunk.len());
let head = chunk.get(..plain).map(<[u8]>::to_vec).unwrap_or_default();
rc4.apply(chunk);
if let Some(dst) = chunk.get_mut(..plain) {
dst.copy_from_slice(&head);
}
}
}
}
}
pub(crate) fn decrypt_word(&self, data: &mut [u8]) {
self.decrypt(data, FIB_PLAIN);
}
pub(crate) fn decrypt_table(&self, data: &mut [u8]) {
let skip = match self {
Cipher::Xor { .. } => 0,
Cipher::Rc4 { table_skip, .. } => *table_skip,
};
self.decrypt(data, skip);
}
pub(crate) fn decrypt_data(&self, data: &mut [u8]) {
self.decrypt(data, 0);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
#[test]
fn md5_matches_the_rfc_1321_vectors() {
assert_eq!(hex(&md5(b"")), "d41d8cd98f00b204e9800998ecf8427e");
assert_eq!(hex(&md5(b"a")), "0cc175b9c0f1b6a831c399e269772661");
assert_eq!(hex(&md5(b"abc")), "900150983cd24fb0d6963f7d28e17f72");
assert_eq!(hex(&md5(b"message digest")), "f96b697d7cb7938d525a2f31aaf161d0");
assert_eq!(
hex(&md5(b"12345678901234567890123456789012345678901234567890123456789012345678901234567890")),
"57edf4a22be3c955ac49da2e2107b67a"
);
}
#[test]
fn rc4_matches_a_published_vector() {
let mut d = b"Plaintext".to_vec();
Rc4::new(b"Key").apply(&mut d);
assert_eq!(hex(&d), "bbf316e8d940af0ad3");
}
#[test]
fn xor_candidates_cut_to_fifteen_and_skip_empty() {
assert!(xor_candidates("").is_empty());
assert_eq!(xor_candidates("abcdefghijklmnopqrst")[0].len(), 15);
// Not representable as single bytes: only the folded form remains.
assert_eq!(xor_candidates("\u{4e2d}").len(), 1);
}
}
+9 -2
View File
@@ -37,16 +37,22 @@ fn plc(table: &[u8], (fc, lcb): (u32, u32), cb: usize) -> Vec<(u32, &[u8])> {
/// The text box stories of a `PlcftxbxTxt` (or `PlcfHdrtxbxTxt`): (shape spid, story CP range
/// relative to the text box subdocument).
///
/// The CPs of a well-formed PLC ascend, so the stories are disjoint. A story starting before
/// the end of the last one kept (a hostile table pointing many text boxes at the same text,
/// each read into its own part) is dropped: every subdocument CP becomes at most one story.
pub(crate) fn textbox_stories(table: &[u8], pair: Option<(u32, u32)>) -> Vec<(u32, (u32, u32))> {
let Some(pair) = pair else { return Vec::new() };
let Some(blob) = table.get(pair.0 as usize..(pair.0 as usize).saturating_add(pair.1 as usize)) else { return Vec::new() };
let mut out = Vec::new();
let mut kept_to = 0u32;
for (i, (cp, ftxbxs)) in plc(table, pair, CB_FTXBXS).iter().enumerate() {
// The story runs to the next entry's CP (the PLC's last CP for the last entry).
let Some(end) = blob.get((i + 1) * 4..(i + 1) * 4 + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes) else { continue };
let Some(lid) = ftxbxs.get(14..18).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes) else { continue };
if end > *cp && lid != 0 {
if end > *cp && *cp >= kept_to && lid != 0 {
out.push((lid, (*cp, end)));
kept_to = end;
}
}
out
@@ -180,7 +186,8 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
2 => ShapeKind::RoundedRectangle,
3 => ShapeKind::Ellipse,
4 => ShapeKind::Diamond,
5 | 6 => ShapeKind::Triangle,
5 => ShapeKind::Triangle,
6 => ShapeKind::RightTriangle,
12 | 187 => ShapeKind::Star,
13 | 66 | 67 | 68 | 69 => ShapeKind::Arrow,
20 | 32 => ShapeKind::Line,
+25 -2
View File
@@ -80,10 +80,33 @@ impl SubDocs {
}
}
/// What the unencrypted head of a password-protected file says (see [`peek_encryption`]).
#[derive(Clone, Copy, Debug)]
pub(crate) struct Protection {
pub(crate) flags: u16,
/// `FibBase.lKey`: the XOR verifier, or the size of the RC4 header in the Table stream.
pub(crate) lkey: u32,
pub(crate) which_tbl_stm: bool,
}
/// Is the WordDocument stream that of an encrypted Word 97-2003 file? Only the first 68 bytes are
/// stored in the clear; this reads nothing else. `None` for anything else, including streams that
/// are too short or have a foreign magic (the full parse reports those).
pub(crate) fn peek_encryption(word: &[u8]) -> Option<Protection> {
if word.len() < crate::crypt::FIB_PLAIN || word.get(0..2)? != W_IDENT.to_le_bytes() {
return None;
}
let flags = u16::from_le_bytes([*word.get(OFF_FLAGS)?, *word.get(OFF_FLAGS + 1)?]);
if flags & crate::crypt::F_ENCRYPTED == 0 {
return None;
}
let lkey = u32::from_le_bytes([*word.get(0x0E)?, *word.get(0x0F)?, *word.get(0x10)?, *word.get(0x11)?]);
Some(Protection { flags, lkey, which_tbl_stm: flags & 0x0200 != 0 })
}
/// The parsed FIB.
#[derive(Clone, Debug)]
pub(crate) struct Fib {
pub(crate) encrypted: bool,
/// Selects 1Table (true) or 0Table (false).
pub(crate) which_tbl_stm: bool,
pub(crate) ccp: SubDocs,
@@ -157,6 +180,6 @@ impl Fib {
pairs.push(u32le(blob_off + i * 4));
}
Ok(Fib { encrypted: flags & 0x0100 != 0, which_tbl_stm: flags & 0x0200 != 0, ccp: sub, pairs })
Ok(Fib { which_tbl_stm: flags & 0x0200 != 0, ccp: sub, pairs })
}
}
+72 -18
View File
@@ -8,12 +8,14 @@
//! capped), so hostile input yields an error or a best-effort document, never a panic.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod crypt;
mod drawing;
mod fib;
mod fkp;
mod fmt;
mod list;
mod media;
mod metadata;
mod notes;
mod officeart;
mod piece;
@@ -43,6 +45,10 @@ use table::ParaOut;
const MAX_STREAM: u64 = 1 << 30;
/// Most header/footer parts we create (matches the docx reader's cap).
const MAX_PARTS: usize = 50_000;
/// Hard ceiling on characters materialised across every story walk of one document (body,
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
const MAX_TOTAL_CHARS: u64 = 64 << 20;
/// `sprmCPicLocation`: the fc of a picture in the Data stream.
const C_PIC_LOCATION: u16 = 0x6A03;
/// `sprmPIlvl` / `sprmPIlfo`: the paragraph's list level and list.
@@ -58,9 +64,16 @@ pub enum DocbinError {
/// The compound file is readable but is not a Word 97-2003 document.
#[error("not a Word 97-2003 document: {0}")]
NotWord(String),
/// The document is password-protected (XOR obfuscation or RC4 encryption).
#[error("encrypted or password-protected .doc files are not supported")]
Encrypted,
/// The document is password-protected and no password was given.
#[error("this document is protected with a password")]
PasswordRequired,
/// The password does not open the document.
#[error("the password is incorrect")]
WrongPassword,
/// The document is encrypted in a way WordCraft can't read (AES, an external provider, an
/// unknown header).
#[error("encrypted document: {0}")]
Encryption(String),
/// The input exceeds a safety limit (stream size, piece count…).
#[error("limit exceeded: {0}")]
Limit(String),
@@ -69,25 +82,51 @@ pub enum DocbinError {
Malformed(String),
}
/// Read a `.doc` (Word 97-2003 binary) file into a [`Document`].
/// Is this `.doc` password-protected (XOR obfuscation, RC4 or RC4 CryptoAPI)? False for
/// anything that is not a readable Word 97-2003 file.
pub fn is_encrypted(bytes: &[u8]) -> bool {
let Ok(mut comp) = cfb::CompoundFile::open(std::io::Cursor::new(bytes)) else { return false };
stream(&mut comp, "WordDocument").is_some_and(|w| fib::peek_encryption(&w).is_some())
}
/// Read a `.doc` (Word 97-2003 binary) file into a [`Document`]. A password-protected file
/// gives [`DocbinError::PasswordRequired`]; see [`read_with_password`].
pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
read_with_password(bytes, None)
}
/// [`read`] for a file that may be password-protected: it is checked against the file's stored
/// verifier ([`DocbinError::WrongPassword`] when it does not match) and then decrypted. The
/// password is ignored for an unprotected file.
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Document, DocbinError> {
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))?;
let word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
if word.len() as u64 > MAX_STREAM {
return Err(DocbinError::Limit(format!("WordDocument stream is {} bytes", word.len())));
}
let fib = fib::Fib::parse(&word)?;
if fib.encrypted {
return Err(DocbinError::Encrypted);
}
let enc = fib::peek_encryption(&word);
// The FIB names the Table stream (0Table or 1Table); fall back to whichever exists,
// since files written by other writers sometimes carry the wrong bit.
let named = if fib.which_tbl_stm { "1Table" } else { "0Table" };
let other = if fib.which_tbl_stm { "0Table" } else { "1Table" };
let table = stream(&mut comp, named).or_else(|| stream(&mut comp, other)).unwrap_or_default();
let which = if let Some(e) = enc { e.which_tbl_stm } else { fib::Fib::parse(&word)?.which_tbl_stm };
let named = if which { "1Table" } else { "0Table" };
let other = if which { "0Table" } else { "1Table" };
let mut table = stream(&mut comp, named).or_else(|| stream(&mut comp, other)).unwrap_or_default();
if table.len() as u64 > MAX_STREAM {
return Err(DocbinError::Limit(format!("Table stream is {} bytes", table.len())));
}
let mut data = stream(&mut comp, "Data").unwrap_or_default();
if data.len() as u64 > MAX_STREAM {
return Err(DocbinError::Limit(format!("Data stream is {} bytes", data.len())));
}
if let Some(e) = enc {
// Verify the password first; only then is anything decrypted.
let password = password.ok_or(DocbinError::PasswordRequired)?;
let cipher = crypt::Cipher::open(e.flags, e.lkey, &table, password)?;
cipher.decrypt_word(&mut word);
cipher.decrypt_table(&mut table);
cipher.decrypt_data(&mut data);
}
let fib = fib::Fib::parse(&word)?;
// The piece table locates every character in the WordDocument stream.
let (fc_clx, lcb_clx) =
@@ -109,12 +148,10 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
None => Bins::parse(&[], 0, 0)?,
};
let data = stream(&mut comp, "Data").unwrap_or_default();
if data.len() as u64 > MAX_STREAM {
return Err(DocbinError::Limit(format!("Data stream is {} bytes", data.len())));
}
let mut doc = Document::new();
if let Some(core) = metadata::read(&mut comp) {
doc.core = core;
}
doc.body.clear();
doc.styles = fmt::stylesheet(&raw_styles, &fonts);
doc.numbering = list::parse(&table, &fib, &fonts);
@@ -129,6 +166,12 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
let hdd_base = fib.ccp.text.saturating_add(fib.ccp.ftn);
let edn_base = hdd_base.saturating_add(fib.ccp.hdd).saturating_add(fib.ccp.atn);
let mut pics = media::Pictures::default();
// Every materialised character comes from at least one byte of the WordDocument stream, so a
// legitimate document's total text (body plus every header, footnote and text box, each read
// once) cannot exceed its stream. A hostile piece or story table that aliases the same bytes
// many times can, so the walk is held to twice the stream's size — generous for real files,
// tight against aliasing — and never past the absolute ceiling.
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
let ctx = WalkCtx {
word: &word,
@@ -143,6 +186,7 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
marks: &[],
drawn: &[],
sect_ends: &[],
budget: &budget,
};
// Drawings: text box stories become parts first, then the floating shapes and pictures
@@ -283,6 +327,10 @@ struct WalkCtx<'a> {
/// Where sections end (main-document CPs, sorted): the section mark (0x0C) just before
/// each ends the section's last paragraph, it isn't a page break inside it.
sect_ends: &'a [u32],
/// Characters still allowed across every story walk of this document. Story ranges (headers,
/// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of
/// them could otherwise materialise the same text thousands of times; this bounds the total.
budget: &'a std::cell::Cell<u64>,
}
/// State while assembling one paragraph from per-CP formatting.
@@ -356,6 +404,12 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
continue;
}
};
// Every real character counts against the document-wide budget; once it is spent, stop
// (a hostile file with thousands of story ranges over the same text can't multiply on).
if ctx.budget.get() == 0 {
break;
}
ctx.budget.set(ctx.budget.get() - 1);
// A section mark (0x0C) at a section's end is that section's last paragraph mark.
let section_mark = c == '\u{C}' && ctx.sect_ends.binary_search(&cp.saturating_add(1)).is_ok();
match c {
@@ -521,7 +575,7 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild) {
return;
}
pb.push('\u{FFFC}', CharProps::default());
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, code: false });
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
}
/// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target
+2 -1
View File
@@ -85,6 +85,7 @@ pub(crate) fn parse(table: &[u8], fib: &Fib, fonts: &[String]) -> Numbering {
id: i as u32,
name: None,
levels: def.levels.iter().enumerate().map(|(j, l)| level_of(l, j, fonts)).collect(),
..Default::default()
});
}
if let Some((fc, lcb)) = fib.pair(pair::PLF_LFO)
@@ -224,7 +225,7 @@ fn parse_plf_lfo(table: &[u8], fc: u32, defs: &[ListDef], numbering: &mut Number
}
}
let id = numbering.abstracts.len() as u32;
numbering.abstracts.push(AbstractNum { id, name: None, levels });
numbering.abstracts.push(AbstractNum { id, name: None, levels, ..Default::default() });
id
};
numbering.nums.push(Num { id: (i + 1) as u32, abstract_id, start_overrides, ..Default::default() });
+32 -19
View File
@@ -19,12 +19,13 @@ const MAX_PICTURES: usize = 5_000;
pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20;
/// The document's pictures, keyed by their location in the Data stream: anchors that share a
/// `fc_pic` share one media entry, and the total bytes copied are capped, so a file with
/// thousands of anchors on one huge picture costs one copy, not thousands. Pictures of the
/// drawing layer's BLIP store are shared the same way, by store index.
/// `fc_pic` share one media entry (or one failure), and the total bytes copied are capped, so a
/// file with thousands of anchors on one huge picture costs one copy, not thousands. Pictures of
/// the drawing layer's BLIP store are shared the same way, by store index.
pub(crate) struct Pictures {
/// fc_pic → the inline picture already materialised.
by_fc: HashMap<u32, Inline>,
/// fc_pic → the inline picture already materialised (`None`: unreadable or over the cap,
/// not retried).
by_fc: HashMap<u32, Option<Inline>>,
/// BLIP store index → media key (`None`: unreadable, not retried).
by_bse: HashMap<u32, Option<String>>,
/// The media entries, merged into the document at the end.
@@ -68,24 +69,34 @@ impl Pictures {
/// count/byte cap is reached.
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<Inline> {
if let Some(hit) = self.by_fc.get(&fc_pic) {
return Some(hit.clone());
return hit.clone();
}
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
return None;
}
let pic = match locate(data, fc_pic)? {
let pic = self.read(data, fc_pic);
self.by_fc.insert(fc_pic, pic.clone());
pic
}
/// Read the inline picture at `fc_pic`: nothing is decoded or copied beyond what is left of
/// the byte cap, and nothing at all once it is used up.
fn read(&mut self, data: &[u8], fc_pic: u32) -> Option<Inline> {
let room = self.limit.saturating_sub(self.total);
if room == 0 {
return None;
}
match locate(data, fc_pic, room)? {
Found::Range(pic) => {
let bytes = data.get(pic.range.clone())?.to_vec();
let bytes = data.get(pic.range.clone()).filter(|b| b.len() <= room)?.to_vec();
let key = self.insert(bytes, pic.ext, false)?;
Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() }
Some(Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() })
}
Found::Shape { w, h, blip, props } => {
let key = self.insert(blip.bytes, blip.ext, blip.placeholder)?;
Inline { key, w, h, crop: props.crop, alt: props.alt }
Some(Inline { key, w, h, crop: props.crop, alt: props.alt })
}
};
self.by_fc.insert(fc_pic, pic.clone());
Some(pic)
}
}
/// The media key of BLIP store entry `pib` (1-based), decoding it the first time; `delay`
@@ -148,8 +159,9 @@ enum Found {
const MM_SHAPE: u16 = 0x0064;
const MM_SHAPEFILE: u16 = 0x0066;
/// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData).
fn locate(data: &[u8], fc_pic: u32) -> Option<Found> {
/// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData);
/// a shape's picture is decoded only up to `room` bytes.
fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
let base = fc_pic as usize;
let pic = data.get(base..)?;
let lcb = u32::from_le_bytes(pic.get(0..4)?.try_into().ok()?) as usize;
@@ -168,7 +180,7 @@ fn locate(data: &[u8], fc_pic: u32) -> Option<Found> {
if mm == MM_SHAPEFILE {
at = at.checked_add(1 + *pic.get(at)? as usize)?;
}
let (blip, props) = inline_shape(pic.get(at..end)?, data)?;
let (blip, props) = inline_shape(pic.get(at..end)?, data, room)?;
// The goal size is before cropping; the crop fractions take their share off it.
let [cl, ct, cr, cb] = props.crop;
let w = (dxa_goal / 20.0 * (1.0 - cl - cr).max(0.01) * scale(mx)).clamp(1.0, 1584.0);
@@ -185,14 +197,15 @@ fn locate(data: &[u8], fc_pic: u32) -> Option<Found> {
}
/// An `OfficeArtInlineSpContainer`: the picture shape, then the `OfficeArtFBSE` records its
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream).
fn inline_shape(buf: &[u8], data: &[u8]) -> Option<(officeart::Blip, officeart::Props)> {
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). `room` caps the bytes
/// the BLIP decodes to.
fn inline_shape(buf: &[u8], data: &[u8], room: usize) -> Option<(officeart::Blip, officeart::Props)> {
let mut budget = 10_000usize;
let recs = officeart::children(buf, &mut budget);
let sp = recs.iter().find(|r| r.typ == officeart::rt::SP_CONTAINER).map(|r| officeart::shape(r.body, &mut budget))?;
let fbses: Vec<&[u8]> = recs.iter().filter(|r| r.typ == officeart::rt::FBSE).map(|r| r.body).collect();
let fbse = sp.props.pib.and_then(|p| fbses.get((p as usize).checked_sub(1)?)).or(fbses.first())?;
let blip = officeart::fbse_blip(fbse, data, MAX_MEDIA_BYTES)?;
let blip = officeart::fbse_blip(fbse, data, room)?;
Some((blip, sp.props))
}
+153
View File
@@ -0,0 +1,153 @@
//! Optional document text properties from [MS-OLEPS] §2.21 and §2.25.1.
//! Broken metadata must not prevent opening the document or allocate from unchecked sizes.
use std::io::{Read, Seek};
use wordcraft_doc::CoreProps;
const MAX_STREAM: u64 = 2 << 20;
const MAX_STRING: usize = 64 << 10;
const SUMMARY_FMTID: &[u8] = &[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0x00, 0x2b, 0x27, 0xb3, 0xd9];
pub(crate) fn read<F: Read + Seek>(comp: &mut cfb::CompoundFile<F>) -> Option<CoreProps> {
let stream = comp.open_stream("/\u{5}SummaryInformation").ok()?;
if stream.len() > MAX_STREAM {
return None;
}
let mut bytes = Vec::new();
stream.take(MAX_STREAM + 1).read_to_end(&mut bytes).ok()?;
if bytes.len() as u64 > MAX_STREAM {
return None;
}
parse(&bytes)
}
fn u32_at(bytes: &[u8], offset: usize) -> Option<u32> {
Some(u32::from_le_bytes(bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?))
}
fn parse(bytes: &[u8]) -> Option<CoreProps> {
if bytes.get(..2)? != [0xfe, 0xff] || bytes.get(28..44)? != SUMMARY_FMTID || u32_at(bytes, 24)? < 1 {
return None;
}
if u16::from_le_bytes(bytes.get(2..4)?.try_into().ok()?) > 1 {
return None;
}
let offset = u32_at(bytes, 44)? as usize;
if offset < 48 || !offset.is_multiple_of(4) {
return None;
}
let size = u32_at(bytes, offset)? as usize;
let set = bytes.get(offset..offset.checked_add(size)?)?;
let count = u32_at(set, 4)? as usize;
if count > 4096 {
return None;
}
let table_end = 8 + count * 8;
let table = set.get(8..table_end)?;
// Offsets are relative to the property set. Bound each value by the next property so a
// forged string length cannot read another property's bytes as part of the title.
let property = |id: u32| -> Option<&[u8]> {
let entry = table.as_chunks::<8>().0.iter().find(|entry| u32_at(entry.as_slice(), 0) == Some(id))?.as_slice();
let start = u32_at(entry, 4)? as usize;
if start < table_end || !start.is_multiple_of(4) {
return None;
}
let end = table
.as_chunks::<8>()
.0
.iter()
.filter_map(|e| u32_at(e, 4))
.map(|n| n as usize)
.filter(|n| *n > start)
.min()
.unwrap_or(set.len())
.min(set.len());
set.get(start..end)
};
let codepage = property(1)
.and_then(|cp| {
let vt = u32_at(cp, 0)?;
if vt == 2 || vt == 3 { Some(u16::from_le_bytes(cp.get(4..6)?.try_into().ok()?)) } else { None }
})
.unwrap_or(1252);
let get = |id| property(id).and_then(|value| text(value, codepage)).unwrap_or_default();
let get_filetime = |id| property(id).and_then(filetime_iso).unwrap_or_default();
Some(CoreProps {
title: get(2),
subject: get(3),
creator: get(4),
keywords: get(5),
description: get(6),
last_modified_by: get(8),
revision: get(9).parse().unwrap_or_default(),
created: get_filetime(12),
modified: get_filetime(13),
..Default::default()
})
}
fn civil(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
let doe = (z - era * 146_097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = y + i64::from(m <= 2);
(y, m, d)
}
fn filetime_iso(value: &[u8]) -> Option<String> {
let kind = u32_at(value, 0)?;
if kind != 64 {
return None;
}
let ft_low = u32_at(value, 4)? as u64;
let ft_high = u32_at(value, 8)? as u64;
let ft = (ft_high << 32) | ft_low;
if ft == 0 {
return None;
}
const EPOCH_DIFFERENCE: u64 = 116_444_736_000_000_000;
let unix_100ns = ft.checked_sub(EPOCH_DIFFERENCE)?;
let unix_secs = unix_100ns / 10_000_000;
let days = (unix_secs / 86_400) as i64;
let secs_of_day = (unix_secs % 86_400) as u32;
let (h, mi, s) = (secs_of_day / 3600, (secs_of_day / 60) % 60, secs_of_day % 60);
let (y, m, d) = civil(days);
Some(format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"))
}
fn text(value: &[u8], codepage: u16) -> Option<String> {
let kind = u32_at(value, 0)?;
let count = u32_at(value, 4)? as usize;
let size = match kind {
30 => count, // CodePageString length is in bytes, even for code page 1200.
31 => count.checked_mul(2)?, // UnicodeString length is in UTF-16 units.
_ => return None,
};
if size > MAX_STRING {
return None;
}
let bytes = value.get(8..8usize.checked_add(size)?)?;
if kind == 31 || codepage == 1200 {
if !bytes.len().is_multiple_of(2) {
return None;
}
let units = bytes.as_chunks::<2>().0.iter().map(|b| u16::from_le_bytes([b[0], b[1]])).take_while(|u| *u != 0);
Some(char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect())
} else {
let bytes = bytes.split(|b| *b == 0).next().unwrap_or_default();
if codepage == 65001 {
Some(String::from_utf8_lossy(bytes).into_owned())
} else if wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(&codepage) {
Some(bytes.iter().map(|b| wordcraft_doc::encoding::windows_byte(codepage, *b)).collect())
} else {
None
}
}
}
+10 -2
View File
@@ -7,6 +7,11 @@ const MAX: usize = 10_000;
/// Parse the note reference/story PLCs. `ref_pair`/`txt_pair` are the FIB pair indices
/// (`(2, 3)` for footnotes, `(46, 47)` for endnotes); story ranges are relative to the
/// note subdocument.
///
/// The story CPs of a well-formed PLC ascend, so the stories are disjoint. A hostile table
/// that steps back would make many notes read the same text again (each one materialised
/// as its own part), so a story starting before the end of the last one kept is dropped:
/// every subdocument CP becomes at most one note.
pub(crate) fn parse_notes(table: &[u8], fib: &crate::fib::Fib, ref_pair: usize, txt_pair: usize) -> Vec<(u32, (u32, u32))> {
let refs = fib.pair(ref_pair).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 2)).unwrap_or_default();
let txts = fib.pair(txt_pair).and_then(|(fc, lcb)| plc_of(table, fc, lcb, 0)).unwrap_or_default();
@@ -14,12 +19,15 @@ pub(crate) fn parse_notes(table: &[u8], fib: &crate::fib::Fib, ref_pair: usize,
return Vec::new();
}
let mut out = Vec::new();
let mut kept_to = 0u32;
// The reference PLC's aFtnIdx order matches the story order; pair them by index.
for (i, &cp) in refs.iter().enumerate() {
if let (Some(a), Some(b)) = (txts.get(i), txts.get(i + 1))
if let (Some(&a), Some(&b)) = (txts.get(i), txts.get(i + 1))
&& b > a
&& a >= kept_to
{
out.push((cp, (*a, *b)));
out.push((cp, (a, b)));
kept_to = b;
}
}
out
+36 -7
View File
@@ -6,6 +6,9 @@
//! and record counts are capped, and decompressed metafiles are size-limited.
use std::collections::HashMap;
use std::io::Write;
use image::ImageEncoder;
use wordcraft_doc::props::Rgb;
@@ -138,7 +141,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
rt::BLIP_PNG => Some(Blip { bytes: data.to_vec(), ext: "png", placeholder: false }),
rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK => Some(Blip { bytes: data.to_vec(), ext: "jpeg", placeholder: false }),
rt::BLIP_TIFF => Some(Blip { bytes: data.to_vec(), ext: "tiff", placeholder: false }),
_ => dib_to_png(data),
_ => dib_to_png(data, limit),
}
}
rt::BLIP_EMF | rt::BLIP_WMF | rt::BLIP_PICT => {
@@ -175,7 +178,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
/// A device-independent bitmap (BITMAPINFOHEADER + palette + pixels) as a PNG: the DIB gets
/// the 14-byte BMP file header it lacks, is decoded with size limits and re-encoded. When the
/// bitmap can't be decoded the BMP bytes are kept (and render as a grey placeholder).
fn dib_to_png(dib: &[u8]) -> Option<Blip> {
fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
let header = u32_at(dib, 0)? as usize;
if !(12..=124).contains(&header) {
return None;
@@ -193,7 +196,7 @@ fn dib_to_png(dib: &[u8]) -> Option<Blip> {
// BI_BITFIELDS masks follow a 40-byte header.
let masks = if header == 40 && (compression == 3 || compression == 6) { if compression == 3 { 12 } else { 16 } } else { 0 };
let off_bits = 14usize.checked_add(header)?.checked_add(colors * entry)?.checked_add(masks)?;
let file_size = 14usize.checked_add(dib.len())?;
let file_size = 14usize.checked_add(dib.len()).filter(|&size| size <= limit)?;
let mut bmp = Vec::with_capacity(file_size);
bmp.extend_from_slice(b"BM");
bmp.extend_from_slice(&u32::try_from(file_size).ok()?.to_le_bytes());
@@ -205,17 +208,43 @@ fn dib_to_png(dib: &[u8]) -> Option<Blip> {
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_DIB_SIDE);
limits.max_image_height = Some(MAX_DIB_SIDE);
limits.max_alloc = Some(256 << 20);
limits.max_alloc = Some((256 << 20).min(limit as u64));
reader.limits(limits);
let mut png = Vec::new();
let mut png = PictureWriter { bytes: Vec::new(), limit };
match reader.decode() {
Ok(img) if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_ok() => {
Some(Blip { bytes: png, ext: "png", placeholder: false })
Ok(img)
if image::codecs::png::PngEncoder::new(&mut png).write_image(img.as_bytes(), img.width(), img.height(), img.color().into()).is_ok() =>
{
Some(Blip { bytes: png.bytes, ext: "png", placeholder: false })
}
_ => Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }),
}
}
/// Cap encoded bytes while the PNG encoder writes, before the buffer can grow.
struct PictureWriter {
bytes: Vec<u8>,
limit: usize,
}
impl Write for PictureWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let Some(end) = self.bytes.len().checked_add(buf.len()).filter(|&end| end <= self.limit) else {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "picture exceeds remaining media budget"));
};
if end > self.bytes.capacity() {
let new_capacity = self.bytes.capacity().saturating_mul(2).max(end).min(self.limit);
self.bytes.try_reserve_exact(new_capacity - self.bytes.len()).map_err(std::io::Error::other)?;
}
self.bytes.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
// ---------------------------------------------------------------------------------------------
// Shapes.
+9 -1
View File
@@ -171,6 +171,11 @@ fn apply_section(s: &mut SectionProps, prl: &Prl) {
/// `[cps[i], cps[i+1])`. Stories 0-5 are footnote/endnote separators; every section then owns
/// six stories in the order even header, odd (default) header, even footer, odd (default)
/// footer, first header, first footer. The CPs are relative to the header subdocument.
///
/// The CPs of a well-formed PLC ascend, so the stories are disjoint. A story starting before
/// the end of an earlier one (a hostile table pointing many sections at the same text, each
/// read into its own part) is left empty, like a slot linked to the previous section's:
/// every subdocument CP becomes at most one story.
pub(crate) fn header_stories(table: &[u8], fib: &Fib) -> Vec<(u32, u32)> {
let Some((fc, lcb)) = fib.pair(pair::PLCF_HDD) else { return Vec::new() };
let Some(plc) = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize)) else { return Vec::new() };
@@ -178,9 +183,12 @@ pub(crate) fn header_stories(table: &[u8], fib: &Fib) -> Vec<(u32, u32)> {
let n = (lcb as usize / 4).min(6 + 6 * MAX_SECTIONS + 1);
let cp_at = |i: usize| -> Option<u32> { plc.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) };
let mut stories = Vec::with_capacity(n);
let mut kept_to = 0u32;
for i in 0..n.saturating_sub(1) {
if let (Some(a), Some(b)) = (cp_at(i), cp_at(i + 1)) {
stories.push((a, b.max(a)));
let b = if a < kept_to { a } else { b.max(a) };
kept_to = kept_to.max(b);
stories.push((a, b));
}
}
stories
+122 -33
View File
@@ -28,6 +28,15 @@ const T_TABLE_BORDERS_80: u16 = 0xD605;
const T_ROW_HEIGHT: u16 = 0x9407;
const T_DEF_TABLE: u16 = 0xD608;
const T_DEF_TABLE_SHD_80: u16 = 0xD609;
/// `sprmTDefTableShd` / `Shd2nd` / `Shd3rd`: 10-byte `Shd`s for cells 0-21, 22-43, 44-62.
const T_DEF_TABLE_SHD: u16 = 0xD612;
const T_DEF_TABLE_SHD_2ND: u16 = 0xD616;
const T_DEF_TABLE_SHD_3RD: u16 = 0xD60C;
/// `sprmTDefTableShdRaw` / `Raw2nd` / `Raw3rd`: the same `Shd` arrays over the same cell
/// ranges, which Word 2007+ writes next to the above.
const T_DEF_TABLE_SHD_RAW: u16 = 0xD670;
const T_DEF_TABLE_SHD_RAW_2ND: u16 = 0xD671;
const T_DEF_TABLE_SHD_RAW_3RD: u16 = 0xD672;
const T_CANT_SPLIT_90: u16 = 0x3403;
const T_MERGE: u16 = 0x5624;
const T_VERT_MERGE: u16 = 0xD62B;
@@ -49,8 +58,10 @@ pub(crate) struct RowInfo {
pub(crate) cell_mark: bool,
/// Table depth from `sprmPItap` (0 or 1 supported; deeper tables degrade to paragraphs).
pub(crate) depth: u32,
/// Column widths (points) from `sprmTDefTable`.
pub(crate) grid: Vec<f32>,
/// Cell-edge positions (points) from `sprmTDefTable` — [MS-DOC] defines them per row,
/// so each row keeps its own; ascending in well-formed rows, the first edge usually 0
/// (the row origin). Rows without a `sprmTDefTable` keep this empty.
pub(crate) edges: Vec<f32>,
height: Option<f32>,
height_rule: HeightRule,
header: bool,
@@ -62,8 +73,11 @@ pub(crate) struct RowInfo {
merges: Vec<(u8, u8)>,
/// Per-cell overrides keyed by (pre-merge) cell index.
vmerge: Vec<(u8, VMerge)>,
shading: Vec<(u8, Rgb)>,
/// Later entries win: the `Shd` arrays override the `Shd80` fallback before them, and an
/// automatic `Shd` (`None`) clears it.
shading: Vec<(u8, Option<Rgb>)>,
widths: Vec<(u8, f32)>,
width_pcts: Vec<(u8, f32)>,
valign: Vec<(u8, VAlign)>,
}
@@ -74,6 +88,24 @@ impl RowInfo {
pub(crate) fn height(&self) -> Option<f32> {
self.height
}
/// The fill cell `i` ends up with (the last shading entry for it wins), for tests.
#[cfg(test)]
pub(crate) fn shading_of(&self, i: u8) -> Option<Rgb> {
self.shading.iter().rfind(|(k, _)| *k == i).and_then(|(_, s)| *s)
}
/// The width cell `i` ends up with, for tests.
#[cfg(test)]
pub(crate) fn width_of(&self, i: u8) -> Option<f32> {
self.widths.iter().rfind(|(k, _)| *k == i).map(|(_, w)| *w)
}
/// The width percentage cell `i` ends up with, for tests.
#[cfg(test)]
pub(crate) fn width_pct_of(&self, i: u8) -> Option<f32> {
self.width_pcts.iter().rfind(|(k, _)| *k == i).map(|(_, w)| *w)
}
}
pub(crate) fn decode(papx: &[u8]) -> RowInfo {
@@ -87,7 +119,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
P_IN_TABLE => r.in_table = prl.operand.first().copied().unwrap_or(0) != 0,
P_TTP => r.ttp = prl.operand.first().copied().unwrap_or(0) != 0,
P_ITAP => r.depth = i32_of(&prl).unwrap_or(0).max(0) as u32,
T_DEF_TABLE => r.grid = def_table_grid(prl.operand),
T_DEF_TABLE => r.edges = def_table_edges(prl.operand),
T_TABLE_HEADER => r.header = prl.operand.first().copied().unwrap_or(0) != 0,
T_CANT_SPLIT | T_CANT_SPLIT_90 => r.cant_split = prl.operand.first().copied().unwrap_or(0) != 0,
T_ROW_HEIGHT => {
@@ -135,8 +167,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
if let Some(rgb) = prl.operand.get(3..).and_then(shd_rgb)
&& let [a, b, ..] = prl.operand.get(1..3).unwrap_or(&[])
{
for c in *a..*b {
r.shading.push((c, rgb));
for c in *a..(*b).min(MAX_COLS as u8) {
r.shading.push((c, Some(rgb)));
}
}
}
@@ -144,18 +176,44 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
let cells = prl.operand.get(1..).unwrap_or(&[]);
for (i, b) in cells.as_chunks::<2>().0.iter().take(MAX_COLS).enumerate() {
if let Some(rgb) = shd80_rgb(b) {
r.shading.push((i as u8, rgb));
r.shading.push((i as u8, Some(rgb)));
}
}
}
T_DEF_TABLE_SHD | T_DEF_TABLE_SHD_RAW | T_DEF_TABLE_SHD_2ND | T_DEF_TABLE_SHD_RAW_2ND | T_DEF_TABLE_SHD_3RD | T_DEF_TABLE_SHD_RAW_3RD => {
// Each array covers its own cell range (0-21, 22-43, 44-62); extra entries
// are ignored so an over-long array can't override the next range.
let (first, lim) = match prl.op {
T_DEF_TABLE_SHD_2ND | T_DEF_TABLE_SHD_RAW_2ND => (22, 44),
T_DEF_TABLE_SHD_3RD | T_DEF_TABLE_SHD_RAW_3RD => (44, MAX_COLS),
_ => (0, 22),
};
let cells = prl.operand.get(1..).unwrap_or(&[]);
let raw = matches!(prl.op, T_DEF_TABLE_SHD_RAW | T_DEF_TABLE_SHD_RAW_2ND | T_DEF_TABLE_SHD_RAW_3RD);
for (i, b) in cells.as_chunks::<10>().0.iter().take(lim - first).enumerate() {
// In the Raw arrays a nil Shd (ipat 0xFFFF) means "as the table style
// shades it", so it leaves any earlier fill alone.
if raw && b.get(8..10) == Some(&[0xFF, 0xFF]) {
continue;
}
r.shading.push(((first + i) as u8, shd_rgb(b)));
}
}
T_CELL_WIDTH => {
// cb, itcFirst, itcLim, ftsWidth (3 = twips), wWidth.
if let [a, b, fts, w0, w1, ..] = prl.operand
&& *fts == 3
{
let w = (i16::from_le_bytes([*w0, *w1]) as f32 / 20.0).clamp(0.0, 1584.0);
for c in *a..(*b).min(MAX_COLS as u8) {
r.widths.push((c, w));
// Operand: cb (1 byte), itcFirst (1 byte), itcLim (1 byte), ftsWidth (1 byte), wWidth (2 bytes).
// [MS-DOC] §2.9.290 sprmTCellWidth (0xD635).
// ftsWidth: 3 = twips (dxa), 2 = percentage (50ths of a percent).
if let [_, a, b, fts, w0, w1, ..] = prl.operand {
if *fts == 3 {
let w = (i16::from_le_bytes([*w0, *w1]) as f32 / 20.0).clamp(0.0, 1584.0);
for c in *a..(*b).min(MAX_COLS as u8) {
r.widths.push((c, w));
}
} else if *fts == 2 {
let pct = (u16::from_le_bytes([*w0, *w1]) as f32 / 50.0).clamp(0.0, 100.0);
for c in *a..(*b).min(MAX_COLS as u8) {
r.width_pcts.push((c, pct));
}
}
}
}
@@ -218,14 +276,17 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
if let Some((_, m)) = info.vmerge.iter().find(|(k, _)| *k == i) {
c.props.vmerge = *m;
}
if let Some((_, w)) = info.widths.iter().find(|(k, _)| *k == i) {
if let Some((_, w)) = info.widths.iter().rfind(|(k, _)| *k == i) {
c.props.width = Some(*w);
}
if let Some((_, s)) = info.shading.iter().find(|(k, _)| *k == i) {
c.props.shading = Some(*s);
if let Some((_, pct)) = info.width_pcts.iter().rfind(|(k, _)| *k == i) {
c.props.width_pct = Some(*pct);
}
if let Some((_, v)) = info.valign.iter().find(|(k, _)| *k == i) {
c.props.valign = *v;
if let Some((_, s)) = info.shading.iter().rfind(|(k, _)| *k == i) {
c.props.shading = *s;
}
if let Some((_, v)) = info.valign.iter().rfind(|(k, _)| *k == i) {
c.props.valign = Some(*v);
}
}
for &(a, b) in info.merges.iter().take(MAX_COLS) {
@@ -250,7 +311,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
height_rule: info.height_rule,
header: info.header,
cant_split: info.cant_split,
fmt_change: None,
..Default::default()
},
cells,
controls: Default::default(),
@@ -295,6 +356,13 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
if !is_table {
if open {
flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);
grid.clear();
// Table props are per-table (first row carrying the sprm wins within the
// table): reset the accumulator so one table's borders, align, indent and
// cell margins never leak into the next (the repro's first table wrote
// Word's default 108-twip gap, and every later table — including the
// change history with its own 40-twip gap — inherited 5.4 pt margins).
props = TableProps::default();
open = false;
depth_seen = 0;
}
@@ -305,8 +373,9 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
open = true;
}
depth_seen = depth_seen.max(info.depth);
if !info.grid.is_empty() && grid.is_empty() {
grid = info.grid.clone();
if grid.is_empty() {
// Column widths from the table's first `sprmTDefTable`.
grid = info.edges.windows(2).take(MAX_COLS).map(|w| (w[1] - w[0]).max(0.0)).collect();
}
if info.borders.is_some() && props.borders.is_none() {
props.borders = info.borders;
@@ -318,10 +387,25 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
props.indent = info.indent;
}
if info.cell_mark {
// A row-end (TTP) mark terminates the row. Word writes rows as N content
// cells each ended by their own cell mark, followed by an empty row-end mark
// that carries the row's TDefTable (the 3GPP change history: 8 content marks
// + an empty TTP whose TDefTable defines itcMac = 8). When that mark ends no
// content of its own and the TDefTable already has a column per flushed cell,
// it is a pure row terminator, not the end of a phantom (N+1)th cell — such a
// cell has no column (width None) and shifts every real cell's span one grid
// column over. A TTP mark with content, or one short of the TDefTable's
// columns, still ends a real cell (rows without a TDefTable keep today's
// shape: the count is unknowable, so the mark always flushes).
let row_end_only = info.ttp && p.para.text.is_empty() && cell.is_empty() && !info.edges.is_empty() && cells.len() + 1 >= info.edges.len();
// ...but a row-end mark with no cells before it still makes a (one-cell) row.
let row_end_only = row_end_only && !cells.is_empty();
if !p.para.text.is_empty() {
cell.push(para_block(p.para));
}
flush_cell(&mut cells, &mut cell);
if !row_end_only {
flush_cell(&mut cells, &mut cell);
}
if info.ttp {
let row = build_row(std::mem::take(&mut cells), &info);
rows.push(row);
@@ -336,21 +420,23 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
out
}
/// Column widths (points) from a `sprmTDefTable` operand: cb u16, itcMac u8, then itcMac+1
/// twip boundaries followed by the TC80s (skipped).
fn def_table_grid(operand: &[u8]) -> Vec<f32> {
/// Cell-edge positions (points) from a `sprmTDefTable` operand: cb u16, itcMac u8, then
/// itcMac+1 twip boundaries followed by the TC80s (skipped). Edges are returned as stored:
/// per-row, in operand order; sorting and capping happen when the table grid is assembled.
fn def_table_edges(operand: &[u8]) -> Vec<f32> {
let itc = operand.get(2).copied().unwrap_or(0) as usize;
if itc == 0 || itc > MAX_COLS {
if itc == 0 {
return Vec::new();
}
let mut edges = Vec::with_capacity(itc + 1);
// itcMac is a u8, so this allocation stays tiny; the loop stops at the operand's end.
let mut edges = Vec::with_capacity((itc + 1).min(256));
for i in 0..=itc {
match operand.get(3 + i * 2..3 + i * 2 + 2) {
Some(b) => edges.push(i16::from_le_bytes([b[0], b[1]]) as f32 / 20.0),
None => break,
}
}
edges.windows(2).map(|w| (w[1] - w[0]).max(0.0)).collect()
edges
}
/// Table borders from `sprmTTableBorders80`: cb (0x18) then six Brc80s in the order top,
@@ -381,7 +467,8 @@ fn brc80(b: &[u8]) -> Option<Border> {
}
/// Background colour from a 10-byte `Shd`: cvFore, cvBack (COLORREFs), ipat. Solid patterns
/// show the foreground; other patterns are approximated by their background.
/// show the foreground; clear (0) and the other patterns show the background (Word writes a
/// plain cell fill as clear with cvBack); nil (0xFFFF) and automatic colours are no fill.
fn shd_rgb(b: &[u8]) -> Option<Rgb> {
let cv = |b: &[u8]| -> Option<Rgb> {
match b {
@@ -392,19 +479,21 @@ fn shd_rgb(b: &[u8]) -> Option<Rgb> {
};
let ipat = b.get(8..10).map(|x| u16::from_le_bytes([x[0], x[1]]))?;
match ipat {
0 | 0xFFFF => None,
0xFFFF => None,
1 => cv(b.get(0..4)?),
_ => cv(b.get(4..8)?),
}
}
/// Background colour from a 2-byte `Shd80` (icoFore, icoBack, ipat bit-fields).
/// Background colour from a 2-byte `Shd80` (icoFore, icoBack, ipat bit-fields). As in
/// [`shd_rgb`], solid shows the foreground and clear (0) the background, which is how Word
/// 97 writes a plain fill; ico 0 is automatic (no fill).
fn shd80_rgb(b: &[u8]) -> Option<Rgb> {
let v = u16::from_le_bytes([*b.first()?, *b.get(1)?]);
let fore = v & 0x1F;
let back = (v >> 5) & 0x1F;
let ipat = v >> 10;
if ipat == 0 || (fore == 0x1F && back == 0x1F) {
if fore == 0x1F && back == 0x1F {
return None;
}
fmt::ico(if ipat == 1 { fore as u8 } else { back as u8 })
+426 -2
View File
@@ -8,6 +8,8 @@ use proptest::prelude::*;
use super::read;
mod drawing;
mod encrypted;
mod metadata;
/// Offset in the WordDocument stream where test text pieces are placed (past the FIB).
const TEXT_FC: u32 = 0x600;
@@ -171,10 +173,10 @@ fn word_6_95_rejected() {
}
#[test]
fn encrypted_rejected() {
fn encrypted_asks_for_a_password() {
let spec = FibSpec { flags: 0x1100, ..Default::default() };
let f = cfb_file(&[("WordDocument", &fib_bytes(&spec))]);
assert!(matches!(read(&f), Err(super::DocbinError::Encrypted)));
assert!(matches!(read(&f), Err(super::DocbinError::PasswordRequired)));
}
#[test]
@@ -1500,6 +1502,92 @@ fn huge_story_ranges_are_clamped_to_their_subdocument() {
assert!(!doc.parts.values().any(|p| p.kind == wordcraft_doc::PartKind::Endnote));
}
/// The text of each note a paragraph references, in order.
fn note_texts(doc: &wordcraft_doc::Document, p: &wordcraft_doc::Paragraph) -> Vec<String> {
p.objects
.iter()
.filter_map(|o| match o {
wordcraft_doc::para::InlineObject::NoteRef { id, .. } => doc.parts.get(id),
_ => None,
})
.map(|part| part.blocks.iter().filter_map(|b| b.as_para()).map(|para| para.text.as_str()).collect::<Vec<_>>().join("\n"))
.collect()
}
#[test]
fn note_stories_pointing_at_the_same_text_are_read_once() {
// 2000 footnote references whose PlcffndTxt steps back to CP 0 after every story
// ([0, 10, 0, 10, …]): every other one was read into its own part, a copy of the whole
// footnote subdocument each (1001 parts here; a large story times thousands of
// references in a hostile file).
let n = 2000u32;
let text: &'static str = Box::leak(format!("A{}\r", "\u{2}".repeat(n as usize)).into_boxed_str());
let ccp = text.chars().count() as u32;
let refs: Vec<u32> = (1..=n).chain([ccp]).collect();
let spec = StructSpec {
text,
note_stories: vec!["Note body\r"],
extra_pairs: vec![(2, plc(&refs, &1u16.to_le_bytes().repeat(n as usize))), (3, plc(&[0u32, 10].repeat(n as usize / 2 + 1), &[]))],
..structured_spec(text, ccp)
};
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(doc.parts.values().filter(|p| p.kind == wordcraft_doc::PartKind::Footnote).count(), 1);
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(note_texts(&doc, p0), vec!["Note body".to_string()]);
}
#[test]
fn note_stories_stepping_back_are_dropped_and_ordered_ones_kept() {
// Story CPs [0, 5, 10, 3, 10] for four footnotes and four endnotes: the first two
// stories are in order and read (the control); the third runs backwards and the fourth
// starts inside the first, so the third and fourth references get no note.
let text = "a\u{2}b\u{2}c\u{2}d\u{2}e\u{2}f\u{2}g\u{2}h\u{2}\r";
let ccp = text.chars().count() as u32;
let stories = plc(&[0, 5, 10, 3, 10], &[]);
let spec = StructSpec {
text,
note_stories: vec!["Aaaa\rBbbb\r"],
endnote_stories: vec!["Cccc\rDddd\r"],
extra_pairs: vec![
(2, plc(&[1, 3, 5, 7, ccp], &1u16.to_le_bytes().repeat(4))),
(3, stories.clone()),
(46, plc(&[9, 11, 13, 15, ccp], &1u16.to_le_bytes().repeat(4))),
(47, stories),
],
..structured_spec(text, ccp)
};
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
assert_eq!(note_texts(&doc, p0), ["Aaaa", "Bbbb", "Cccc", "Dddd"].map(String::from));
assert_eq!(doc.parts.len(), 4);
}
#[test]
fn header_stories_pointing_at_the_same_text_are_read_once() {
// Two sections whose odd headers both claim the header subdocument's [0, 5) (Plcfhdd
// steps back to CP 0 inside the second section's slots): the second used to become a
// second copy of the text. Its slot is now empty, which links it to the first section's.
let text = "a\rb\r";
let ccp = text.chars().count() as u32;
// Six empty separator stories, then each section's six slots; the odd header is the
// second slot.
let hdd = plc(&[0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 5, 5, 5, 0, 5, 5, 5, 5, 5], &[]);
let spec = StructSpec {
text,
header_stories: vec!["Head\r"],
sections: vec![(2, Vec::new()), (ccp, Vec::new())],
papx_entries: vec![(0, 0, Vec::new()), (2, 0, Vec::new())],
extra_pairs: vec![(11, hdd)],
..structured_spec(text, ccp)
};
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(doc.parts.values().filter(|p| p.kind == wordcraft_doc::PartKind::Header).count(), 1);
let first = doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.section.as_ref()).expect("first section");
let header = first.headers.default.and_then(|id| doc.parts.get(&id)).expect("first section's header");
assert!(header.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text == "Head")), "header: {:?}", header.blocks);
assert_eq!(doc.last_section.headers.default, None);
}
/// A document whose piece table is given raw (cps + one Pcd per piece), with `text` bytes
/// at TEXT_FC and the FIB claiming `ccp_text` main-document characters.
fn raw_piece_doc(cps: &[u32], fcs: &[u32], text: &[u8], ccp_text: u32) -> Vec<u8> {
@@ -1637,6 +1725,31 @@ fn picture_bytes_are_capped_per_document() {
const { assert!(crate::media::MAX_MEDIA_BYTES <= 256 << 20) };
}
#[test]
fn rejected_inline_pictures_are_not_read_again() {
// A picture over the cap was located and copied again for every anchor on it.
let big = picf_with_png(1200);
let small = picf_with_png(300);
let data: Vec<u8> = [big.clone(), small.clone()].concat();
let mut pics = crate::media::Pictures::default();
pics.limit = 1000;
assert!(pics.get(&data, 0).is_none(), "over the cap");
// The failure is remembered: that place isn't read again, whatever it holds now.
assert!(pics.get(&small, 0).is_none());
// The rejected picture took nothing off the cap, and anchors sharing a picture share it.
let at = big.len() as u32;
let fits = pics.get(&data, at).expect("fits");
assert_eq!(pics.get(&data, at).map(|p| p.key), Some(fits.key));
assert_eq!(pics.media.len(), 1);
// Filling the cap exactly works; after that, a place never tried before isn't read.
let rest = picf_with_png(700);
let rest_at = data.len() as u32;
let tail: Vec<u8> = [data, rest.clone(), small].concat();
assert!(pics.get(&tail, rest_at).is_some(), "700 more bytes fill the cap");
assert!(pics.get(&tail, rest_at + rest.len() as u32).is_none(), "the cap is used up");
assert_eq!(pics.media.len(), 2);
}
#[test]
fn row_height_of_i16_min_does_not_overflow() {
// sprmTDyaRowHeight = -32768: negating the i16 overflowed (a debug-build panic).
@@ -1906,3 +2019,314 @@ fn section_marks_end_paragraphs() {
let doc = read(&struct_doc(&spec)).expect("opens");
assert_eq!(body_texts(&doc), ["a\u{C}b"]);
}
// #1380: Word 2000+ tables. Word writes each row as its cell marks plus a separate row-end
// (TTP) mark, cell shading as 10-byte `Shd`s in `sprmTDefTableShd`/`sprmTDefTableShdRaw` (the
// `Shd80` palette copy in `sprmTDefTableShd80` is only a fallback), and a horizontally
// merged row as a row with fewer, wider cells in its own `sprmTDefTable`.
/// A `sprmTDefTable` Prl with `edges` twip boundaries and no TC80s (cb counts the bytes
/// after it, plus one; Word may store fewer TC80s than cells).
fn t_def_edges(edges: &[i16]) -> Vec<u8> {
let mut body = vec![(edges.len() as u8).saturating_sub(1)];
for e in edges {
body.extend_from_slice(&e.to_le_bytes());
}
let mut v = (0xD608u16).to_le_bytes().to_vec();
v.extend_from_slice(&((body.len() + 1) as u16).to_le_bytes());
v.extend_from_slice(&body);
v
}
/// A variable-length table sprm whose operand is cb then `body`.
fn sprm_cb(op: u16, body: &[u8]) -> Vec<u8> {
let mut v = op.to_le_bytes().to_vec();
v.push(body.len() as u8);
v.extend_from_slice(body);
v
}
/// `n` copies of a 10-byte `Shd`: cvFore auto, cvBack = `back`, ipat 0 (clear), the way
/// Word writes a plain cell fill.
fn shd_clear(back: [u8; 3], n: usize) -> Vec<u8> {
let one = [0, 0, 0, 0xFF, back[0], back[1], back[2], 0, 0, 0];
one.repeat(n)
}
/// Word-shaped 5-column table: header row, a shaded body row, a one-cell legend row.
fn word_table_doc(row1: Vec<u8>, row2: Vec<u8>, row3: Vec<u8>) -> wordcraft_doc::Document {
let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\u{7}f\u{7}g\u{7}h\u{7}i\u{7}j\u{7}\u{7}legend\u{7}\u{7}tail\r";
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
let ttp = |extra: Vec<u8>| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), extra].concat();
let spec = StructSpec {
text,
header_stories: vec![],
sections: vec![(text.chars().count() as u32, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![
(0, 0, in_tbl.clone()), // a..e cell marks
(10, 0, ttp(row1)), // row 1 end mark
(11, 0, in_tbl.clone()),
(21, 0, ttp(row2)),
(22, 0, in_tbl),
(29, 0, ttp(row3)),
(30, 0, Vec::new()), // tail
],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
read(&struct_doc(&spec)).expect("opens")
}
#[test]
fn word_table_row_end_mark_and_cell_shading() {
let edges5: [i16; 6] = [0, 1440, 2880, 4320, 5760, 7200];
// Row 1: the Shd80 fallback first (icoBack 9 = navy, clear), then the exact fill, dark
// blue 1C3A70, in sprmTDefTableShd (0xD612) and sprmTDefTableShdRaw (0xD670), as Word
// 365 writes it. The exact colours must beat the palette colour.
let shd80: Vec<u8> = [0x20u8, 0x01].repeat(5);
let row1 = [
t_def_edges(&edges5),
sprm_cb(0xD609, &shd80),
sprm_cb(0xD612, &shd_clear([0x1C, 0x3A, 0x70], 5)),
sprm_cb(0xD670, &shd_clear([0x1C, 0x3A, 0x70], 5)),
]
.concat();
// Row 2: a Shd80 yellow fill (icoBack 7) on every cell, then sprmTDefTableShd with light
// blue E8F0FA for cells 0-3 and an automatic Shd for cell 4, which clears its fill.
let shd80_yellow: Vec<u8> = [0xE0u8, 0x00].repeat(5);
let shd2 = [shd_clear([0xE8, 0xF0, 0xFA], 4), vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0, 0]].concat();
let row2 = [t_def_edges(&edges5), sprm_cb(0xD609, &shd80_yellow), sprm_cb(0xD612, &shd2)].concat();
// Row 3: one cell from the first edge to the last, filled FFF2CC.
let row3 = [t_def_edges(&[0, 7200]), sprm_cb(0xD612, &shd_clear([0xFF, 0xF2, 0xCC], 1))].concat();
let doc = word_table_doc(row1, row2, row3);
let tbl = doc.body.iter().find_map(|b| b.as_table()).expect("table");
assert_eq!(tbl.grid.len(), 5, "grid columns");
assert_eq!(tbl.rows.len(), 3);
let counts: Vec<usize> = tbl.rows.iter().map(|r| r.cells.len()).collect();
assert_eq!(counts, [5, 5, 1], "cells per row (the row-end mark is not a cell)");
let shd = |r: usize| tbl.rows[r].cells.iter().map(|c| c.props.shading).collect::<Vec<_>>();
assert_eq!(shd(0), vec![Some(wordcraft_doc::Rgb(0x1C, 0x3A, 0x70)); 5], "exact fill beats the Shd80 palette colour");
let light = Some(wordcraft_doc::Rgb(0xE8, 0xF0, 0xFA));
assert_eq!(shd(1), vec![light, light, light, light, None], "automatic Shd clears the Shd80 fill");
assert_eq!(shd(2), vec![Some(wordcraft_doc::Rgb(0xFF, 0xF2, 0xCC))], "legend fill");
let legend = tbl.rows[2].cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<String>();
assert_eq!(legend, "legend");
let tail = doc.body.last().and_then(|b| b.as_para()).expect("tail para");
assert_eq!(tail.text, "tail");
}
#[test]
fn word97_shd80_clear_fill_shows_its_background() {
// Word 97 writes only sprmTDefTableShd80: a plain fill is pattern clear (ipat 0) with
// the colour in icoBack. Cells: icoBack 7 (yellow), icoBack 16 (silver), auto (none),
// solid (ipat 1) with icoFore 2 (blue).
let shd80: Vec<u8> = [0xE0u16, 16 << 5, 0, (1 << 10) | 2].iter().flat_map(|v| v.to_le_bytes()).collect();
let papx = sprm_cb(0xD609, &shd80);
let row = crate::table::decode(&papx);
assert_eq!(row.shading_of(0), Some(wordcraft_doc::Rgb(255, 255, 0)));
assert_eq!(row.shading_of(1), Some(wordcraft_doc::Rgb(192, 192, 192)));
assert_eq!(row.shading_of(2), None);
assert_eq!(row.shading_of(3), Some(wordcraft_doc::Rgb(0, 0, 255)));
}
#[test]
fn shd_arrays_keep_to_their_cell_ranges() {
// sprmTDefTableShd and sprmTDefTableShdRaw cover cells 0-21; their 2nd and 3rd forms
// cover 22-43 and 44-62. An over-long first array must not reach cell 22.
let red = shd_clear([0xFF, 0, 0], 1);
let green = shd_clear([0, 0xFF, 0], 1);
let blue = shd_clear([0, 0, 0xFF], 1);
let over_long = shd_clear([0x11, 0x22, 0x33], 23);
for (first, second, third) in [(0xD612u16, 0xD616u16, 0xD60Cu16), (0xD670, 0xD671, 0xD672)] {
let papx = [sprm_cb(second, &green), sprm_cb(third, &blue), sprm_cb(first, &over_long)].concat();
let row = crate::table::decode(&papx);
assert_eq!(row.shading_of(21), Some(wordcraft_doc::Rgb(0x11, 0x22, 0x33)), "{first:04X}");
assert_eq!(row.shading_of(22), Some(wordcraft_doc::Rgb(0, 0xFF, 0)), "{second:04X} keeps cell 22");
assert_eq!(row.shading_of(44), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "{third:04X} starts at cell 44");
let papx = sprm_cb(first, &red);
assert_eq!(crate::table::decode(&papx).shading_of(0), Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
}
}
#[test]
fn hostile_table_shading_and_spans_are_safe() {
// SHD arrays longer than the row, odd lengths, empty ones; itcMac past 63; edges that
// run backwards or past the grid; merges past the row's end.
// (Kept small: the fixture's single PAPX page holds every row's grpprl.)
let long = shd_clear([1, 2, 3], 6);
let rows: Vec<(Vec<u8>, Vec<u8>, Vec<u8>)> = vec![
([sprm_cb(0xD612, &long), sprm_cb(0xD616, &long)].concat(), sprm_cb(0xD60C, &long), sprm_cb(0xD670, &long)),
(sprm_cb(0xD612, &[1, 2, 3, 4, 5, 6, 7]), sprm_cb(0xD670, &[]), sprm_cb(0xD672, &long[..13])),
(
{
let mut v = (0xD608u16).to_le_bytes().to_vec();
v.extend_from_slice(&[0xFF, 0x00, 200]);
v.extend_from_slice(&[0x10; 24]);
v
},
t_def_edges(&[7200, 0, -5, 30000]),
t_def_edges(&[-32000, 32000]),
),
(t_def_edges(&[0]), t_def_edges(&[]), [t_def_edges(&[0, 100, i16::MAX]), sprm_cb(0xD612, &long)].concat()),
(grpprl(&[(0x5624, &[0, 200])]), grpprl(&[(0x5624, &[200, 3])]), grpprl(&[(0x5624, &[1, 255])])),
];
for (r1, r2, r3) in rows {
let doc = word_table_doc(r1, r2, r3);
let text = doc.plain_text(wordcraft_doc::StoryRef::Body);
assert!(text.contains("legend") && text.contains("tail"), "{text:?}");
if let Some(t) = doc.body.iter().find_map(|b| b.as_table()) {
assert!(t.grid.len() <= 63);
for r in &t.rows {
assert!(r.cells.iter().all(|c| c.props.span <= 63));
}
}
}
// A row that is only a row-end mark (no cell marks before it), with an out-of-range
// merge and a SHD array, then a normal row.
// The second variant's sprmTDefTable is truncated to a single edge (itcMac 3), which
// must not turn the lone row-end mark into a row with no cells.
let text = "\u{7}a\u{7}\u{7}tail\r";
let ttp = |extra: Vec<u8>| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), extra].concat();
let one_edge = {
let mut v = (0xD608u16).to_le_bytes().to_vec();
v.extend_from_slice(&4u16.to_le_bytes()); // cb: itcMac + one edge, plus one
v.extend_from_slice(&[3, 0, 0]);
v
};
for lone in [[t_def_edges(&[0, 1440]), grpprl(&[(0x5624, &[0, 9])]), sprm_cb(0xD612, &long)].concat(), one_edge] {
let spec = one_section(
text,
Vec::new(),
vec![
(0, 0, ttp(lone)),
(1, 0, grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])])),
(3, 0, ttp(t_def_edges(&[0, 1440]))),
(4, 0, Vec::new()),
],
);
let doc = read(&struct_doc(&spec)).expect("opens");
let text = doc.plain_text(wordcraft_doc::StoryRef::Body);
assert!(text.contains('a') && text.contains("tail"), "{text:?}");
for t in doc.body.iter().filter_map(|b| b.as_table()) {
assert!(t.rows.iter().all(|r| !r.cells.is_empty()), "no zero-cell rows");
}
}
}
#[test]
fn nil_raw_shading_keeps_the_earlier_colour() {
// In sprmTDefTableShdRaw (and its 2nd/3rd forms) a nil Shd (ipat 0xFFFF) means "as the
// table style shades it", so it must not override sprmTDefTableShd; automatic clears.
let blue = shd_clear([0, 0, 0xFF], 1);
let red = shd_clear([0xFF, 0, 0], 1);
let nil = vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0xFF, 0xFF];
let auto = vec![0, 0, 0, 0xFF, 0, 0, 0, 0xFF, 0, 0];
let papx = [sprm_cb(0xD612, &[blue.clone(), blue.clone(), blue.clone()].concat()), sprm_cb(0xD670, &[nil.clone(), red, auto].concat())].concat();
let row = crate::table::decode(&papx);
assert_eq!(row.shading_of(0), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "nil keeps blue");
assert_eq!(row.shading_of(1), Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
assert_eq!(row.shading_of(2), None, "automatic clears");
for (first, raw) in [(0xD616u16, 0xD671u16), (0xD60C, 0xD672)] {
let papx = [sprm_cb(first, &blue), sprm_cb(raw, &nil)].concat();
let cell = if first == 0xD616 { 22 } else { 44 };
assert_eq!(crate::table::decode(&papx).shading_of(cell), Some(wordcraft_doc::Rgb(0, 0, 0xFF)), "{raw:04X}");
}
}
#[test]
fn second_table_gets_its_own_grid() {
// #1380: a 5-column table, a paragraph, then a 3-column table. The second table's grid
// comes from its own rows, not the first table's, and its borders, alignment and indent
// are its own too (the first table's centred, bordered and indented).
let text = "a\u{7}b\u{7}c\u{7}d\u{7}e\u{7}\u{7}between\rx\u{7}y\u{7}z\u{7}\u{7}tail\r";
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
let ttp = |edges: &[i16]| [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]), t_def_edges(edges)].concat();
let table1_props = {
let mut borders = (0xD605u16).to_le_bytes().to_vec();
borders.push(0x18);
for _ in 0..6 {
borders.extend_from_slice(&[4, 1, 1, 0]);
}
[borders, grpprl(&[(0x5400, &1u16.to_le_bytes()), (0x9601, &720i16.to_le_bytes())])].concat()
};
let spec = StructSpec {
text,
header_stories: vec![],
sections: vec![(text.chars().count() as u32, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![
(0, 0, in_tbl.clone()),
(10, 0, [ttp(&[0, 1440, 2880, 4320, 5760, 7200]), table1_props].concat()),
(11, 0, Vec::new()), // "between"
(19, 0, in_tbl),
(25, 0, ttp(&[0, 2000, 4000, 7200])),
(26, 0, Vec::new()), // tail
],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
let tables: Vec<_> = doc.body.iter().filter_map(|b| b.as_table()).collect();
assert_eq!(tables.len(), 2);
assert_eq!(tables[0].grid.len(), 5);
assert_eq!(tables[1].grid, vec![100.0, 100.0, 160.0], "second table's own grid");
assert_eq!(tables[1].rows[0].cells.iter().map(|c| c.span()).collect::<Vec<_>>(), [1, 1, 1]);
assert!(tables[0].props.borders.is_some() && tables[0].props.align.is_some() && tables[0].props.indent.is_some());
assert_eq!(tables[1].props.borders, None, "borders reset");
assert_eq!(tables[1].props.align, None, "alignment reset");
assert_eq!(tables[1].props.indent, None, "indent reset");
}
#[test]
fn sprm_t_cell_width_twips_and_percentage() {
// [MS-DOC] §2.9.290 sprmTCellWidth (0xD635):
// cb (1 byte), itcFirst (1 byte), itcLim (1 byte), ftsWidth (1 byte), wWidth (2 bytes).
// ftsWidth = 3 (twips): 1440 twips = 72.0 pt.
let w_twips = [0u8, 2, 3, 0xA0, 0x05]; // cell 0..2, fts=3, 1440 dxa
// ftsWidth = 2 (percentage in 50ths of a percent): 2500 = 50.0%.
let w_pct = [2u8, 3, 2, 0xC4, 0x09]; // cell 2..3, fts=2, 2500 (50%)
let papx = [sprm_cb(0xD635, &w_twips), sprm_cb(0xD635, &w_pct)].concat();
let row = crate::table::decode(&papx);
assert_eq!(row.width_of(0), Some(72.0));
assert_eq!(row.width_of(1), Some(72.0));
assert_eq!(row.width_pct_of(2), Some(50.0));
// End-to-end document test: row cells receive the preferred widths.
let text = "a\u{7}b\u{7}c\u{7}\u{7}\r";
let in_tbl = grpprl(&[(0x2416, &[0x01]), (0x6649, &[1, 0, 0, 0])]);
let ttp = [
grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01]), (0x6649, &[1, 0, 0, 0])]),
t_def_edges(&[0, 1440, 2880, 4320]),
sprm_cb(0xD635, &w_twips),
sprm_cb(0xD635, &w_pct),
]
.concat();
let spec = StructSpec {
text,
header_stories: vec![],
sections: vec![(text.chars().count() as u32, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, in_tbl), (6, 0, ttp), (7, 0, Vec::new())],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
let table = doc.body[0].as_table().expect("table block");
assert_eq!(table.rows[0].cells[0].props.width, Some(72.0));
assert_eq!(table.rows[0].cells[1].props.width, Some(72.0));
assert_eq!(table.rows[0].cells[2].props.width_pct, Some(50.0));
}
+57
View File
@@ -182,6 +182,36 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() {
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
}
#[test]
fn text_boxes_pointing_at_the_same_story_text_read_it_once() {
// Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0
// in between): each was read into its own part, so a hostile table could copy one story
// thousands of times. The second story is dropped and its box stays empty.
let tb = |spid: u32| sp(&[fsp(202, spid, 0xA00), fopt(&[(0x01BF, 1 << 20)], &[]), rec(0, 0, 0xF00D, &0x0001_0000u32.to_le_bytes())]);
let art = art_content(&[], &[tb(1027), tb(1028)]);
let flags = spa_flags(0, 0, 1, false);
let spas = plc(&[1, 2, 4], &[spa(1027, [0, 0, 2880, 720], flags), spa(1028, [0, 0, 2880, 720], flags)].concat());
// FTXBXS: cTxbx, cReusable, fReusable, reserved, lid, txidUndo.
let ftxbxs = |lid: u32| {
[1i32.to_le_bytes().as_slice(), &0i32.to_le_bytes(), &0i16.to_le_bytes(), &0i32.to_le_bytes(), &lid.to_le_bytes(), &0i32.to_le_bytes()]
.concat()
};
let txbx = plc(&[0, 7, 0, 7], &[ftxbxs(1027), ftxbxs(1), ftxbxs(1028)].concat());
let text = "A\u{8}\u{8}B\r";
let ccp = text.chars().count() as u32;
// As above: the endnote slot holds the text box subdocument, then the FIB is patched.
let spec =
StructSpec { text, endnote_stories: vec!["Inside\r"], extra_pairs: vec![(40, spas), (50, art), (56, txbx)], ..structured_spec(text, ccp) };
let doc = read(&patch_word(&struct_doc(&spec), &[(0x60, 0), (0x64, 7)])).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let stories: Vec<Option<u32>> =
p0.objects.iter().filter_map(|o| if let InlineObject::Shape { story, .. } = o { Some(*story) } else { None }).collect();
let [Some(first), None] = stories[..] else { panic!("stories: {stories:?}") };
let part = doc.parts.get(&first).expect("text box story part");
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
assert_eq!(doc.parts.len(), 1);
}
/// Overwrite little-endian u32s in the WordDocument stream of a compound file.
fn patch_word(file: &[u8], at: &[(usize, u32)]) -> Vec<u8> {
use std::io::Read;
@@ -274,3 +304,30 @@ proptest! {
let _ = read(&struct_doc(&spec));
}
}
// A 1×1 24-bit DIB, including its padded four-byte row.
fn one_pixel_dib() -> Vec<u8> {
let mut dib = [40u32, 1, 1].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>();
dib.extend_from_slice(&1u16.to_le_bytes());
dib.extend_from_slice(&24u16.to_le_bytes());
dib.extend_from_slice(&[0u8; 24]);
dib.extend_from_slice(&[30, 60, 90, 0]);
dib
}
#[test]
fn dib_conversion_caps_header_and_encoded_bytes() {
let dib = one_pixel_dib();
let record = rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat());
let (record, _) = officeart::record(&record).unwrap();
let bmp_size = 14 + dib.len();
assert!(officeart::blip(&record, bmp_size - 1).is_none(), "the BMP header also needs room");
let fallback = officeart::blip(&record, bmp_size).expect("the BMP fallback fits exactly");
assert_eq!((fallback.ext, fallback.placeholder, fallback.bytes.len()), ("bmp", true, bmp_size));
let img = image::load_from_memory(&fallback.bytes).unwrap().to_rgb8();
assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (1, 1, [90, 60, 30]));
let normal = officeart::blip(&record, 512).expect("normal conversion fits");
assert_eq!((normal.ext, normal.placeholder), ("png", false));
assert!(normal.bytes.len() > bmp_size && normal.bytes.len() <= 512);
assert_eq!(image::load_from_memory(&normal.bytes).unwrap().width(), 1);
}
+238
View File
@@ -0,0 +1,238 @@
//! Password-protected files. The fixtures are encrypted here with a small encryptor written from
//! [MS-DOC] §2.2.6 and [MS-OFFCRYPTO] §2.3.5-2.3.7; no file produced by Word is used.
use std::io::Read;
use sha1::{Digest, Sha1};
use super::{Piece, body_texts, cfb_file, text_doc};
use crate::DocbinError;
use crate::crypt::{F_ENCRYPTED, F_OBFUSCATED, Rc4, md5, xor_array, xor_verifier};
use crate::{is_encrypted, read, read_with_password};
const TEXT: &str = "Secret \u{20ac} plans";
/// Where the piece table's pair lives in the FIB: `fcClx` of pair 33.
const CLX_PAIR: usize = 0x9A + 33 * 8;
fn plain() -> (Vec<u8>, Vec<u8>) {
let f = text_doc(&[Piece { compressed: false, text: format!("{TEXT}\r") }], &[], 0);
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(f)).unwrap();
let mut get = |n: &str| {
let mut v = Vec::new();
comp.open_stream(format!("/{n}")).unwrap().read_to_end(&mut v).unwrap();
v
};
(get("WordDocument"), get("0Table"))
}
fn rewrap(word: &[u8], table: &[u8]) -> Vec<u8> {
cfb_file(&[("WordDocument", word), ("0Table", table)])
}
fn put16(v: &mut [u8], at: usize, x: u16) {
v[at..at + 2].copy_from_slice(&x.to_le_bytes());
}
fn put32(v: &mut [u8], at: usize, x: u32) {
v[at..at + 4].copy_from_slice(&x.to_le_bytes());
}
fn utf16(s: &str) -> Vec<u8> {
s.encode_utf16().flat_map(u16::to_le_bytes).collect()
}
fn sha1(parts: &[&[u8]]) -> Vec<u8> {
let mut h = Sha1::new();
for p in parts {
h.update(p);
}
h.finalize().to_vec()
}
/// Encrypt (= RC4 is symmetric) `data` in 512-byte blocks; the first `skip` bytes stay as they are
/// but the keystream still runs over them.
fn rc4_stream(data: &mut [u8], skip: usize, key: &dyn Fn(u32) -> Vec<u8>) {
for (n, chunk) in data.chunks_mut(512).enumerate() {
let orig = chunk.to_vec();
Rc4::new(&key(n as u32)).apply(chunk);
let keep = skip.saturating_sub(n * 512).min(chunk.len());
chunk[..keep].copy_from_slice(&orig[..keep]);
}
}
/// Put `header` in front of the table and set the FIB's encryption fields.
fn assemble(mut word: Vec<u8>, table: &[u8], header: &[u8], flags: u16, lkey: u32) -> (Vec<u8>, Vec<u8>) {
let fl = u16::from_le_bytes([word[0x0A], word[0x0B]]) | flags;
put16(&mut word, 0x0A, fl);
put32(&mut word, 0x0E, lkey);
let fc = u32::from_le_bytes(word[CLX_PAIR..CLX_PAIR + 4].try_into().unwrap());
put32(&mut word, CLX_PAIR, fc + header.len() as u32);
([word].concat(), [header, table].concat())
}
fn xor_file(pw: &[u8]) -> Vec<u8> {
let (word, table) = plain();
let (mut word, mut table) = assemble(word, &table, &[], F_ENCRYPTED | F_OBFUSCATED, xor_verifier(pw));
let arr = xor_array(pw);
for (data, skip) in [(&mut word, 68), (&mut table, 0)] {
for (i, b) in data.iter_mut().enumerate().skip(skip) {
if *b != 0 && *b ^ arr[i % 16] != 0 {
*b ^= arr[i % 16];
}
}
}
rewrap(&word, &table)
}
fn rc4_header(pw: &str) -> (Vec<u8>, Box<dyn Fn(u32) -> Vec<u8>>) {
let salt = [7u8; 16];
let h0 = md5(&utf16(pw));
let h1 = md5(&(0..16).flat_map(|_| [&h0[..5], &salt[..]].concat()).collect::<Vec<u8>>());
let trunc = h1[..5].to_vec();
let key: Box<dyn Fn(u32) -> Vec<u8>> = Box::new(move |b| md5(&[trunc.as_slice(), &b.to_le_bytes()].concat()).to_vec());
let verifier = *b"0123456789abcdef";
let mut enc = [verifier.to_vec(), md5(&verifier).to_vec()].concat();
Rc4::new(&key(0)).apply(&mut enc);
([&[1, 0, 1, 0][..], &salt, &enc].concat(), key)
}
fn capi_header(pw: &str, bits: u32, hflags: u32) -> (Vec<u8>, Box<dyn Fn(u32) -> Vec<u8>>) {
let salt = [9u8; 16];
let h0 = sha1(&[&salt, &utf16(pw)]);
let n = (bits / 8) as usize;
let key: Box<dyn Fn(u32) -> Vec<u8>> = Box::new(move |b| {
let mut k = sha1(&[&h0, &b.to_le_bytes()])[..n].to_vec();
if n == 5 {
k.resize(16, 0);
}
k
});
let verifier = *b"fedcba9876543210";
let mut enc = [verifier.to_vec(), sha1(&[&verifier])].concat();
Rc4::new(&key(0)).apply(&mut enc);
let mut body = Vec::new();
for x in [hflags, 0, 0x6801, 0x8004, bits, 1, 0, 0] {
body.extend_from_slice(&x.to_le_bytes());
}
body.extend_from_slice(&utf16("Test RC4 provider\0"));
let mut h = vec![3, 0, 2, 0];
h.extend_from_slice(&hflags.to_le_bytes());
h.extend_from_slice(&(body.len() as u32).to_le_bytes());
h.extend_from_slice(&body);
h.extend_from_slice(&16u32.to_le_bytes());
h.extend_from_slice(&salt);
h.extend_from_slice(&enc[..16]);
h.extend_from_slice(&20u32.to_le_bytes());
h.extend_from_slice(&enc[16..]);
(h, key)
}
fn rc4_file_with(header: Vec<u8>, key: &dyn Fn(u32) -> Vec<u8>) -> Vec<u8> {
let (word, table) = plain();
let (mut word, mut table) = assemble(word, &table, &header, F_ENCRYPTED, header.len() as u32);
rc4_stream(&mut word, 68, key);
rc4_stream(&mut table, header.len(), key);
rewrap(&word, &table)
}
fn rc4_file(pw: &str) -> Vec<u8> {
let (h, k) = rc4_header(pw);
rc4_file_with(h, &*k)
}
fn capi_file(pw: &str, bits: u32) -> Vec<u8> {
let (h, k) = capi_header(pw, bits, 0x04);
rc4_file_with(h, &*k)
}
fn check(f: &[u8], pw: &str, wrong: &str) {
assert!(is_encrypted(f));
assert_eq!(read(f).unwrap_err(), DocbinError::PasswordRequired);
assert_eq!(read_with_password(f, Some(wrong)).unwrap_err(), DocbinError::WrongPassword);
assert_eq!(read_with_password(f, Some("")).unwrap_err(), DocbinError::WrongPassword);
let doc = read_with_password(f, Some(pw)).unwrap();
assert_eq!(body_texts(&doc), [TEXT]);
}
#[test]
fn xor_obfuscation_round_trip() {
check(&xor_file(b"hunter2"), "hunter2", "hunter3");
// Longer than 15 characters: only the first 15 count, as in the format.
let long = xor_file(b"abcdefghijklmno");
assert_eq!(body_texts(&read_with_password(&long, Some("abcdefghijklmnopqrst")).unwrap()), [TEXT]);
// A non-ASCII (Latin-1) password.
check(&xor_file(&[b'p', 0xE4, b's', b's']), "p\u{e4}ss", "pass");
}
#[test]
fn rc4_round_trip() {
check(&rc4_file("correct horse"), "correct horse", "Correct horse");
check(&rc4_file("p\u{e4}ssw\u{f6}rd \u{1f511}"), "p\u{e4}ssw\u{f6}rd \u{1f511}", "password");
}
#[test]
fn rc4_crypto_api_round_trip() {
check(&capi_file("battery staple", 128), "battery staple", "battery stapl");
// 40-bit keys are zero-padded to 128 bits.
check(&capi_file("battery staple", 40), "battery staple", "x");
}
#[test]
fn unencrypted_files_ignore_the_password() {
let f = text_doc(&[Piece { compressed: false, text: "Plain\r".into() }], &[], 0);
assert!(!is_encrypted(&f));
assert_eq!(body_texts(&read_with_password(&f, Some("whatever")).unwrap()), ["Plain"]);
}
#[test]
fn a_tampered_verifier_is_a_wrong_password_never_a_garbled_document() {
let (h, k) = rc4_header("pw");
let mut bad = h.clone();
bad[40] ^= 1; // inside the encrypted verifier hash
assert_eq!(read_with_password(&rc4_file_with(bad, &*k), Some("pw")).unwrap_err(), DocbinError::WrongPassword);
}
#[test]
fn unsupported_variants_say_so() {
// AES and external providers, an unknown header version.
for hflags in [0x04 | 0x20, 0x10] {
let (h, k) = capi_header("pw", 128, hflags);
let e = read_with_password(&rc4_file_with(h, &*k), Some("pw")).unwrap_err();
assert!(matches!(e, DocbinError::Encryption(_)), "{e:?}");
}
let (mut h, k) = rc4_header("pw");
h[0] = 9;
assert!(matches!(read_with_password(&rc4_file_with(h, &*k), Some("pw")), Err(DocbinError::Encryption(_))));
}
#[test]
fn damaged_headers_are_errors_never_panics() {
for f in [rc4_header("pw"), capi_header("pw", 128, 0x04)] {
let (h, k) = f;
let full = rc4_file_with(h.clone(), &*k);
let (word, table) = {
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(&full)).unwrap();
let mut get = |n: &str| {
let mut v = Vec::new();
comp.open_stream(format!("/{n}")).unwrap().read_to_end(&mut v).unwrap();
v
};
(get("WordDocument"), get("0Table"))
};
// Truncated everywhere in and around the header: an error, not a panic.
for n in 0..h.len() + 8 {
let r = read_with_password(&rewrap(&word, &table[..n.min(table.len())]), Some("pw"));
assert!(r.is_err(), "length {n}");
}
// Every header byte garbled.
for at in 0..h.len() {
let mut t = table.clone();
t[at] ^= 0xFF;
let _ = read_with_password(&rewrap(&word, &t), Some("pw"));
}
}
// A header size claiming more than the stream holds.
let (mut h, k) = capi_header("pw", 128, 0x04);
h[8..12].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(read_with_password(&rc4_file_with(h, &*k), Some("pw")).is_err());
}
+164
View File
@@ -0,0 +1,164 @@
//! OLE property sets constructed from MS-OLEPS, exercised through the public DOC reader.
use super::{Piece, body_texts, text_doc};
use std::io::{Cursor, Seek, SeekFrom, Write};
fn summary(cp: u16, values: &[(u32, u32, Vec<u8>)]) -> Vec<u8> {
let mut props = vec![(1, [2u32.to_le_bytes().as_slice(), &cp.to_le_bytes(), &[0, 0]].concat())];
for (id, kind, bytes) in values {
let size = if *kind == 31 { bytes.len() / 2 } else { bytes.len() } as u32;
let mut value = [kind.to_le_bytes().as_slice(), &size.to_le_bytes(), bytes].concat();
while !value.len().is_multiple_of(4) {
value.push(0);
}
props.push((*id, value));
}
let mut section = vec![0; 8 + 8 * props.len()];
section[4..8].copy_from_slice(&(props.len() as u32).to_le_bytes());
for (i, (id, value)) in props.iter().enumerate() {
section[8 + i * 8..12 + i * 8].copy_from_slice(&id.to_le_bytes());
let offset = section.len() as u32;
section[12 + i * 8..16 + i * 8].copy_from_slice(&offset.to_le_bytes());
section.extend_from_slice(value);
}
let size = section.len() as u32;
section[..4].copy_from_slice(&size.to_le_bytes());
let mut header = vec![0; 48];
header[..2].copy_from_slice(&[0xfe, 0xff]);
header[24..28].copy_from_slice(&1u32.to_le_bytes());
header[28..44].copy_from_slice(&[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0, 0x2b, 0x27, 0xb3, 0xd9]);
header[44..48].copy_from_slice(&48u32.to_le_bytes());
header.extend(section);
header
}
fn open_with_metadata(bytes: &[u8], template: bool) -> wordcraft_doc::Document {
let file = text_doc(&[Piece { compressed: false, text: "Body stays readable\r".into() }], &[], 0);
let mut cfb = cfb::CompoundFile::open(Cursor::new(file)).unwrap();
cfb.create_stream("/\u{5}SummaryInformation").unwrap().write_all(bytes).unwrap();
if template {
let mut stream = cfb.open_stream("/WordDocument").unwrap();
stream.seek(SeekFrom::Start(0x0a)).unwrap();
stream.write_all(&0x1001u16.to_le_bytes()).unwrap();
}
let doc = crate::read(&cfb.into_inner().into_inner()).unwrap();
assert_eq!(body_texts(&doc), ["Body stays readable"]);
doc
}
#[test]
fn doc_and_dot_import_summary_text_properties() {
let data = summary(
1252,
&[
(2, 30, b"R\xe9sum\xe9\0".to_vec()),
(3, 30, b"Subject\0".to_vec()),
(4, 30, b"Writer\0".to_vec()),
(5, 30, b"one;two\0".to_vec()),
(6, 30, b"Description\0".to_vec()),
(8, 30, b"Editor\0".to_vec()),
(9, 30, b"12\0".to_vec()),
],
);
for template in [false, true] {
let c = open_with_metadata(&data, template).core;
assert_eq!(c.title, "Résumé");
assert_eq!(c.subject, "Subject");
assert_eq!(c.creator, "Writer");
assert_eq!(c.keywords, "one;two");
assert_eq!(c.description, "Description");
assert_eq!(c.last_modified_by, "Editor");
assert_eq!(c.revision, 12);
}
}
#[test]
fn summary_handles_codepages_and_utf16_length_units() {
let wide: Vec<u8> = "Unicode ä 文 😀\0".encode_utf16().flat_map(u16::to_le_bytes).collect();
for kind in [30, 31] {
assert_eq!(open_with_metadata(&summary(1200, &[(2, kind, wide.clone())]), false).core.title, "Unicode ä 文 😀");
}
assert_eq!(open_with_metadata(&summary(65001, &[(2, 30, "UTF-8 ä\0".as_bytes().to_vec())]), false).core.title, "UTF-8 ä");
assert_eq!(open_with_metadata(&summary(1251, &[(2, 30, vec![0xcf, 0xf0, 0xe8, 0xe2, 0xe5, 0xf2, 0])]), false).core.title, "Привет");
}
#[test]
fn malformed_or_oversized_summary_does_not_break_doc_open() {
let valid = summary(1252, &[(2, 30, b"Title\0".to_vec())]);
for n in [0, 2, 24, 44, 47, 55, valid.len() - 5] {
assert!(open_with_metadata(&valid[..n], false).core.title.is_empty());
}
for (at, value) in [(44, u32::MAX), (48, u32::MAX), (52, u32::MAX), (68, 4), (84, u32::MAX)] {
let mut bad = valid.clone();
bad[at..at + 4].copy_from_slice(&value.to_le_bytes());
assert!(open_with_metadata(&bad, false).core.title.is_empty(), "offset {at}");
}
let huge = summary(1252, &[(2, 30, vec![b'x'; (64 << 10) + 1])]);
assert!(open_with_metadata(&huge, false).core.title.is_empty());
assert!(open_with_metadata(&vec![0; (2 << 20) + 1], false).core.title.is_empty());
}
fn custom_summary(props: &[(u32, Vec<u8>)], num_property_sets: u32) -> Vec<u8> {
let mut section = vec![0; 8 + 8 * props.len()];
section[4..8].copy_from_slice(&(props.len() as u32).to_le_bytes());
for (i, (id, value)) in props.iter().enumerate() {
section[8 + i * 8..12 + i * 8].copy_from_slice(&id.to_le_bytes());
let offset = section.len() as u32;
section[12 + i * 8..16 + i * 8].copy_from_slice(&offset.to_le_bytes());
section.extend_from_slice(value);
}
let size = section.len() as u32;
section[..4].copy_from_slice(&size.to_le_bytes());
let mut header = vec![0; 28 + 20 * num_property_sets as usize];
header[..2].copy_from_slice(&[0xfe, 0xff]);
header[24..28].copy_from_slice(&num_property_sets.to_le_bytes());
// First property set FMTID and offset
header[28..44].copy_from_slice(&[0xe0, 0x85, 0x9f, 0xf2, 0xf9, 0x4f, 0x68, 0x10, 0xab, 0x91, 0x08, 0, 0x2b, 0x27, 0xb3, 0xd9]);
let first_offset = header.len() as u32;
header[44..48].copy_from_slice(&first_offset.to_le_bytes());
if num_property_sets > 1 {
// Dummy second property set header
header[48..64].copy_from_slice(&[1; 16]);
let second_offset = (first_offset + size) as u32;
header[64..68].copy_from_slice(&second_offset.to_le_bytes());
}
header.extend(section);
header
}
#[test]
fn summary_defaults_codepage_and_accepts_vti4_and_num_property_sets() {
let title_prop = [30u32.to_le_bytes().as_slice(), &7u32.to_le_bytes(), b"Default\0"].concat();
// 1. Missing PID_CODEPAGE (property 1) should default to 1252 per MS-OLEPS.
let no_cp = custom_summary(&[(2, title_prop.clone())], 1);
assert_eq!(open_with_metadata(&no_cp, false).core.title, "Default");
// 2. PID_CODEPAGE as VT_I4 (type 3).
let cp_vti4 = [3u32.to_le_bytes().as_slice(), &1252u32.to_le_bytes()].concat();
let with_vti4 = custom_summary(&[(1, cp_vti4), (2, title_prop.clone())], 1);
assert_eq!(open_with_metadata(&with_vti4, false).core.title, "Default");
// 3. Stream with NumPropertySets = 2.
let multi_sets = custom_summary(&[(2, title_prop)], 2);
assert_eq!(open_with_metadata(&multi_sets, false).core.title, "Default");
}
#[test]
fn summary_parses_filetime_created_and_modified_dates() {
// 2024-03-15T12:30:45Z = 133549794450000000 in 100ns since 1601-01-01
let ft_val: u64 = 133_549_794_450_000_000;
let ft_low = (ft_val & 0xFFFFFFFF) as u32;
let ft_high = (ft_val >> 32) as u32;
let filetime_bytes = [64u32.to_le_bytes().as_slice(), &ft_low.to_le_bytes(), &ft_high.to_le_bytes()].concat();
let stream = custom_summary(
&[
(12, filetime_bytes.clone()), // PIDSI_CREATE_DTM
(13, filetime_bytes), // PIDSI_LASTSAVE_DTM
],
1,
);
let core = open_with_metadata(&stream, false).core;
assert_eq!(core.created, "2024-03-15T12:30:45Z");
assert_eq!(core.modified, "2024-03-15T12:30:45Z");
}
+1
View File
@@ -15,6 +15,7 @@ quick-xml = { workspace = true }
zip = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
unicode-bidi = { workspace = true }
cfb = { workspace = true }
aes = { workspace = true }
sha1 = { workspace = true }
+280
View File
@@ -0,0 +1,280 @@
//! Word bibliography sources: a `b:Sources` document — the custom XML part of a document, or
//! Word's own `Sources.xml` master list, imported through Manage Sources (#2640).
use std::collections::HashSet;
use wordcraft_doc::Source;
use crate::DocxError;
use crate::xml::El;
/// Bibliography sources kept from one file (Word's own source manager holds far fewer).
pub const MAX_SOURCES: usize = 10_000;
/// Characters kept of a bibliography's citation style name.
pub const MAX_STYLE_NAME: usize = 64;
/// A `b:Sources` document read into the document model: its sources in file order, and the
/// citation style it names (`b:Sources/@StyleName`), when it has one.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Sources {
pub sources: Vec<Source>,
pub style: Option<String>,
}
/// The bibliography namespace Word's own `Sources.xml` files may carry instead of OOXML's (the
/// VBA `Source.XML` format, `schemas.microsoft.com/office/word/2004/10/bibliography`).
const WORD_2004_BIBLIOGRAPHY: &str = "http://schemas.microsoft.com/office/word/2004/10/bibliography";
/// Read a Word bibliography sources file — Word's `Sources.xml`, or any `b:Sources` document in
/// one of the bibliography namespaces (ECMA-376's, or Word's own 2004/10) — into [`Sources`].
pub fn read_sources(bytes: &[u8]) -> Result<Sources, DocxError> {
// `parse_ns` maps Word's 2004/10 namespace onto the canonical `b` prefix as well, so both
// formats read; the root check then refuses an un-namespaced or unrelated `<Sources>`.
let root = crate::xml::parse_ns(bytes, &[("b", WORD_2004_BIBLIOGRAPHY)])?;
if root.name != "b:Sources" {
return Err(DocxError::NotWord(format!("root element is {}", root.name)));
}
Ok(parse(&root, &mut Seen::default(), MAX_SOURCES))
}
/// Bibliography sources already read: tags, and (title, author) of untagged ones.
#[derive(Default)]
pub(crate) struct Seen {
tags: HashSet<String>,
untagged: HashSet<(String, String)>,
}
/// The fresh sources and style of a parsed `b:Sources` root element: `seen` carries the tags and
/// untagged (title, author) keys already taken, so repeated parts collapse into one source, and
/// at most `room` fresh sources are built (later ones are left unread, bounding hostile files).
pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
let style = root
.attrs
.iter()
.find(|(k, _)| k.rsplit(':').next() == Some("StyleName"))
.map(|(_, v)| v.trim())
.filter(|name| !name.is_empty())
.map(|name| name.chars().take(MAX_STYLE_NAME).collect());
let child_text = |el: &El, local: &str| el.els().find(|e| e.local() == local).map(|e| e.text()).unwrap_or_default();
let parse_persons = |parent: &El| -> String {
let mut names = Vec::new();
let person_els: Vec<&El> = {
let in_lists: Vec<&El> =
parent.els().filter(|e| e.local() == "NameList").flat_map(|n| n.els().filter(|e| e.local() == "Person")).collect();
if in_lists.is_empty() { parent.els().filter(|e| e.local() == "Person").collect() } else { in_lists }
};
for name_el in person_els {
let last = child_text(name_el, "Last");
let first = child_text(name_el, "First");
let middle = child_text(name_el, "Middle");
let name = if !first.is_empty() && !middle.is_empty() {
format!("{last}, {first} {middle}")
} else if !first.is_empty() {
format!("{last}, {first}")
} else {
last
};
if !name.is_empty() {
names.push(name);
}
}
names.join("; ")
};
let mut out = Sources { sources: Vec::new(), style };
for src_el in root.els().filter(|e| e.local() == "Source") {
if out.sources.len() >= room {
log::warn!("docx: more than {MAX_SOURCES} bibliography sources; the rest are dropped");
break;
}
let tag = child_text(src_el, "Tag");
let raw_kind = child_text(src_el, "SourceType");
let kind = crate::write::from_source_type(&raw_kind);
let (author, editor) = match src_el.els().find(|e| e.local() == "Author") {
Some(a) => {
let auth = a.els().find(|e| e.local() == "Author").map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a));
let ed = a.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default();
(auth, ed)
}
None => (String::new(), String::new()),
};
let editor = if editor.is_empty() { src_el.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default() } else { editor };
let title = child_text(src_el, "Title");
let year = child_text(src_el, "Year");
let month = child_text(src_el, "Month");
let day = child_text(src_el, "Day");
let city = child_text(src_el, "City");
let publisher = child_text(src_el, "Publisher");
let journal = child_text(src_el, "JournalName");
let edition = child_text(src_el, "Edition");
let volume = child_text(src_el, "Volume");
let issue = child_text(src_el, "Issue");
let pages = child_text(src_el, "Pages");
let url = child_text(src_el, "URL");
let accessed = match child_text(src_el, "Accessed") {
a if !a.is_empty() => a,
_ => {
let y = child_text(src_el, "YearAccessed");
let m = child_text(src_el, "MonthAccessed");
let d = child_text(src_el, "DayAccessed");
if !y.is_empty() && !m.is_empty() && !d.is_empty() {
format!("{y}-{m}-{d}")
} else if !y.is_empty() && !m.is_empty() {
format!("{y}-{m}")
} else {
y
}
}
};
let doi = match child_text(src_el, "DOI") {
d if !d.is_empty() => d,
_ => child_text(src_el, "StandardNumber"),
};
// Keep sources with some content, once each (by tag, or title and author untagged).
if tag.is_empty() && title.is_empty() && author.is_empty() && editor.is_empty() {
continue;
}
let fresh = if tag.is_empty() {
seen.untagged.insert((title.clone(), if author.is_empty() { editor.clone() } else { author.clone() }))
} else {
seen.tags.insert(tag.clone())
};
if !fresh {
continue;
}
out.sources.push(Source {
tag,
kind,
author,
title,
year,
city,
publisher,
journal,
volume,
pages,
url,
editor,
edition,
issue,
month,
day,
accessed,
doi,
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const WORD_SOURCES: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<b:Sources xmlns:b="http://schemas.openxmlformats.org/officeDocument/2006/bibliography"
xmlns="http://schemas.openxmlformats.org/officeDocument/2006/bibliography"
SelectedStyle="\MLA.XSL" StyleName="MLA">
<b:Source>
<b:Tag>Riv21</b:Tag>
<b:SourceType>Book</b:SourceType>
<b:Author>
<b:Author>
<b:NameList>
<b:Person><b:Last>Rivera</b:Last><b:First>Alex</b:First></b:Person>
<b:Person><b:Last>Chen</b:Last><b:First>Mei</b:First><b:Middle>L.</b:Middle></b:Person>
</b:NameList>
</b:Author>
</b:Author>
<b:Title>Shared Spaces</b:Title>
<b:Year>2021</b:Year>
<b:City>Portland</b:City>
<b:Publisher>Harbor Books</b:Publisher>
</b:Source>
<b:Source>
<b:Tag>Web19</b:Tag>
<b:SourceType>InternetSite</b:SourceType>
<b:Author>
<b:Author>
<b:NameList>
<b:Person><b:Last>Okafor</b:Last><b:First>Ada</b:First></b:Person>
</b:NameList>
</b:Author>
</b:Author>
<b:Title>Dockyards of the north</b:Title>
<b:Year>2019</b:Year>
<b:URL>https://example.org/docks</b:URL>
<b:YearAccessed>2024</b:YearAccessed>
<b:MonthAccessed>May</b:MonthAccessed>
<b:DayAccessed>17</b:DayAccessed>
<b:StandardNumber>10.1234/x</b:StandardNumber>
</b:Source>
<b:Source>
<b:Tag>NoDate</b:Tag>
<b:SourceType>JournalArticle</b:SourceType>
<b:Title>Untitled notes</b:Title>
<b:JournalName>Field Notes</b:JournalName>
<b:Volume>4</b:Volume>
</b:Source>
<b:Source>
<b:Tag>Riv21</b:Tag>
<b:SourceType>Book</b:SourceType>
<b:Title>A duplicate of Riv21</b:Title>
<b:Year>2021</b:Year>
</b:Source>
<b:Source>
<b:SourceType>Book</b:SourceType>
</b:Source>
</b:Sources>"#;
/// A Word `Sources.xml` (`b:Sources`): its style, the `b:Source` elements with their names,
/// kinds and fallback fields; a repeated tag and an entry with no content at all are dropped.
#[test]
fn a_word_sources_file_reads_into_sources() {
let f = read_sources(WORD_SOURCES.as_bytes()).unwrap();
assert_eq!(f.style.as_deref(), Some("MLA"), "the citation style comes from StyleName");
assert_eq!(
f.sources.len(),
3,
"the repeated tag and the empty b:Source are dropped: {:?}",
f.sources.iter().map(|s| &s.tag).collect::<Vec<_>>()
);
let book = &f.sources[0];
assert_eq!((book.tag.as_str(), book.kind.as_str()), ("Riv21", "book"));
assert_eq!(book.author, "Rivera, Alex; Chen, Mei L.");
assert_eq!(book.title, "Shared Spaces", "the first source of a repeated tag wins");
assert_eq!(
(book.title.as_str(), book.year.as_str(), book.city.as_str(), book.publisher.as_str()),
("Shared Spaces", "2021", "Portland", "Harbor Books")
);
let web = &f.sources[1];
assert_eq!(web.kind, "website");
assert_eq!(web.author, "Okafor, Ada");
assert_eq!(web.accessed, "2024-May-17", "assemble the accessed date from its parts");
assert_eq!(web.doi, "10.1234/x", "StandardNumber stands in for a missing DOI");
let notes = &f.sources[2];
assert_eq!((notes.kind.as_str(), notes.journal.as_str(), notes.volume.as_str()), ("article", "Field Notes", "4"));
}
/// Anything whose root isn't `b:Sources` is refused, not read as an empty bibliography.
#[test]
fn a_file_that_is_not_b_sources_is_an_error() {
assert!(read_sources(b"<html><body>hi</body></html>").is_err());
assert!(matches!(read_sources(b"not xml at all"), Err(DocxError::Xml(_))));
assert!(read_sources(b"<Sources><Source><Tag>A</Tag></Source></Sources>").is_err(), "no namespace");
assert!(read_sources(br#"<o:Sources xmlns:o="urn:example:other"><o:Source/></o:Sources>"#).is_err(), "an unrelated namespace");
}
/// Word's own 2004/10 bibliography namespace (`Sources.xml` written by scripts and older
/// builds, the VBA `Source.XML` format) reads as well as OOXML's.
#[test]
fn word_2004_bibliography_namespace_is_accepted() {
let xml = br#"<b:Sources xmlns:b="http://schemas.microsoft.com/office/word/2004/10/bibliography" StyleName="MLA">
<b:Source><b:Tag>Riv21</b:Tag><b:SourceType>Book</b:SourceType><b:Title>Shared Spaces</b:Title><b:Year>2021</b:Year></b:Source>
</b:Sources>"#;
let f = read_sources(xml).unwrap();
assert_eq!(f.style.as_deref(), Some("MLA"));
assert_eq!(f.sources.len(), 1);
assert_eq!(f.sources[0].tag, "Riv21");
}
}
+101 -18
View File
@@ -5,7 +5,9 @@
//!
//! Reading unpacks the parts into an in-memory [`Package`] and hands it to the DOCX reader;
//! writing runs the DOCX writer and lays its parts out in one file. Bounded like the zip reader:
//! part count, part size, total size and base64 length are capped.
//! every part counts against the part count, part size and total size limits whether it's kept
//! or not (a repeated name), data is checked before it's copied, and nesting and elements are
//! capped as the XML tree reader caps a part's.
use std::collections::BTreeMap;
@@ -92,8 +94,24 @@ struct Part {
data: Option<Data>,
}
/// Longest base64 text of one part (the size limit, encoded; whitespace isn't kept).
const MAX_BASE64: u64 = (MAX_PART / 3 + 1) * 4;
/// Deepest element nesting read: a part's inline XML starts below the package, part and data
/// elements and may nest as deep as the XML tree reader takes ([`xml::MAX_DEPTH`] open elements
/// and an empty one inside the deepest).
const MAX_NESTING: usize = 3 + xml::MAX_DEPTH + 1;
/// Most elements in one child of the package: a part, its data element and as many as the XML
/// tree reader takes in a part ([`xml::MAX_ELEMENTS`]).
const MAX_PART_ELEMENTS: usize = 2 + xml::MAX_ELEMENTS;
/// Longest base64 text of a part that decodes to at most `bytes` (whitespace isn't kept).
fn max_base64(bytes: u64) -> u64 {
(bytes / 3).saturating_add(1).saturating_mul(4)
}
/// Bytes the next part may have: its own size limit, or what's left of the package's.
fn room(total: u64) -> u64 {
MAX_PART.min(MAX_TOTAL.saturating_sub(total))
}
/// Unpack a Flat OPC file into its parts, with a `[Content_Types].xml` made from the parts'
/// content types (unless the file carries one).
@@ -103,6 +121,9 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result<Package, DocxError> {
let mut r = NsReader::from_reader(src);
r.config_mut().check_end_names = true;
let mut depth = 0usize;
// Children of the package (parts or anything else), and elements in the current one.
let mut entries = 0usize;
let mut elements = 0usize;
let mut files: BTreeMap<String, Vec<u8>> = BTreeMap::new();
let mut types: Vec<(String, String)> = Vec::new();
let mut total: u64 = 0;
@@ -118,15 +139,26 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result<Package, DocxError> {
Event::Start(ref e) | Event::Empty(ref e) => {
let empty = matches!(ev, Event::Empty(_));
depth += 1;
if depth > MAX_NESTING {
return Err(DocxError::Limit("XML nesting too deep".into()));
}
if depth == 2 {
entries += 1;
elements = 0;
if entries > MAX_ENTRIES {
return Err(DocxError::Limit(format!("more than {MAX_ENTRIES} parts")));
}
}
elements += 1;
if elements > MAX_PART_ELEMENTS {
return Err(DocxError::Limit("too many XML elements".into()));
}
let local = e.local_name().as_ref().to_vec();
match depth {
1 if !(in_pkg && local == b"package") => {
return Err(DocxError::NotWord("the root element isn't a Flat OPC pkg:package".into()));
}
2 if in_pkg && local == b"part" => {
if files.len() + usize::from(part.is_some()) >= MAX_ENTRIES {
return Err(DocxError::Limit(format!("more than {MAX_ENTRIES} parts")));
}
let name = attr(e, b"name").unwrap_or_default();
let content_type = attr(e, b"contentType").unwrap_or_default();
part = Some(Part { name, content_type, data: None });
@@ -167,7 +199,12 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result<Package, DocxError> {
&& let Some(Data::Xml(start)) = p.data
{
p.data = None;
let xml = with_scope(src.get(start..before).unwrap_or_default(), &scope);
let frag = src.get(start..before).unwrap_or_default();
// Refused before it's copied when it can't fit.
if frag.len() as u64 > room(total) {
return Err(DocxError::Limit(format!("part {} is larger than the size limit", p.name)));
}
let xml = with_scope(frag, &scope);
store(&p.name, &p.content_type, xml, &mut files, &mut types, &mut total)?;
}
if depth == 2
@@ -183,15 +220,18 @@ pub(crate) fn unpack(bytes: &[u8]) -> Result<Package, DocxError> {
break;
}
}
Event::Text(ref t) if depth == 3 => {
if let Some(Part { data: Some(Data::Binary(b64)), .. }) = part.as_mut() {
let s = std::str::from_utf8(t.as_ref()).map_err(|e| DocxError::Xml(e.to_string()))?;
b64.extend(s.chars().filter(|c| !c.is_ascii_whitespace()));
if b64.len() as u64 > MAX_BASE64 {
return Err(DocxError::Limit("a binary part is larger than the size limit".into()));
Event::Text(ref t) if depth == 3 => add_base64(&mut part, t, total)?,
Event::CData(ref t) if depth == 3 => add_base64(&mut part, t, total)?,
Event::GeneralRef(ref g) if depth == 3 => match g.resolve_char_ref() {
// A character reference stands for its character (`&#65;` is an `A`).
Ok(Some(c)) => add_base64(&mut part, c.encode_utf8(&mut [0; 4]).as_bytes(), total)?,
// An entity isn't base64: binary data holding one is broken, not shorter.
_ => {
if let Some(Part { name, data: Some(Data::Binary(_)), .. }) = &part {
return Err(DocxError::Xml(format!("part {name} has invalid base64 data")));
}
}
}
},
Event::Eof => break,
_ => {}
}
@@ -225,7 +265,21 @@ fn finish_part(p: Part, files: &mut BTreeMap<String, Vec<u8>>, types: &mut Vec<(
}
}
/// Add a part, within the size limits; a repeated or empty name is ignored.
/// Text (or CDATA) inside a part's `pkg:binaryData`: its base64 without whitespace, checked
/// against the size limits before it's copied.
fn add_base64(part: &mut Option<Part>, text: &[u8], total: u64) -> Result<(), DocxError> {
let Some(Part { data: Some(Data::Binary(b64)), .. }) = part else { return Ok(()) };
let s = std::str::from_utf8(text).map_err(|e| DocxError::Xml(e.to_string()))?;
let more = s.bytes().filter(|b| !b.is_ascii_whitespace()).count();
if b64.len().saturating_add(more) as u64 > max_base64(room(total)) {
return Err(DocxError::Limit("a binary part is larger than the size limit".into()));
}
b64.extend(s.chars().filter(|c| !c.is_ascii_whitespace()));
Ok(())
}
/// Add a part, within the size limits; a repeated or empty name isn't stored but counts against
/// them all the same (as a repeated zip entry does).
fn store(
name: &str,
content_type: &str,
@@ -235,9 +289,6 @@ fn store(
total: &mut u64,
) -> Result<(), DocxError> {
let name = name.trim_start_matches('/');
if name.is_empty() || files.contains_key(name) {
return Ok(());
}
let len = bytes.len() as u64;
if len > MAX_PART {
return Err(DocxError::Limit(format!("part {name} is larger than the size limit")));
@@ -246,6 +297,9 @@ fn store(
if *total > MAX_TOTAL {
return Err(DocxError::Limit("the package is larger than the size limit".into()));
}
if name.is_empty() || files.contains_key(name) {
return Ok(());
}
if !content_type.is_empty() && !content_type.contains(['\r', '\n']) {
types.push((name.to_string(), content_type.to_string()));
}
@@ -408,4 +462,33 @@ mod tests {
assert_eq!(with_scope(own, &scope), own.to_vec());
assert_eq!(with_scope(b"no element", &scope), b"no element".to_vec());
}
#[test]
fn nesting_is_capped_where_the_xml_tree_reader_stops() {
let inline = |levels: usize| format!("{}<b/>{}", "<a>".repeat(levels), "</a>".repeat(levels));
let package = |levels: usize| {
format!(
"<pkg:package xmlns:pkg=\"{PKG_NS}\"><pkg:part pkg:name=\"/a.xml\"><pkg:xmlData>{}</pkg:xmlData></pkg:part></pkg:package>",
inline(levels)
)
};
assert!(xml::parse(inline(xml::MAX_DEPTH).as_bytes()).is_ok());
assert!(unpack(package(xml::MAX_DEPTH).as_bytes()).is_ok());
assert!(matches!(xml::parse(inline(xml::MAX_DEPTH + 1).as_bytes()), Err(DocxError::Limit(_))));
assert!(matches!(unpack(package(xml::MAX_DEPTH + 1).as_bytes()), Err(DocxError::Limit(_))));
}
#[test]
fn repeated_and_nameless_parts_count_against_the_size_limits() {
let (mut files, mut types, mut total) = (BTreeMap::new(), Vec::new(), 0u64);
for name in ["/a.bin", "/a.bin", ""] {
store(name, "", vec![0; 10], &mut files, &mut types, &mut total).unwrap();
}
assert_eq!((files.len(), total), (1, 30));
// Near the package limit, what's left of it is all the next part may have.
total = MAX_TOTAL - 5;
assert_eq!(room(total), 5);
assert_eq!(max_base64(5), 8);
assert!(matches!(store("/b.bin", "", vec![0; 6], &mut files, &mut types, &mut total), Err(DocxError::Limit(_))));
}
}
+85
View File
@@ -0,0 +1,85 @@
//! Fonts a file embeds through its font table (#1171).
//!
//! The reader keeps the font table and its font parts as opaque passthrough parts, for writing
//! back. [`embedded_fonts`] hands their font files to whoever lays the document out, undoing the
//! obfuscation Word applies to them (ECMA-376 Part 1, 17.8.1).
use wordcraft_doc::Document;
use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART};
/// Most font files taken from one document.
const MAX_FONTS: usize = 64;
/// The font table's references to embedded font files, one per style.
const EMBEDS: [&str; 4] = ["w:embedRegular", "w:embedBold", "w:embedItalic", "w:embedBoldItalic"];
/// A font file a document embeds, and the name its font table gives the font: the name the text
/// asks for, which a subset Word embeds doesn't carry inside (#2240).
#[derive(Clone, Debug, PartialEq)]
pub struct EmbeddedFont {
/// `w:font/@w:name`.
pub name: String,
/// The font file, de-obfuscated.
pub data: Vec<u8>,
}
/// The font files `doc`'s font table embeds, de-obfuscated, in table order. Not checked to be
/// fonts: that is up to the font database. Empty for a document read from anything but a package
/// with a font table.
pub fn embedded_fonts(doc: &Document) -> Vec<EmbeddedFont> {
let Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else {
return Vec::new();
};
let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default();
let Some(part) = manifest.parts.get(FONT_TABLE_PART) else {
return Vec::new();
};
let mut out = Vec::new();
let embeds = table
.children("w:font")
.flat_map(|f| f.els().filter(|e| EMBEDS.contains(&e.name.as_str())).map(move |e| (f.attr("w:name").unwrap_or(""), e)));
for (name, embed) in embeds {
if out.len() >= MAX_FONTS {
log::warn!("docx: more than {MAX_FONTS} embedded fonts; the rest are not loaded");
break;
}
let Some(rel) = embed.attr("r:id").and_then(|id| part.rels.iter().find(|r| r.id == id && !r.external)) else {
continue;
};
let Some(bytes) = doc.passthrough.get(&rel.target) else {
continue;
};
// No key: the file is stored as is, as with the all-zero key.
let key = match embed.attr("w:fontKey") {
Some(k) => match font_key(k) {
Some(key) => key,
None => {
log::warn!("docx: embedded font {} has an unreadable key {k:?}", rel.target);
continue;
}
},
None => [0; 16],
};
let mut data = bytes.to_vec();
for (i, b) in data.iter_mut().take(32).enumerate() {
*b ^= key.get(i % 16).copied().unwrap_or(0);
}
out.push(EmbeddedFont { name: name.to_string(), data });
}
out
}
/// The obfuscation key of a `w:fontKey` GUID (`{XXXXXXXX-XXXX-…}`): its 16 bytes, reversed.
fn font_key(guid: &str) -> Option<[u8; 16]> {
let digits: Vec<u8> = guid.bytes().filter(|c| !matches!(c, b'{' | b'}' | b'-')).collect();
if digits.len() != 32 {
return None;
}
let mut key = [0u8; 16];
for (i, pair) in digits.chunks(2).enumerate() {
let byte = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?;
*key.get_mut(15 - i)? = byte;
}
Some(key)
}
+4
View File
@@ -7,10 +7,12 @@
//! error or a best-effort document, never a panic.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod bibliography;
mod chart_spec;
mod crypt;
mod custom;
mod flat;
mod fonts;
mod package;
mod read;
mod smart_art;
@@ -20,12 +22,14 @@ mod wordml2003_write;
mod write;
mod xml;
pub use bibliography::{MAX_SOURCES, Sources, read_sources};
pub use chart_spec::{chart_items, chart_xml};
pub use crypt::{
DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_with_spin_count,
is_encrypted, protection_hash,
};
pub use flat::{XmlKind, sniff_xml, write as write_flat};
pub use fonts::{EmbeddedFont, embedded_fonts};
pub use read::read;
pub use smart_art::smart_art_items;
pub use wordml2003_write::write as write_wordml2003;
+85 -5
View File
@@ -2,6 +2,7 @@
use std::collections::{BTreeMap, HashMap};
use std::io::{Cursor, Read, Write};
use std::sync::Arc;
use crate::DocxError;
use crate::xml::{self, El};
@@ -30,6 +31,8 @@ pub mod rt {
pub const ENDNOTES: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes";
pub const COMMENTS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
pub const COMMENTS_EX: &str = "http://schemas.microsoft.com/office/2011/relationships/commentsExtended";
pub const COMMENTS_IDS: &str = "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds";
pub const COMMENTS_EXTENSIBLE: &str = "http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible";
pub const HEADER: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
pub const FOOTER: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
pub const IMAGE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
@@ -42,6 +45,38 @@ pub mod rt {
pub const DIAGRAM_QUICK_STYLE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramQuickStyle";
pub const DIAGRAM_COLORS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramColors";
pub const CUSTOM_XML: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
pub const GLOSSARY: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/glossaryDocument";
}
/// Content type of a glossary document part (ECMA-376 Part 1 §11.3.8).
pub const GLOSSARY_CONTENT_TYPE: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml";
/// `Document::passthrough` key: a fingerprint of `Document::glossary` as read, so a save can tell
/// an untouched glossary (written back verbatim) from an edited one (written from the model).
/// Not a package part: WordCraft's own bookkeeping.
pub const GLOSSARY_READ: &str = "wordcraft:glossary.read";
/// Most building blocks read from one glossary.
pub const MAX_GLOSSARY_ENTRIES: usize = 10_000;
/// The glossary entry types `w:type` takes (ST_DocPartType); others aren't kept.
pub const GLOSSARY_TYPES: [&str; 7] = ["none", "normal", "autoExp", "toolbar", "speller", "formFld", "bbPlcHdr"];
/// The insertion behaviours `w:behavior` takes (ST_DocPartBehavior); others aren't kept.
pub const GLOSSARY_BEHAVIORS: [&str; 3] = ["content", "p", "pg"];
/// A fingerprint of `glossary` (FNV-1a over its debug form): stable across runs and builds that
/// keep the model's shape, and cheap (nothing is allocated).
pub fn glossary_fingerprint(glossary: &[wordcraft_doc::BuildingBlock]) -> String {
struct Fnv(u64);
impl std::fmt::Write for Fnv {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
for b in s.bytes() {
self.0 = (self.0 ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3);
}
Ok(())
}
}
let mut h = Fnv(0xcbf2_9ce4_8422_2325);
// Writing into `Fnv` never fails.
let _ = std::fmt::Write::write_fmt(&mut h, format_args!("{glossary:?}"));
format!("{:016x}", h.0)
}
/// `Document::passthrough` key for a macro project, kept as opaque bytes (never parsed or run).
@@ -58,9 +93,26 @@ pub const MAX_VBA_RELATED: usize = 64;
/// types and own relationships, as [`EmbeddedManifest`] lines. Each part's bytes are kept under its
/// part name. Not a package part: WordCraft's own bookkeeping.
pub const EMBEDDED_PARTS: &str = "wordcraft:embedded.parts";
/// The font table kept from a file (read by `Reader::keep_part_tree`), with the fonts it embeds.
pub const FONT_TABLE_PART: &str = "word/fontTable.xml";
/// Custom XML roots kept by the package (P) or main document (D): `owner\ttype\tpart`.
/// Their opaque bytes and related parts use the same bounded manifest as embedded objects.
pub const CUSTOM_XML_ROOTS: &str = "wordcraft:customXml.roots";
/// The original `word/settings.xml` when it holds settings the model doesn't (merged into on
/// save by `write::settings_merge`). Not a package part: WordCraft's own bookkeeping.
pub const SETTINGS_ORIG: &str = "wordcraft:settings.orig";
/// Extended properties (`docProps/app.xml`) the model doesn't hold but a save keeps
/// ([`APP_KEPT_PROPS`]): `name\tvalue` lines. Not a package part: WordCraft's own bookkeeping.
pub const APP_KEPT: &str = "wordcraft:app.kept";
/// The `docProps/app.xml` properties carried over from the file. Counts (pages, lines,
/// characters, editing time) and the producing application's version are not: they describe
/// the file Word saved, not the one WordCraft writes.
pub const APP_KEPT_PROPS: &[&str] =
&["Template", "Manager", "Company", "HyperlinkBase", "ScaleCrop", "LinksUpToDate", "SharedDoc", "HyperlinksChanged"];
/// Parts WordCraft doesn't model, carried verbatim: `owner\ttype\tpart` lines (owner `P` for
/// the package, `D` for the main document), with their bytes and relationships in the
/// [`EmbeddedManifest`]. Not a package part: WordCraft's own bookkeeping.
pub const UNMODELLED_ROOTS: &str = "wordcraft:unmodelled.roots";
/// Most parts we carry along for a file's embedded objects.
pub const MAX_EMBEDDED_PARTS: usize = 4096;
/// Most relationships we keep for one such part.
@@ -135,9 +187,10 @@ pub fn rel_is(t: &str, full: &str) -> bool {
t == full || tail(t) == tail(full)
}
/// The decompressed parts of a package (bounded).
/// The decompressed parts of a package (bounded). Shared, so a part the document keeps as it is (a
/// picture, an embedded object) is handed over rather than copied.
pub struct Package {
files: BTreeMap<String, Vec<u8>>,
files: BTreeMap<String, Arc<Vec<u8>>>,
}
impl Package {
@@ -179,7 +232,7 @@ impl Package {
return Err(DocxError::Limit(format!("part {name} decompresses past the size limit")));
}
total = total.saturating_add(buf.len() as u64);
files.insert(name, buf);
files.insert(name, Arc::new(buf));
}
Ok(Package { files })
}
@@ -187,16 +240,22 @@ impl Package {
/// A package from parts already in memory (a Flat OPC file, a converted document), by part
/// name without the leading `/`.
pub fn from_parts(files: BTreeMap<String, Vec<u8>>) -> Package {
Package { files }
Package { files: files.into_iter().map(|(name, bytes)| (name, Arc::new(bytes))).collect() }
}
/// Part bytes by name (leading `/` ignored; case-insensitive fallback).
pub fn get(&self, name: &str) -> Option<&[u8]> {
self.get_shared(name).map(|v| v.as_slice())
}
/// [`Package::get`], as the shared bytes themselves: for a part the document keeps whole, which
/// then costs no copy (a picture document's pictures used to be held twice while it opened).
pub fn get_shared(&self, name: &str) -> Option<&Arc<Vec<u8>>> {
let n = name.trim_start_matches('/');
if let Some(v) = self.files.get(n) {
return Some(v);
}
self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v.as_slice())
self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v)
}
pub fn xml(&self, name: &str) -> Result<Option<El>, DocxError> {
@@ -381,6 +440,27 @@ mod tests {
}
}
#[test]
fn the_document_shares_its_pictures_with_the_package() {
// Each picture was copied out of the package, so a picture document held its pictures
// twice while it opened, and left the package's copies behind as freed memory.
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture""#;
let document = format!(
r#"<w:document {ns}><w:body><w:p><w:r><w:drawing><wp:inline><wp:extent cx="635000" cy="635000"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rIdImg"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p></w:body></w:document>"#
);
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdImg" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/pic.png"/></Relationships>"#;
let z = zip_of(&[
("word/document.xml", document.into_bytes()),
("word/_rels/document.xml.rels", rels.as_bytes().to_vec()),
("word/media/pic.png", b"\x89PNG picture bytes".to_vec()),
]);
let pkg = Package::open(&z).unwrap();
let doc = crate::read::read_opened(&pkg).unwrap();
let held = doc.media.values().next().expect("the picture is read");
assert!(Arc::ptr_eq(held, pkg.get_shared("word/media/pic.png").unwrap()), "the same bytes, not a copy");
assert_eq!(pkg.get("/WORD/media/PIC.png"), Some(&b"\x89PNG picture bytes"[..]), "lookups are unchanged");
}
#[test]
fn resolves_targets() {
assert_eq!(resolve("word", "media/image1.png"), "word/media/image1.png");
+28 -3
View File
@@ -79,7 +79,8 @@ pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
/// A preset colour (`a:prstClr/@val`, ECMA-376 Part 1 §20.1.10.48): the common ones; others are
/// unknown (`None`).
pub(crate) fn preset_color(name: &str) -> Option<Rgb> {
Some(match name {
let lower = name.to_ascii_lowercase();
Some(match lower.as_str() {
"black" => Rgb(0, 0, 0),
"white" => Rgb(0xFF, 0xFF, 0xFF),
"red" => Rgb(0xFF, 0, 0),
@@ -91,8 +92,8 @@ pub(crate) fn preset_color(name: &str) -> Option<Rgb> {
"magenta" | "fuchsia" => Rgb(0xFF, 0, 0xFF),
"gray" | "grey" => Rgb(0x80, 0x80, 0x80),
"silver" => Rgb(0xC0, 0xC0, 0xC0),
"ltGray" | "ltGrey" => Rgb(0xD3, 0xD3, 0xD3),
"dkGray" | "dkGrey" => Rgb(0xA9, 0xA9, 0xA9),
"ltgray" | "ltgrey" | "lightgray" | "lightgrey" => Rgb(0xD3, 0xD3, 0xD3),
"dkgray" | "dkgrey" | "darkgray" | "darkgrey" => Rgb(0xA9, 0xA9, 0xA9),
"maroon" => Rgb(0x80, 0, 0),
"navy" => Rgb(0, 0, 0x80),
"olive" => Rgb(0x80, 0x80, 0),
@@ -101,6 +102,18 @@ pub(crate) fn preset_color(name: &str) -> Option<Rgb> {
"orange" => Rgb(0xFF, 0xA5, 0),
"brown" => Rgb(0xA5, 0x2A, 0x2A),
"pink" => Rgb(0xFF, 0xC0, 0xCB),
"gold" => Rgb(0xFF, 0xD7, 0),
"darkblue" | "dkblue" => Rgb(0, 0, 0x8B),
"darkred" | "dkred" => Rgb(0x8B, 0, 0),
"darkgreen" | "dkgreen" => Rgb(0, 0x64, 0),
"lightblue" | "ltblue" => Rgb(0xAD, 0xD8, 0xE6),
"coral" => Rgb(0xFF, 0x7F, 0x50),
"salmon" => Rgb(0xFA, 0x80, 0x72),
"tomato" => Rgb(0xFF, 0x63, 0x47),
"violet" => Rgb(0xEE, 0x82, 0xEE),
"indigo" => Rgb(0x4B, 0, 0x82),
"beige" => Rgb(0xF5, 0xF5, 0xDC),
"ivory" => Rgb(0xFF, 0xFF, 0xF0),
_ => return None,
})
}
@@ -241,4 +254,16 @@ mod tests {
let b = el("a:srgbClr", r#"val="808080""#, r#"<a:lumOff val="50000"/><a:lumMod val="50000"/>"#);
assert_ne!(color(&a, &theme), color(&b, &theme));
}
#[test]
fn preset_colors_are_case_insensitive_and_include_common_names() {
assert_eq!(preset_color("red"), Some(Rgb(255, 0, 0)));
assert_eq!(preset_color("Red"), Some(Rgb(255, 0, 0)));
assert_eq!(preset_color("darkBlue"), Some(Rgb(0, 0, 0x8B)));
assert_eq!(preset_color("dkBlue"), Some(Rgb(0, 0, 0x8B)));
assert_eq!(preset_color("lightGray"), Some(Rgb(0xD3, 0xD3, 0xD3)));
assert_eq!(preset_color("ltGray"), Some(Rgb(0xD3, 0xD3, 0xD3)));
assert_eq!(preset_color("gold"), Some(Rgb(0xFF, 0xD7, 0)));
assert_eq!(preset_color("unknownColor"), None);
}
}
+86 -2
View File
@@ -19,6 +19,48 @@ use crate::package::{
};
use crate::xml::{El, MAX_DEPTH, NAMESPACES, esc};
/// Relationship types (last path segment) the reader models or the writer produces itself, so
/// they are never carried verbatim: carrying them would duplicate or contradict the written part.
/// Types the main document refers to by id (headers, pictures, objects…) are listed too.
pub const HANDLED_RELS: &[&str] = &[
"officeDocument",
"core-properties",
"extended-properties",
"custom-properties",
"styles",
"numbering",
"settings",
"theme",
"fontTable",
"footnotes",
"endnotes",
"comments",
"commentsExtended",
"commentsIds",
"commentsExtensible",
"customXml",
"vbaProject",
"header",
"footer",
"image",
"hyperlink",
"oleObject",
"package",
"chart",
"chartEx",
"diagramData",
"diagramDrawing",
"diagramLayout",
"diagramQuickStyle",
"diagramColors",
"video",
"audio",
"media",
"control",
"subDocument",
"ink",
];
/// Most relationship ids one object's markup may use.
const MAX_IDS: usize = 64;
@@ -35,6 +77,12 @@ impl Reader<'_> {
if !self.doc.sources.is_empty() && self.xml(&rel.target).ok().flatten().is_some_and(|r| r.local() == "Sources") {
continue;
}
// Native ink shares the custom XML relationship type, but its part belongs to the
// drawing that refers to it: kept with it, or dropped with it (no orphan part).
let pkg = self.pkg;
if self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&rel.target) == Some("application/inkml+xml") {
continue;
}
if self.keep_part_tree(&rel.target) {
roots.push_str(&format!("{owner}\t{}\t{}\n", rel.kind, rel.target));
}
@@ -45,6 +93,42 @@ impl Reader<'_> {
}
}
/// Keep the parts the package and the main document relate to that WordCraft neither models
/// nor writes itself (web settings, styles-with-effects, add-in task panes, the thumbnail, a
/// glossary…), with every part they reach, so saving carries them unchanged. Skipped: the
/// relationship types the writer produces or the reader models ([`HANDLED_RELS`]) and any
/// relationship the main document's markup refers to by id (its target goes with the
/// element, or not at all).
pub(super) fn keep_unmodelled(&mut self, package: &Rels, document: &Rels) {
let pkg = self.pkg;
let main = pkg.get(&self.main).unwrap_or_default();
let referenced = |id: &str| {
let needle = format!("\"{id}\"");
main.windows(needle.len()).any(|w| w == needle.as_bytes())
};
let mut roots = String::new();
// One glossary per main document (its first relationship; see `read_glossary`).
let mut glossary = false;
for (owner, rels) in [("P", package), ("D", document)] {
for rel in rels.list.iter().filter(|r| !r.external).take(MAX_EMBEDDED_RELS) {
let tail = rel.kind.rsplit('/').next().unwrap_or("");
if HANDLED_RELS.contains(&tail)
|| (owner == "D" && referenced(&rel.id))
|| (owner == "D" && tail == "glossaryDocument" && std::mem::replace(&mut glossary, true))
|| ![&rel.kind, &rel.target].iter().all(|s| manifest_field_ok(s))
{
continue;
}
if self.keep_part_tree(&rel.target) {
roots.push_str(&format!("{owner}\t{}\t{}\n", rel.kind, rel.target));
}
}
}
if !roots.is_empty() {
self.doc.passthrough.insert(crate::package::UNMODELLED_ROOTS.into(), Arc::new(roots.into_bytes()));
}
}
/// Keep `el` (an object's `a:graphic` or `w:object`, read with `rels`) and the parts it refers
/// to, so the object can be written back. `extra` names relationships the object's parts use
/// by id although its markup doesn't (a SmartArt drawing, named in the diagram's data part).
@@ -118,14 +202,14 @@ impl Reader<'_> {
log::warn!("docx: more than {MAX_EMBEDDED_PARTS} parts for embedded objects; the rest are dropped");
return false;
}
let Some(bytes) = self.pkg.get(part) else {
let Some(bytes) = self.pkg.get_shared(part) else {
log::warn!("docx: part {part} of an embedded object is missing");
return false;
};
let pkg = self.pkg;
let ct = self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(part).unwrap_or("application/octet-stream");
let content_type = if manifest_field_ok(ct) { ct.to_string() } else { "application/octet-stream".to_string() };
self.doc.passthrough.entry(part.to_string()).or_insert_with(|| Arc::new(bytes.to_vec()));
self.doc.passthrough.entry(part.to_string()).or_insert_with(|| bytes.clone());
self.embedded.parts.insert(part.to_string(), crate::package::EmbeddedPart { content_type, rels: Vec::new() });
true
}
+267 -3
View File
@@ -1,4 +1,5 @@
//! Freeform shapes (DrawingML custom geometry, `a:custGeom`) and ink strokes drawn with them.
//! Freeform shapes (DrawingML custom geometry, `a:custGeom`; legacy VML paths) and ink strokes
//! drawn with them.
use wordcraft_doc::freeform::{FreePath, Freeform, InkTool, MAX_PATHS, MAX_POINTS};
@@ -7,6 +8,10 @@ use crate::xml::El;
/// Straight pieces a Bézier curve is flattened into.
const CURVE_STEPS: usize = 8;
/// Points read from one path segment. A conformant `a:lnTo`/`a:moveTo` has one, `a:quadBezTo`
/// two and `a:cubicBezTo` three; the cap bounds the de Casteljau control polygon so a segment
/// stuffed with points can't make curve flattening quadratic in the point count.
const MAX_SEG_POINTS: usize = 8;
/// Largest path coordinate kept, in EMUs.
const MAX_EMU: f64 = 1.0e12;
@@ -41,7 +46,7 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option<Freeform> {
if cur.len() >= MAX_POINTS || done.len() >= MAX_PATHS {
break;
}
let pts: Vec<[f64; 2]> = seg.children("a:pt").filter_map(pt).collect();
let pts: Vec<[f64; 2]> = seg.children("a:pt").filter_map(pt).take(MAX_SEG_POINTS).collect();
match seg.local() {
"moveTo" => {
if !cur.is_empty() {
@@ -65,7 +70,10 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option<Freeform> {
}
}
if !cur.is_empty() {
done.push((cur, false));
// A path drawn back to where it started is closed, though it has no `a:close` (#743:
// Word's cover pages), so it's filled.
let back = cur.len() > 2 && cur.first() == cur.last();
done.push((cur, back));
}
for (pts, closed) in done.into_iter().take(MAX_PATHS.saturating_sub(paths.len())) {
paths.push(FreePath { pts: pts.into_iter().take(MAX_POINTS).map(scale).collect(), closed: closed && !fill_none });
@@ -75,6 +83,229 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option<Freeform> {
(!f.paths.is_empty()).then_some(f)
}
/// Largest VML path coordinate kept (coordinate-space units).
const MAX_VML: f64 = 1.0e12;
/// Characters of a VML `path` attribute read; the rest is ignored.
const MAX_VML_PATH: usize = 1 << 20;
/// Parameters of a VML path kept, all commands together.
const MAX_VML_PARAMS: usize = MAX_POINTS * 8;
/// A legacy VML freeform (`v:shape` with a `path`, ECMA-376 Part 4 §14.1.2.19).
pub struct VmlPath {
pub freeform: Freeform,
/// `nf` in the path: no fill.
pub no_fill: bool,
/// `ns` in the path: no outline.
pub no_stroke: bool,
}
/// The VML path commands that are two letters long.
const VML_TWO: [&str; 11] = ["nf", "ns", "ae", "al", "at", "ar", "wa", "wr", "qx", "qy", "qb"];
/// The VML `path` of a shape, mapped onto its `w` × `h` frame (points) through its `coordsize`
/// (default `1000,1000`) and `coordorigin` (default `0,0`). Commands: `m`/`t` move (absolute /
/// relative), `l`/`r` line, `c`/`v` cubic Bézier (flattened), `x` close, `e` end, `nf`/`ns` no
/// fill / no outline; arcs (`at`, `ar`, `wa`, `wr`) and quadrants (`qx`, `qy`) are drawn straight
/// to their end, `qb` straight to its last point, and ellipses (`ae`, `al`) are skipped. An empty
/// parameter is 0. `None` when the path has no points, or uses formulas (`@n`, from `v:formulas`,
/// which aren't evaluated): such a shape isn't drawn rather than drawn wrong.
pub fn vml_path(path: &str, coordsize: Option<&str>, coordorigin: Option<&str>, w: f32, h: f32) -> Option<VmlPath> {
if path.contains('@') {
return None;
}
let pair = |v: Option<&str>| -> Option<(f64, f64)> {
let v = v?;
let (a, b) = v.split_once(',').unwrap_or((v, ""));
let n = |s: &str| int(s).map(|i| (i as f64).clamp(-MAX_VML, MAX_VML));
Some((n(a)?, n(b).unwrap_or(0.0)))
};
let size = |v: f64| if v > 0.0 { v } else { 1000.0 };
let (cw, ch) = pair(coordsize).map_or((1000.0, 1000.0), |(a, b)| (size(a), size(b)));
let (ox, oy) = pair(coordorigin).unwrap_or((0.0, 0.0));
let (sx, sy) = (w as f64 / cw, h as f64 / ch);
let scale = |[x, y]: [f64; 2]| -> [f32; 2] { [((x - ox) * sx) as f32, ((y - oy) * sy) as f32] };
let mut d = Draw::default();
let (mut no_fill, mut no_stroke) = (false, false);
for (cmd, params) in vml_commands(path) {
if d.full() {
break;
}
let pts: Vec<[f64; 2]> = params.as_chunks::<2>().0.to_vec();
match cmd.as_str() {
"m" | "t" => {
for (i, &p) in pts.iter().enumerate() {
let p = if cmd == "t" { d.rel(p) } else { p };
if i == 0 {
d.move_to(p);
} else {
d.line_to(p);
}
}
}
"l" | "r" | "qx" | "qy" => {
for &p in &pts {
let p = if cmd == "r" { d.rel(p) } else { p };
d.line_to(p);
}
}
"c" | "v" => {
for c in pts.as_chunks::<3>().0 {
let start = d.at;
let ctrl: Vec<[f64; 2]> = std::iter::once(start).chain(c.iter().map(|&p| if cmd == "v" { d.rel(p) } else { p })).collect();
for i in 1..=CURVE_STEPS {
d.line_to(bezier(&ctrl, i as f64 / CURVE_STEPS as f64));
}
}
}
"qb" => {
if let Some(&p) = pts.last() {
d.line_to(p);
}
}
// Bounding box, then start and end: `at`/`wa` draw a line to the start, `ar`/`wr` move there.
"at" | "ar" | "wa" | "wr" => {
for [_, _, s, e] in pts.as_chunks::<4>().0 {
if matches!(cmd.as_str(), "ar" | "wr") {
d.move_to(*s);
} else {
d.line_to(*s);
}
d.line_to(*e);
}
}
"x" => d.end(true),
"e" => d.end(false),
"nf" => no_fill = true,
"ns" => no_stroke = true,
_ => {}
}
}
d.end(false);
let paths = d.done.into_iter().map(|(pts, closed)| FreePath { pts: pts.into_iter().map(scale).collect(), closed }).collect();
let freeform = Freeform { w, h, paths, alpha: 1.0, ink: None, order: None }.sanitized();
(!freeform.paths.is_empty()).then_some(VmlPath { freeform, no_fill, no_stroke })
}
/// The pen while a VML path is drawn: where it is, where the subpath began, the subpath being
/// drawn and those done (closed or not).
#[derive(Default)]
struct Draw {
at: [f64; 2],
start: [f64; 2],
cur: Vec<[f64; 2]>,
done: Vec<(Vec<[f64; 2]>, bool)>,
}
impl Draw {
fn full(&self) -> bool {
self.done.len() >= MAX_PATHS
}
fn rel(&self, [x, y]: [f64; 2]) -> [f64; 2] {
[(self.at[0] + x).clamp(-MAX_VML, MAX_VML), (self.at[1] + y).clamp(-MAX_VML, MAX_VML)]
}
fn move_to(&mut self, p: [f64; 2]) {
self.end(false);
self.at = p;
self.start = p;
self.cur.push(p);
}
fn line_to(&mut self, p: [f64; 2]) {
if self.cur.is_empty() {
self.cur.push(self.at);
self.start = self.at;
}
if self.cur.len() < MAX_POINTS {
self.cur.push(p);
}
self.at = p;
}
/// Finish the subpath being drawn, closed back to its start or not.
fn end(&mut self, closed: bool) {
if !self.cur.is_empty() && !self.full() {
self.done.push((std::mem::take(&mut self.cur), closed));
}
self.cur.clear();
if closed {
self.at = self.start;
}
}
}
/// The commands of a VML path with their parameters: a command is a letter (or one of
/// [`VML_TWO`]) followed by numbers separated by commas or spaces. An empty parameter (nothing
/// before a comma) is 0, and so is a formula reference (`@n`, not expected here: see
/// [`vml_path`]); fractions are dropped. Anything else is skipped.
fn vml_commands(path: &str) -> Vec<(String, Vec<f64>)> {
let mut out: Vec<(String, Vec<f64>)> = Vec::new();
let mut chars = path.chars().take(MAX_VML_PATH).peekable();
// Whether a number was read since the command or the last comma.
let mut has_num = true;
let mut kept = 0usize;
let mut push = |out: &mut Vec<(String, Vec<f64>)>, v: f64| {
if kept < MAX_VML_PARAMS
&& let Some((_, p)) = out.last_mut()
{
p.push(v);
kept += 1;
}
};
while let Some(c) = chars.next() {
if c.is_ascii_alphabetic() {
if !has_num {
push(&mut out, 0.0);
}
has_num = false;
let lower = c.to_ascii_lowercase();
let mut cmd = lower.to_string();
if let Some(&n) = chars.peek() {
let two: String = [lower, n.to_ascii_lowercase()].iter().collect();
if VML_TWO.contains(&two.as_str()) {
chars.next();
cmd = two;
}
}
out.push((cmd, Vec::new()));
} else if c == ',' {
if !has_num {
push(&mut out, 0.0);
}
has_num = false;
} else if matches!(c, '@' | '-' | '+' | '.') || c.is_ascii_digit() {
let mut v = c.to_digit(10).map_or(0.0, f64::from);
let mut frac = c == '.';
while let Some(&n) = chars.peek() {
if n == '.' {
frac = true;
} else if n.is_ascii_digit() {
if !frac {
v = (v * 10.0 + n.to_digit(10).map_or(0.0, f64::from)).min(MAX_VML);
}
} else {
break;
}
chars.next();
}
let v = match c {
'@' => 0.0,
'-' => -v,
_ => v,
};
has_num = true;
push(&mut out, v);
}
// Whitespace and anything else only separate numbers.
}
if !has_num {
push(&mut out, 0.0);
}
out
}
/// The pen of an ink stroke written by WordCraft, from its drawing's name (`Ink Pen 3`,
/// `Ink Highlighter 4`).
pub fn ink_tool(name: &str) -> Option<InkTool> {
@@ -115,3 +346,36 @@ fn bezier(c: &[[f64; 2]], t: f64) -> [f64; 2] {
}
p.first().copied().unwrap_or([0.0, 0.0])
}
#[cfg(test)]
mod tests {
use super::*;
fn sppr(seg: &str) -> crate::xml::El {
let xml = format!(
"<wps:spPr xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" \
xmlns:wps=\"http://schemas.microsoft.com/office/word/2010/wordprocessingShape\">\
<a:custGeom><a:pathLst><a:path w=\"100\" h=\"100\">\
<a:moveTo><a:pt x=\"0\" y=\"0\"/></a:moveTo>{seg}</a:path></a:pathLst></a:custGeom></wps:spPr>"
);
crate::xml::parse(xml.as_bytes()).expect("parse spPr")
}
#[test]
fn a_bezier_segment_with_many_points_is_bounded() {
// One cubicBezTo stuffed with 50 000 points must not make de Casteljau quadratic: the
// control polygon is capped, so flattening stays a handful of points and returns at once.
let pts = "<a:pt x=\"1\" y=\"1\"/>".repeat(50_000);
let sppr = sppr(&format!("<a:cubicBezTo>{pts}</a:cubicBezTo>"));
let f = cust_geom(&sppr, 100.0, 100.0).expect("a path");
assert!(f.paths.iter().all(|p| p.pts.len() <= 1 + CURVE_STEPS), "{:?}", f.paths.iter().map(|p| p.pts.len()).collect::<Vec<_>>());
}
#[test]
fn a_conformant_cubic_still_flattens_to_a_curve() {
let sppr = sppr("<a:cubicBezTo><a:pt x=\"0\" y=\"100\"/><a:pt x=\"100\" y=\"100\"/><a:pt x=\"100\" y=\"0\"/></a:cubicBezTo>");
let f = cust_geom(&sppr, 100.0, 100.0).expect("a path");
assert_eq!(f.paths.len(), 1);
assert!(f.paths[0].pts.len() >= 2 && f.paths[0].pts.len() <= 1 + CURVE_STEPS);
}
}
+114
View File
@@ -0,0 +1,114 @@
//! The glossary document (ECMA-376 Part 1 §17.12): the document's own building blocks (Quick
//! Parts, AutoText…), read into `Document::glossary`.
//!
//! The glossary part and everything it reaches are also kept verbatim (`keep_unmodelled`), with a
//! fingerprint of the entries as read ([`GLOSSARY_READ`]): a save writes the kept part while the
//! entries are unchanged, so whatever the model doesn't hold survives, and writes the glossary from
//! the model once they change.
use std::sync::Arc;
use wordcraft_doc::{Blocks, BuildingBlock};
use super::Reader;
use super::story::StoryCtx;
use crate::package::{
ContentTypes, GLOSSARY_BEHAVIORS, GLOSSARY_CONTENT_TYPE, GLOSSARY_READ, GLOSSARY_TYPES, MAX_GLOSSARY_ENTRIES, Rels, glossary_fingerprint, rel_is,
rt,
};
use crate::xml::El;
/// Longest name, gallery, category, description, guid or behaviour value kept (characters).
const MAX_FIELD: usize = 1024;
/// The known values of `parent`'s `tag` children, each once, in file order.
fn listed(parent: Option<&El>, tag: &str, known: &[&str]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for v in parent.into_iter().flat_map(|p| p.children(tag)).filter_map(|e| e.attr("w:val")) {
if known.contains(&v) && !out.iter().any(|o| o == v) {
out.push(v.to_string());
}
}
out
}
impl Reader<'_> {
/// Read the glossary the main document relates to (`document`: its relationships). The first
/// internal glossary relationship counts; one pointing at the main document, a part that isn't a
/// glossary, or a missing one gives no entries. Entries without a name or a body are skipped.
pub(super) fn read_glossary(&mut self, document: &Rels) {
// The first internal one, as `keep_unmodelled` keeps.
let Some(rel) = document.list.iter().find(|r| !r.external && rel_is(&r.kind, rt::GLOSSARY)) else { return };
let mut entries = Vec::new();
if !rel.target.eq_ignore_ascii_case(&self.main) {
let pkg = self.pkg;
let ct = self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&rel.target).map(str::to_string);
let root = match self.xml(&rel.target) {
Ok(r) => r,
Err(e) => {
log::warn!("docx: ignoring unreadable glossary {}: {e}", rel.target);
None
}
};
// Without content types (a converted package) the root element alone decides.
if let Some(root) = root.filter(|r| r.name == "w:glossaryDocument" && ct.as_deref().is_none_or(|t| t == GLOSSARY_CONTENT_TYPE)) {
let rels = self.pkg.rels(&rel.target);
entries = self.doc_parts(&root, &rels);
}
}
self.doc.glossary = entries;
let print = glossary_fingerprint(&self.doc.glossary);
self.doc.passthrough.insert(GLOSSARY_READ.into(), Arc::new(print.into_bytes()));
}
/// The building blocks of a `w:glossaryDocument`, read with the glossary's relationships.
fn doc_parts(&mut self, root: &El, rels: &Rels) -> Vec<BuildingBlock> {
// The glossary's notes, comments, bookmarks, permissions and moves are its own: what the
// main document's ids mean doesn't apply to it, and its ids mustn't leak into the body's.
let footnotes = std::mem::take(&mut self.footnotes);
let endnotes = std::mem::take(&mut self.endnotes);
let comments = std::mem::take(&mut self.comment_map);
let ended = std::mem::take(&mut self.comments_ended);
let bookmarks = std::mem::take(&mut self.bookmarks);
let perms = std::mem::take(&mut self.perms);
let moves = std::mem::take(&mut self.moves);
let mut out = Vec::new();
for part in root.child("w:docParts").into_iter().flat_map(|d| d.children("w:docPart")) {
if out.len() >= MAX_GLOSSARY_ENTRIES {
log::warn!("docx: glossary has more than {MAX_GLOSSARY_ENTRIES} entries; the rest are dropped");
break;
}
let pr = part.child("w:docPartPr");
let val = |e: Option<&El>| e.and_then(|e| e.attr("w:val")).unwrap_or("").chars().take(MAX_FIELD).collect::<String>();
let name = val(pr.and_then(|p| p.child("w:name")));
let Some(body) = part.child("w:docPartBody").filter(|_| !name.is_empty()) else { continue };
let category = pr.and_then(|p| p.child("w:category"));
let mut sc = StoryCtx::default();
let mut blocks = Blocks::new();
self.read_blocks(&mut sc, body, rels, &mut blocks, 0);
self.flush_pending(&mut sc, &mut blocks);
if blocks.is_empty() {
continue;
}
out.push(BuildingBlock {
name,
gallery: val(category.and_then(|c| c.child("w:gallery"))),
category: val(category.and_then(|c| c.child("w:name"))),
description: val(pr.and_then(|p| p.child("w:description"))),
guid: val(pr.and_then(|p| p.child("w:guid"))),
style: val(pr.and_then(|p| p.child("w:style"))),
types: listed(pr.and_then(|p| p.child("w:types")), "w:type", &GLOSSARY_TYPES),
behaviors: listed(pr.and_then(|p| p.child("w:behaviors")), "w:behavior", &GLOSSARY_BEHAVIORS),
blocks,
});
}
self.footnotes = footnotes;
self.endnotes = endnotes;
self.comment_map = comments;
self.comments_ended = ended;
self.bookmarks = bookmarks;
self.perms = perms;
self.moves = moves;
out
}
}
+148 -5
View File
@@ -4,6 +4,7 @@ use wordcraft_doc::math::{
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, merge_runs,
};
use wordcraft_doc::props::Rgb;
use wordcraft_doc::symbol_fonts;
use crate::xml::El;
@@ -12,6 +13,20 @@ pub fn read_omath(e: &El, jc: MathJc) -> Math {
Math { nodes: merge_runs(arg(e, 0)), omml: e.to_xml(), jc, linear: false }
}
/// A colour every math run shares, taken off the runs: it is the equation's own colour, as
/// [`write_equation`](crate::write::math::write_equation) spreads it (#1467). The source OMML
/// is kept: the writer replaces it only when the colour changes.
pub fn lift_color(math: &mut Math) -> Option<Rgb> {
let (mut shared, mut uniform) = (None, true);
wordcraft_doc::math_edit::each_run_mut(&mut math.nodes, &mut |r| match shared {
None => shared = Some(r.color),
Some(c) => uniform &= c == r.color,
});
let c = shared.flatten().filter(|_| uniform)?;
wordcraft_doc::math_edit::each_run_mut(&mut math.nodes, &mut |r| r.color = None);
Some(c)
}
/// `m:oMathParaPr/m:jc`.
pub fn para_jc(para: &El) -> MathJc {
match para.child("m:oMathParaPr").and_then(|p| p.child("m:jc")).and_then(|j| j.attr("m:val")) {
@@ -218,12 +233,21 @@ fn node(k: &El, depth: usize, out: &mut Arg) {
e: sub_arg(k, "m:e", d),
});
}
// Tracked insertions show; deletions don't.
"w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "m:oMath" if depth <= MAX_DEPTH => {
// Tracked insertions show; deletions don't. Hyperlinks, simple fields and direction
// wrappers (EG_PContentMath, §22.1.2.77) keep their runs (#1273).
"w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "w:hyperlink" | "w:fldSimple" | "w:dir" | "w:bdo" | "m:oMath" if depth <= MAX_DEPTH => {
for c in k.els() {
node(c, d, out);
}
}
// Markup compatibility: the math choice (we read math), else the fallback.
"mc:AlternateContent" if depth <= MAX_DEPTH => {
if let Some(c) = k.child("mc:Choice").or_else(|| k.child("mc:Fallback")) {
for c in c.els() {
node(c, d, out);
}
}
}
"w:sdt" => {
if let Some(c) = k.child("w:sdtContent") {
for c in c.els() {
@@ -235,13 +259,24 @@ fn node(k: &El, depth: usize, out: &mut Arg) {
}
}
/// `m:t` (and `w:t`) text of a run.
/// `m:t` (and `w:t`) text of a run, with symbol characters (`w:sym`) and special hyphens.
/// Text in a symbol font (Symbol, Wingdings…) becomes its Unicode equivalent: the math font has
/// no glyphs at those private-use codes (#1273).
fn run_text(r: &El) -> String {
let font = r.child("w:rPr").and_then(|p| p.child("w:rFonts")).and_then(|f| f.attr("w:ascii").or_else(|| f.attr("w:hAnsi"))).unwrap_or("");
let mut s = String::new();
for c in r.els() {
match c.name.as_str() {
"m:t" | "w:t" => s.push_str(&c.text()),
"m:t" | "w:t" => s.push_str(&symbol_fonts::map_text(font, &c.text())),
"w:tab" => s.push('\t'),
"w:noBreakHyphen" => s.push('\u{2011}'),
"w:sym" => {
let ch = c.attr("w:char").and_then(|h| u32::from_str_radix(h.trim(), 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20);
if let Some(ch) = ch {
let f = c.attr("w:font").unwrap_or(font);
s.push(symbol_fonts::symbol_char(f, ch).unwrap_or(ch));
}
}
_ => {}
}
}
@@ -294,7 +329,10 @@ fn wrpr(pr: Option<&El>, out: &mut MRun) {
out.color = Some(c);
}
if out.nor {
if let Some(f) = pr.child("w:rFonts").and_then(|f| f.attr("w:ascii").or_else(|| f.attr("w:hAnsi"))) {
// Symbol-font text was mapped to Unicode: it takes the text font.
if let Some(f) =
pr.child("w:rFonts").and_then(|f| f.attr("w:ascii").or_else(|| f.attr("w:hAnsi"))).filter(|f| !symbol_fonts::is_symbol_font(f))
{
out.font = Some(f.to_string());
}
let bold = pr.child("w:b").is_some_and(crate::units::on_off);
@@ -350,4 +388,109 @@ mod tests {
assert!(matches!(m.nodes[2], MNode::Nary { chr: '∫', .. }));
assert!(matches!(&m.nodes[3], MNode::Matrix { rows, .. } if rows.len() == 1 && rows[0].len() == 2));
}
/// Every run's text in the tree, in reading order.
fn texts(m: &mut Math) -> Vec<String> {
let mut out = Vec::new();
wordcraft_doc::math_edit::each_run_mut(&mut m.nodes, &mut |r| out.push(r.text.clone()));
out
}
fn t(s: &str) -> String {
format!("<m:r><m:t>{s}</m:t></m:r>")
}
/// #1273: one equation using every Office Math element of ECMA-376 §22.1 (each argument
/// holding a distinct marker) keeps every argument's text: nothing is silently dropped.
#[test]
fn every_omml_element_keeps_its_content() {
let parts = [
format!("<m:acc><m:accPr><m:chr m:val=\"̃\"/></m:accPr><m:e>{}</m:e></m:acc>", t("A1")),
format!("<m:bar><m:barPr><m:pos m:val=\"top\"/></m:barPr><m:e>{}</m:e></m:bar>", t("B1")),
format!("<m:box><m:boxPr><m:opEmu m:val=\"1\"/></m:boxPr><m:e>{}</m:e></m:box>", t("C1")),
format!("<m:borderBox><m:borderBoxPr><m:strikeH/></m:borderBoxPr><m:e>{}</m:e></m:borderBox>", t("D1")),
format!("<m:d><m:dPr><m:begChr m:val=\"[\"/></m:dPr><m:e>{}</m:e><m:e>{}</m:e></m:d>", t("E1"), t("E2")),
format!("<m:eqArr><m:e>{}</m:e><m:e>{}</m:e></m:eqArr>", t("F1"), t("F2")),
format!("<m:f><m:fPr><m:type m:val=\"skw\"/></m:fPr><m:num>{}</m:num><m:den>{}</m:den></m:f>", t("G1"), t("G2")),
format!("<m:func><m:fName>{}</m:fName><m:e>{}</m:e></m:func>", t("H1"), t("H2")),
format!("<m:groupChr><m:groupChrPr><m:chr m:val=\"⏞\"/><m:pos m:val=\"top\"/></m:groupChrPr><m:e>{}</m:e></m:groupChr>", t("I1")),
format!("<m:limLow><m:e>{}</m:e><m:lim>{}</m:lim></m:limLow>", t("J1"), t("J2")),
format!("<m:limUpp><m:e>{}</m:e><m:lim>{}</m:lim></m:limUpp>", t("K1"), t("K2")),
format!("<m:m><m:mr><m:e>{}</m:e><m:e>{}</m:e></m:mr><m:mr><m:e>{}</m:e><m:e/></m:mr></m:m>", t("L1"), t("L2"), t("L3")),
format!(
"<m:nary><m:naryPr><m:chr m:val=\"∑\"/></m:naryPr><m:sub>{}</m:sub><m:sup>{}</m:sup><m:e>{}</m:e></m:nary>",
t("M1"),
t("M2"),
t("M3")
),
format!("<m:phant><m:phantPr><m:show m:val=\"0\"/></m:phantPr><m:e>{}</m:e></m:phant>", t("N1")),
format!("<m:rad><m:deg>{}</m:deg><m:e>{}</m:e></m:rad>", t("O1"), t("O2")),
format!("<m:sPre><m:sub>{}</m:sub><m:sup>{}</m:sup><m:e>{}</m:e></m:sPre>", t("P1"), t("P2"), t("P3")),
format!("<m:sSub><m:e>{}</m:e><m:sub>{}</m:sub></m:sSub>", t("Q1"), t("Q2")),
format!("<m:sSubSup><m:e>{}</m:e><m:sub>{}</m:sub><m:sup>{}</m:sup></m:sSubSup>", t("R1"), t("R2"), t("R3")),
format!("<m:sSup><m:e>{}</m:e><m:sup>{}</m:sup></m:sSup>", t("S1"), t("S2")),
// Run-level wrappers allowed in math (EG_PContentMath): hyperlinks, simple fields,
// tracked insertions, content controls, markup compatibility.
format!("<w:hyperlink w:anchor=\"x\">{}</w:hyperlink>", t("T1")),
format!("<w:fldSimple w:instr=\"QUOTE\">{}</w:fldSimple>", t("T2")),
format!("<w:ins w:id=\"1\" w:author=\"a\">{}</w:ins>", t("T3")),
format!("<w:sdt><w:sdtContent>{}</w:sdtContent></w:sdt>", t("T4")),
format!(
"<mc:AlternateContent><mc:Choice Requires=\"m\">{}</mc:Choice><mc:Fallback><w:r><w:t>picture</w:t></w:r></mc:Fallback></mc:AlternateContent>",
t("T5")
),
"<w:r><w:t>T6</w:t></w:r>".to_string(),
];
let x = format!(
r#"<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006">{}</m:oMath>"#,
parts.concat()
);
let e = crate::xml::parse(x.as_bytes()).unwrap();
let mut m = read_omath(&e, MathJc::CenterGroup);
let got = texts(&mut m).concat();
let markers = [
"A1", "B1", "C1", "D1", "E1", "E2", "F1", "F2", "G1", "G2", "H1", "H2", "I1", "J1", "J2", "K1", "K2", "L1", "L2", "L3", "M1", "M2", "M3",
"N1", "O1", "O2", "P1", "P2", "P3", "Q1", "Q2", "R1", "R2", "R3", "S1", "S2", "T1", "T2", "T3", "T4", "T5", "T6",
];
for k in markers {
assert!(got.contains(k), "{k} dropped from {got}");
}
assert!(!got.contains("picture"), "the math choice wins over the fallback: {got}");
}
/// #1273: deleted text stays out; symbol characters and symbol-font text become Unicode.
#[test]
fn symbols_and_deletions_in_math() {
let e = omath(
r#"<m:r><m:t>x</m:t></m:r><w:del w:id="1" w:author="a"><m:r><m:t>gone</m:t></m:r></w:del><m:r><w:sym w:font="Symbol" w:char="F061"/></m:r><m:r><w:rPr><w:rFonts w:ascii="Symbol" w:hAnsi="Symbol"/></w:rPr><m:t>&#xF062;</m:t><w:noBreakHyphen/></m:r><w:r><w:rPr><w:rFonts w:ascii="Symbol"/></w:rPr><w:t>&#xF067;</w:t></w:r>"#,
);
let mut m = read_omath(&e, MathJc::CenterGroup);
let got = texts(&mut m).concat();
assert_eq!(got, "xαβ\u{2011}γ");
let mut font = None;
wordcraft_doc::math_edit::each_run_mut(&mut m.nodes, &mut |r| {
if r.nor {
font = Some(r.font.clone());
}
});
assert_eq!(font, Some(None), "mapped symbol text takes the text font");
}
/// Hostile nesting through the wrappers is bounded (no stack overflow, no panic).
#[test]
fn deeply_nested_wrappers_are_bounded() {
// Deeper than the math limit, within the XML parser's own depth limit.
let n = 120;
let inner = format!("{}{}{}", "<w:hyperlink>".repeat(n), t("deep"), "</w:hyperlink>".repeat(n));
let e = omath(&inner);
let _ = read_omath(&e, MathJc::CenterGroup);
let inner = format!(
"{}{}{}",
"<mc:AlternateContent xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\"><mc:Choice Requires=\"m\">".repeat(n),
t("deep"),
"</mc:Choice></mc:AlternateContent>".repeat(n)
);
let e = omath(&inner);
let _ = read_omath(&e, MathJc::CenterGroup);
}
}
+210 -192
View File
@@ -5,7 +5,8 @@ pub(crate) mod diagram;
mod drawing_color;
mod embed;
mod freeform;
mod math;
mod glossary;
pub(crate) mod math;
mod props;
mod sdt;
mod shape_style;
@@ -20,35 +21,34 @@ use wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, Num};
use wordcraft_doc::para::NoteKind;
use wordcraft_doc::para::OBJ;
use wordcraft_doc::props::Rgb;
use wordcraft_doc::props::VAlign;
use wordcraft_doc::section::NumFormat;
use wordcraft_doc::styles::{Style, StyleKind, StyleSheet, TableStyleParts};
use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, Paragraph, PartKind, Revision, RevisionKind, para_block};
use wordcraft_doc::styles::{PartFormat, Style, StyleKind, StyleSheet, TablePart, TableStyleParts};
use wordcraft_doc::{
Block, Blocks, Comment, Document, InlineObject, PagePicture, Paragraph, PartKind, Revision, RevisionKind, Zoom, ZoomFit, para_block,
};
use crate::DocxError;
use crate::bibliography::{MAX_SOURCES, Seen};
use crate::package::{ContentTypes, EMBEDDED_PARTS, EmbeddedManifest, MAX_VBA_RELATED, Package, Rels, VBA_PROJECT_PART, VBA_RELATED, rel_is, rt};
use crate::units::{flag, int, on_off, tw, u32_of};
use crate::xml::El;
pub(crate) use props::PropCtx;
use story::StoryCtx;
/// Most fonts read from a font table, and the longest name (hostile files).
const MAX_TABLE_FONTS: usize = 2000;
const MAX_FONT_NAME: usize = 256;
/// Most parts (headers, notes, comments, text boxes) we create from one file.
const MAX_PARTS: usize = 50_000;
/// Deepest chain of headers/footers whose own sections reference further headers/footers.
const MAX_HF_DEPTH: usize = 4;
/// Most graphic work (items plus path segments, see `story::graphic_work`) built for one file's
/// charts and diagrams; later ones are left empty.
const MAX_GRAPHIC_WORK: usize = 2_000_000;
/// Bibliography sources kept from a file (Word's own source manager holds far fewer).
const MAX_SOURCES: usize = 10_000;
/// Custom XML parts looked at for bibliography sources.
const MAX_BIBLIOGRAPHY_PARTS: usize = 64;
/// Characters kept of a bibliography's citation style name.
const MAX_STYLE_NAME: usize = 64;
/// Bibliography sources already read: tags, and (title, author) of untagged ones.
#[derive(Default)]
struct BibSeen {
tags: HashSet<String>,
untagged: HashSet<(String, String)>,
}
/// Longest attached-template path we keep (bytes); a longer one is not a real path.
const MAX_TEMPLATE_PATH: usize = 32 * 1024;
@@ -62,6 +62,8 @@ pub(crate) struct Reader<'p> {
pub moves: MoveCtx,
media_by_path: HashMap<String, String>,
hf_by_path: HashMap<String, u32>,
/// Header/footer parts being read right now (see [`MAX_HF_DEPTH`]).
hf_depth: usize,
pub footnotes: HashMap<i64, u32>,
pub endnotes: HashMap<i64, u32>,
/// The note being read (kind, part id): its `w:footnoteRef` / `w:endnoteRef` mark is a
@@ -139,6 +141,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
moves: MoveCtx::default(),
media_by_path: HashMap::new(),
hf_by_path: HashMap::new(),
hf_depth: 0,
footnotes: HashMap::new(),
endnotes: HashMap::new(),
current_note: None,
@@ -164,6 +167,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
// The font table and the fonts embedded through it ride along as opaque parts.
if let Some(p) = part(rt::FONT_TABLE) {
r.keep_part_tree(&p);
r.read_font_families(&p);
}
if let Some(p) = part(rt::THEME) {
r.read_theme(&p);
@@ -197,7 +201,9 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
}
if let Some(p) = part(rt::COMMENTS) {
let ex = part(rt::COMMENTS_EX);
lenient("comments", r.read_comments(&p, ex.as_deref()));
let ids = part(rt::COMMENTS_IDS);
let cex = part(rt::COMMENTS_EXTENSIBLE);
lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref()));
}
// Bibliography sources live in a custom XML data store (`b:Sources`), related from the
// package root or the main part; Word's own files sometimes leave the relationship out, so
@@ -217,7 +223,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
bib_parts.push(path);
}
}
let mut seen_sources = BibSeen::default();
let mut seen_sources = Seen::default();
for path in &bib_parts {
if r.doc.sources.len() >= MAX_SOURCES {
break;
@@ -228,6 +234,17 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
if let Some(bg) = root.child("w:background").and_then(|b| b.attr("w:color")).and_then(Rgb::parse) {
r.doc.settings.page_color = Some(bg);
}
// A picture or texture fill: `w:background/v:background/v:fill` with a relationship id.
if let Some(fill) = root.child("w:background").and_then(|b| b.child("v:background")).and_then(|b| b.child("v:fill")) {
let rid = fill.attr("r:id").or_else(|| fill.attr("r:embed")).or_else(|| fill.attr("o:relid"));
if let Some(media) = rid.and_then(|id| r.media_for(&rels, id)) {
r.doc.settings.page_picture = Some(PagePicture {
media,
fill_type: fill.attr("type").unwrap_or("").chars().take(32).collect(),
title: fill.attr("o:title").unwrap_or("").chars().take(255).collect(),
});
}
}
if let Some(body) = root.child("w:body") {
let mut sc = StoryCtx::default();
let mut blocks = Blocks::new();
@@ -243,6 +260,9 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
let core = root_rels.by_type(rt::CORE).map(|x| x.target.clone()).unwrap_or_else(|| "docProps/core.xml".into());
r.read_core(&core);
if let Some(app) = root_rels.by_type(rt::APP).map(|x| x.target.clone()) {
r.read_app(&app);
}
if let Some(c) = root_rels.by_type(rt::CUSTOM)
&& let Ok(Some(x)) = r.xml(&c.target)
{
@@ -252,12 +272,14 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
// bytes, never parsed or run, so a .docm/.dotm saved again carries exactly what the file had.
// Only the relationships and content types are read.
if let Some(v) = part(rt::VBA_PROJECT)
&& let Some(b) = pkg.get(&v).filter(|b| !b.is_empty())
&& let Some(b) = pkg.get_shared(&v).filter(|b| !b.is_empty())
{
r.doc.passthrough.insert(VBA_PROJECT_PART.into(), Arc::new(b.to_vec()));
r.doc.passthrough.insert(VBA_PROJECT_PART.into(), b.clone());
r.read_vba_related(&v);
}
r.read_glossary(&rels);
r.keep_custom_xml(&root_rels, &rels);
r.keep_unmodelled(&root_rels, &rels);
if !r.embedded.parts.is_empty() {
let manifest = r.embedded.to_bytes();
r.doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest));
@@ -418,9 +440,17 @@ fn toc_field_to_heading(body: &mut Blocks) {
}
let Some((field, props, end)) = body[i].as_para().and_then(|p| {
let (field, end) = match p.object_at(0) {
Some(InlineObject::FieldStart { instr, locked }) if is_toc(instr) => {
(InlineObject::Field { instr: instr.clone(), result: String::new(), locked: *locked, code: false }, Some(toc_range_end(body, i)?))
}
Some(InlineObject::FieldStart { instr, locked, dirty }) if is_toc(instr) => (
InlineObject::Field {
instr: instr.clone(),
result: String::new(),
locked: *locked,
dirty: *dirty,
code: false,
form: String::new(),
},
Some(toc_range_end(body, i)?),
),
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None),
_ => return None,
};
@@ -539,7 +569,7 @@ impl Reader<'_> {
log::warn!("docx: not carrying VBA-related part {path:?}");
continue;
}
let Some(bytes) = self.pkg.get(path) else {
let Some(bytes) = self.pkg.get_shared(path) else {
log::warn!("docx: VBA-related part {path} is missing; keeping the project without it");
continue;
};
@@ -549,13 +579,26 @@ impl Reader<'_> {
}
kept += 1;
manifest.push_str(&format!("{}\t{path}\t{ct}\n", rel.kind));
self.doc.passthrough.entry(path.to_string()).or_insert_with(|| Arc::new(bytes.to_vec()));
self.doc.passthrough.entry(path.to_string()).or_insert_with(|| bytes.clone());
}
if !manifest.is_empty() {
self.doc.passthrough.insert(VBA_RELATED.into(), Arc::new(manifest.into_bytes()));
}
}
/// The fonts the font table classes as roman (`w:font/w:family w:val="roman"`).
fn read_font_families(&mut self, path: &str) {
let Ok(Some(table)) = self.xml(path) else { return };
self.doc.settings.roman_fonts = table
.children("w:font")
.filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman"))
.filter_map(|f| f.attr("w:name"))
.filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME)
.take(MAX_TABLE_FONTS)
.map(str::to_string)
.collect();
}
fn xml(&self, path: &str) -> Result<Option<El>, DocxError> {
self.pkg.xml(path)
}
@@ -591,7 +634,7 @@ impl Reader<'_> {
if let Some(k) = self.media_by_path.get(&rel.target) {
return Some(k.clone());
}
let bytes = self.pkg.get(&rel.target)?;
let bytes = self.pkg.get_shared(&rel.target)?;
let base: String =
rel.target.rsplit('/').next().unwrap_or("image").chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')).collect();
let base = if base.is_empty() || base.starts_with('.') { format!("image{base}") } else { base };
@@ -604,7 +647,7 @@ impl Reader<'_> {
None => format!("{base}_{n}"),
};
}
self.doc.media.insert(key.clone(), Arc::new(bytes.to_vec()));
self.doc.media.insert(key.clone(), bytes.clone());
self.media_by_path.insert(rel.target.clone(), key.clone());
Some(key)
}
@@ -623,7 +666,7 @@ impl Reader<'_> {
if let Some(id) = self.hf_by_path.get(path) {
return Some(*id);
}
if self.doc.parts.len() >= MAX_PARTS {
if self.doc.parts.len() >= MAX_PARTS || self.hf_depth >= MAX_HF_DEPTH {
return None;
}
// Reserve the id first so a (malformed) self-reference can't recurse.
@@ -636,8 +679,11 @@ impl Reader<'_> {
};
let rels = self.pkg.rels(path);
let mut sc = StoryCtx::default();
sc.in_hf = true;
let mut blocks = Blocks::new();
self.hf_depth += 1;
self.read_blocks(&mut sc, &root, &rels, &mut blocks, 0);
self.hf_depth = self.hf_depth.saturating_sub(1);
self.flush_pending(&mut sc, &mut blocks);
if let Some(p) = self.doc.parts.get_mut(&id)
&& !blocks.is_empty()
@@ -680,6 +726,9 @@ impl Reader<'_> {
if let Some(f) = face("a:minorFont") {
self.doc.settings.minor_font = f;
}
let cs = |n: &str| fs.child(n).and_then(|f| f.child("a:cs")).and_then(|c| c.attr("typeface")).unwrap_or_default().to_string();
self.pc.major_cs = cs("a:majorFont");
self.pc.minor_cs = cs("a:minorFont");
}
self.pc.major_font = self.doc.settings.major_font.clone();
self.pc.minor_font = self.doc.settings.minor_font.clone();
@@ -728,6 +777,8 @@ impl Reader<'_> {
}
fn read_styles(&mut self, path: &str) -> Result<(), DocxError> {
/// Largest `w:latentStyles` kept (Word's has ~380 exceptions, ~40 KB).
const MAX_LATENT_STYLES_XML: usize = 1 << 20;
let Some(root) = self.xml(path)? else { return Ok(()) };
// The default paragraph style must be called `Normal` in our model.
let has_normal = root.children("w:style").any(|s| s.attr("w:styleId") == Some("Normal"));
@@ -752,12 +803,11 @@ impl Reader<'_> {
if sheet.default_chr.size.is_none() {
sheet.default_chr.size = Some(10.0);
}
// Built-in styles' behaviour (gallery, priority, visibility), written back as is.
sheet.latent_styles = root.child("w:latentStyles").map(|l| l.to_xml()).filter(|x| x.len() <= MAX_LATENT_STYLES_XML);
for s in root.children("w:style").take(10_000) {
let Some(raw_id) = s.attr("w:styleId").filter(|i| !i.is_empty()) else { continue };
let id = self.pc.style_id(raw_id);
if sheet.get(&id).is_some() {
continue;
}
let kind = match s.attr("w:type").unwrap_or("paragraph") {
"character" => StyleKind::Character,
"table" => StyleKind::Table,
@@ -778,6 +828,15 @@ impl Reader<'_> {
hidden: flag(s, "w:semiHidden").unwrap_or(false) || flag(s, "w:hidden").unwrap_or(false),
builtin: !s.attr("w:customStyle").is_some_and(|v| v == "1" || v == "true"),
locked: flag(s, "w:locked").unwrap_or(false),
// Only where the writer couldn't tell from the id (it flags the English ids).
default: s.attr("w:default").is_some_and(|v| v == "1" || v == "true")
&& !matches!(
(kind, id.as_str()),
(StyleKind::Paragraph, "Normal")
| (StyleKind::Character, "DefaultParagraphFont")
| (StyleKind::Table, "TableNormal")
| (StyleKind::Numbering, "NoList")
),
..Default::default()
};
if let Some(p) = s.child("w:pPr") {
@@ -790,7 +849,13 @@ impl Reader<'_> {
let parts = self.table_style_parts(s, &mut st);
st.table = (parts != TableStyleParts::default()).then_some(parts);
}
sheet.styles.push(st);
// A style defined twice (some generators write their own Heading 1 after a
// placeholder one) is the last definition, whole: Word drops the earlier one rather
// than merging the two (observed with synthetic documents).
match sheet.styles.iter_mut().find(|s| s.id == id) {
Some(earlier) => *earlier = st,
None => sheet.styles.push(st),
}
}
// Styles that give only a list level take the list from the style they are based on
// (Heading 3: level 3 of Heading 2's outline list).
@@ -811,6 +876,10 @@ impl Reader<'_> {
if sheet.default_para.numbering.is_some_and(|n| n.num == props::LEVEL_ONLY) {
sheet.default_para.numbering = None;
}
// A producer that never writes `w:qFormat` (LibreOffice) still gets Word's usual gallery.
if !sheet.styles.iter().any(|s| s.quick) {
sheet.add_default_gallery();
}
self.doc.styles = sheet;
Ok(())
}
@@ -828,56 +897,38 @@ impl Reader<'_> {
t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).filter(|n| *n > 0).map(|n| n.min(1000));
}
for c in s.children("w:tblStylePr").take(64) {
let fill = c.child("w:tcPr").and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e));
let cell_borders = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).map(props::borders);
let chr = c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default();
match c.attr("w:type") {
Some("wholeTable") => {
if let Some(p) = c.child("w:pPr") {
st.para.overlay(&self.pc.ppr(p).0);
}
st.chr.overlay(&chr);
if fill.is_some() {
t.fill = fill;
}
if let Some(b) = c.child("w:tblPr").and_then(|p| p.child("w:tblBorders")) {
t.borders = Some(props::borders(b));
}
let Some(part) = c.attr("w:type").and_then(TablePart::from_ooxml) else { continue };
let tc = c.child("w:tcPr");
let ppr = c.child("w:pPr").map(|p| self.pc.ppr(p).0);
let f = PartFormat {
chr: c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default(),
fill: tc.and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e)),
borders: tc.and_then(|p| p.child("w:tcBorders")).map(props::borders),
align: ppr.as_ref().and_then(|p| p.align),
v_align: tc.and_then(|p| p.child_val("w:vAlign")).map(|v| match v {
"center" => VAlign::Center,
"bottom" => VAlign::Bottom,
_ => VAlign::Top,
}),
};
if part == TablePart::WholeTable {
// The whole table's paragraph and run properties are the style's own; its
// borders the table borders.
if let Some(p) = ppr {
st.para.overlay(&p);
}
Some("firstRow") => {
t.header_fill = fill;
t.header_chr = chr;
t.header_borders = cell_borders;
st.chr.overlay(&f.chr);
if f.fill.is_some() {
t.fill = f.fill;
}
Some("band1Horz") => {
t.band_fill = fill;
t.band_chr = chr;
t.band_borders = cell_borders;
if let Some(b) = c.child("w:tblPr").and_then(|p| p.child("w:tblBorders")) {
t.borders = Some(props::borders(b));
}
Some("firstCol") => {
t.first_col_chr = chr;
t.first_col_fill = fill;
t.first_col_borders = cell_borders;
if f.v_align.is_some() {
t.regions.insert(part, PartFormat { v_align: f.v_align, ..PartFormat::default() });
}
Some("lastCol") => {
t.last_col_chr = chr;
t.last_col_fill = fill;
t.last_col_borders = cell_borders;
}
Some("lastRow") => {
t.total_chr = chr;
t.total_fill = fill;
// The top rule is kept on its own (as built-in styles have it); the other edges
// are the region's borders.
t.total_border_top = cell_borders.and_then(|b| b.top);
t.total_borders = cell_borders.map(|b| wordcraft_doc::props::Borders { top: None, ..b }).filter(|b| *b != Default::default());
}
Some("band1Vert") => {
t.col_band_fill = fill;
t.col_band_chr = chr;
t.col_band_borders = cell_borders;
}
_ => {}
} else {
t.set_part(part, f);
}
}
t
@@ -899,7 +950,19 @@ impl Reader<'_> {
if let Some(link) = a.child_val("w:numStyleLink") {
links.push((id, link.to_string()));
}
abstracts.push(AbstractNum { id, name: a.child_val("w:name").map(str::to_string), levels });
let hex = |v: &str| v.len() == 8 && v.bytes().all(|b| b.is_ascii_hexdigit());
abstracts.push(AbstractNum {
id,
name: a.child_val("w:name").map(str::to_string),
levels,
nsid: a.child_val("w:nsid").filter(|v| hex(v)).map(str::to_ascii_uppercase),
tmpl: a.child_val("w:tmpl").filter(|v| hex(v)).map(str::to_ascii_uppercase),
multi_level_type: a
.child_val("w:multiLevelType")
// `multilevel` is what the writer derives for the nine levels read here.
.filter(|v| matches!(*v, "singleLevel" | "hybridMultilevel"))
.map(str::to_string),
});
}
let mut nums = Vec::new();
for n in root.children("w:num").take(10_000) {
@@ -981,6 +1044,9 @@ impl Reader<'_> {
fn read_settings(&mut self, path: &str) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
if let Some(b) = self.pkg.get(path).filter(|b| crate::write::settings_has_unowned(b)) {
self.doc.passthrough.insert(crate::package::SETTINGS_ORIG.into(), Arc::new(b.to_vec()));
}
let rels = self.pkg.rels(path);
let s = &mut self.doc.settings;
for k in root.els() {
@@ -997,6 +1063,7 @@ impl Reader<'_> {
"w:removePersonalInformation" => s.remove_personal_info = on_off(k),
"w:removeDateAndTime" => s.remove_date_time = on_off(k),
"w:compat" => {
s.allow_space_of_same_style_in_table = k.child("w:allowSpaceOfSameStyleInTable").is_some_and(on_off);
// Only Word's own setting: another `w:uri` names a different application's.
let mode = k
.children("w:compatSetting")
@@ -1008,8 +1075,10 @@ impl Reader<'_> {
if let Some(m) = mode.and_then(u32_of) {
s.compat_mode = m.clamp(11, 99);
}
s.sum_paragraph_spacing = k.children("w:doNotUseHTMLParagraphAutoSpacing").any(on_off);
}
"w:trackRevisions" => s.track_changes = on_off(k),
"w:doNotTrackMoves" => s.track_moves = !on_off(k),
"w:defaultTabStop" => {
if let Some(v) = tw(k, "w:val").filter(|v| *v > 0.0) {
s.default_tab = v.clamp(1.0, 1584.0);
@@ -1049,6 +1118,16 @@ impl Reader<'_> {
s.endnote_pr = props::note_pr(k, false);
}
"w:documentProtection" => read_protection(k, s),
"w:zoom" => {
let percent = k.attr("w:percent").and_then(|v| u32_of(v.trim_end_matches('%'))).unwrap_or(100).clamp(10, 500);
let fit = match k.attr("w:val") {
Some("fullPage") => Some(ZoomFit::OnePage),
Some("bestFit") => Some(ZoomFit::PageWidth),
Some("textFit") => Some(ZoomFit::TextWidth),
_ => None,
};
s.zoom = Some(Zoom { percent, fit }).filter(|z| z.percent != 100 || z.fit.is_some());
}
_ => {}
}
}
@@ -1125,7 +1204,23 @@ impl Reader<'_> {
*slot = Some(blocks);
}
fn read_comments(&mut self, path: &str, ext: Option<&str>) -> Result<(), DocxError> {
/// The extended properties a save carries over ([`crate::package::APP_KEPT_PROPS`]).
fn read_app(&mut self, path: &str) {
let Ok(Some(root)) = self.xml(path) else { return };
let mut kept = String::new();
for e in root.els() {
let name = e.local();
let value = e.text();
if crate::package::APP_KEPT_PROPS.contains(&name) && value.len() <= 2048 && !value.contains(['\t', '\n']) {
kept.push_str(&format!("{name}\t{value}\n"));
}
}
if !kept.is_empty() {
self.doc.passthrough.insert(crate::package::APP_KEPT.into(), Arc::new(kept.into_bytes()));
}
}
fn read_comments(&mut self, path: &str, ext: Option<&str>, ids: Option<&str>, cex: Option<&str>) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
let rels = self.pkg.rels(path);
let mut by_para: HashMap<String, u32> = HashMap::new();
@@ -1165,6 +1260,7 @@ impl Reader<'_> {
parent: None,
resolved: false,
part,
..Default::default()
},
);
self.comment_map.insert(file_id, id);
@@ -1182,131 +1278,53 @@ impl Reader<'_> {
}
}
}
// Durable ids (by paragraph id) and UTC dates (by durable id), which the writer
// carries over (`write_comment_ids`). Their namespaces (w16cid, w16cex) have no canonical
// prefix here, so attributes are found by local name.
let local = |e: &crate::xml::El, name: &str| -> Option<String> {
e.attrs.iter().find(|(k, _)| k.rsplit(':').next() == Some(name)).map(|(_, v)| v.clone())
};
let mut by_durable: HashMap<String, u32> = HashMap::new();
if let Some(ids) = ids
&& let Ok(Some(x)) = self.xml(ids)
{
for e in x.els().filter(|e| e.local() == "commentId") {
let Some(cid) = local(e, "paraId").and_then(|p| by_para.get(&p).copied()) else { continue };
let Some(durable) = local(e, "durableId").filter(|d| crate::write::durable_id_ok(d)) else { continue };
if let Some(c) = entries.get_mut(&cid) {
c.durable_id = Some(durable.to_ascii_uppercase());
by_durable.insert(durable.to_ascii_uppercase(), cid);
}
}
}
if let Some(cex) = cex
&& let Ok(Some(x)) = self.xml(cex)
{
for e in x.els().filter(|e| e.local() == "commentExtensible") {
let Some(cid) = local(e, "durableId").and_then(|d| by_durable.get(&d.to_ascii_uppercase()).copied()) else { continue };
let date = local(e, "dateUtc").filter(|d| d.len() <= 40 && d.chars().all(|c| c.is_ascii_alphanumeric() || ":-.+".contains(c)));
if let (Some(c), Some(date)) = (entries.get_mut(&cid), date) {
c.date_utc = Some(date);
}
}
}
self.doc.comments = entries;
Ok(())
}
/// Sources from a `b:Sources` custom XML part (other custom XML is left alone); at most
/// [`MAX_SOURCES`] in all, and the document's citation style from `StyleName`.
fn read_bibliography(&mut self, path: &str, seen: &mut BibSeen) -> Result<(), DocxError> {
fn read_bibliography(&mut self, path: &str, seen: &mut Seen) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
if root.local() != "Sources" {
return Ok(());
}
if self.doc.settings.bib_style.is_none()
&& let Some(name) = root.attrs.iter().find(|(k, _)| k.rsplit(':').next() == Some("StyleName")).map(|(_, v)| v.trim())
&& !name.is_empty()
{
self.doc.settings.bib_style = Some(name.chars().take(MAX_STYLE_NAME).collect());
}
let child_text = |el: &El, local: &str| el.els().find(|e| e.local() == local).map(|e| e.text()).unwrap_or_default();
let parse_persons = |parent: &El| -> String {
let mut names = Vec::new();
let person_els: Vec<&El> = {
let in_lists: Vec<&El> =
parent.els().filter(|e| e.local() == "NameList").flat_map(|n| n.els().filter(|e| e.local() == "Person")).collect();
if in_lists.is_empty() { parent.els().filter(|e| e.local() == "Person").collect() } else { in_lists }
};
for name_el in person_els {
let last = child_text(name_el, "Last");
let first = child_text(name_el, "First");
let middle = child_text(name_el, "Middle");
let name = if !first.is_empty() && !middle.is_empty() {
format!("{last}, {first} {middle}")
} else if !first.is_empty() {
format!("{last}, {first}")
} else {
last
};
if !name.is_empty() {
names.push(name);
}
}
names.join("; ")
};
for src_el in root.els().filter(|e| e.local() == "Source") {
if self.doc.sources.len() >= MAX_SOURCES {
log::warn!("docx: more than {MAX_SOURCES} bibliography sources; the rest are dropped");
break;
}
let tag = child_text(src_el, "Tag");
let raw_kind = child_text(src_el, "SourceType");
let kind = crate::write::from_source_type(&raw_kind);
let (author, editor) = match src_el.els().find(|e| e.local() == "Author") {
Some(a) => {
let auth =
a.els().find(|e| e.local() == "Author").map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a));
let ed = a.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default();
(auth, ed)
}
None => (String::new(), String::new()),
};
let editor = if editor.is_empty() { src_el.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default() } else { editor };
let title = child_text(src_el, "Title");
let year = child_text(src_el, "Year");
let month = child_text(src_el, "Month");
let day = child_text(src_el, "Day");
let city = child_text(src_el, "City");
let publisher = child_text(src_el, "Publisher");
let journal = child_text(src_el, "JournalName");
let edition = child_text(src_el, "Edition");
let volume = child_text(src_el, "Volume");
let issue = child_text(src_el, "Issue");
let pages = child_text(src_el, "Pages");
let url = child_text(src_el, "URL");
let accessed = match child_text(src_el, "Accessed") {
a if !a.is_empty() => a,
_ => {
let y = child_text(src_el, "YearAccessed");
let m = child_text(src_el, "MonthAccessed");
let d = child_text(src_el, "DayAccessed");
if !y.is_empty() && !m.is_empty() && !d.is_empty() {
format!("{y}-{m}-{d}")
} else if !y.is_empty() && !m.is_empty() {
format!("{y}-{m}")
} else {
y
}
}
};
let doi = match child_text(src_el, "DOI") {
d if !d.is_empty() => d,
_ => child_text(src_el, "StandardNumber"),
};
// Keep sources with some content, once each (by tag, or title and author untagged).
if tag.is_empty() && title.is_empty() && author.is_empty() && editor.is_empty() {
continue;
}
let fresh = if tag.is_empty() {
seen.untagged.insert((title.clone(), if author.is_empty() { editor.clone() } else { author.clone() }))
} else {
seen.tags.insert(tag.clone())
};
if fresh {
self.doc.sources.push(wordcraft_doc::Source {
tag,
kind,
author,
title,
year,
city,
publisher,
journal,
volume,
pages,
url,
editor,
edition,
issue,
month,
day,
accessed,
doi,
});
}
let room = MAX_SOURCES.saturating_sub(self.doc.sources.len());
let bib = crate::bibliography::parse(&root, seen, room);
if self.doc.settings.bib_style.is_none() {
self.doc.settings.bib_style = bib.style;
}
self.doc.sources.extend(bib.sources);
Ok(())
}
+92 -24
View File
@@ -4,16 +4,17 @@ use std::collections::HashMap;
use wordcraft_doc::para::cap_alt;
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CellProps, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign,
TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge,
VertAlign,
};
use wordcraft_doc::section::{
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
SectionProps, SectionStart,
PageVAlign, SectionProps, SectionStart,
};
use super::drawing_color::adjust_lum;
use crate::units::{int, measure, on_off, tw, u32_of};
use crate::units::{int, line_multiple, measure, on_off, tw, u32_of};
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
/// points (clamped), names kept as OOXML writes them.
@@ -43,6 +44,9 @@ use crate::xml::El;
pub struct PropCtx {
pub major_font: String,
pub minor_font: String,
/// The theme's complex-script fonts (`a:cs`), which the `…Bidi` theme slots name.
pub major_cs: String,
pub minor_cs: String,
/// The theme's colour scheme (dk1, lt1, dk2, lt2, accent1..6, hlink, folHlink); empty when the
/// package has none, and then `w:themeColor` falls back to the fixed colour.
pub theme_colors: Vec<Rgb>,
@@ -82,7 +86,10 @@ impl PropCtx {
}
fn theme_font(&self, t: &str) -> Option<String> {
let f = if t.starts_with("major") { &self.major_font } else { &self.minor_font };
let (latin, cs) = if t.starts_with("major") { (&self.major_font, &self.major_cs) } else { (&self.minor_font, &self.minor_cs) };
// Measured in Word: `minorBidi`/`majorBidi` name the complex-script font, and an empty one
// names nothing, so the font beside it or the inherited one applies.
let f = if t.ends_with("Bidi") { cs } else { latin };
(!f.is_empty()).then(|| f.clone())
}
@@ -201,11 +208,8 @@ impl PropCtx {
"w:szCs" => c.size_cs = k.attr("w:val").and_then(|v| measure(v, 2.0)).filter(|v| *v > 0.0).map(|v| v.clamp(1.0, 1638.0)),
"w:highlight" => c.highlight = k.attr("w:val").map(Highlight::from_ooxml),
"w:u" => {
if let Some(v) = k.attr("w:val") {
c.underline = Some(Underline::from_ooxml(v));
} else {
c.underline = Some(Underline::Single);
}
// w:val has no default in the schema: without it Word draws no underline
c.underline = k.attr("w:val").map(Underline::from_ooxml);
c.underline_color =
self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade").or_else(|| k.attr("w:color").and_then(Rgb::parse));
}
@@ -218,10 +222,15 @@ impl PropCtx {
_ => VertAlign::Baseline,
})
}
"w:eastAsianLayout" => {
let l = east_asian_layout(k);
c.east_asian_layout = (l != EastAsianLayout::default()).then_some(l);
}
"w:lang" => {
c.lang = k.attr("w:val").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
c.lang_bidi = k.attr("w:bidi").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
}
"w14:ligatures" => c.ligatures = k.attr("w14:val").and_then(wordcraft_doc::props::Ligatures::from_ooxml),
n if n.starts_with("w14:") => {
let fx = c.text_effects.get_or_insert_with(Default::default);
super::wordart::read(k, &self.theme, fx);
@@ -260,8 +269,11 @@ impl PropCtx {
"w:autoSpaceDN" => p.auto_space_dn = Some(on_off(k)),
"w:snapToGrid" => p.snap_to_grid = Some(on_off(k)),
"w:framePr" => {
if matches!(k.attr("w:dropCap"), Some("drop") | Some("margin")) {
if let Some(kind @ ("drop" | "margin")) = k.attr("w:dropCap") {
p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8);
p.drop_cap_margin = (kind == "margin").then_some(true);
p.drop_cap_distance =
tw(k, "w:hSpace").filter(|d| d.is_finite() && *d > 0.0).map(|d| d.min(wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
} else {
p.frame = Some(text_frame(k));
}
@@ -306,7 +318,8 @@ impl PropCtx {
p.line_spacing = match rule.as_str() {
"exact" => measure(line, 20.0).map(|v| LineSpacing::Exactly(v.abs())),
"atleast" => measure(line, 20.0).map(|v| LineSpacing::AtLeast(v.abs())),
_ => measure(line, 240.0).map(|v| LineSpacing::Multiple(v.abs().clamp(0.06, 132.0))),
// Strict OOXML writes the height with a unit (`13.90pt`, #2329).
_ => line_multiple(line).map(|v| LineSpacing::Multiple(v.abs().clamp(0.06, 132.0))),
};
}
}
@@ -366,14 +379,18 @@ fn tab_stop(t: &El) -> Option<TabStop> {
"clear" => TabAlign::Clear,
_ => TabAlign::Left,
};
let leader = match t.attr("w:leader").unwrap_or("none") {
Some(TabStop { pos, align, leader: tab_leader(t.attr("w:leader").unwrap_or("none")) })
}
/// A tab stop's or positional tab's `w:leader` (`ST_TabTlc`, `ST_PTabLeader`).
pub(crate) fn tab_leader(v: &str) -> TabLeader {
match v {
"dot" => TabLeader::Dot,
"hyphen" => TabLeader::Hyphen,
"underscore" | "heavy" => TabLeader::Underscore,
"middleDot" => TabLeader::MiddleDot,
_ => TabLeader::None,
};
Some(TabStop { pos, align, leader })
}
}
/// `w:shd/@w:fill` (a solid fill colour; `auto` = none), ignoring theme fills ([`PropCtx::shd_fill`]).
@@ -416,7 +433,11 @@ pub fn borders(e: &El) -> Borders {
/// Margins element (`w:tblCellMar`, `w:tcMar`) → [top, left, bottom, right].
pub fn margins(e: &El) -> [f32; 4] {
let get = |names: &[&str]| names.iter().find_map(|n| e.child(n)).and_then(|c| tw(c, "w:w")).unwrap_or(0.0).clamp(0.0, 1584.0);
margin_sides(e).map(|side| side.unwrap_or(0.0))
}
fn margin_sides(e: &El) -> [Option<f32>; 4] {
let get = |names: &[&str]| names.iter().find_map(|n| e.child(n)).and_then(|c| tw(c, "w:w")).map(|v| v.clamp(0.0, 1584.0));
[get(&["w:top"]), get(&["w:left", "w:start"]), get(&["w:bottom"]), get(&["w:right", "w:end"])]
}
@@ -510,12 +531,24 @@ pub fn trpr(e: &El) -> RowProps {
}
"w:tblHeader" => r.header = on_off(k),
"w:cantSplit" => r.cant_split = on_off(k),
"w:gridBefore" => r.grid_before = k.attr("w:val").and_then(u32_of).unwrap_or(0).min(63),
"w:gridAfter" => r.grid_after = k.attr("w:val").and_then(u32_of).unwrap_or(0).min(63),
"w:wBefore" => r.w_before = gap_width(k),
"w:wAfter" => r.w_after = gap_width(k),
_ => {}
}
}
r
}
/// `w:wBefore`/`w:wAfter` in points: only a width in twips (`dxa`) is kept.
fn gap_width(k: &El) -> Option<f32> {
match k.attr("w:type") {
None | Some("dxa") => tw(k, "w:w").filter(|v| *v >= 0.0).map(|v| v.min(crate::units::MAX_LEN_PT)),
_ => None,
}
}
/// `w:tcPr`; returns the props and whether the cell is a legacy `hMerge` continuation.
pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) {
let mut c = CellProps { span: 1, ..Default::default() };
@@ -539,14 +572,24 @@ pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) {
"w:shd" => c.shading = pc.shd_fill(k),
"w:noWrap" => c.no_wrap = on_off(k),
"w:hideMark" => c.hide_mark = on_off(k),
"w:tcMar" => c.margins = Some(margins(k)),
"w:textDirection" => c.text_direction = TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")),
"w:tcMar" => {
let sides = margin_sides(k);
if sides.iter().all(Option::is_some) {
c.margins = Some(sides.map(|side| side.unwrap_or(0.0)));
} else {
c.partial_margins = Some(sides);
}
}
"w:textDirection" => {
c.text_direction = TextDirection::from_ooxml(k.attr("w:val").unwrap_or(""));
c.text_direction_set = k.attr("w:val") == Some("lrTb");
}
"w:vAlign" => {
c.valign = match k.attr("w:val") {
c.valign = Some(match k.attr("w:val") {
Some("center") => VAlign::Center,
Some("bottom") => VAlign::Bottom,
_ => VAlign::Top,
}
})
}
_ => {}
}
@@ -629,14 +672,19 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
}
"w:footnotePr" => s.footnote_pr = note_pr(k, true),
"w:endnotePr" => s.endnote_pr = note_pr(k, true),
"w:formProt" => s.form_prot = Some(on_off(k)),
"w:titlePg" => s.title_page = on_off(k),
"w:bidi" => s.rtl = on_off(k),
"w:textDirection" => s.text_direction = wordcraft_doc::props::TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")),
"w:textDirection" => {
s.text_direction = wordcraft_doc::props::TextDirection::from_ooxml(k.attr("w:val").unwrap_or(""));
s.text_direction_set = k.attr("w:val") == Some("lrTb");
}
"w:pgNumType" => {
s.page_num_start = k.attr("w:start").and_then(u32_of);
if let Some(f) = k.attr("w:fmt") {
s.page_num_format = NumFormat::from_ooxml(f);
}
s.page_num_set = s.page_num_start.is_none() && s.page_num_format == NumFormat::Decimal;
}
"w:lnNumType" => {
let mut l = LineNumbering::default();
@@ -656,9 +704,10 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec<HfRef>) {
}
"w:vAlign" => {
s.valign = match k.attr("w:val") {
Some("center") | Some("both") => VAlign::Center,
Some("bottom") => VAlign::Bottom,
_ => VAlign::Top,
Some("center") => PageVAlign::Center,
Some("both") => PageVAlign::Justify,
Some("bottom") => PageVAlign::Bottom,
_ => PageVAlign::Top,
}
}
"w:pgBorders" => s.page_borders = Some(borders(k)),
@@ -715,3 +764,22 @@ fn table_float(e: &El) -> TableFloat {
overlap: true,
}
}
/// `w:eastAsianLayout` (ISO/IEC 29500-1 §17.3.2.10). Attributes the schema doesn't allow (an id
/// out of `xsd:int`, an unknown bracket, an on/off that isn't one) are left unset.
fn east_asian_layout(e: &El) -> EastAsianLayout {
let flag = |name: &str| {
e.attr(name).and_then(|v| match v.trim() {
"1" | "true" | "on" => Some(true),
"0" | "false" | "off" => Some(false),
_ => None,
})
};
EastAsianLayout {
id: e.attr("w:id").and_then(int).and_then(|v| i32::try_from(v).ok()),
combine: flag("w:combine"),
combine_brackets: e.attr("w:combineBrackets").and_then(|v| CombineBrackets::from_ooxml(v.trim())),
vert: flag("w:vert"),
vert_compress: flag("w:vertCompress"),
}
}
+145 -9
View File
@@ -29,7 +29,9 @@ pub(crate) fn preset_kind(prst: &str) -> ShapeKind {
"ellipse" | "wedgeEllipseCallout" | "cloudCallout" | "cloud" | "flowChartConnector" | "donut" | "flowChartOnlineStorage" => {
ShapeKind::Ellipse
}
"triangle" | "rtTriangle" | "flowChartExtract" | "flowChartMerge" => ShapeKind::Triangle,
"flowChartOr" => ShapeKind::FlowchartOr,
"triangle" | "flowChartExtract" | "flowChartMerge" => ShapeKind::Triangle,
"rtTriangle" => ShapeKind::RightTriangle,
"diamond" | "flowChartDecision" => ShapeKind::Diamond,
"line" | "lineInv" => ShapeKind::Line,
"straightConnector1" => ShapeKind::StraightConnector,
@@ -44,6 +46,65 @@ pub(crate) fn preset_kind(prst: &str) -> ShapeKind {
}
}
/// The shape kind drawn for a legacy VML preset shape type `spt` (`o:spt`, the MSOSPT numbers of
/// [MS-ODRAW] §2.4.24): that of the DrawingML preset of the same shape, through [`preset_kind`],
/// so a preset WordCraft has no shape for is drawn as the nearest one; None for shapes with no
/// preset geometry (freeforms, WordArt text, picture frames) and numbers MSOSPT doesn't define.
pub(crate) fn vml_preset_kind(spt: u32) -> Option<ShapeKind> {
let prst = match spt {
// A text box's shapetype: a text box even without text, as DrawingML's `txBox`.
202 => return Some(ShapeKind::TextBox),
// Not primitive (freeform), WordArt text shapes, picture frame, host control.
0 | 24..=31 | 75 | 136..=175 | 201 => return None,
// Line and straight connector: corner to corner of the shape's box.
20 | 32 => "line",
2 => "roundRect",
3 => "ellipse",
4 => "diamond",
5 => "triangle",
6 => "rtTriangle",
12 => "star5",
13 => "rightArrow",
21 => "plaque",
23 => "donut",
33 => "bentConnector2",
34 => "bentConnector3",
35 => "bentConnector4",
36 => "bentConnector5",
37 => "curvedConnector2",
38 => "curvedConnector3",
39 => "curvedConnector4",
40 => "curvedConnector5",
58 => "star8",
59 => "star16",
60 => "star32",
62 => "wedgeRoundRectCallout",
63 => "wedgeEllipseCallout",
66 => "leftArrow",
67 => "downArrow",
68 => "upArrow",
69 => "leftRightArrow",
70 => "upDownArrow",
74 => "heart",
92 => "star24",
93 => "stripedRightArrow",
94 => "notchedRightArrow",
106 => "cloudCallout",
110 => "flowChartDecision",
116 => "flowChartTerminator",
120 => "flowChartConnector",
127 => "flowChartExtract",
128 => "flowChartMerge",
130 => "flowChartOnlineStorage",
176 => "flowChartAlternateProcess",
187 => "star4",
// Every other preset is drawn as its box, as `preset_kind` does.
1..=201 => "rect",
_ => return None,
};
Some(preset_kind(prst))
}
/// A shape's fill and outline.
#[derive(Debug, Default, PartialEq)]
pub(crate) struct ShapePaint {
@@ -84,18 +145,63 @@ pub(crate) fn shape_paint(sppr: Option<&El>, style: Option<&El>, theme: &[Rgb])
ShapePaint { fill, stroke, stroke_width }
}
/// A VML colour (`strokecolor`, `fillcolor`): `#rrggbb` or `#rgb` (Word follows it with the
/// theme slot, as in `#4472c4 [3204]`), or a colour name.
/// Explicit DrawingML dash stops override the solid default.
pub(crate) fn line_dash(ln: Option<&El>) -> wordcraft_doc::line_dash::LineDash {
use wordcraft_doc::line_dash::{LineDash, MAX_DASH_STOPS};
let Some(ln) = ln else { return LineDash::Solid };
if let Some(custom) = ln.child("a:custDash") {
let stops: Vec<_> =
custom.children("a:ds").take(MAX_DASH_STOPS).filter_map(|e| Some((dash_ratio(e.attr("d")?)?, dash_ratio(e.attr("sp")?)?))).collect();
return if stops.is_empty() { LineDash::Solid } else { LineDash::Custom(stops) };
}
ln.child("a:prstDash").and_then(|e| e.attr("val")).map(LineDash::from_ooxml).unwrap_or_default()
}
/// Strict OOXML may spell percentages with a percent sign instead of integer units.
fn dash_ratio(value: &str) -> Option<u32> {
const PERCENT_UNITS: f64 = 1000.0;
let value = value.trim();
let Some(percent) = value.strip_suffix('%') else { return value.parse().ok() };
let units = percent.parse::<f64>().ok()? * PERCENT_UNITS;
if !units.is_finite() || !(0.0..=f64::from(u32::MAX)).contains(&units) {
return None;
}
Some(units.round() as u32)
}
/// A VML colour (`strokecolor`, `fillcolor`): `#rrggbb`, `rrggbb`, `#rgb` or `rgb` (Word follows it
/// with the theme slot, as in `#4472c4 [3204]` or `4472c4 [3204]`), an `rgb(...)` function, or a
/// colour name.
pub(crate) fn vml_color(v: &str) -> Option<Rgb> {
let v = v.split_whitespace().next()?;
if let Some(hex) = v.strip_prefix('#') {
if hex.len() == 3 && hex.is_ascii() {
let long: String = hex.chars().flat_map(|c| [c, c]).collect();
return Rgb::parse(&long);
let s = v.trim();
if let Some(inner) = s.strip_prefix("rgb(").or_else(|| s.strip_prefix("rgba(")) {
let nums: Vec<u8> = inner
.trim_end_matches(')')
.split(',')
.take(3)
.filter_map(|x| {
let x = x.trim();
if let Some(p) = x.strip_suffix('%') {
p.trim().parse::<f32>().ok().map(|f| (f.clamp(0.0, 100.0) * 2.55).round() as u8)
} else {
x.parse::<f32>().ok().map(|f| f.clamp(0.0, 255.0).round() as u8)
}
})
.collect();
if let [r, g, b] = nums[..] {
return Some(Rgb(r, g, b));
}
}
let tok = s.split_whitespace().next()?;
let hex = tok.strip_prefix('#').unwrap_or(tok);
if hex.len() == 3 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
let long: String = hex.chars().flat_map(|c| [c, c]).collect();
return Rgb::parse(&long);
}
if hex.len() == 6 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Rgb::parse(hex);
}
super::drawing_color::preset_color(v)
super::drawing_color::preset_color(tok)
}
#[cfg(test)]
@@ -122,6 +228,11 @@ mod tests {
assert_eq!(p.fill, None);
}
#[test]
fn flowchart_or_keeps_its_spokes() {
assert_eq!(preset_kind("flowChartOr"), ShapeKind::FlowchartOr);
}
#[test]
fn explicit_paint_wins_over_the_style() {
let sp = el(r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:noFill/><a:ln><a:solidFill><a:srgbClr val="EE0000"/></a:solidFill></a:ln></wps:spPr>"#);
@@ -166,11 +277,36 @@ mod tests {
assert_eq!(preset_kind("flowChartDecision"), ShapeKind::Diamond);
}
#[test]
fn vml_presets_map_like_drawingml_presets() {
assert_eq!(vml_preset_kind(13), Some(ShapeKind::Arrow));
assert_eq!(vml_preset_kind(12), Some(ShapeKind::Star));
assert_eq!(vml_preset_kind(32), Some(ShapeKind::Line));
assert_eq!(vml_preset_kind(202), Some(ShapeKind::TextBox));
// The nearest shape, as `preset_kind` gives it: left arrow, right triangle, four-point
// star, rounded callout, elbow connector, a bevel (its box).
assert_eq!(vml_preset_kind(66), Some(ShapeKind::Arrow));
assert_eq!(vml_preset_kind(6), Some(ShapeKind::RightTriangle));
assert_eq!(vml_preset_kind(187), Some(ShapeKind::Star));
assert_eq!(vml_preset_kind(62), Some(ShapeKind::RoundedRectangle));
assert_eq!(vml_preset_kind(34), Some(ShapeKind::ElbowConnector));
assert_eq!(vml_preset_kind(84), Some(ShapeKind::Rectangle));
// No preset geometry: freeform, WordArt text, picture frame, host control, out of range.
for spt in [0, 24, 31, 136, 175, 75, 201, 203, u32::MAX] {
assert_eq!(vml_preset_kind(spt), None, "{spt}");
}
}
#[test]
fn vml_colours() {
assert_eq!(vml_color("#ffc000 [3207]"), Some(Rgb(0xFF, 0xC0, 0)));
assert_eq!(vml_color("ffc000 [3207]"), Some(Rgb(0xFF, 0xC0, 0)));
assert_eq!(vml_color("#f00"), Some(Rgb(0xFF, 0, 0)));
assert_eq!(vml_color("f00"), Some(Rgb(0xFF, 0, 0)));
assert_eq!(vml_color("black"), Some(Rgb(0, 0, 0)));
assert_eq!(vml_color("Red"), Some(Rgb(0xFF, 0, 0)));
assert_eq!(vml_color("rgb(255, 128, 0)"), Some(Rgb(255, 128, 0)));
assert_eq!(vml_color("rgb(100%, 0%, 0%)"), Some(Rgb(255, 0, 0)));
assert_eq!(vml_color("#é"), None);
assert_eq!(vml_color(""), None);
}
File diff suppressed because it is too large Load Diff
+21
View File
@@ -34,6 +34,17 @@ pub fn measure(s: &str, per_pt: f32) -> Option<f32> {
pt.is_finite().then_some(pt.clamp(-MAX_LEN_PT, MAX_LEN_PT))
}
/// `w:line` under `lineRule="auto"`: the multiple of single line spacing, in 240ths (240 =
/// single). Without a unit the value already is that — the transitional form. A universal
/// measure is a height (strict OOXML writes `w:line="13.90pt"`, ISO 29500
/// `ST_SignedTwipsMeasure`), so it goes through twips as Word reads it: × 20 ÷ 240 (13.90 pt =
/// 278 twips, the 1.1583 multiple Word renders).
pub fn line_multiple(s: &str) -> Option<f32> {
let t = s.trim();
let measured = t.ends_with(|c: char| c.is_ascii_alphabetic());
measure(t, 240.0).map(|v| if measured { v * 20.0 / 240.0 } else { v })
}
/// Plain integer (accepts decimals, rounds; clamps into i64 range).
pub fn int(s: &str) -> Option<i64> {
let t = s.trim();
@@ -98,6 +109,16 @@ mod tests {
assert_eq!(measure("1e400", 20.0), None);
assert_eq!(measure("99999999999999", 20.0), Some(MAX_LEN_PT));
assert_eq!(measure("x", 20.0), None);
// `w:line` auto: the multiple in 240ths without a unit, through twips with one (#2329).
assert_eq!(line_multiple("240"), Some(1.0));
assert_eq!(line_multiple("276"), Some(1.15));
assert_eq!(line_multiple("0"), Some(0.0));
let strict = line_multiple("13.90pt").expect("strict line height");
assert!((strict - 278.0 / 240.0).abs() < 1e-5, "{strict}");
let inches = line_multiple("1.2in").expect("inches");
assert!((inches - 7.2).abs() < 1e-5, "{inches}");
assert_eq!(line_multiple("junk"), None);
assert_eq!(line_multiple(""), None);
assert_eq!(int("12.6"), Some(13));
assert_eq!(u32_of("-5"), Some(0));
}

Some files were not shown because too many files have changed in this diff Show More