Files
wordcraft/crates/engine/src/cmd/file.rs
T
+10 4045d76052 Validate PNG extension in file.exportPng (#3837)
* Restrict Editing: a file command that changes the document is checked too

`always_allowed` waved through every `file.*` id, so on a protected document
the file commands that do change it went unchecked: the privacy options and
the properties wrote document settings, and the inspector's removals deleted
content. The privacy options' own comment already said they were "refused on
a protected document"; that wasn't true.

The allow-list now names the file commands that don't change the open
document, and the ones that do are checked like any other command. Reading
the document stays allowed: the inspector without removals, the privacy
settings, the info, and saving a version snapshot.

Restoring a stored version replaces the whole document, and a snapshot's own
protection is stale — restoring one lifted the protection and dropped its
password. The live document's protection, password and formatting limit now
survive a restore.

Co-Authored-By: opencode <noreply@opencode.ai>

* Restrict Editing: the user name can't change under protection

`file.setAuthor` sets the name a protected document's named exception
ranges are checked against, and the author new revisions and comments are
recorded under. `always_allowed` waved through every `file.*` id, so on a
protected document the name could be changed: an agent could take the
identity of a named editor and unlock their exception range, and forge the
authorship of the revisions it leaves behind.

A protected document refuses the change now, with the rule Word uses for a
content control's properties: stop protection first. The check is in the
command itself — `file.setAuthor` is a pure command, so `check_before`
never sees it — and in `check_before` for the day it isn't.

Word lets the user name change under protection: it is a preference there.
This project doesn't, because that identity gates what may be edited.

Co-Authored-By: opencode <noreply@opencode.ai>

* Restrict Editing: a protected document's protection needs an explicit stop

The guard in `restrict` refused a protected document only when its
protection had a password, so a password-less one could have its
protection changed — and `file.protect {"mode": "none"}` stopped it
outright. An agent could strip a user's protection silently and then edit
the document freely.

Two rules now, as Word has them (Stop Protection first):

- The protection of a protected document doesn't change, whatever the
  mode. The formatting-only protection doesn't count: it stores the mode
  "none", and it limits the styles, not the editing.
- Stopping the protection is explicit. The "none" mode is the shorthand
  for it, but on a protected document only `{"stop": true}` stops it — the
  way the pane's Stop Protection button does it — so a command that
  merely names a mode can't strip it.

The password still guards the stop, and the formatting-only path (the
mode "none" with the formatting restriction) still works.

`cmd::tools::tests::protection_blocks_edits` stops the protection with
`{"mode": "none"}`; it has to stop it explicitly now.

Co-Authored-By: opencode <noreply@opencode.ai>

* Restrict Editing: the post-check compares the settings and the metadata

`outside_unchanged` is the check that rolls a command back when it
changed anything outside the editable regions. It compared the text, the
formatting, the page setup, the document defaults and the styles — but
not the document's settings, its metadata, its custom properties, the
resolved flag of its comments, its list definitions or its bibliography
sources. A command that changed the metadata of a protected document was
accepted and never rolled back: `file.properties {"title": "Forged
title"}` on a protected document with an exception changed the title and
stayed.

The settings, the core properties, the custom properties, the comments'
resolved flags, the list definitions and the sources are compared now, so
a command that changes them is rolled back like any other change outside
the editable regions.

The revisions and the media store stay out of it on purpose: an edit
inside an exception legitimately leaves a revision, and a picture
inserted there adds to the media store. The list definitions and the
sources are compared like the styles: the ones the document had must be
unchanged, but a new one may be added — a list or a citation inserted
inside an editable region adds its definition, like applying a style adds
one.

Saving is unaffected: it writes the metadata, but `file.save` is a pure
command and isn't post-checked, and the protection itself is changed by
`review.restrict`, which is always allowed.

Co-Authored-By: opencode <noreply@opencode.ai>

* The protection is stopped explicitly, and the docs say so

The test that stopped the protection used the "none" shorthand, which a
protected document now refuses; it stops it the explicit way instead, and
pins that the shorthand doesn't stop it. `file.protect` says the same: it
starts enforcement, and "none" doesn't stop a protected document.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the Restrict Editing gaps that are closed

Co-Authored-By: opencode <noreply@opencode.ai>

* A password guards the protection whatever its mode

The guard that stops the protection of a protected document from changing
asked whether the editing was restricted, so a formatting-only protection —
the mode "none", which limits the styles rather than the editing — was
exempted. A formatting-only protection can carry a password, and that password
guarded it: it was exempt from the guard, so `{"mode": "readOnly", "password":
"other"}` replaced the protection and the password with ones of the caller's
choosing, and the protection could then be stopped with them.

A password guards the protection whatever its mode, as it did before.

Co-Authored-By: opencode <noreply@opencode.ai>

* The CLI's --author names the session, it doesn't change the document

`--author` dispatched `file.setAuthor`, which a protected document refuses
now that the name is checked as the identity its exceptions are matched
against — so a script that named itself on a protected file failed before it
ran a single command.

The flag names the session, not the open document, so it sets the name
directly, with the same normalisation and blank-name check the command uses.
`file.setAuthor` keeps its protection check for the callers that mean to
change the document's identity.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the progress docs' status lines carry today's date

Co-Authored-By: opencode <noreply@opencode.ai>

* The document's properties and a version restore need the protection stopped

Two gaps the review found, both of them a change to a protected document that
went through without the protection seeing it.

The properties (`file.properties`) and the privacy options
(`file.privacyOptions`) are document-wide state, and they aren't tracked: under
tracked changes they changed the document with no revision to show for it, and
under the other modes they were only rolled back by the post-check, after the
command had run. They are refused now, before the command runs, as the content
control's properties are.

A version restore (`file.versions`) replaces the whole document, including the
content the protection kept read-only, and it did that without the password:
`file.versions` is a pure command, so the protection never saw it. Keeping the
live protection and its password stopped them being dropped, but the content
underneath was still replaced. A restore needs the protection stopped first,
and the password with it when the document has one.

The live protection still wins over the snapshot's stale one, so a restore
can't lock the user out of their own document either.

Co-Authored-By: opencode <noreply@opencode.ai>

* changes to filter dialogue

* The inspector's removals are refused in every protected mode

`file.inspect` with removals takes content out of the document — the
comments, the revisions, the properties, the hidden text, the headers.
Under readOnly and comments it was refused, or rolled back by the
post-check where the document had an exception; under trackedChanges
nothing stopped it, so a document that tracks changes could have its
comments and revisions taken out with no revision to show for it.

The removals are refused now, in every mode a protected document can be
in, next to the properties and the privacy options. An inspection without
removals only reads the document, and still works.

The protection test covers the modes now, not only readOnly.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the session author, not the user name

"the user name can't change while the document is protected" was the
wrong term twice over: it is the session author — the name new tracked
changes and comments are recorded under, and the one a named exception
is matched against — and it is `file.setAuthor` that refuses to change
it. The CLI's `--author` names the session directly, so it works on a
protected document.

The parity row, the two status lines and the two progress rows now say
that.

Co-Authored-By: opencode <noreply@opencode.ai>

* #3279: Keep protected session author fixed in CLI and Options

* The Document Inspector refuses its removals while the document tracks changes

The inspector's removals delete straight from the document: the hidden
text, the comments, the revisions and the headers go without ever reaching
the engine's tracked-delete path. On a document that tracks changes — and
`trackedChanges` protection turns tracking on — the content was gone with
no revision to show for it, and the revision trail was broken behind the
user's back.

The removals are not tracked, so they cannot be: rather than untrack them
silently, the inspector refuses the whole removal form while the document
tracks changes, and says why ("the document tracks changes: stop tracking
before removing content"). The check comes before anything is removed, so
the document is left untouched, and reading the document — the inspector
without removals — keeps working. A document that does not track changes
is inspected as before.

Co-Authored-By: opencode <noreply@opencode.ai>

* The inspector's tracking refusal doesn't cite an unrelated issue

#1344 is about the inspector missing hidden text, not about its removals
going untracked, so the reference only sent a reader to the wrong place.

Co-Authored-By: opencode <noreply@opencode.ai>

* A version snapshot belongs to the document it was taken from

`Session::set_document` resets everything about the open document — the
document id, the document, the selection, the undo history, the password,
the file name — but not the version snapshots. A snapshot taken of one
document therefore survived `file.new`, `file.open`, `file.newFromTemplate`
and `file.recover`, and `file.versions {"restore": index}` then put the
previous document's content into the open one.

The snapshots go with the document they were taken from: `set_document`
clears them, so a restore can only ever bring back a version of the
document that is open. Every caller replaces the document with another
one (a new or opened file, a recovered copy, a merge result, a sheet of
labels), so none of them loses anything it needs.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the inspector's tracking refusal and the version snapshot's scope

Co-Authored-By: opencode <noreply@opencode.ai>

* A comparison clears the version snapshots of the document it replaces

`review.compare` and `review.combine` open the original with `s.doc = doc`,
which bypasses `set_document` — so the version snapshots of the document the
comparison replaces survived it, and a restore could put the previous document
back over the comparison result.

The snapshots go with the document they were taken from, as everywhere else.
The document itself is still assigned directly: `set_document` resets the undo
history, and the comparison's own undo step would go with it.

Co-Authored-By: opencode <noreply@opencode.ai>

* #3277: Test Inspector refusal without protection

* #1327: Keep snapshots with their document through Compare

* #1327: Preserve file identity after rejected document replacement

* test: mark the portal test's blocking sleep as intentional (SonarCloud) (#3989)

The future is polled by hand with no async runtime, so a blocking sleep
is how the test models a slow dialog.

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>

* Engine: no dead-code warnings on wasm (#3542)

* fix(engine): do not treat decimal points as sentence boundaries in Sentence case

In crates/engine/src/cmd/format.rs, convert_chars treated any period as a sentence boundary, causing numbers with decimal points (e.g. 3.14, 2.5) to capitalize the subsequent word in Sentence case.

Check if a period is surrounded by ASCII digits, and do not trigger uppercase capitalization when encountering a decimal point.

Closes #3485

* proof: drop U+FE0F from FORBIDDENWORD and NOSUGGEST flags too

Review follow-up: the directives that name one flag read the same emoji
form, so they now skip the variation selector like PFX/SFX and stem flags.

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

* fmt: rustfmt equation array centring

* #3108 review: behind-text and in-front-of-text floats don't grow the row

FredApps' review: the row-growth loop counted every non-inline object, so a
page-anchored logo or watermark behind a cell's text would stretch its row to
the object's bottom edge - contradicting table.rs (behind-text frames count
for vertical alignment but don't increase the row's flow height) and Word.
Only floats that take part in the text flow (Square, Tight, Through,
TopAndBottom) grow the row now, the same classification layout already uses
elsewhere for "takes part in wrapping".

New test: a 300pt behind-text and a 300pt in-front-of-text picture each leave
the row at its text height; it fails with the old condition. The existing
Square-wrap tests and cell_frame_centres_empty still pass.

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

* Engine: no dead-code warnings on wasm

`cargo check -p wordcraft-engine --target wasm32-unknown-unknown` warned
about five items whose only callers are platform-gated:
- citations `by_author`, user_templates `MAX_ENTRIES` and `label_of`: used
  only by the desktop (non-wasm) source import and template listing.
- speech `voice_language`: used by espeak (Unix but macOS) and the macOS
  voice picker, so `any(unix, test)`, not `any(not(windows), test)` (which
  also took in wasm).
- speech `primary_tag`: used by those plus the Windows synthesizer, so
  `any(unix, windows, test)`.

Each item now carries exactly its callers' gate rather than a blanket
allow(dead_code). Checked with cargo check on Linux, Windows (gnu), macOS
and wasm32 (zero warnings each), clippy -D warnings, and the engine tests.

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

* Integration: #1847 reject comment IDs above u32::MAX

* Integration: #3267 leave footnote definitions out of link definitions

* Integration: #3471 drop duplicate egui options block; install_ui_fonts sets them

* Integration: #2153 drop trailing blank line and use assert! for bool checks in tab_separated_list_markers

* Integration: #2268 ODT lengths accept only the standard pc unit for picas

* Integration: #2269 LaTeX lengths keep macro names case-sensitive

* Integration: #2673 Khmer substitutes return the (list, flag) pair after #3245

* Integration: #2273 Latin weight/style and line styles take precedence over Asian, complex and line-type attributes

* Integration: #1851 DOCPROPERTY accepts the reference app's LastSavedBy name

* Integration: #1985 only ^0nnn codes read as Windows-1252

* Integration: #2416 collapse the nested vertical-align if (clippy::collapsible_if)

* Integration: #2185 hyperlink_target slices with get() and needs the whole field name

* Integration: #2034 objects without a stacking order draw above the ones before them

* Integration: #3434 drop review-bot notes from test doc comments

* Integration: #3419 doc comments back on their own items (EquationFrame and its test above them)

* Integration: #2334 test a formula string result continued in a CONTINUE record

* Integration: #3429 the based-on-chain test's doc comment back above its test

* Integration: #2453 drawing_payload test builds the shape fill as a Fill

* Integration: #3257 separate its Find-box test from #2407's recent-files test (auto-merge interleaved them)

* Integration: cargo fmt

* Integration: #1728 close the layout test the merge resolution left open

* Integration: cargo fmt

* Integration: cargo fmt

* Integration: cargo fmt

* Integration: #2722 Dutch registry comment on its own line

* Integration: #3203 English text for the keys the Romanian catalog lacks

Its complete-catalog test needs every key of every other catalog; the merged PRs added 72 since it was written. Rows carry the English text until translated.

* Integration: #1076 English text for the keys the Slovenian catalog lacks

Its complete-catalog test needs every key of every other catalog. Rows carry the English text until translated.

* Integration: status lines list each change once

Combining the tracks' status lines repeated a few entries (#2852, #1500, #2617).

* Integration: cargo xtask parity

* Integration: cargo fmt

* Integration: #1832 drop a useless .into() in the end-of-document citation test

clippy::useless_conversion failed cargo xtask ci.

* Integration: #2244 VML on/off values read case-insensitively by the shape fill reader

Main (#1500) moved VML fill reading into shape_style.rs, where filled="False" still counted as on; #2244's test vml_shape_wrap_side_and_anchor_case_insensitivity expects no fill.

* Integration: #2041 English text in the Polish catalog for the keys Italian gained

polish_covers_every_key_of_every_other_catalog expects Italian to have only "Style: APA" beyond Polish; #2041 translated 14 newer strings into Italian. Rows carry the English text until translated.

* Integration: #2722 English text for four labels the Dutch and Danish catalogs lack

every_tab_and_command_is_translated needs Format Shape Pane, Full Screen View, Personal Templates and Underline Color, added by other merged PRs. Rows carry the English text until translated.

* Integration: #3335 header Esc test allows the half-pixel return scroll that #3201 brings

#3201 makes entering an empty header scroll to it; Esc then scrolls back to the caret, landing 0.5 px from the start (17291.0 vs 17290.5). The test checks the view is back within a pixel.

* IME: the caret follows the composition text (#3556)

* Header and footer: a click in the other band edits it there (#3557)

* README: the app icon above the title

* Release 0.10.0

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

* The inspector's removals are refused in every protected mode

`file.inspect` with removals takes content out of the document — the
comments, the revisions, the properties, the hidden text, the headers.
Under readOnly and comments it was refused, or rolled back by the
post-check where the document had an exception; under trackedChanges
nothing stopped it, so a document that tracks changes could have its
comments and revisions taken out with no revision to show for it.

The removals are refused now, in every mode a protected document can be
in, next to the properties and the privacy options. An inspection without
removals only reads the document, and still works.

The protection test covers the modes now, not only readOnly.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the session author, not the user name

"the user name can't change while the document is protected" was the
wrong term twice over: it is the session author — the name new tracked
changes and comments are recorded under, and the one a named exception
is matched against — and it is `file.setAuthor` that refuses to change
it. The CLI's `--author` names the session directly, so it works on a
protected document.

The parity row, the two status lines and the two progress rows now say
that.

Co-Authored-By: opencode <noreply@opencode.ai>

* #3279: Keep protected session author fixed in CLI and Options

* The Document Inspector refuses its removals while the document tracks changes

The inspector's removals delete straight from the document: the hidden
text, the comments, the revisions and the headers go without ever reaching
the engine's tracked-delete path. On a document that tracks changes — and
`trackedChanges` protection turns tracking on — the content was gone with
no revision to show for it, and the revision trail was broken behind the
user's back.

The removals are not tracked, so they cannot be: rather than untrack them
silently, the inspector refuses the whole removal form while the document
tracks changes, and says why ("the document tracks changes: stop tracking
before removing content"). The check comes before anything is removed, so
the document is left untouched, and reading the document — the inspector
without removals — keeps working. A document that does not track changes
is inspected as before.

Co-Authored-By: opencode <noreply@opencode.ai>

* The inspector's tracking refusal doesn't cite an unrelated issue

#1344 is about the inspector missing hidden text, not about its removals
going untracked, so the reference only sent a reader to the wrong place.

Co-Authored-By: opencode <noreply@opencode.ai>

* A version snapshot belongs to the document it was taken from

`Session::set_document` resets everything about the open document — the
document id, the document, the selection, the undo history, the password,
the file name — but not the version snapshots. A snapshot taken of one
document therefore survived `file.new`, `file.open`, `file.newFromTemplate`
and `file.recover`, and `file.versions {"restore": index}` then put the
previous document's content into the open one.

The snapshots go with the document they were taken from: `set_document`
clears them, so a restore can only ever bring back a version of the
document that is open. Every caller replaces the document with another
one (a new or opened file, a recovered copy, a merge result, a sheet of
labels), so none of them loses anything it needs.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the inspector's tracking refusal and the version snapshot's scope

Co-Authored-By: opencode <noreply@opencode.ai>

* A comparison clears the version snapshots of the document it replaces

`review.compare` and `review.combine` open the original with `s.doc = doc`,
which bypasses `set_document` — so the version snapshots of the document the
comparison replaces survived it, and a restore could put the previous document
back over the comparison result.

The snapshots go with the document they were taken from, as everywhere else.
The document itself is still assigned directly: `set_document` resets the undo
history, and the comparison's own undo step would go with it.

Co-Authored-By: opencode <noreply@opencode.ai>

* #3277: Test Inspector refusal without protection

* #1327: Keep snapshots with their document through Compare

* #1327: Preserve file identity after rejected document replacement

* test: mark the portal test's blocking sleep as intentional (SonarCloud) (#3989)

The future is polled by hand with no async runtime, so a blocking sleep
is how the test models a slow dialog.

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>

* #3542: gate the label_of test off wasm32

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

---------

Co-authored-by: Divyanshu Gupta <divyanshu2074@gmail.com>
Co-authored-by: dmim2502-commits <234869083+dmim2502-commits@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Mat Strange <mat@matstrange.net>
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Drop U+FFFE and U+FFFF from ODT export text (#3817)

Found while testing crates/formats/src/odt.rs:49-63 (fn x, the ODT text escaper; filter at line 58)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* Cap the number of VML path commands parsed from a path attribute (#3951)

Fixes #3683

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* Return None from parse_number when the value overflows f64 (#3577)

Found while testing crates/doc/src/locale_number.rs:104

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Fix spurious 零 after a group's trailing zeros in Chinese numerals (#3745)

Found while testing crates/doc/src/cjk_numbers.rs:47-55 (Counting::format)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* picture.corrections: a tiny sharpen value no longer panics in the blur (#3646) (#3830)

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* Reject signs and non-digits in parse_date fields (#3575)

Found while testing crates/engine/src/cmd/controls.rs:113-123 (parse_date)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Layout: keep with next takes the 0.01 pt of slack the other fit checks take (#3816)

* Restrict Editing: a file command that changes the document is checked too

`always_allowed` waved through every `file.*` id, so on a protected document
the file commands that do change it went unchecked: the privacy options and
the properties wrote document settings, and the inspector's removals deleted
content. The privacy options' own comment already said they were "refused on
a protected document"; that wasn't true.

The allow-list now names the file commands that don't change the open
document, and the ones that do are checked like any other command. Reading
the document stays allowed: the inspector without removals, the privacy
settings, the info, and saving a version snapshot.

Restoring a stored version replaces the whole document, and a snapshot's own
protection is stale — restoring one lifted the protection and dropped its
password. The live document's protection, password and formatting limit now
survive a restore.

Co-Authored-By: opencode <noreply@opencode.ai>

* Restrict Editing: the user name can't change under protection

`file.setAuthor` sets the name a protected document's named exception
ranges are checked against, and the author new revisions and comments are
recorded under. `always_allowed` waved through every `file.*` id, so on a
protected document the name could be changed: an agent could take the
identity of a named editor and unlock their exception range, and forge the
authorship of the revisions it leaves behind.

A protected document refuses the change now, with the rule Word uses for a
content control's properties: stop protection first. The check is in the
command itself — `file.setAuthor` is a pure command, so `check_before`
never sees it — and in `check_before` for the day it isn't.

Word lets the user name change under protection: it is a preference there.
This project doesn't, because that identity gates what may be edited.

Co-Authored-By: opencode <noreply@opencode.ai>

* Restrict Editing: a protected document's protection needs an explicit stop

The guard in `restrict` refused a protected document only when its
protection had a password, so a password-less one could have its
protection changed — and `file.protect {"mode": "none"}` stopped it
outright. An agent could strip a user's protection silently and then edit
the document freely.

Two rules now, as Word has them (Stop Protection first):

- The protection of a protected document doesn't change, whatever the
  mode. The formatting-only protection doesn't count: it stores the mode
  "none", and it limits the styles, not the editing.
- Stopping the protection is explicit. The "none" mode is the shorthand
  for it, but on a protected document only `{"stop": true}` stops it — the
  way the pane's Stop Protection button does it — so a command that
  merely names a mode can't strip it.

The password still guards the stop, and the formatting-only path (the
mode "none" with the formatting restriction) still works.

`cmd::tools::tests::protection_blocks_edits` stops the protection with
`{"mode": "none"}`; it has to stop it explicitly now.

Co-Authored-By: opencode <noreply@opencode.ai>

* Restrict Editing: the post-check compares the settings and the metadata

`outside_unchanged` is the check that rolls a command back when it
changed anything outside the editable regions. It compared the text, the
formatting, the page setup, the document defaults and the styles — but
not the document's settings, its metadata, its custom properties, the
resolved flag of its comments, its list definitions or its bibliography
sources. A command that changed the metadata of a protected document was
accepted and never rolled back: `file.properties {"title": "Forged
title"}` on a protected document with an exception changed the title and
stayed.

The settings, the core properties, the custom properties, the comments'
resolved flags, the list definitions and the sources are compared now, so
a command that changes them is rolled back like any other change outside
the editable regions.

The revisions and the media store stay out of it on purpose: an edit
inside an exception legitimately leaves a revision, and a picture
inserted there adds to the media store. The list definitions and the
sources are compared like the styles: the ones the document had must be
unchanged, but a new one may be added — a list or a citation inserted
inside an editable region adds its definition, like applying a style adds
one.

Saving is unaffected: it writes the metadata, but `file.save` is a pure
command and isn't post-checked, and the protection itself is changed by
`review.restrict`, which is always allowed.

Co-Authored-By: opencode <noreply@opencode.ai>

* The protection is stopped explicitly, and the docs say so

The test that stopped the protection used the "none" shorthand, which a
protected document now refuses; it stops it the explicit way instead, and
pins that the shorthand doesn't stop it. `file.protect` says the same: it
starts enforcement, and "none" doesn't stop a protected document.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the Restrict Editing gaps that are closed

Co-Authored-By: opencode <noreply@opencode.ai>

* A password guards the protection whatever its mode

The guard that stops the protection of a protected document from changing
asked whether the editing was restricted, so a formatting-only protection —
the mode "none", which limits the styles rather than the editing — was
exempted. A formatting-only protection can carry a password, and that password
guarded it: it was exempt from the guard, so `{"mode": "readOnly", "password":
"other"}` replaced the protection and the password with ones of the caller's
choosing, and the protection could then be stopped with them.

A password guards the protection whatever its mode, as it did before.

Co-Authored-By: opencode <noreply@opencode.ai>

* The CLI's --author names the session, it doesn't change the document

`--author` dispatched `file.setAuthor`, which a protected document refuses
now that the name is checked as the identity its exceptions are matched
against — so a script that named itself on a protected file failed before it
ran a single command.

The flag names the session, not the open document, so it sets the name
directly, with the same normalisation and blank-name check the command uses.
`file.setAuthor` keeps its protection check for the callers that mean to
change the document's identity.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the progress docs' status lines carry today's date

Co-Authored-By: opencode <noreply@opencode.ai>

* The document's properties and a version restore need the protection stopped

Two gaps the review found, both of them a change to a protected document that
went through without the protection seeing it.

The properties (`file.properties`) and the privacy options
(`file.privacyOptions`) are document-wide state, and they aren't tracked: under
tracked changes they changed the document with no revision to show for it, and
under the other modes they were only rolled back by the post-check, after the
command had run. They are refused now, before the command runs, as the content
control's properties are.

A version restore (`file.versions`) replaces the whole document, including the
content the protection kept read-only, and it did that without the password:
`file.versions` is a pure command, so the protection never saw it. Keeping the
live protection and its password stopped them being dropped, but the content
underneath was still replaced. A restore needs the protection stopped first,
and the password with it when the document has one.

The live protection still wins over the snapshot's stale one, so a restore
can't lock the user out of their own document either.

Co-Authored-By: opencode <noreply@opencode.ai>

* Layout: keep with next takes the 0.01 pt of slack the other fit checks take

place_para_in_flow moved a paragraph kept with the next one to a new page
when the two needed more than the room left by any amount, while lines, rows
and floats fit with 0.01 pt to spare. A kept paragraph whose need came to a
rounding error over the page bottom moved on alone.

* Release 0.10.0

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

* The inspector's removals are refused in every protected mode

`file.inspect` with removals takes content out of the document — the
comments, the revisions, the properties, the hidden text, the headers.
Under readOnly and comments it was refused, or rolled back by the
post-check where the document had an exception; under trackedChanges
nothing stopped it, so a document that tracks changes could have its
comments and revisions taken out with no revision to show for it.

The removals are refused now, in every mode a protected document can be
in, next to the properties and the privacy options. An inspection without
removals only reads the document, and still works.

The protection test covers the modes now, not only readOnly.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the session author, not the user name

"the user name can't change while the document is protected" was the
wrong term twice over: it is the session author — the name new tracked
changes and comments are recorded under, and the one a named exception
is matched against — and it is `file.setAuthor` that refuses to change
it. The CLI's `--author` names the session directly, so it works on a
protected document.

The parity row, the two status lines and the two progress rows now say
that.

Co-Authored-By: opencode <noreply@opencode.ai>

* #3279: Keep protected session author fixed in CLI and Options

* The Document Inspector refuses its removals while the document tracks changes

The inspector's removals delete straight from the document: the hidden
text, the comments, the revisions and the headers go without ever reaching
the engine's tracked-delete path. On a document that tracks changes — and
`trackedChanges` protection turns tracking on — the content was gone with
no revision to show for it, and the revision trail was broken behind the
user's back.

The removals are not tracked, so they cannot be: rather than untrack them
silently, the inspector refuses the whole removal form while the document
tracks changes, and says why ("the document tracks changes: stop tracking
before removing content"). The check comes before anything is removed, so
the document is left untouched, and reading the document — the inspector
without removals — keeps working. A document that does not track changes
is inspected as before.

Co-Authored-By: opencode <noreply@opencode.ai>

* The inspector's tracking refusal doesn't cite an unrelated issue

#1344 is about the inspector missing hidden text, not about its removals
going untracked, so the reference only sent a reader to the wrong place.

Co-Authored-By: opencode <noreply@opencode.ai>

* A version snapshot belongs to the document it was taken from

`Session::set_document` resets everything about the open document — the
document id, the document, the selection, the undo history, the password,
the file name — but not the version snapshots. A snapshot taken of one
document therefore survived `file.new`, `file.open`, `file.newFromTemplate`
and `file.recover`, and `file.versions {"restore": index}` then put the
previous document's content into the open one.

The snapshots go with the document they were taken from: `set_document`
clears them, so a restore can only ever bring back a version of the
document that is open. Every caller replaces the document with another
one (a new or opened file, a recovered copy, a merge result, a sheet of
labels), so none of them loses anything it needs.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the inspector's tracking refusal and the version snapshot's scope

Co-Authored-By: opencode <noreply@opencode.ai>

* A comparison clears the version snapshots of the document it replaces

`review.compare` and `review.combine` open the original with `s.doc = doc`,
which bypasses `set_document` — so the version snapshots of the document the
comparison replaces survived it, and a restore could put the previous document
back over the comparison result.

The snapshots go with the document they were taken from, as everywhere else.
The document itself is still assigned directly: `set_document` resets the undo
history, and the comparison's own undo step would go with it.

Co-Authored-By: opencode <noreply@opencode.ai>

* #3277: Test Inspector refusal without protection

* #1327: Keep snapshots with their document through Compare

* #1327: Preserve file identity after rejected document replacement

* test: mark the portal test's blocking sleep as intentional (SonarCloud) (#3989)

The future is polled by hand with no async runtime, so a blocking sleep
is how the test models a slow dialog.

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>

* Engine: no dead-code warnings on wasm (#3542)

* fix(engine): do not treat decimal points as sentence boundaries in Sentence case

In crates/engine/src/cmd/format.rs, convert_chars treated any period as a sentence boundary, causing numbers with decimal points (e.g. 3.14, 2.5) to capitalize the subsequent word in Sentence case.

Check if a period is surrounded by ASCII digits, and do not trigger uppercase capitalization when encountering a decimal point.

Closes #3485

* proof: drop U+FE0F from FORBIDDENWORD and NOSUGGEST flags too

Review follow-up: the directives that name one flag read the same emoji
form, so they now skip the variation selector like PFX/SFX and stem flags.

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

* fmt: rustfmt equation array centring

* #3108 review: behind-text and in-front-of-text floats don't grow the row

FredApps' review: the row-growth loop counted every non-inline object, so a
page-anchored logo or watermark behind a cell's text would stretch its row to
the object's bottom edge - contradicting table.rs (behind-text frames count
for vertical alignment but don't increase the row's flow height) and Word.
Only floats that take part in the text flow (Square, Tight, Through,
TopAndBottom) grow the row now, the same classification layout already uses
elsewhere for "takes part in wrapping".

New test: a 300pt behind-text and a 300pt in-front-of-text picture each leave
the row at its text height; it fails with the old condition. The existing
Square-wrap tests and cell_frame_centres_empty still pass.

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

* Engine: no dead-code warnings on wasm

`cargo check -p wordcraft-engine --target wasm32-unknown-unknown` warned
about five items whose only callers are platform-gated:
- citations `by_author`, user_templates `MAX_ENTRIES` and `label_of`: used
  only by the desktop (non-wasm) source import and template listing.
- speech `voice_language`: used by espeak (Unix but macOS) and the macOS
  voice picker, so `any(unix, test)`, not `any(not(windows), test)` (which
  also took in wasm).
- speech `primary_tag`: used by those plus the Windows synthesizer, so
  `any(unix, windows, test)`.

Each item now carries exactly its callers' gate rather than a blanket
allow(dead_code). Checked with cargo check on Linux, Windows (gnu), macOS
and wasm32 (zero warnings each), clippy -D warnings, and the engine tests.

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

* Integration: #1847 reject comment IDs above u32::MAX

* Integration: #3267 leave footnote definitions out of link definitions

* Integration: #3471 drop duplicate egui options block; install_ui_fonts sets them

* Integration: #2153 drop trailing blank line and use assert! for bool checks in tab_separated_list_markers

* Integration: #2268 ODT lengths accept only the standard pc unit for picas

* Integration: #2269 LaTeX lengths keep macro names case-sensitive

* Integration: #2673 Khmer substitutes return the (list, flag) pair after #3245

* Integration: #2273 Latin weight/style and line styles take precedence over Asian, complex and line-type attributes

* Integration: #1851 DOCPROPERTY accepts the reference app's LastSavedBy name

* Integration: #1985 only ^0nnn codes read as Windows-1252

* Integration: #2416 collapse the nested vertical-align if (clippy::collapsible_if)

* Integration: #2185 hyperlink_target slices with get() and needs the whole field name

* Integration: #2034 objects without a stacking order draw above the ones before them

* Integration: #3434 drop review-bot notes from test doc comments

* Integration: #3419 doc comments back on their own items (EquationFrame and its test above them)

* Integration: #2334 test a formula string result continued in a CONTINUE record

* Integration: #3429 the based-on-chain test's doc comment back above its test

* Integration: #2453 drawing_payload test builds the shape fill as a Fill

* Integration: #3257 separate its Find-box test from #2407's recent-files test (auto-merge interleaved them)

* Integration: cargo fmt

* Integration: #1728 close the layout test the merge resolution left open

* Integration: cargo fmt

* Integration: cargo fmt

* Integration: cargo fmt

* Integration: #2722 Dutch registry comment on its own line

* Integration: #3203 English text for the keys the Romanian catalog lacks

Its complete-catalog test needs every key of every other catalog; the merged PRs added 72 since it was written. Rows carry the English text until translated.

* Integration: #1076 English text for the keys the Slovenian catalog lacks

Its complete-catalog test needs every key of every other catalog. Rows carry the English text until translated.

* Integration: status lines list each change once

Combining the tracks' status lines repeated a few entries (#2852, #1500, #2617).

* Integration: cargo xtask parity

* Integration: cargo fmt

* Integration: #1832 drop a useless .into() in the end-of-document citation test

clippy::useless_conversion failed cargo xtask ci.

* Integration: #2244 VML on/off values read case-insensitively by the shape fill reader

Main (#1500) moved VML fill reading into shape_style.rs, where filled="False" still counted as on; #2244's test vml_shape_wrap_side_and_anchor_case_insensitivity expects no fill.

* Integration: #2041 English text in the Polish catalog for the keys Italian gained

polish_covers_every_key_of_every_other_catalog expects Italian to have only "Style: APA" beyond Polish; #2041 translated 14 newer strings into Italian. Rows carry the English text until translated.

* Integration: #2722 English text for four labels the Dutch and Danish catalogs lack

every_tab_and_command_is_translated needs Format Shape Pane, Full Screen View, Personal Templates and Underline Color, added by other merged PRs. Rows carry the English text until translated.

* Integration: #3335 header Esc test allows the half-pixel return scroll that #3201 brings

#3201 makes entering an empty header scroll to it; Esc then scrolls back to the caret, landing 0.5 px from the start (17291.0 vs 17290.5). The test checks the view is back within a pixel.

* IME: the caret follows the composition text (#3556)

* Header and footer: a click in the other band edits it there (#3557)

* README: the app icon above the title

* Release 0.10.0

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

* The inspector's removals are refused in every protected mode

`file.inspect` with removals takes content out of the document — the
comments, the revisions, the properties, the hidden text, the headers.
Under readOnly and comments it was refused, or rolled back by the
post-check where the document had an exception; under trackedChanges
nothing stopped it, so a document that tracks changes could have its
comments and revisions taken out with no revision to show for it.

The removals are refused now, in every mode a protected document can be
in, next to the properties and the privacy options. An inspection without
removals only reads the document, and still works.

The protection test covers the modes now, not only readOnly.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the session author, not the user name

"the user name can't change while the document is protected" was the
wrong term twice over: it is the session author — the name new tracked
changes and comments are recorded under, and the one a named exception
is matched against — and it is `file.setAuthor` that refuses to change
it. The CLI's `--author` names the session directly, so it works on a
protected document.

The parity row, the two status lines and the two progress rows now say
that.

Co-Authored-By: opencode <noreply@opencode.ai>

* #3279: Keep protected session author fixed in CLI and Options

* The Document Inspector refuses its removals while the document tracks changes

The inspector's removals delete straight from the document: the hidden
text, the comments, the revisions and the headers go without ever reaching
the engine's tracked-delete path. On a document that tracks changes — and
`trackedChanges` protection turns tracking on — the content was gone with
no revision to show for it, and the revision trail was broken behind the
user's back.

The removals are not tracked, so they cannot be: rather than untrack them
silently, the inspector refuses the whole removal form while the document
tracks changes, and says why ("the document tracks changes: stop tracking
before removing content"). The check comes before anything is removed, so
the document is left untouched, and reading the document — the inspector
without removals — keeps working. A document that does not track changes
is inspected as before.

Co-Authored-By: opencode <noreply@opencode.ai>

* The inspector's tracking refusal doesn't cite an unrelated issue

#1344 is about the inspector missing hidden text, not about its removals
going untracked, so the reference only sent a reader to the wrong place.

Co-Authored-By: opencode <noreply@opencode.ai>

* A version snapshot belongs to the document it was taken from

`Session::set_document` resets everything about the open document — the
document id, the document, the selection, the undo history, the password,
the file name — but not the version snapshots. A snapshot taken of one
document therefore survived `file.new`, `file.open`, `file.newFromTemplate`
and `file.recover`, and `file.versions {"restore": index}` then put the
previous document's content into the open one.

The snapshots go with the document they were taken from: `set_document`
clears them, so a restore can only ever bring back a version of the
document that is open. Every caller replaces the document with another
one (a new or opened file, a recovered copy, a merge result, a sheet of
labels), so none of them loses anything it needs.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the inspector's tracking refusal and the version snapshot's scope

Co-Authored-By: opencode <noreply@opencode.ai>

* A comparison clears the version snapshots of the document it replaces

`review.compare` and `review.combine` open the original with `s.doc = doc`,
which bypasses `set_document` — so the version snapshots of the document the
comparison replaces survived it, and a restore could put the previous document
back over the comparison result.

The snapshots go with the document they were taken from, as everywhere else.
The document itself is still assigned directly: `set_document` resets the undo
history, and the comparison's own undo step would go with it.

Co-Authored-By: opencode <noreply@opencode.ai>

* #3277: Test Inspector refusal without protection

* #1327: Keep snapshots with their document through Compare

* #1327: Preserve file identity after rejected document replacement

* test: mark the portal test's blocking sleep as intentional (SonarCloud) (#3989)

The future is polled by hand with no async runtime, so a blocking sleep
is how the test models a slow dialog.

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>

* #3542: gate the label_of test off wasm32

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

---------

Co-authored-by: Divyanshu Gupta <divyanshu2074@gmail.com>
Co-authored-by: dmim2502-commits <234869083+dmim2502-commits@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Mat Strange <mat@matstrange.net>
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Drop U+FFFE and U+FFFF from ODT export text (#3817)

Found while testing crates/formats/src/odt.rs:49-63 (fn x, the ODT text escaper; filter at line 58)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* Cap the number of VML path commands parsed from a path attribute (#3951)

Fixes #3683

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* Return None from parse_number when the value overflows f64 (#3577)

Found while testing crates/doc/src/locale_number.rs:104

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Fix spurious 零 after a group's trailing zeros in Chinese numerals (#3745)

Found while testing crates/doc/src/cjk_numbers.rs:47-55 (Counting::format)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* picture.corrections: a tiny sharpen value no longer panics in the blur (#3646) (#3830)

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* Reject signs and non-digits in parse_date fields (#3575)

Found while testing crates/engine/src/cmd/controls.rs:113-123 (parse_date)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* #3816: also assert the next paragraph's first line stays on page 0

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

---------

Co-authored-by: Mat Strange <mat@matstrange.net>
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com>
Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
Co-authored-by: Michael Mu <69072633+MichaelMuClaude@users.noreply.github.com>
Co-authored-by: Divyanshu Gupta <divyanshu2074@gmail.com>
Co-authored-by: dmim2502-commits <234869083+dmim2502-commits@users.noreply.github.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Co-authored-by: Joseph Anglada <fluorine@users.noreply.github.com>

* Parse the Flat OPC part root tag once when inheriting namespaces (#3944)

* Parse the Flat OPC part root tag once when inheriting namespaces

Fixes #3727

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

* test: mark the portal test's blocking sleep as intentional (SonarCloud) (#3989)

The future is polled by hand with no async runtime, so a blocking sleep
is how the test models a slow dialog.

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>

* Engine: no dead-code warnings on wasm (#3542)

* fix(engine): do not treat decimal points as sentence boundaries in Sentence case

In crates/engine/src/cmd/format.rs, convert_chars treated any period as a sentence boundary, causing numbers with decimal points (e.g. 3.14, 2.5) to capitalize the subsequent word in Sentence case.

Check if a period is surrounded by ASCII digits, and do not trigger uppercase capitalization when encountering a decimal point.

Closes #3485

* proof: drop U+FE0F from FORBIDDENWORD and NOSUGGEST flags too

Review follow-up: the directives that name one flag read the same emoji
form, so they now skip the variation selector like PFX/SFX and stem flags.

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

* fmt: rustfmt equation array centring

* #3108 review: behind-text and in-front-of-text floats don't grow the row

FredApps' review: the row-growth loop counted every non-inline object, so a
page-anchored logo or watermark behind a cell's text would stretch its row to
the object's bottom edge - contradicting table.rs (behind-text frames count
for vertical alignment but don't increase the row's flow height) and Word.
Only floats that take part in the text flow (Square, Tight, Through,
TopAndBottom) grow the row now, the same classification layout already uses
elsewhere for "takes part in wrapping".

New test: a 300pt behind-text and a 300pt in-front-of-text picture each leave
the row at its text height; it fails with the old condition. The existing
Square-wrap tests and cell_frame_centres_empty still pass.

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

* Engine: no dead-code warnings on wasm

`cargo check -p wordcraft-engine --target wasm32-unknown-unknown` warned
about five items whose only callers are platform-gated:
- citations `by_author`, user_templates `MAX_ENTRIES` and `label_of`: used
  only by the desktop (non-wasm) source import and template listing.
- speech `voice_language`: used by espeak (Unix but macOS) and the macOS
  voice picker, so `any(unix, test)`, not `any(not(windows), test)` (which
  also took in wasm).
- speech `primary_tag`: used by those plus the Windows synthesizer, so
  `any(unix, windows, test)`.

Each item now carries exactly its callers' gate rather than a blanket
allow(dead_code). Checked with cargo check on Linux, Windows (gnu), macOS
and wasm32 (zero warnings each), clippy -D warnings, and the engine tests.

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

* Integration: #1847 reject comment IDs above u32::MAX

* Integration: #3267 leave footnote definitions out of link definitions

* Integration: #3471 drop duplicate egui options block; install_ui_fonts sets them

* Integration: #2153 drop trailing blank line and use assert! for bool checks in tab_separated_list_markers

* Integration: #2268 ODT lengths accept only the standard pc unit for picas

* Integration: #2269 LaTeX lengths keep macro names case-sensitive

* Integration: #2673 Khmer substitutes return the (list, flag) pair after #3245

* Integration: #2273 Latin weight/style and line styles take precedence over Asian, complex and line-type attributes

* Integration: #1851 DOCPROPERTY accepts the reference app's LastSavedBy name

* Integration: #1985 only ^0nnn codes read as Windows-1252

* Integration: #2416 collapse the nested vertical-align if (clippy::collapsible_if)

* Integration: #2185 hyperlink_target slices with get() and needs the whole field name

* Integration: #2034 objects without a stacking order draw above the ones before them

* Integration: #3434 drop review-bot notes from test doc comments

* Integration: #3419 doc comments back on their own items (EquationFrame and its test above them)

* Integration: #2334 test a formula string result continued in a CONTINUE record

* Integration: #3429 the based-on-chain test's doc comment back above its test

* Integration: #2453 drawing_payload test builds the shape fill as a Fill

* Integration: #3257 separate its Find-box test from #2407's recent-files test (auto-merge interleaved them)

* Integration: cargo fmt

* Integration: #1728 close the layout test the merge resolution left open

* Integration: cargo fmt

* Integration: cargo fmt

* Integration: cargo fmt

* Integration: #2722 Dutch registry comment on its own line

* Integration: #3203 English text for the keys the Romanian catalog lacks

Its complete-catalog test needs every key of every other catalog; the merged PRs added 72 since it was written. Rows carry the English text until translated.

* Integration: #1076 English text for the keys the Slovenian catalog lacks

Its complete-catalog test needs every key of every other catalog. Rows carry the English text until translated.

* Integration: status lines list each change once

Combining the tracks' status lines repeated a few entries (#2852, #1500, #2617).

* Integration: cargo xtask parity

* Integration: cargo fmt

* Integration: #1832 drop a useless .into() in the end-of-document citation test

clippy::useless_conversion failed cargo xtask ci.

* Integration: #2244 VML on/off values read case-insensitively by the shape fill reader

Main (#1500) moved VML fill reading into shape_style.rs, where filled="False" still counted as on; #2244's test vml_shape_wrap_side_and_anchor_case_insensitivity expects no fill.

* Integration: #2041 English text in the Polish catalog for the keys Italian gained

polish_covers_every_key_of_every_other_catalog expects Italian to have only "Style: APA" beyond Polish; #2041 translated 14 newer strings into Italian. Rows carry the English text until translated.

* Integration: #2722 English text for four labels the Dutch and Danish catalogs lack

every_tab_and_command_is_translated needs Format Shape Pane, Full Screen View, Personal Templates and Underline Color, added by other merged PRs. Rows carry the English text until translated.

* Integration: #3335 header Esc test allows the half-pixel return scroll that #3201 brings

#3201 makes entering an empty header scroll to it; Esc then scrolls back to the caret, landing 0.5 px from the start (17291.0 vs 17290.5). The test checks the view is back within a pixel.

* IME: the caret follows the composition text (#3556)

* Header and footer: a click in the other band edits it there (#3557)

* README: the app icon above the title

* Release 0.10.0

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

* The inspector's removals are refused in every protected mode

`file.inspect` with removals takes content out of the document — the
comments, the revisions, the properties, the hidden text, the headers.
Under readOnly and comments it was refused, or rolled back by the
post-check where the document had an exception; under trackedChanges
nothing stopped it, so a document that tracks changes could have its
comments and revisions taken out with no revision to show for it.

The removals are refused now, in every mode a protected document can be
in, next to the properties and the privacy options. An inspection without
removals only reads the document, and still works.

The protection test covers the modes now, not only readOnly.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the session author, not the user name

"the user name can't change while the document is protected" was the
wrong term twice over: it is the session author — the name new tracked
changes and comments are recorded under, and the one a named exception
is matched against — and it is `file.setAuthor` that refuses to change
it. The CLI's `--author` names the session directly, so it works on a
protected document.

The parity row, the two status lines and the two progress rows now say
that.

Co-Authored-By: opencode <noreply@opencode.ai>

* #3279: Keep protected session author fixed in CLI and Options

* The Document Inspector refuses its removals while the document tracks changes

The inspector's removals delete straight from the document: the hidden
text, the comments, the revisions and the headers go without ever reaching
the engine's tracked-delete path. On a document that tracks changes — and
`trackedChanges` protection turns tracking on — the content was gone with
no revision to show for it, and the revision trail was broken behind the
user's back.

The removals are not tracked, so they cannot be: rather than untrack them
silently, the inspector refuses the whole removal form while the document
tracks changes, and says why ("the document tracks changes: stop tracking
before removing content"). The check comes before anything is removed, so
the document is left untouched, and reading the document — the inspector
without removals — keeps working. A document that does not track changes
is inspected as before.

Co-Authored-By: opencode <noreply@opencode.ai>

* The inspector's tracking refusal doesn't cite an unrelated issue

#1344 is about the inspector missing hidden text, not about its removals
going untracked, so the reference only sent a reader to the wrong place.

Co-Authored-By: opencode <noreply@opencode.ai>

* A version snapshot belongs to the document it was taken from

`Session::set_document` resets everything about the open document — the
document id, the document, the selection, the undo history, the password,
the file name — but not the version snapshots. A snapshot taken of one
document therefore survived `file.new`, `file.open`, `file.newFromTemplate`
and `file.recover`, and `file.versions {"restore": index}` then put the
previous document's content into the open one.

The snapshots go with the document they were taken from: `set_document`
clears them, so a restore can only ever bring back a version of the
document that is open. Every caller replaces the document with another
one (a new or opened file, a recovered copy, a merge result, a sheet of
labels), so none of them loses anything it needs.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the inspector's tracking refusal and the version snapshot's scope

Co-Authored-By: opencode <noreply@opencode.ai>

* A comparison clears the version snapshots of the document it replaces

`review.compare` and `review.combine` open the original with `s.doc = doc`,
which bypasses `set_document` — so the version snapshots of the document the
comparison replaces survived it, and a restore could put the previous document
back over the comparison result.

The snapshots go with the document they were taken from, as everywhere else.
The document itself is still assigned directly: `set_document` resets the undo
history, and the comparison's own undo step would go with it.

Co-Authored-By: opencode <noreply@opencode.ai>

* #3277: Test Inspector refusal without protection

* #1327: Keep snapshots with their document through Compare

* #1327: Preserve file identity after rejected document replacement

* test: mark the portal test's blocking sleep as intentional (SonarCloud) (#3989)

The future is polled by hand with no async runtime, so a blocking sleep
is how the test models a slow dialog.

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>

* #3542: gate the label_of test off wasm32

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

---------

Co-authored-by: Divyanshu Gupta <divyanshu2074@gmail.com>
Co-authored-by: dmim2502-commits <234869083+dmim2502-commits@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Mat Strange <mat@matstrange.net>
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Drop U+FFFE and U+FFFF from ODT export text (#3817)

Found while testing crates/formats/src/odt.rs:49-63 (fn x, the ODT text escaper; filter at line 58)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* Cap the number of VML path commands parsed from a path attribute (#3951)

Fixes #3683

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* Return None from parse_number when the value overflows f64 (#3577)

Found while testing crates/doc/src/locale_number.rs:104

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Fix spurious 零 after a group's trailing zeros in Chinese numerals (#3745)

Found while testing crates/doc/src/cjk_numbers.rs:47-55 (Counting::format)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* picture.corrections: a tiny sharpen value no longer panics in the blur (#3646) (#3830)

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* Reject signs and non-digits in parse_date fields (#3575)

Found while testing crates/engine/src/cmd/controls.rs:113-123 (parse_date)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Layout: keep with next takes the 0.01 pt of slack the other fit checks take (#3816)

* Restrict Editing: a file command that changes the document is checked too

`always_allowed` waved through every `file.*` id, so on a protected document
the file commands that do change it went unchecked: the privacy options and
the properties wrote document settings, and the inspector's removals deleted
content. The privacy options' own comment already said they were "refused on
a protected document"; that wasn't true.

The allow-list now names the file commands that don't change the open
document, and the ones that do are checked like any other command. Reading
the document stays allowed: the inspector without removals, the privacy
settings, the info, and saving a version snapshot.

Restoring a stored version replaces the whole document, and a snapshot's own
protection is stale — restoring one lifted the protection and dropped its
password. The live document's protection, password and formatting limit now
survive a restore.

Co-Authored-By: opencode <noreply@opencode.ai>

* Restrict Editing: the user name can't change under protection

`file.setAuthor` sets the name a protected document's named exception
ranges are checked against, and the author new revisions and comments are
recorded under. `always_allowed` waved through every `file.*` id, so on a
protected document the name could be changed: an agent could take the
identity of a named editor and unlock their exception range, and forge the
authorship of the revisions it leaves behind.

A protected document refuses the change now, with the rule Word uses for a
content control's properties: stop protection first. The check is in the
command itself — `file.setAuthor` is a pure command, so `check_before`
never sees it — and in `check_before` for the day it isn't.

Word lets the user name change under protection: it is a preference there.
This project doesn't, because that identity gates what may be edited.

Co-Authored-By: opencode <noreply@opencode.ai>

* Restrict Editing: a protected document's protection needs an explicit stop

The guard in `restrict` refused a protected document only when its
protection had a password, so a password-less one could have its
protection changed — and `file.protect {"mode": "none"}` stopped it
outright. An agent could strip a user's protection silently and then edit
the document freely.

Two rules now, as Word has them (Stop Protection first):

- The protection of a protected document doesn't change, whatever the
  mode. The formatting-only protection doesn't count: it stores the mode
  "none", and it limits the styles, not the editing.
- Stopping the protection is explicit. The "none" mode is the shorthand
  for it, but on a protected document only `{"stop": true}` stops it — the
  way the pane's Stop Protection button does it — so a command that
  merely names a mode can't strip it.

The password still guards the stop, and the formatting-only path (the
mode "none" with the formatting restriction) still works.

`cmd::tools::tests::protection_blocks_edits` stops the protection with
`{"mode": "none"}`; it has to stop it explicitly now.

Co-Authored-By: opencode <noreply@opencode.ai>

* Restrict Editing: the post-check compares the settings and the metadata

`outside_unchanged` is the check that rolls a command back when it
changed anything outside the editable regions. It compared the text, the
formatting, the page setup, the document defaults and the styles — but
not the document's settings, its metadata, its custom properties, the
resolved flag of its comments, its list definitions or its bibliography
sources. A command that changed the metadata of a protected document was
accepted and never rolled back: `file.properties {"title": "Forged
title"}` on a protected document with an exception changed the title and
stayed.

The settings, the core properties, the custom properties, the comments'
resolved flags, the list definitions and the sources are compared now, so
a command that changes them is rolled back like any other change outside
the editable regions.

The revisions and the media store stay out of it on purpose: an edit
inside an exception legitimately leaves a revision, and a picture
inserted there adds to the media store. The list definitions and the
sources are compared like the styles: the ones the document had must be
unchanged, but a new one may be added — a list or a citation inserted
inside an editable region adds its definition, like applying a style adds
one.

Saving is unaffected: it writes the metadata, but `file.save` is a pure
command and isn't post-checked, and the protection itself is changed by
`review.restrict`, which is always allowed.

Co-Authored-By: opencode <noreply@opencode.ai>

* The protection is stopped explicitly, and the docs say so

The test that stopped the protection used the "none" shorthand, which a
protected document now refuses; it stops it the explicit way instead, and
pins that the shorthand doesn't stop it. `file.protect` says the same: it
starts enforcement, and "none" doesn't stop a protected document.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the Restrict Editing gaps that are closed

Co-Authored-By: opencode <noreply@opencode.ai>

* A password guards the protection whatever its mode

The guard that stops the protection of a protected document from changing
asked whether the editing was restricted, so a formatting-only protection —
the mode "none", which limits the styles rather than the editing — was
exempted. A formatting-only protection can carry a password, and that password
guarded it: it was exempt from the guard, so `{"mode": "readOnly", "password":
"other"}` replaced the protection and the password with ones of the caller's
choosing, and the protection could then be stopped with them.

A password guards the protection whatever its mode, as it did before.

Co-Authored-By: opencode <noreply@opencode.ai>

* The CLI's --author names the session, it doesn't change the document

`--author` dispatched `file.setAuthor`, which a protected document refuses
now that the name is checked as the identity its exceptions are matched
against — so a script that named itself on a protected file failed before it
ran a single command.

The flag names the session, not the open document, so it sets the name
directly, with the same normalisation and blank-name check the command uses.
`file.setAuthor` keeps its protection check for the callers that mean to
change the document's identity.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the progress docs' status lines carry today's date

Co-Authored-By: opencode <noreply@opencode.ai>

* The document's properties and a version restore need the protection stopped

Two gaps the review found, both of them a change to a protected document that
went through without the protection seeing it.

The properties (`file.properties`) and the privacy options
(`file.privacyOptions`) are document-wide state, and they aren't tracked: under
tracked changes they changed the document with no revision to show for it, and
under the other modes they were only rolled back by the post-check, after the
command had run. They are refused now, before the command runs, as the content
control's properties are.

A version restore (`file.versions`) replaces the whole document, including the
content the protection kept read-only, and it did that without the password:
`file.versions` is a pure command, so the protection never saw it. Keeping the
live protection and its password stopped them being dropped, but the content
underneath was still replaced. A restore needs the protection stopped first,
and the password with it when the document has one.

The live protection still wins over the snapshot's stale one, so a restore
can't lock the user out of their own document either.

Co-Authored-By: opencode <noreply@opencode.ai>

* Layout: keep with next takes the 0.01 pt of slack the other fit checks take

place_para_in_flow moved a paragraph kept with the next one to a new page
when the two needed more than the room left by any amount, while lines, rows
and floats fit with 0.01 pt to spare. A kept paragraph whose need came to a
rounding error over the page bottom moved on alone.

* Release 0.10.0

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

* The inspector's removals are refused in every protected mode

`file.inspect` with removals takes content out of the document — the
comments, the revisions, the properties, the hidden text, the headers.
Under readOnly and comments it was refused, or rolled back by the
post-check where the document had an exception; under trackedChanges
nothing stopped it, so a document that tracks changes could have its
comments and revisions taken out with no revision to show for it.

The removals are refused now, in every mode a protected document can be
in, next to the properties and the privacy options. An inspection without
removals only reads the document, and still works.

The protection test covers the modes now, not only readOnly.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the session author, not the user name

"the user name can't change while the document is protected" was the
wrong term twice over: it is the session author — the name new tracked
changes and comments are recorded under, and the one a named exception
is matched against — and it is `file.setAuthor` that refuses to change
it. The CLI's `--author` names the session directly, so it works on a
protected document.

The parity row, the two status lines and the two progress rows now say
that.

Co-Authored-By: opencode <noreply@opencode.ai>

* #3279: Keep protected session author fixed in CLI and Options

* The Document Inspector refuses its removals while the document tracks changes

The inspector's removals delete straight from the document: the hidden
text, the comments, the revisions and the headers go without ever reaching
the engine's tracked-delete path. On a document that tracks changes — and
`trackedChanges` protection turns tracking on — the content was gone with
no revision to show for it, and the revision trail was broken behind the
user's back.

The removals are not tracked, so they cannot be: rather than untrack them
silently, the inspector refuses the whole removal form while the document
tracks changes, and says why ("the document tracks changes: stop tracking
before removing content"). The check comes before anything is removed, so
the document is left untouched, and reading the document — the inspector
without removals — keeps working. A document that does not track changes
is inspected as before.

Co-Authored-By: opencode <noreply@opencode.ai>

* The inspector's tracking refusal doesn't cite an unrelated issue

#1344 is about the inspector missing hidden text, not about its removals
going untracked, so the reference only sent a reader to the wrong place.

Co-Authored-By: opencode <noreply@opencode.ai>

* A version snapshot belongs to the document it was taken from

`Session::set_document` resets everything about the open document — the
document id, the document, the selection, the undo history, the password,
the file name — but not the version snapshots. A snapshot taken of one
document therefore survived `file.new`, `file.open`, `file.newFromTemplate`
and `file.recover`, and `file.versions {"restore": index}` then put the
previous document's content into the open one.

The snapshots go with the document they were taken from: `set_document`
clears them, so a restore can only ever bring back a version of the
document that is open. Every caller replaces the document with another
one (a new or opened file, a recovered copy, a merge result, a sheet of
labels), so none of them loses anything it needs.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the inspector's tracking refusal and the version snapshot's scope

Co-Authored-By: opencode <noreply@opencode.ai>

* A comparison clears the version snapshots of the document it replaces

`review.compare` and `review.combine` open the original with `s.doc = doc`,
which bypasses `set_document` — so the version snapshots of the document the
comparison replaces survived it, and a restore could put the previous document
back over the comparison result.

The snapshots go with the document they were taken from, as everywhere else.
The document itself is still assigned directly: `set_document` resets the undo
history, and the comparison's own undo step would go with it.

Co-Authored-By: opencode <noreply@opencode.ai>

* #3277: Test Inspector refusal without protection

* #1327: Keep snapshots with their document through Compare

* #1327: Preserve file identity after rejected document replacement

* test: mark the portal test's blocking sleep as intentional (SonarCloud) (#3989)

The future is polled by hand with no async runtime, so a blocking sleep
is how the test models a slow dialog.

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>

* Engine: no dead-code warnings on wasm (#3542)

* fix(engine): do not treat decimal points as sentence boundaries in Sentence case

In crates/engine/src/cmd/format.rs, convert_chars treated any period as a sentence boundary, causing numbers with decimal points (e.g. 3.14, 2.5) to capitalize the subsequent word in Sentence case.

Check if a period is surrounded by ASCII digits, and do not trigger uppercase capitalization when encountering a decimal point.

Closes #3485

* proof: drop U+FE0F from FORBIDDENWORD and NOSUGGEST flags too

Review follow-up: the directives that name one flag read the same emoji
form, so they now skip the variation selector like PFX/SFX and stem flags.

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

* fmt: rustfmt equation array centring

* #3108 review: behind-text and in-front-of-text floats don't grow the row

FredApps' review: the row-growth loop counted every non-inline object, so a
page-anchored logo or watermark behind a cell's text would stretch its row to
the object's bottom edge - contradicting table.rs (behind-text frames count
for vertical alignment but don't increase the row's flow height) and Word.
Only floats that take part in the text flow (Square, Tight, Through,
TopAndBottom) grow the row now, the same classification layout already uses
elsewhere for "takes part in wrapping".

New test: a 300pt behind-text and a 300pt in-front-of-text picture each leave
the row at its text height; it fails with the old condition. The existing
Square-wrap tests and cell_frame_centres_empty still pass.

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

* Engine: no dead-code warnings on wasm

`cargo check -p wordcraft-engine --target wasm32-unknown-unknown` warned
about five items whose only callers are platform-gated:
- citations `by_author`, user_templates `MAX_ENTRIES` and `label_of`: used
  only by the desktop (non-wasm) source import and template listing.
- speech `voice_language`: used by espeak (Unix but macOS) and the macOS
  voice picker, so `any(unix, test)`, not `any(not(windows), test)` (which
  also took in wasm).
- speech `primary_tag`: used by those plus the Windows synthesizer, so
  `any(unix, windows, test)`.

Each item now carries exactly its callers' gate rather than a blanket
allow(dead_code). Checked with cargo check on Linux, Windows (gnu), macOS
and wasm32 (zero warnings each), clippy -D warnings, and the engine tests.

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

* Integration: #1847 reject comment IDs above u32::MAX

* Integration: #3267 leave footnote definitions out of link definitions

* Integration: #3471 drop duplicate egui options block; install_ui_fonts sets them

* Integration: #2153 drop trailing blank line and use assert! for bool checks in tab_separated_list_markers

* Integration: #2268 ODT lengths accept only the standard pc unit for picas

* Integration: #2269 LaTeX lengths keep macro names case-sensitive

* Integration: #2673 Khmer substitutes return the (list, flag) pair after #3245

* Integration: #2273 Latin weight/style and line styles take precedence over Asian, complex and line-type attributes

* Integration: #1851 DOCPROPERTY accepts the reference app's LastSavedBy name

* Integration: #1985 only ^0nnn codes read as Windows-1252

* Integration: #2416 collapse the nested vertical-align if (clippy::collapsible_if)

* Integration: #2185 hyperlink_target slices with get() and needs the whole field name

* Integration: #2034 objects without a stacking order draw above the ones before them

* Integration: #3434 drop review-bot notes from test doc comments

* Integration: #3419 doc comments back on their own items (EquationFrame and its test above them)

* Integration: #2334 test a formula string result continued in a CONTINUE record

* Integration: #3429 the based-on-chain test's doc comment back above its test

* Integration: #2453 drawing_payload test builds the shape fill as a Fill

* Integration: #3257 separate its Find-box test from #2407's recent-files test (auto-merge interleaved them)

* Integration: cargo fmt

* Integration: #1728 close the layout test the merge resolution left open

* Integration: cargo fmt

* Integration: cargo fmt

* Integration: cargo fmt

* Integration: #2722 Dutch registry comment on its own line

* Integration: #3203 English text for the keys the Romanian catalog lacks

Its complete-catalog test needs every key of every other catalog; the merged PRs added 72 since it was written. Rows carry the English text until translated.

* Integration: #1076 English text for the keys the Slovenian catalog lacks

Its complete-catalog test needs every key of every other catalog. Rows carry the English text until translated.

* Integration: status lines list each change once

Combining the tracks' status lines repeated a few entries (#2852, #1500, #2617).

* Integration: cargo xtask parity

* Integration: cargo fmt

* Integration: #1832 drop a useless .into() in the end-of-document citation test

clippy::useless_conversion failed cargo xtask ci.

* Integration: #2244 VML on/off values read case-insensitively by the shape fill reader

Main (#1500) moved VML fill reading into shape_style.rs, where filled="False" still counted as on; #2244's test vml_shape_wrap_side_and_anchor_case_insensitivity expects no fill.

* Integration: #2041 English text in the Polish catalog for the keys Italian gained

polish_covers_every_key_of_every_other_catalog expects Italian to have only "Style: APA" beyond Polish; #2041 translated 14 newer strings into Italian. Rows carry the English text until translated.

* Integration: #2722 English text for four labels the Dutch and Danish catalogs lack

every_tab_and_command_is_translated needs Format Shape Pane, Full Screen View, Personal Templates and Underline Color, added by other merged PRs. Rows carry the English text until translated.

* Integration: #3335 header Esc test allows the half-pixel return scroll that #3201 brings

#3201 makes entering an empty header scroll to it; Esc then scrolls back to the caret, landing 0.5 px from the start (17291.0 vs 17290.5). The test checks the view is back within a pixel.

* IME: the caret follows the composition text (#3556)

* Header and footer: a click in the other band edits it there (#3557)

* README: the app icon above the title

* Release 0.10.0

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

* The inspector's removals are refused in every protected mode

`file.inspect` with removals takes content out of the document — the
comments, the revisions, the properties, the hidden text, the headers.
Under readOnly and comments it was refused, or rolled back by the
post-check where the document had an exception; under trackedChanges
nothing stopped it, so a document that tracks changes could have its
comments and revisions taken out with no revision to show for it.

The removals are refused now, in every mode a protected document can be
in, next to the properties and the privacy options. An inspection without
removals only reads the document, and still works.

The protection test covers the modes now, not only readOnly.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the session author, not the user name

"the user name can't change while the document is protected" was the
wrong term twice over: it is the session author — the name new tracked
changes and comments are recorded under, and the one a named exception
is matched against — and it is `file.setAuthor` that refuses to change
it. The CLI's `--author` names the session directly, so it works on a
protected document.

The parity row, the two status lines and the two progress rows now say
that.

Co-Authored-By: opencode <noreply@opencode.ai>

* #3279: Keep protected session author fixed in CLI and Options

* The Document Inspector refuses its removals while the document tracks changes

The inspector's removals delete straight from the document: the hidden
text, the comments, the revisions and the headers go without ever reaching
the engine's tracked-delete path. On a document that tracks changes — and
`trackedChanges` protection turns tracking on — the content was gone with
no revision to show for it, and the revision trail was broken behind the
user's back.

The removals are not tracked, so they cannot be: rather than untrack them
silently, the inspector refuses the whole removal form while the document
tracks changes, and says why ("the document tracks changes: stop tracking
before removing content"). The check comes before anything is removed, so
the document is left untouched, and reading the document — the inspector
without removals — keeps working. A document that does not track changes
is inspected as before.

Co-Authored-By: opencode <noreply@opencode.ai>

* The inspector's tracking refusal doesn't cite an unrelated issue

#1344 is about the inspector missing hidden text, not about its removals
going untracked, so the reference only sent a reader to the wrong place.

Co-Authored-By: opencode <noreply@opencode.ai>

* A version snapshot belongs to the document it was taken from

`Session::set_document` resets everything about the open document — the
document id, the document, the selection, the undo history, the password,
the file name — but not the version snapshots. A snapshot taken of one
document therefore survived `file.new`, `file.open`, `file.newFromTemplate`
and `file.recover`, and `file.versions {"restore": index}` then put the
previous document's content into the open one.

The snapshots go with the document they were taken from: `set_document`
clears them, so a restore can only ever bring back a version of the
document that is open. Every caller replaces the document with another
one (a new or opened file, a recovered copy, a merge result, a sheet of
labels), so none of them loses anything it needs.

Co-Authored-By: opencode <noreply@opencode.ai>

* Docs: the inspector's tracking refusal and the version snapshot's scope

Co-Authored-By: opencode <noreply@opencode.ai>

* A comparison clears the version snapshots of the document it replaces

`review.compare` and `review.combine` open the original with `s.doc = doc`,
which bypasses `set_document` — so the version snapshots of the document the
comparison replaces survived it, and a restore could put the previous document
back over the comparison result.

The snapshots go with the document they were taken from, as everywhere else.
The document itself is still assigned directly: `set_document` resets the undo
history, and the comparison's own undo step would go with it.

Co-Authored-By: opencode <noreply@opencode.ai>

* #3277: Test Inspector refusal without protection

* #1327: Keep snapshots with their document through Compare

* #1327: Preserve file identity after rejected document replacement

* test: mark the portal test's blocking sleep as intentional (SonarCloud) (#3989)

The future is polled by hand with no async runtime, so a blocking sleep
is how the test models a slow dialog.

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>

* #3542: gate the label_of test off wasm32

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

---------

Co-authored-by: Divyanshu Gupta <divyanshu2074@gmail.com>
Co-authored-by: dmim2502-commits <234869083+dmim2502-commits@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Mat Strange <mat@matstrange.net>
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>

* Drop U+FFFE and U+FFFF from ODT export text (#3817)

Found while testing crates/formats/src/odt.rs:49-63 (fn x, the ODT text escaper; filter at line 58)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* Cap the number of VML path commands parsed from a path attribute (#3951)

Fixes #3683

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* Return None from parse_number when the value overflows f64 (#3577)

Found while testing crates/doc/src/locale_number.rs:104

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Fix spurious 零 after a group's trailing zeros in Chinese numerals (#3745)

Found while testing crates/doc/src/cjk_numbers.rs:47-55 (Counting::format)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* picture.corrections: a tiny sharpen value no longer panics in the blur (#3646) (#3830)

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

* Reject signs and non-digits in parse_date fields (#3575)

Found while testing crates/engine/src/cmd/controls.rs:113-123 (parse_date)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* #3816: also assert the next paragraph's first line stays on page 0

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

---------

Co-authored-by: Mat Strange <mat@matstrange.net>
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com>
Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
Co-authored-by: Michael Mu <69072633+MichaelMuClaude@users.noreply.github.com>
Co-authored-by: Divyanshu Gupta <divyanshu2074@gmail.com>
Co-authored-by: dmim2502-commits <234869083+dmim2502-commits@users.noreply.github.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Co-authored-by: Joseph Anglada <fluorine@users.noreply.github.com>

* #3944: drop the wall-clock assert from the long-root-tag test

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

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
Co-authored-by: Michael Mu <69072633+MichaelMuClaude@users.noreply.github.com>
Co-authored-by: Divyanshu Gupta <divyanshu2074@gmail.com>
Co-authored-by: dmim2502-commits <234869083+dmim2502-commits@users.noreply.github.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Mat Strange <mat@matstrange.net>
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Joseph Anglada <fluorine@users.noreply.github.com>
Co-authored-by: dfgodvan <dfgodvan@gmail.com>
Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com>

* Write a text-box story shared by many shapes only once on docx save (#3983)

Fixes #3653

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Keep pending formatting revisions when Style Inspector clears formatting (#3971)

Fixes #3671

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

* Cap the headless ui_requests queue and keep one Paste Special request (#3997)

Fixes #3641

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Mat Strange <mat@matstrange.net>
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
Co-authored-by: Michael Mu <69072633+MichaelMuClaude@users.noreply.github.com>
Co-authored-by: Divyanshu Gupta <divyanshu2074@gmail.com>
Co-authored-by: dmim2502-commits <234869083+dmim2502-commits@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: vangel76 <vangel1976@googlemail.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
Co-authored-by: Brandon Thomas <brandon@storyteller.ai>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Co-authored-by: Joseph Anglada <fluorine@users.noreply.github.com>
Co-authored-by: dfgodvan <dfgodvan@gmail.com>
Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com>
2026-10-23 11:06:17 -04:00

544 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! File tab (Backstage): new, open, save, export, print, properties; document inspection.
use serde_json::{Value, json};
use wordcraft_doc::{Block, Document, StoryRef};
use super::{pos_json, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// Longest user name kept, in characters; longer names are cut, not refused.
/// `format` of the save commands for an `.xml` file: Word 2003 XML Document (WordprocessingML 2003).
pub const FORMAT_WORD_2003: &str = "wordml2003";
/// `format` for an `.xml` file as a Word XML Document (Flat OPC), the default.
pub const FORMAT_FLAT_OPC: &str = "flatopc";
pub const MAX_AUTHOR_CHARS: usize = 255;
/// The user name as it is stored: trimmed, stripped of control characters (they can't go into
/// the saved XML) and cut to [`MAX_AUTHOR_CHARS`]; a blank name is refused so revisions always
/// carry an author.
pub fn normalize_author(name: &str) -> Result<String, CmdError> {
let name: String = name.trim().chars().filter(|c| !c.is_control()).take(MAX_AUTHOR_CHARS).collect();
let name = name.trim_end();
if name.is_empty() {
return Err(CmdError::Params("`name` must not be blank".into()));
}
Ok(name.to_string())
}
/// Sets the user name that new tracked changes and comments are recorded under. The name is
/// normalised by [`normalize_author`].
/// A protected document refuses it: the name is the identity its exceptions are checked against.
pub(crate) fn set_author(s: &mut Session, v: &Value) -> CmdResult {
super::protect::check_user_name(s)?;
s.author = normalize_author(p::req_str(v, "name")?)?;
let mut r = sel_result(s)?;
if let Some(o) = r.as_object_mut() {
o.insert("name".into(), json!(s.author));
}
Ok(r)
}
/// Rename the current file in its directory, or name a document before its first save.
fn rename(s: &mut Session, v: &Value) -> CmdResult {
let name = p::req_str(v, "name")?.trim();
if name.is_empty()
|| name.chars().count() > 200
|| name.ends_with(['.', ' '])
|| name.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'))
|| matches!(name, "." | "..")
{
return Err(CmdError::Params("Enter a valid document name (up to 200 characters, without path separators).".into()));
}
if let Some(path) = &s.path {
let mut destination = path.with_file_name(name);
// The field edits the stem; retain the original format, including macro/template files.
// A name that already ends in that extension is taken as the whole file name, since a
// script or an agent passes one ("renamed.docx" became "renamed.docx.docx", #2983). The
// extension alone (".docx") is a stem, not a file name, so it still gets one.
if let Some(ext) = path.extension() {
let ext = ext.to_string_lossy();
// Split at the name's own last dot rather than counting bytes back from its end: a
// name whose characters are more than one byte each ("résumé.docm") would be cut
// inside one of them, and slicing there panics (#3128 review).
let given = name.rsplit_once('.').is_some_and(|(stem, suffix)| !stem.is_empty() && suffix.eq_ignore_ascii_case(ext.as_ref()));
destination.set_file_name(if given { name.to_string() } else { format!("{name}.{ext}") });
}
if destination != *path {
#[cfg(not(target_arch = "wasm32"))]
{
crate::io::rename_file(path, &destination).map_err(CmdError::Failed)?;
}
s.path = Some(destination);
s.relayout();
}
} else {
s.file_name = Some(name.to_string());
s.relayout();
}
Ok(json!({"name": name, "path": s.path}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("file.rename", "Rename Document", "File › Info", rename).params(r#"{"name": string (document name; its extension is kept, so the name may be given with or without it)}"#).pure(),
CommandSpec::new("file.new", "New", "File", new).key("Mod+N").params(r#"{"template"?: "blank|sample|letter|resume|report"}"#).pure(),
CommandSpec::new("file.open", "Open", "File", open)
.key("Mod+O / Mod+F12 / Mod+Alt+F2")
.params(r#"{"path": string, "data"?: base64 (the file's bytes; `path` then only names it), "password"?: string (for a password-protected Word document)}"#)
.pure(),
CommandSpec::new("file.save", "Save", "File", save)
.key("Mod+S / Shift+F12 / Alt+Shift+F2")
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format)}"#)
.pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key(super::mac_or("F12 / Mod+Shift+S", "F12 / Alt+F2")).params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(),
// Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit
// to undo; it marks the document as changed itself.
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt)
.params(r#"{"password": string (Word documents are saved encrypted with it) | null or "" (saved unencrypted)}"#)
.pure(),
CommandSpec::new("file.exportPdf", "Export PDF", "File › Export", |s, v| {
// No path (the command search, say): the app asks where to save, as File › Export
// does, instead of failing on the required parameter (#2313).
let Some(path) = p::str(v, "path") else {
s.push_ui_request(json!({"open": "exportPdf"}));
return Ok(json!({"exported": false}));
};
let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") };
let opts = export_options(s, v);
crate::io::save_path_with(std::path::Path::new(&path), &s.doc, None, &opts).map_err(CmdError::Failed)?;
Ok(json!({"path": path, "markup": opts.markup}))
})
.params(r#"{"path": string, "markup"?: bool (Print Markup: tracked changes marked up and each page scaled down beside the comments in balloons; default file.printMarkup)}"#)
.pure(),
CommandSpec::new("file.exportPng", "Export Page as PNG", "File › Export", export_png)
.params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt, "markup"?: bool (the page with its comments in balloons beside it; default file.printMarkup)}"#)
.pure(),
// A print setting, like Word's: not part of the document, so no undo step.
CommandSpec::new("file.printMarkup", "Print Markup", "File › Print", |s, v| {
s.prefs.print_markup = p::bool(v, "value").unwrap_or(!s.prefs.print_markup);
Ok(json!({"value": s.prefs.print_markup}))
})
.params(r#"{"value"?: bool (omit to toggle)} — PDFs, prints and copies show comments (balloons beside each page; a list at the end of HTML, Markdown and text) and tracked changes"#)
.pure(),
CommandSpec::new("file.printPdf", "Print to PDF", "File › Print", crate::print::print_pdf_to_file)
.params(
r#"{"path": string, "range"?: "all" | "current" | "selection" (the pages the selection touches) | "custom", "pages"?: "1-3,5" (implies custom), "parity"?: "all" | "odd" | "even", "copies"?: 1-999, "collate"?: bool (default true), "paper"?: "none" | "Letter" | "Legal" | "A4" | … (a Layout › Size name: scale every page to this paper, centred; the document keeps its size)}"#,
)
.pure(),
CommandSpec::new("file.print", "Print", "File", |s, _| {
s.push_ui_request(json!({"open": "print"}));
sel_result(s)
})
.key("Mod+P / Mod+Shift+F12 / Mod+F2 / Mod+Alt+I")
.pure(),
CommandSpec::new("file.close", "Close", "File", |s, _| {
s.push_ui_request(json!({"close": true}));
sel_result(s)
})
.key("Mod+W / Mod+F4")
.params(r#"{"force"?: bool (desktop app's control channel and MCP bridge only: without it a document with unsaved changes is an error and stays open; with it the changes are discarded. Headless sessions never ask)}"#)
.pure(),
CommandSpec::new("file.properties", "Properties", "File › Info", properties)
.params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?, "custom"?: {name: string | null (removes)}}"#),
CommandSpec::new("file.info", "Info", "File", info).pure(),
CommandSpec::new("file.setAuthor", "User Name", "File › Options › General", set_author)
.params(r#"{"name": string (author of new tracked changes and comments)}"#)
.pure(),
// Trust Center › Privacy Options: the document's `w:removePersonalInformation` and
// `w:removeDateAndTime` settings, honoured when it is saved as .docx (#1062).
// Changing them is a document change (undoable, refused on a protected document); reading
// them is `file.privacy`, which changes nothing.
CommandSpec::new("file.privacyOptions", "Privacy Options", "File › Options › Save", |s, v| {
let personal = p::bool(v, "removePersonalInfo");
let date_time = p::bool(v, "removeDateTime");
if personal.is_none() && date_time.is_none() {
return Err(CmdError::Params("give removePersonalInfo or removeDateTime (file.privacy reads them)".into()));
}
if let Some(on) = personal {
s.doc.settings.remove_personal_info = on;
}
if let Some(on) = date_time {
s.doc.settings.remove_date_time = on;
}
Ok(privacy(s))
})
.params(r#"{"removePersonalInfo"?: bool (save comment and revision authors as "Author", no creator or last-modified-by), "removeDateTime"?: bool (save comments and tracked changes without dates)} — at least one; the open document keeps its real names and dates"#),
CommandSpec::new("file.privacy", "Privacy Settings", "File › Options › Save", |s, _| Ok(privacy(s)))
.params(r#"{} → {"removePersonalInfo": bool, "removeDateTime": bool} (file.privacyOptions changes them)"#)
.pure(),
CommandSpec::new("document.inspect", "Inspect Document", "Agents", inspect).params(r#"{"text"?: bool}"#).pure(),
CommandSpec::new("document.text", "Document Text", "Agents", |s, v| {
let story = super::story_param(s, v);
Ok(json!({"text": s.doc.final_text(story)}))
})
.pure(),
CommandSpec::new("document.paragraph", "Paragraph Details", "Agents", paragraph).params(r#"{"path": [n], "story"?: Story}"#).pure(),
CommandSpec::new("document.selection", "Selection", "Agents", |s, _| {
let mut r = sel_result(s)?;
if let Some(o) = r.as_object_mut() {
o.insert("text".into(), json!(s.selected_text()));
o.insert("format".into(), super::format::state(s));
}
Ok(r)
})
.pure(),
CommandSpec::new("document.layout", "Layout Summary", "Agents", layout_summary).pure(),
CommandSpec::new("document.setText", "Replace Document Text", "Agents", |s, v| {
let text = p::req_str(v, "text")?;
let d = Document::from_text(text);
s.doc.body = d.body;
s.sel = crate::Selection::caret(s.doc.start_of(StoryRef::Body));
sel_result(s)
})
.params(r#"{"text": string}"#),
]
}
/// The document's privacy settings, as `file.privacy` and `file.privacyOptions` return them.
fn privacy(s: &Session) -> Value {
json!({"removePersonalInfo": s.doc.settings.remove_personal_info, "removeDateTime": s.doc.settings.remove_date_time})
}
/// Create a blank document in the saved proofing language, or use the requested template.
fn new(s: &mut Session, v: &Value) -> CmdResult {
let doc = match p::str(v, "template").unwrap_or("blank") {
"sample" => crate::sample::sample_document(),
"letter" => crate::sample::letter(),
"resume" => crate::sample::resume(),
"report" => crate::sample::report(),
_ => normal_document(s),
};
s.set_document(doc);
s.path = None;
sel_result(s)
}
/// A blank document on the Normal template: the region's paper (#838), written in the editing
/// language (the templates' text is English).
fn normal_document(s: &Session) -> Document {
let mut d = s.blank_document();
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref()) {
d.styles.default_chr.lang = Some(lang.clone());
}
d
}
/// Open a document, using the saved proofing default only for plain text and Markdown.
fn open(s: &mut Session, v: &Value) -> CmdResult {
let Some(path) = p::str(v, "path") else {
s.push_ui_request(json!({"open": "openFile"}));
return sel_result(s);
};
let password = p::str(v, "password");
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
// An empty file (File Explorer's New › Microsoft Word Document makes one) opens as a new
// blank document that saves back to it (#1384).
if bytes.is_empty() {
(normal_document(s), false)
} else {
crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)?
}
} else if crate::io::is_empty_file(std::path::Path::new(path)) {
(normal_document(s), false)
} else {
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
};
// Plain text and Markdown carry no language: they are in the editing language.
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
{
doc.styles.default_chr.lang = Some(lang.clone());
}
s.set_document(doc);
if let Some(z) = s.doc.settings.zoom {
super::view::restore_zoom(s, z);
}
s.path = Some(path.into());
// Like Word, a document opened with a password is saved with it again.
if encrypted {
s.password = password.map(crate::io::Password::new);
}
Ok(json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count(), "encrypted": encrypted}))
}
/// File › Info › Protect Document › Encrypt with Password: the password Word documents are saved
/// with from now on, or none.
fn encrypt(s: &mut Session, v: &Value) -> CmdResult {
let password = match v.get("password") {
Some(Value::Null) => None,
Some(Value::String(pw)) if pw.is_empty() => None,
Some(Value::String(pw)) => {
wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?;
Some(crate::io::Password::new(pw))
}
_ => return Err(CmdError::Params("`password` must be a string, or null to remove the password".into())),
};
if password != s.password {
if password.is_some() {
s.clear_recovery_before_password()?;
}
s.password = password;
s.dirty = true;
}
Ok(json!({"encrypted": s.password.is_some()}))
}
fn save(s: &mut Session, v: &Value) -> CmdResult {
let path = match p::str(v, "path").map(std::path::PathBuf::from).or_else(|| s.path.clone()) {
Some(p) => p,
None => {
s.push_ui_request(json!({"open": "saveAs"}));
return Ok(json!({"saved": false}));
}
};
if let Some(pw) = v.get("password").filter(|pw| !pw.is_null()) {
let Some(pw) = pw.as_str().filter(|pw| !pw.is_empty()) else {
return Err(CmdError::Params("`password` must be a non-empty string (file.encrypt removes a password)".into()));
};
if !crate::io::can_encrypt(&path.to_string_lossy()) {
return Err(CmdError::Params("only Word documents (.docx, .docm, .dotx, .dotm) can be saved with a password".into()));
}
wordcraft_docx::check_password(pw).map_err(|e| CmdError::Params(e.to_string()))?;
s.clear_recovery_before_password()?;
s.password = Some(crate::io::Password::new(pw));
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
let mut opts = export_options(s, v);
match v.get("format").filter(|f| !f.is_null()) {
None => {
// Saving over a Word 2003 XML file (one opened as such, say) keeps its format.
opts.word2003 = ext == "xml" && is_word_2003_file(&path);
}
Some(f) if matches!(f.as_str(), Some(FORMAT_WORD_2003 | FORMAT_FLAT_OPC)) && ext == "xml" => {}
Some(_) => {
return Err(CmdError::Params(format!("`format` is \"{FORMAT_WORD_2003}\" or \"{FORMAT_FLAT_OPC}\", and only for an .xml file")));
}
}
// Undone below if the write fails: a failed save (no permission, disk full, a bad path)
// must not leave the document's own save stamps — zoom, modified/created, author, revision —
// changed, or a later save would persist them for a write that never actually happened.
let before_stamp =
(s.doc.settings.zoom, s.doc.core.last_modified_by.clone(), s.doc.core.created.clone(), s.doc.core.modified.clone(), s.doc.core.revision);
s.stamp_save();
let password = s.password.as_ref().map(crate::io::Password::as_str);
// A header/footer just made and left empty isn't saved (#960).
let copy = super::insert::saved_copy(s);
if let Err(e) = crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts) {
(s.doc.settings.zoom, s.doc.core.last_modified_by, s.doc.core.created, s.doc.core.modified, s.doc.core.revision) = before_stamp;
return Err(CmdError::Failed(e));
}
let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy());
// A save in a format that doesn't keep everything (plain text, Markdown, a page image) is a
// copy, except over the document's own file: a note.txt opened and saved is saved (#2376).
let own_file = s.path.as_deref() == Some(path.as_path());
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
s.path = Some(path.clone());
s.dirty = false;
s.relayout();
}
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted}))
}
/// Does `path` hold a Word 2003 XML Document already (so a plain save keeps that format)?
#[cfg(not(target_arch = "wasm32"))]
fn is_word_2003_file(path: &std::path::Path) -> bool {
// The root element and its namespaces come first.
let Ok(head) = crate::io::read_file_head(&path.to_string_lossy(), 64 * 1024) else { return false };
wordcraft_docx::sniff_xml(&head) == wordcraft_docx::XmlKind::WordMl2003
}
#[cfg(target_arch = "wasm32")]
fn is_word_2003_file(_path: &std::path::Path) -> bool {
false
}
fn save_as(s: &mut Session, v: &Value) -> CmdResult {
if p::str(v, "path").is_none() {
s.push_ui_request(json!({"open": "saveAs"}));
return Ok(json!({"saved": false}));
}
save(s, v)
}
/// How a copy is written: Print Markup from the command's `markup`, else the File › Print
/// setting (`file.printMarkup`); comments as Track Changes Options show them.
pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
let markup = p::bool(v, "markup").unwrap_or(s.prefs.print_markup);
crate::io::ExportOptions { markup, comments: markup && s.prefs.markup.comments, word2003: p::str(v, "format") == Some(FORMAT_WORD_2003) }
}
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
// No path: the app asks where to save, as file.exportPdf does (#2313).
let Some(path) = p::str(v, "path") else {
s.push_ui_request(json!({"open": "exportPng"}));
return Ok(json!({"exported": false}));
};
let page = p::u64(v, "page").unwrap_or(1).max(1) as usize - 1;
let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0);
let l = s.export_layout_with(export_options(s, v).markup);
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
// A page image is the printed page: headers and footers at full strength. Dimming them is
// the on-screen cue while editing the body, not part of the page.
let mut opts = wordcraft_render::RenderOptions::default();
opts.display.dim_header = false;
if !path.to_ascii_lowercase().ends_with(".png") {
return Err(CmdError::Params("path must end in .png".into()));
}
let img = wordcraft_render::render_page(&s.doc, pg, scale, &opts);
let png = img.to_png();
crate::io::write_file(path, &png).map_err(CmdError::Failed)?;
Ok(json!({"path": path, "width": img.width, "height": img.height, "bytes": png.len()}))
}
fn properties(s: &mut Session, v: &Value) -> CmdResult {
let c = &mut s.doc.core;
let set = |dst: &mut String, k: &str| {
if let Some(x) = p::str(v, k) {
*dst = x.to_string();
}
};
set(&mut c.title, "title");
set(&mut c.subject, "subject");
set(&mut c.creator, "author");
set(&mut c.keywords, "keywords");
set(&mut c.description, "comments");
set(&mut c.category, "category");
if let Some(custom) = v.get("custom") {
let Some(m) = custom.as_object() else {
return Err(CmdError::Params("`custom` must be an object of name → string or null".into()));
};
for (name, val) in m {
if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME {
return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters")));
}
match val {
Value::Null => {
s.doc.remove_custom_prop(name);
}
Value::String(x) if x.len() <= MAX_CUSTOM_VALUE => s.doc.set_custom_prop(name, x),
_ => return Err(CmdError::Params(format!("custom property `{name}` must be a string (≤ {MAX_CUSTOM_VALUE} bytes) or null"))),
}
}
}
let mut out = serde_json::to_value(&s.doc.core).unwrap_or(Value::Null);
if let Some(o) = out.as_object_mut() {
o.insert("custom".into(), serde_json::to_value(&s.doc.custom_props).unwrap_or(Value::Null));
}
Ok(out)
}
/// Longest custom property name (Word's limit).
const MAX_CUSTOM_NAME: usize = 255;
/// Largest custom property value we accept from a command.
const MAX_CUSTOM_VALUE: usize = 1024 * 1024;
fn info(s: &mut Session, _: &Value) -> CmdResult {
let l = s.layout();
Ok(json!({
"path": s.path.as_ref().map(|p| p.to_string_lossy().to_string()),
"dirty": s.dirty,
"pages": l.pages.len(),
"words": s.doc.word_count(),
"paragraphs": s.doc.paragraph_count(),
"properties": s.doc.core,
"trackChanges": s.doc.settings.track_changes,
"comments": s.doc.comments.len(),
"sections": s.doc.sections().len(),
"encrypted": s.password.is_some(),
"layoutMs": l.ms,
}))
}
/// Structure for agents: blocks with text and formatting summary, selection, stats.
fn inspect(s: &mut Session, v: &Value) -> CmdResult {
let with_text = p::bool(v, "text").unwrap_or(true);
let mut blocks = Vec::new();
for (i, b) in s.doc.body.iter().enumerate().take(2000) {
match &**b {
Block::Para(p) => {
let style = p.props.style.clone().unwrap_or_else(|| "Normal".into());
let mut o = json!({"index": i, "type": "paragraph", "style": style, "len": p.len()});
if with_text {
o["text"] = json!(p.plain_text());
}
if let Some(n) = p.props.numbering.filter(|n| n.num != 0) {
o["list"] = json!({"num": n.num, "level": n.level});
}
if !p.props.is_empty() {
o["props"] = serde_json::to_value(&p.props).unwrap_or(Value::Null);
}
let runs: Vec<Value> = p
.run_ranges()
.filter(|(_, c)| !c.is_empty())
.map(|(r, c)| json!({"start": r.start, "end": r.end, "props": c}))
.take(50)
.collect();
if !runs.is_empty() {
o["runs"] = json!(runs);
}
if !p.objects.is_empty() {
o["objects"] = serde_json::to_value(&p.objects).unwrap_or(Value::Null);
}
if p.section.is_some() {
o["sectionBreak"] = json!(true);
}
blocks.push(o);
}
Block::Table(t) => {
let cells: Vec<Vec<String>> = t
.rows
.iter()
.map(|r| {
r.cells
.iter()
.map(|c| c.blocks.iter().filter_map(|b| b.as_para().map(|p| p.plain_text())).collect::<Vec<_>>().join("\n"))
.collect()
})
.collect();
blocks.push(json!({"index": i, "type": "table", "rows": t.rows.len(), "cols": t.cols(), "style": t.props.style, "cells": cells}));
}
}
}
let l = s.layout();
let parts: Vec<Value> =
s.doc.parts.iter().map(|(id, p)| json!({"id": id, "kind": p.kind, "text": s.doc.plain_text(StoryRef::Part(*id))})).collect();
Ok(json!({
"blocks": blocks,
"parts": parts,
"selection": {"anchor": pos_json(&s.sel.anchor), "focus": pos_json(&s.sel.focus), "text": s.selected_text()},
"pages": l.pages.len(),
"words": s.doc.word_count(),
"sections": s.doc.sections().iter().map(|(end, sp)| json!({"endBlock": end, "props": sp})).collect::<Vec<_>>(),
"comments": s.doc.comments.len(),
"trackChanges": s.doc.settings.track_changes,
"properties": s.doc.core,
"canUndo": s.can_undo(),
"canRedo": s.can_redo(),
}))
}
fn paragraph(s: &mut Session, v: &Value) -> CmdResult {
let path: Vec<u32> = serde_json::from_value(v.get("path").cloned().unwrap_or(json!([0]))).map_err(|e| CmdError::Params(e.to_string()))?;
let story = super::story_param(s, v);
let p = s.doc.para(story, &wordcraft_doc::Path(path)).ok_or_else(|| CmdError::Params("no paragraph there".into()))?;
let resolved = s.doc.styles.resolve_para(&p.props);
Ok(json!({"paragraph": p, "resolved": resolved}))
}
fn layout_summary(s: &mut Session, _: &Value) -> CmdResult {
let l = s.layout();
let pages: Vec<Value> = l
.pages
.iter()
.map(|p| {
let lines: usize = p.items.iter().map(|it| if let wordcraft_layout::Placed::Lines { l0, l1, .. } = it { l1 - l0 } else { 0 }).sum();
json!({"number": p.number, "w": p.w, "h": p.h, "section": p.section, "lines": lines, "firstBlock": p.first_block, "header": p.header_story, "footer": p.footer_story})
})
.collect();
let caret = l.caret_on(&s.sel.focus, s.page_hint).map(|c| json!({"page": c.page, "x": c.x, "y": c.top, "h": c.height}));
Ok(json!({"pages": pages, "caret": caret, "ms": l.ms}))
}