Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor f0f64cbb92 ci: verify the actions lockfile in CI
Fails the build when .github/workflows/actions.lock stops matching the
workflows, or when a locked pin no longer verifies upstream, so a stale or
tampered lockfile cannot go unnoticed between dependency updates.
2026-11-12 07:23:26 -05:00
Dylan M. Taylor d1f8e13103 ci: lock workflow actions through an actions.lock lockfile
Mutable action tags are a supply-chain risk: a moved tag runs different code
without a diff. Every workflow action is now pinned through
.github/workflows/actions.lock, and CI verifies the lockfile so an out-of-sync
or tampered one fails instead of running.
2026-11-12 07:18:33 -05:00
FredApps 07f977372d CI: nightly release dry run (#6367)
release.yml also runs nightly on main with craft-common-actions' dry-run: every release build
except macOS, with nothing signed, pushed or drafted. Breakage that only the release build hits
(riscv64, Flatpak, FreeBSD, packaging) then shows up the next morning instead of on release
day, as it did on 11 Nov 2026.
2026-11-11 23:04:54 +01:00
Dylan M. Taylor 06c2ae6f9d ci: use StepSecurity's maintained actions (#6343)
Each third-party action that StepSecurity publishes a secure drop-in
replacement for now uses that replacement, pinned to a commit with its
version as a comment. Versions follow what we used: the newest
StepSecurity release in the same major, or the closest major they
publish. dtolnay/rust-toolchain refs that named a toolchain (stable,
nightly, 1.76) become the replacement's `toolchain` input.
2026-11-11 14:59:04 -05:00
Dylan M. Taylor a58b41a716 chore: bump version to 0.18.1 for the Flatpak update fix (#6344) 2026-11-11 14:56:11 -05:00
Dylan M. Taylor d86d2b69f8 Fix Flatpak permissions for update checks and self-updates (#6339)
* fix(flatpak): allow release checks and self-update helpers

* test: keep Flatpak permission parsing within strict Clippy rules
2026-11-11 14:24:25 -05:00
FredApps a8db235e05 Release: WordCraft v0.18.0 (#6294) 2026-11-11 19:35:57 +01:00
batistalucasv 4706420e94 tools: chunk LCS alignment past MAX_LCS window to prevent false revisions (#6110) (#6124) 2026-11-11 19:11:33 +01:00
batistalucasv ae7ae89eb2 fix(engine): surface character-level compare fallback on long paragraphs (#6111) (#6120) 2026-11-11 19:09:43 +01:00
Dylan M. Taylor 1327d20055 Merge pull request #6333 from storytold/chore/craft-update-6e03f65
Updates: take craft-update from craft-libs main at 6e03f65bf
2026-11-11 12:53:23 -05:00
Dylan M. Taylor 9dba000e86 Updates: take craft-update from craft-libs main at 6e03f65bf 2026-11-11 12:53:17 -05:00
batistalucasv 7013411a2f cli: skip update-fields with warning on protected document (#5839) (#5849) 2026-11-11 18:53:10 +01:00
Dylan M. Taylor 411a4915a1 ci: run Harden-Runner on every job (#6327)
Adds step-security/harden-runner (audit, pinned to v2.22.4 like
craft-common-actions) as the first step of each job that did not have it,
so every workflow in the org's public repos reports its egress to
StepSecurity. Audit, not block: block-mode allow lists come from each
job's observed traffic.
2026-11-11 12:44:00 -05:00
bmh129andbmh129 639b8133ea Linux: stay on native Wayland when the X11 libraries are missing (#3359)
The deb and rpm only recommend the X11 libraries, and the release tarball
doesn't install them. When the display-server choice (#2184) or
WORDCRAFT_WAYLAND=0 asked for XWayland without them, the app failed to
start: winit panicked in xkbcommon-dl without libxkbcommon-x11 (seen on
Fedora 44 and Ubuntu 26.04).

uses_x11 now also takes a check of the libraries winit's X11 backend opens
(libX11, libXcursor, libX11-xcb, libXi, libxkbcommon-x11), asked only when
X11 would be forced. If any is missing, X11 isn't forced and the log line
names them. The packages now also recommend libX11-xcb and
libxkbcommon-x11, and the README lists the libraries XWayland needs.

Tests: missing_x11_libraries_leave_the_app_on_wayland (every way of asking
for X11, and that asking for Wayland doesn't run the check).

Co-authored-by: bmh129 <11478682+bmh129@users.noreply.github.com>
2026-11-11 12:04:55 -05:00
FredApps e0659e45ce CI: Print installs its Linux packages through the shared fallback mirrors (#6308)
The Print job runs its own apt-get, so it failed while ubuntu.com did not resolve on the runners
(11 Nov 2026). configure-apt-mirrors (craft-common-actions) points apt at mirrors outside
ubuntu.com when those don't answer, as the shared jobs already do.
2026-11-11 17:56:20 +01:00
batistalucasv ad5e1a65fc #6159: refuse ui.screenshot on secondary document windows and support root window selection (#6169) 2026-11-11 14:22:51 +01:00
batistalucasvandfelipegavilanes-source bf426c1804 Insert > Date & Time: Word-style dialog with regional formats and field support (#5093) (#6022)
* feat(engine): format Brazilian date fields and insert localized dates

* feat(ui): persist preferred date and time format

* feat(ui): implement Word-style Insert > Date & Time modal

* feat(ribbon): open Date & Time dialog from Insert tab

* test: Brazilian Word TIME fields respect document defaults after DOCX round-trip

* fix(docx): refresh Word date/time fields using their run language

* ci: add temporary date/time regression validation workflow

* style: apply rustfmt output to insert.rs

* style: apply rustfmt output to date_fields.rs

* style: apply rustfmt output to dialogs_insert.rs

* i18n: reuse translated date-format and language labels

* i18n(pt-BR): translate Update automatically in date/time dialog

* fix(i18n): translate date dialog options and audit generated date previews

* chore: remove temporary date/time regression workflow after focused validation

* fix(date): resolve inherited locale and preserve it in fixed text

* fix(ui): display date insertion errors without closing the dialog and document state

* test: cover inherited language, static date text, and escaped date pictures

* test(ui): exercise date language, picture, checkbox, OK, and invalid-value feedback via egui

* style: apply rustfmt to date insertion and regression tests

* fix(engine): borrow caret position while resolving inherited date language

* fix(date): honor selected locale when the date picture is omitted

* test(ui): target date dialog language combo instead of ribbon menu

* test(date): use raw field instruction literal in locale regression

* test(date): match actual single-backslash DATE field instruction

* test(ui): select actual date dialog language option by accessible label

* fix(i18n): add missing automatic date update translation (Romanian)

* fix(i18n): add missing automatic date update translation (Slovenian)

* fix(ui): open date_time dialog from keytip, normalize pt tags, and document items

* fix(ui): default date_time_update preference to false

---------

Co-authored-by: felipegavilanes-source <felipegavilanes@gmail.com>
2026-11-11 14:21:31 +01:00
batistalucasv b67d3b8735 Engine: fall back to fsync on Apple targets when full flush is unsupported (#6155) (#6161) 2026-11-11 14:20:39 +01:00
batistalucasv e3fef2a9c3 backstage: right-align template titles and thumbnails in RTL (#6151) (#6163) 2026-11-11 14:19:51 +01:00
Mex 060768f1ed Preserve header/footer content when adding page numbers (#2201)
Fixes #2201. Top/bottom page numbering preserves neighboring header/footer text, formatting and tables, recognizes existing PAGE fields through nested tables/text boxes, and updates gallery-generated numbering in place.

The latest review follow-up lets refreshed PAGE/NUMPAGES result caches vary during generated-number recognition. It retains those caches and paragraph marks during format/alignment changes; identical requests remain clean and add no undo step. Field instructions, flags and paragraph/run formatting remain conservatively checked. Original history, incoming progress notes and readiness estimates are preserved.

Local validation before the integration refresh:

- Actual RED: 13 existing tests passed; the new reopened cached-PAGE fixture failed because right alignment stayed centered.
- GREEN: all **14 preservation tests** pass, with all 13 originals an exact byte prefix. The fixture checks DOCX-reopened caches 7/12, no-op cleanliness/undo, later changes, content/marks/selection and undo/redo.
- Full engine package: **1,350 passed, zero failed, one existing ignored measurement**, including the focused 14. Engine formatting and strict all-target Clippy pass.
- Synthetic DOCX coverage; native Word interoperability was not exercised. No new whole-workspace local run is claimed.

Current head: `5eb4c8cb8b205ebcfcb5b1a48d55c5e21b1683e6`, a normal preserving merge of upstream `d40d1b27181b61990c84c75f77d5fd23ef81d452`. The new roadmap and format-parity documentation conflicts retain all incoming progress and revision history plus this PR's existing page-number notes. All **1,087 published files** match incoming upstream plus the five reviewed changes; both owned Rust files remain byte-for-byte unchanged from `ca615f3` and `447dec8`, and manifests/lock match incoming upstream. No new local Rust run is claimed for this documentation-only integration. The [preceding ca615f3 full hosted gate](https://github.com/storytold/wordcraft/actions/runs/42679017335/job/129368197175?pr=2251) passed in 13m38s. The [required current-head full gate](https://github.com/storytold/wordcraft/actions/runs/42693258780/job/129418282235?pr=2251) passed in **13m06s**; SonarCloud planning/scanning also passed. Required validation is complete. Maintainer edits are enabled; FredApps re-review remains requested.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

## Summary

* **Bug Fixes**
  * Adding page numbers to headers and footers now preserves existing content and formatting, including content in tables and text boxes.
  * Existing user-owned page fields remain unchanged, while generated page numbers can be updated without disturbing surrounding content.
  * Repeating an identical page-number request no longer changes the document’s state or undo history.
* **Documentation**
  * Updated page-numbering guidance to reflect these preservation behaviors.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 09:06:18 +01:00
FredApps 45db128c15 fix(wordcraft): satisfy clippy on clipboard.rs after #5441
#5441 (92e0e67f) landed a Wayland clipboard size check in `apps/wordcraft/src/clipboard.rs` that left two clippy `-D warnings` failures on main:

- an unused `self` import in the Unix `platform` module (`use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture}`)
- `items_after_test_module`: `platform::picture()` was defined after `mod tests`

This removes the unused import and moves `picture()` above the test module. Behaviour is unchanged.

Local checks: `cargo fmt -p wordcraft` and `cargo clippy -p wordcraft --all-targets --locked -- -D warnings` on Linux.

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Refactor**
  * No user-visible changes.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 08:51:03 +01:00
FredApps 118eb8af1f Merge pull request #6033 from batistalucasv/fix/6011-docx-equation-revision-marks
DOCX: save and load tracked revision marks around equations (#6011)
2026-11-11 07:59:24 +01:00
FredApps 30c1279fd8 Merge pull request #6047 from vangel76/fix/5922-doc-header-empty-para
.doc: first body line no longer starts too low under a header
2026-11-11 07:58:20 +01:00
FredApps f936270317 Merge pull request #6049 from vangel76/fix/6018-line-numbers-columns
Line numbers appear beside every column and keep their Word formatting
2026-11-11 07:56:26 +01:00
Martin Ball 0fab74aa38 #4501 follow-up: restore pre-refactor behaviour
#4501 split functions for `clippy::cognitive_complexity` and was meant to change no behaviour. A post-merge review of the whole diff found four places where it did. This PR restores the old behaviour in each.

- **Layout (`crates/layout/src/pass.rs`).** From section 2 on, headers, footers and footnote separators were measured under the previous section's document grid, with its SECTION/SECTIONPAGES values. Before #4501 the grid was reset first. With a docGrid line pitch in section 1 (common in CJK documents), section 2's body top moved and pages could break differently. The order is now:
  1. `begin_section_state`: reset the grid, set the fields and separators.
  2. `section_tops`: measure the header and footer room.
  3. `apply_section_grid`: switch to the section's own grid.
  
  New regression test `a_sections_header_is_measured_without_the_previous_sections_grid`. Without the fix, section 2's body top is 265.6 pt; with it, 226.9 pt, the same as with no grid.
- **ODT (`crates/formats/src/odt/styles.rs`).** A later paragraph default-style with no font, or a mono font, wiped the default font. It is now kept, as before #4501. Covered by a test.
- **Canvas (`crates/ui-egui/src/canvas/input.rs`).** A press that was handled showed the hover-link tooltip for one frame, which the old early `return` had skipped. This covers Ctrl+click on a link, switching to the header or footer, entering an equation, and a miss. `handle_primary_press` now returns `bool`. No test.
- **Czech suggestions (`crates/proof/src/cs/suggest.rs`).** The one-letter edits are bounded by the word's letter count as typed again. That count is clamped to the lowercased length so indexing can't go out of range. #4501 had bounded them by the lowercased length, which differs for e.g. `İ`.

`cargo xtask ci` passes locally (all 8 steps, 4606 tests).


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
  * Default document fonts remain available when a later paragraph style does not specify a usable font, and a later monospaced default does not replace an earlier proportional font.
  * Headers, footers, and note separators use the layout settings and section values of their own section, helping prevent content from overlapping.
  * Link hover tooltips are suppressed during canvas-handled clicks and return when the pointer hovers over the link after the press is released.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 07:32:14 +01:00
Joseph Anglada aef086976b Ribbon: hover fades, group glow, sliding tab underline and a brief content slide
## What

Builds on #3131 (the contextual tab glide); this PR is based on that branch.

- Every ribbon button and tab fades its hover in and out. Before, only some buttons did.
- Disabled buttons do not answer hover or press.
- A ribbon group glows from its bottom edge while the pointer is anywhere in it. The glow is about 9 pt tall and fades out upwards and towards both sides. It fades in and out with the pointer.
- The tab underline slides to the tab you choose, with the same ease as the contextual tab glide.
- The ribbon content fades and slides in (14 pt, 0.16 s) from the side the new tab lies on.

All motion follows the animation setting: with animations off, nothing moves and nothing fades.

## Screenshots
<img width="298" height="115" alt="image" src="https://github.com/user-attachments/assets/9b5ba886-1dcd-4260-a2dd-cecdf199e111" />
<img width="498" height="192" alt="ribbon-animated-dark" src="https://github.com/user-attachments/assets/bc43168b-183a-4ec0-875c-a9550f22fb36" />
<img width="548" height="158" alt="ribbon-animated" src="https://github.com/user-attachments/assets/4d50427e-376d-4a6a-a0bd-b1ed42fe0c9c" />

## Tests

- Pure tests for the underline slide and the tab change direction.
- `cargo test -p wordcraft-ui-egui` and clippy `-D warnings` pass.

## Related

- Overlaps with #3256 and #1420 (issue #1419): both also slide the underline and fade the content on a tab change. The hover fade and the group glow are new here. Whichever lands first, the other needs a small merge in `ribbon.rs`.
- Possibly related: issue #585.
- The Search box text centring is its own PR, #3802 (issue #3779).
- Based on #3131.


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **New Features**
  * Ribbon tabs animate as they appear and disappear, with a sliding active-tab indicator and fading, directional transitions between tab content. Departing contextual tabs remain visible briefly but cannot be selected or appear in the overflow menu.
  * Tab and overflow-button hover feedback is animated. Ribbon groups display a soft accent glow when hovered.

* **Bug Fixes**
  * Disabled buttons no longer show hover or pressed backgrounds. With animations disabled, tabs and content appear immediately.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-11 07:31:45 +01:00
vangel76andClaude Opus 5.5 bce47689ad Line numbers: number every column, and use a localised Line Number style
In a multi-column section every line number was drawn left of the page
margin, so the second column's numbers sat on top of the first column's.
Each column now has its numbers left of its own edge, as in Word.

Word in other languages saves the built-in "line number" style under a
localised id (German: Zeilennummer); the numbers now find it by its
built-in name when there is no LineNumber id, so its formatting applies.

Fixes #6018.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:40:14 +01:00
vangel76andClaude Opus 5.5 5ad865831f .doc: drop the guard paragraph mark at the end of header and footer stories
[MS-DOC] 2.3.3 ends every non-empty header/footer story with a guard paragraph
mark that is not part of the story. It was read as an empty paragraph, which
added a line under every header and footer and pushed the first body line down
when the header reached past the top margin (#5922).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:18:08 +01:00
batistalucasv 0b7cfa86fe #6011: docx: strengthen tracked equation roundtrip assertions 2026-11-08 15:50:29 -03:00
batistalucasv 85eb8bb92e #6011: docx: write and read revision marks around equations 2026-11-08 15:13:02 -03:00
71 changed files with 3231 additions and 248 deletions
+57
View File
@@ -0,0 +1,57 @@
# This file is machine-generated by `gh actions-lock`.
# Do not edit by hand; run `gh actions-lock` to update.
# Docs: https://gh.io/actions-lockfile
version: 'v0.0.3'
workflows:
'.github/workflows/ci.yml': []
'.github/workflows/freebsd.yml': []
'.github/workflows/house-rules.yml': []
'.github/workflows/lockfile.yml':
- 'actions/checkout@v7.0.1'
- 'step-security/harden-runner@v2.22.4'
'.github/workflows/nix.yml': []
'.github/workflows/packaging-lint.yml':
- 'actions/checkout@v7.0.1'
- 'step-security/harden-runner@v2.22.4'
'.github/workflows/printing.yml':
- 'actions/checkout@v7.0.1'
- 'step-security/dtolnay-rust-toolchain@v1.0.0'
- 'step-security/harden-runner@v2.22.4'
- 'step-security/rust-cache@v2.9.2'
- 'storytold/craft-common-actions@main'
'.github/workflows/release.yml': []
'.github/workflows/sonarcloud.yml': []
'.github/workflows/windows-arm64.yml': []
dependencies:
'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'actions/checkout@v7.0.1':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'step-security/dtolnay-rust-toolchain@v1.0.0':
ref: 'v1.0.0'
commit: 'sha1-8dd607977d06adf4ed49b190381e74c17a9dd25f'
owner_id: 88700172
repo_id: 1249865941
'step-security/harden-runner@v2.22.4':
ref: 'v2.22.4'
commit: 'sha1-1d88384fd173af6ecb012dfd0264a306b711241a'
owner_id: 88700172
repo_id: 422287306
'step-security/rust-cache@v2.9.2':
ref: 'v2.9.2'
commit: 'sha1-8871c8ba063416da7c32246932ea6ae08700453e'
owner_id: 88700172
repo_id: 822636713
'storytold/craft-common-actions@main':
ref: 'main'
commit: 'sha1-293de72429d3ed5d9fa92aded5e500544c0fa28f'
owner_id: 76897702
repo_id: 1414429108
uses:
- 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'
+34
View File
@@ -0,0 +1,34 @@
name: Lockfile
# Fails when .github/workflows/actions.lock is out of sync with the workflows, or when a locked pin
# no longer verifies upstream, so a moved tag cannot slip a different commit into a run.
# Docs: https://gh.io/actions-lockfile
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
env:
GH_TOKEN: ${{ github.token }}
jobs:
lockfile:
name: Lockfile is in sync
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Install gh-actions-lock
run: gh extension install github/gh-actions-lock --pin v0.4.1
- name: Verify the lockfile
run: gh actions-lock --verify
+5 -1
View File
@@ -27,7 +27,11 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
+13 -3
View File
@@ -42,7 +42,11 @@ jobs:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: '0'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
@@ -64,6 +68,11 @@ jobs:
echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV"
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
# apt falls back to mirrors outside ubuntu.com when it doesn't resolve (as on 11 Nov 2026).
- name: Configure apt mirrors
if: runner.os == 'Linux'
uses: storytold/craft-common-actions/actions/configure-apt-mirrors@main
- name: Install Linux build and software-rendering dependencies
if: runner.os == 'Linux'
run: |
@@ -72,12 +81,13 @@ jobs:
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libvulkan1 mesa-vulkan-drivers
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: step-security/dtolnay-rust-toolchain@v1.0.0
with:
toolchain: stable
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: step-security/rust-cache@v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
+5
View File
@@ -1,6 +1,10 @@
name: Release
on:
# A nightly dry run on main: every release build except macOS, nothing signed or released
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
schedule:
- cron: "0 3 * * *"
push:
branches: [release]
workflow_dispatch:
@@ -33,6 +37,7 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
dry-run: ${{ github.event_name == 'schedule' }}
app-name: wordcraft
app-title: WordCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
Generated
+26 -22
View File
@@ -1081,7 +1081,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -1135,7 +1135,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"chrono",
"dunce",
@@ -1145,6 +1145,7 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -2981,7 +2982,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -6806,7 +6807,7 @@ dependencies = [
[[package]]
name = "wordcraft"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"arboard",
"chrono",
@@ -6838,13 +6839,15 @@ dependencies = [
"wordcraft-engine",
"wordcraft-fonts",
"wordcraft-ui-egui",
"x11-dl",
"x11rb",
"xkbcommon-dl",
"zbus",
]
[[package]]
name = "wordcraft-cli"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"image",
"serde_json",
@@ -6860,11 +6863,11 @@ dependencies = [
[[package]]
name = "wordcraft-control-key"
version = "0.15.1"
version = "0.18.1"
[[package]]
name = "wordcraft-doc"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"base64 0.23.1",
"log",
@@ -6878,7 +6881,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"cfb",
"chrono",
@@ -6895,7 +6898,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6920,7 +6923,7 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"cfb",
"chrono",
@@ -6935,6 +6938,7 @@ dependencies = [
"log",
"regex",
"regex-automata",
"rustix 1.1.5",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -6953,7 +6957,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"brotli-decompressor",
"harfrust 0.13.3",
@@ -6967,7 +6971,7 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6994,7 +6998,7 @@ dependencies = [
[[package]]
name = "wordcraft-geom"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"image",
"serde",
@@ -7002,7 +7006,7 @@ dependencies = [
[[package]]
name = "wordcraft-layout"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"craft-proof",
"image",
@@ -7022,7 +7026,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"log",
"serde_json",
@@ -7034,7 +7038,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -7042,7 +7046,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"chrono",
"craft-pdf",
@@ -7063,7 +7067,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"image",
"log",
@@ -7077,7 +7081,7 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"accesskit_consumer",
"cfb",
@@ -7110,7 +7114,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"craft-web-api",
"craft-web-host",
@@ -7131,7 +7135,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"log",
"proptest",
@@ -7294,7 +7298,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"serde_json",
]
+4 -4
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.15.1"
version = "0.18.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -67,9 +67,9 @@ wuff = "0.2.9"
brotli-decompressor = "5"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", default-features = false, features = ["write"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", default-features = false, features = ["write"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
@@ -134,7 +134,7 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+5
View File
@@ -354,6 +354,11 @@ XWayland:
WORDCRAFT_WAYLAND=1 wordcraft
```
XWayland needs the X11 libraries, which the packages only recommend and the tarball doesn't
install: libX11, libX11-xcb, libXcursor, libXi and libxkbcommon-x11 (on Debian and Ubuntu,
`libx11-6`, `libx11-xcb1`, `libxcursor1`, `libxi6` and `libxkbcommon-x11-0`). Without them the app
opens on native Wayland, whatever the setting says, and logs which ones are missing.
**Arch Linux (AUR).** Community members maintain three AUR packages; the project doesn't publish
to the AUR itself. Install one of them:
| Package | What it installs | Choose it when |
+3 -1
View File
File diff suppressed because one or more lines are too long
+28 -3
View File
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
use std::process::ExitCode;
use serde_json::{Value, json};
use wordcraft_engine::Session;
use wordcraft_engine::{CmdError, Session};
const USAGE: &str = "\
wordcraft-cli — WordCraft from the command line
@@ -19,7 +19,8 @@ wordcraft-cli — WordCraft from the command line
USAGE:
wordcraft-cli convert <in> <out> [--markup] [--update-fields] [--strict] [--format wordml2003|flatopc]
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png,
xml); --update-fields refreshes unlocked fields before saving;
xml); --update-fields refreshes unlocked fields before saving
(if protection blocks updates, they are skipped with a warning);
an .xml file is a Word XML Document (Flat OPC), or with
--format wordml2003 a Word 2003 XML Document;
--markup (Print Markup): comments beside each page and
@@ -451,6 +452,30 @@ fn report_lines(json: &Value) -> Vec<String> {
wordcraft_engine::strict::lines_from_json(json)
}
/// Explicitly update unlocked fields in a session before saving the document.
///
/// If field updates are disabled by document protection (`CmdError::Disabled`), logs a warning to
/// stderr and continues conversion rather than aborting. Other command failures are converted to
/// [`Failure`] via [`engine_error`].
///
/// # Arguments
///
/// * `s` - The active document [`Session`] whose unlocked fields should be refreshed.
///
/// # Errors
///
/// Returns `Err(Failure)` if the command fails with an error other than `CmdError::Disabled`.
fn update_fields(s: &mut Session) -> Result<(), Failure> {
if let Err(e) = s.run("references.updateFields", &json!({})) {
if matches!(e, CmdError::Disabled(_)) {
eprintln!("wordcraft-cli: warning: {e}");
} else {
return Err(engine_error(e));
}
}
Ok(())
}
/// `convert <in> <out>`: `rest` is the command line after `convert`, `open` opens the input.
fn run_convert(rest: &[String], open: impl Fn(&str, Option<&str>) -> Result<Session, Failure>) -> Result<(), Failure> {
validate_options("convert", rest, CONVERT_OPTIONS)?;
@@ -473,7 +498,7 @@ fn run_convert(rest: &[String], open: impl Fn(&str, Option<&str>) -> Result<Sess
let password = password_from(rest)?;
let mut s = open(&i, password.as_deref())?;
if rest.iter().any(|a| a == "--update-fields") {
s.run("references.updateFields", &json!({})).map_err(engine_error)?;
update_fields(&mut s)?;
}
let r = if to_png {
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup, "strict": strict}))
@@ -47,3 +47,65 @@ fn convert_refresh_is_opt_in() {
}
std::fs::remove_dir_all(dir).unwrap();
}
/// Converting a protected document with `--update-fields` skips updating fields with a warning,
/// retains the cached field result, and preserves the protection mode on output.
#[test]
fn convert_protected_document_with_update_fields_skips_with_warning() {
let dir = std::env::temp_dir().join(format!("wc-convert-fields-prot-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for (mode, expected_warning) in [("readOnly", "protected"), ("forms", "protected"), ("comments", "comments")] {
let mut doc = Document::from_text("Contents\nTarget");
doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap().props.page_break_before = Some(true);
doc.insert_object(&Pos::body(1, 0), InlineObject::BookmarkStart { name: "target".into() }, &Default::default()).unwrap();
doc.insert_object(
&Pos::body(0, 0),
InlineObject::Field {
instr: "PAGEREF target".into(),
result: "104".into(),
locked: false,
dirty: true,
code: false,
form: String::new(),
},
&Default::default(),
)
.unwrap();
doc.settings.protection = Some(mode.into());
let input = dir.join(format!("in_{mode}.json"));
std::fs::write(&input, serde_json::to_vec(&doc).unwrap()).unwrap();
let docx = dir.join(format!("in_{mode}.docx"));
let make = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("convert").arg(&input).arg(&docx).output().unwrap();
assert!(make.status.success(), "{}", String::from_utf8_lossy(&make.stderr));
let output = dir.join(format!("out_{mode}.json"));
let _ = std::fs::remove_file(&output);
let mut cmd = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
cmd.arg("convert").arg(&docx).arg(&output).arg("--update-fields");
let run = cmd.output().unwrap();
let stderr = String::from_utf8_lossy(&run.stderr);
assert!(run.status.success(), "exit status was failure for mode {mode}: {stderr}");
assert!(stderr.contains("warning:"), "stderr missing warning for mode {mode}: {stderr}");
assert!(stderr.contains(expected_warning), "stderr missing '{expected_warning}' for mode {mode}: {stderr}");
assert!(output.exists(), "output file was not created for mode {mode}");
let out: Document = serde_json::from_slice(&std::fs::read(&output).unwrap()).unwrap();
let results: Vec<_> = out
.para(StoryRef::Body, &Path::top(0))
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.as_str()),
_ => None,
})
.collect();
// Result should remain "104" (cached) because the field could not be updated under protection
assert_eq!(results, vec!["104"], "field result was unexpectedly modified for mode {mode}");
assert_eq!(out.settings.protection.as_deref(), Some(mode), "protection mode was not preserved for mode {mode}");
}
std::fs::remove_dir_all(dir).unwrap();
}
+5
View File
@@ -64,6 +64,11 @@ wl-clipboard-rs = "0.9"
# Another program's RTF on X11 (`text/rtf`), which arboard doesn't read. Already in the build
# through arboard, at this version.
x11rb = "0.13"
# Check that the libraries winit's X11 backend opens are installed before choosing XWayland (see
# `x11_libraries` in main.rs). Both are already in the build through winit's `x11` feature, at
# these versions and with this feature.
x11-dl = "2.21"
xkbcommon-dl = { version = "0.4.2", features = ["x11"] }
[target.'cfg(target_os = "windows")'.dependencies]
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
+17 -17
View File
@@ -164,7 +164,7 @@ mod platform {
use std::io::Read;
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents, get_mime_types_ordered};
use wordcraft_ui_egui::paste_picture::{self, ClipboardPicture};
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
fn wayland() -> bool {
std::env::var_os("WAYLAND_DISPLAY").is_some()
@@ -348,6 +348,22 @@ mod platform {
}
}
/// The Wayland clipboard's picture, read encoded under the paste's byte cap and refused from
/// its header when a paste can't take its size, before anything decodes it. arboard decodes
/// and converts the PNG to RGBA with no size limits. `None` when Wayland offers no picture
/// (or isn't reachable): arboard is asked then, which also covers X11.
pub fn picture() -> Option<ClipboardPicture> {
if !wayland() {
return None;
}
let offered = formats();
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
Some(match read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES) {
Some(bytes) => wordcraft_ui_egui::paste_picture::encoded_picture(bytes),
None => ClipboardPicture::Unreadable("the clipboard picture is larger than 200 MB, or couldn't be read".into()),
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -369,22 +385,6 @@ mod platform {
assert!(complete(0, 0, 0));
}
}
/// The Wayland clipboard's picture, read encoded under the paste's byte cap and refused from
/// its header when a paste can't take its size, before anything decodes it. arboard decodes
/// and converts the PNG to RGBA with no size limits. `None` when Wayland offers no picture
/// (or isn't reachable): arboard is asked then, which also covers X11.
pub fn picture() -> Option<ClipboardPicture> {
if !wayland() {
return None;
}
let offered = formats();
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
Some(match read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES) {
Some(bytes) => wordcraft_ui_egui::paste_picture::encoded_picture(bytes),
None => ClipboardPicture::Unreadable("the clipboard picture is larger than 200 MB, or couldn't be read".into()),
})
}
}
#[cfg(target_os = "windows")]
+67 -8
View File
@@ -276,9 +276,16 @@ fn saved_ui() -> Option<UiState> {
/// whenever there's an X server: winit has no file drag-and-drop on Wayland, so dropping files from
/// the file manager showed a "no" cursor. X11 is only forced with an X server (`DISPLAY`), and
/// unforced winit picks Wayland when it's there and X11 otherwise, so a choice that isn't
/// available falls back to the other instead of failing to start.
/// available falls back to the other instead of failing to start. The X11 libraries are only
/// recommended by the packages, so when `x11_libraries` reports some missing (it's asked only when
/// X11 would be forced) the app stays on Wayland: forced X11 without them failed to start, and
/// winit panicked without libxkbcommon-x11.
#[cfg(all(unix, not(target_os = "macos")))]
fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Option<std::ffi::OsString>) -> (bool, String) {
fn uses_x11(
setting: wordcraft_ui_egui::DisplayServer,
env: impl Fn(&str) -> Option<std::ffi::OsString>,
x11_libraries: impl FnOnce() -> Result<(), String>,
) -> (bool, String) {
use wordcraft_ui_egui::DisplayServer;
let set = |name: &str| env(name).is_some_and(|v| !v.is_empty());
let (wanted, by) = match env("WORDCRAFT_WAYLAND") {
@@ -288,17 +295,39 @@ fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Opt
Some(_) => (DisplayServer::Wayland, "WORDCRAFT_WAYLAND"),
None => (setting, "the setting"),
};
let x11 = wanted != DisplayServer::Wayland && set("DISPLAY");
let missing = if wanted != DisplayServer::Wayland && set("DISPLAY") { x11_libraries().err() } else { None };
let x11 = wanted != DisplayServer::Wayland && set("DISPLAY") && missing.is_none();
let wayland = set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET");
let runs = if x11 {
"X11"
} else if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
} else if wayland {
"native Wayland"
} else if set("DISPLAY") {
"X11 (no Wayland)"
} else {
"neither (no Wayland or X server found)"
};
(x11, format!("display: {runs}; {} asks for {}", by, wanted.code()))
let mut why = format!("display: {runs}; {} asks for {}", by, wanted.code());
if let Some(missing) = missing {
why += &format!("; not X11 because its libraries are missing: {missing}");
}
(x11, why)
}
/// `Err` naming the libraries winit's X11 backend opens at startup that can't be loaded: libX11,
/// libXcursor, libX11-xcb (which brings in libxcb), libXi, and libxkbcommon-x11 for the keyboard.
#[cfg(all(unix, not(target_os = "macos")))]
fn x11_libraries() -> Result<(), String> {
use x11_dl::{xcursor::Xcursor, xinput2::XInput2, xlib::Xlib, xlib_xcb::Xlib_xcb};
let mut missing: Vec<String> = [Xlib::open().err(), Xcursor::open().err(), Xlib_xcb::open().err(), XInput2::open().err()]
.into_iter()
.flatten()
.map(|e| e.to_string())
.collect();
if xkbcommon_dl::x11::xkbcommon_x11_option().is_none() {
missing.push("libxkbcommon-x11.so.0 could not be loaded".into());
}
if missing.is_empty() { Ok(()) } else { Err(missing.join("; ")) }
}
fn save_prefs(app: &WordApp) {
@@ -558,7 +587,7 @@ fn main() -> eframe::Result {
}
#[cfg(all(unix, not(target_os = "macos")))]
{
let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name));
let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name), x11_libraries);
log::info!("{why}");
if x11 {
options.event_loop_builder = Some(Box::new(|b| {
@@ -690,7 +719,7 @@ mod tests {
fn the_display_server_follows_the_variable_then_the_setting() {
use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11};
let x11 = |setting, vars: &[(&str, &str)]| {
uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v))).0
uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v)), || Ok(())).0
};
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
assert!(x11(Auto, &both), "Automatic keeps XWayland, for file drag-and-drop");
@@ -706,10 +735,40 @@ mod tests {
let wayland_only = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")];
assert!(!x11(X11, &wayland_only) && !x11(Auto, &wayland_only));
// Without Wayland, Wayland isn't forced either: unforced winit picks X11.
let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()));
let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()), || Ok(()));
assert!(!forced && why.contains("X11 (no Wayland)"), "{why}");
}
/// Without the X11 libraries (the packages only recommend them) X11 isn't forced, whatever asks
/// for it: forced X11 failed to start, and winit panicked without libxkbcommon-x11. The check
/// runs only when X11 would be forced.
#[test]
fn missing_x11_libraries_leave_the_app_on_wayland() {
use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11};
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
for (setting, variable) in [(Auto, None), (X11, None), (Wayland, Some("0"))] {
let env = |name: &str| {
both.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| (*v).into())
.or_else(|| (name == "WORDCRAFT_WAYLAND").then_some(variable).flatten().map(Into::into))
};
let (x11, why) = uses_x11(setting, env, || Err("libxkbcommon-x11.so.0 could not be loaded".into()));
assert!(!x11, "{setting:?} with WORDCRAFT_WAYLAND={variable:?}: {why}");
assert!(why.contains("native Wayland") && why.contains("libxkbcommon-x11"), "{why}");
}
let checked = std::cell::Cell::new(false);
let (x11, _) = uses_x11(
Wayland,
|name| both.iter().find(|(k, _)| *k == name).map(|(_, v)| (*v).into()),
|| {
checked.set(true);
Ok(())
},
);
assert!(!x11 && !checked.get(), "asked for Wayland, the libraries aren't checked");
}
/// A named pipe or a device under such a name is a warning, not a read: the pipe blocked
/// start-up until something wrote to it, and `/dev/zero` read until memory ran out.
#[test]
+31 -1
View File
@@ -348,7 +348,7 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
0 | 1 | 4 => PartKind::Header,
_ => PartKind::Footer,
};
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
let blocks = header_story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
if !blocks.is_empty() {
let id = doc.add_part(kind, blocks);
hf_ids.push((si, k, id));
@@ -436,6 +436,36 @@ fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, m
Ok(table::assemble(paras))
}
/// A header or footer story's blocks without its guard paragraph mark. [MS-DOC] 2.3.3: a
/// non-empty header story ends with a paragraph mark that only separates it from the next story
/// and is not part of its contents, so a one-line header is stored as `text\r\r`. Laid out, the
/// guard would add an empty paragraph to every header and footer and push the body down when
/// the header reaches past the top margin (#5922).
///
/// Only an empty last paragraph after an ordinary one is dropped: a story that is nothing but a
/// mark stays a part (an empty header, not one linked to the previous section), and a mark right
/// after a table row stays the paragraph that closes the table.
fn header_story_blocks(
ctx: &WalkCtx<'_>,
a: u32,
b: u32,
pics: &mut media::Pictures,
max_paragraphs: usize,
) -> Result<wordcraft_doc::Blocks, DocbinError> {
let mut paras = walk(ctx, a, b, pics);
if ctx.paragraphs.exceeded.get() {
return Err(paragraph_limit(max_paragraphs));
}
let outside_table = |p: &table::ParaOut| p.row.as_ref().is_none_or(|r| !r.in_table && !r.ttp && !r.cell_mark);
if let [.., prev, last] = paras.as_slice() {
let guard = outside_table(last) && last.para.text.is_empty() && last.para.objects.is_empty() && last.para.section.is_none();
if guard && outside_table(prev) {
paras.pop();
}
}
Ok(table::assemble(paras))
}
/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len`
/// CPs, as absolute CPs clamped to that subdocument; `None` when empty.
fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> {
+52
View File
@@ -1157,6 +1157,58 @@ fn header_story_becomes_part() {
assert!(part.blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains("Header text"))));
}
/// The odd header's blocks for a one-section document whose header story is `story`, with
/// `papx_entries` over the whole stream (the header story starts at CP 5, after `body\r`):
/// paragraph texts, a table as `<table>`; `None` when the slot has no part.
fn odd_header_blocks(story: &'static str, papx_entries: Vec<(u32, u16, Vec<u8>)>) -> Option<Vec<String>> {
let spec = StructSpec {
text: "body\r",
header_stories: vec![story],
sections: vec![(5, Vec::new())],
chpx_runs: vec![(0, Vec::new())],
papx_entries,
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
let id = doc.last_section.headers.default?;
let part = doc.parts.get(&id).expect("part");
Some(part.blocks.iter().map(|b| b.as_para().map_or_else(|| "<table>".to_string(), |p| p.text.clone())).collect())
}
/// #5922: [MS-DOC] 2.3.3 ends every non-empty header story with a guard paragraph mark that
/// is not part of its contents. Read as a paragraph, it put an empty line under every header
/// and pushed the first body line down when the header reached past the top margin.
#[test]
fn header_story_guard_mark_is_not_a_paragraph() {
let plain = || vec![(0, 0, Vec::new())];
// One line typed into the header: content "Header\r" + guard.
assert_eq!(odd_header_blocks("Header\r\r", plain()), Some(vec!["Header".to_string()]));
// Enter pressed after it: the empty paragraph is content and stays, only the guard goes.
assert_eq!(odd_header_blocks("Header\r\r\r", plain()), Some(vec!["Header".to_string(), String::new()]));
// An empty header (one empty content paragraph + guard) is still a header of its own.
assert_eq!(odd_header_blocks("\r\r", plain()), Some(vec![String::new()]));
// A story that is only a mark (no guard) keeps its paragraph and its part.
assert_eq!(odd_header_blocks("\r", plain()), Some(vec![String::new()]));
// A story without a guard keeps its last paragraph.
assert_eq!(odd_header_blocks("Header\r", plain()), Some(vec!["Header".to_string()]));
}
/// #5922: a mark right after a table row in a header story is kept: it is the paragraph that
/// closes the table, not a guard to drop.
#[test]
fn header_story_mark_after_a_table_row_is_kept() {
let in_tbl = grpprl(&[(0x2416, &[0x01])]);
let ttp: Vec<u8> = [grpprl(&[(0x2416, &[0x01]), (0x2417, &[0x01])]), t_def_table(&[0, 2880])].concat();
// Stream: "body\r" (CPs 0-4), then the header story "a\x07" (5-6), row end "\x07" (7), "\r" (8).
let papx = vec![(0, 0, Vec::new()), (5, 0, in_tbl), (7, 0, ttp), (8, 0, Vec::new())];
assert_eq!(odd_header_blocks("a\u{7}\u{7}\r", papx), Some(vec!["<table>".to_string(), String::new()]));
}
/// A `sprmTDefTable` Prl for `edges` twip boundaries and TC80s of 20 bytes per column,
/// built per the spec (cb counts the bytes after it, plus one).
fn t_def_table(edges: &[i16]) -> Vec<u8> {
+42 -15
View File
@@ -1,3 +1,5 @@
use wordcraft_doc::math::MathJc;
use super::{
CharProps, El, InlineObject, Item, MAX_FORM, MAX_INSTR, NoteKind, PB, PTabAlign, PTabBase, Phase, Reader, Rels, RevisionKind, RunCtx, StoryCtx,
TextColor, children_of_choice, clean_text, int, measure, on_off_attr, placeholder_of, sink,
@@ -13,6 +15,7 @@ impl Reader<'_> {
}
}
/// Read an inline child element (`w:r`, `m:oMath`, `m:oMathPara`, `w:ins`, etc.).
fn read_inline(&mut self, sc: &mut StoryCtx, pb: &mut PB, k: &El, rels: &Rels, ctx: &RunCtx, depth: usize) {
match k.name.as_str() {
"w:r" => self.read_run(sc, pb, k, rels, ctx, depth),
@@ -76,25 +79,38 @@ impl Reader<'_> {
}
"w:smartTag" | "w:customXml" | "w:dir" | "w:bdo" => self.read_inline_children(sc, pb, k, rels, ctx, depth + 1),
"m:oMath" => {
let mut props = self.run_props_none(ctx);
let mut math = crate::read::math::read_omath(k, Default::default());
if let Some(c) = crate::read::math::lift_color(&mut math) {
props.color = Some(TextColor::Rgb(c));
}
let linear = wordcraft_doc::math::to_linear(&math.nodes);
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: false, math }, &props);
let props = self.run_props_none(ctx);
self.emit_omath(sc, pb, k, Default::default(), false, &props);
}
"m:oMathPara" => {
let props = self.run_props_none(ctx);
let base_props = self.run_props_none(ctx);
let jc = crate::read::math::para_jc(k);
for m in k.children("m:oMath") {
let mut math = crate::read::math::read_omath(m, jc);
let mut props = props.clone();
if let Some(c) = crate::read::math::lift_color(&mut math) {
props.color = Some(TextColor::Rgb(c));
for c in k.els() {
match c.name.as_str() {
"m:oMath" => self.emit_omath(sc, pb, c, jc, true, &base_props),
"w:ins" | "w:moveTo" => {
let mut sub_ctx = ctx.clone();
sub_ctx.ins =
Some(if c.name == "w:moveTo" { self.move_revision(true, c, false) } else { self.revision(RevisionKind::Insert, c) });
let sub_props = self.run_props_none(&sub_ctx);
for m in c.children("m:oMath") {
self.emit_omath(sc, pb, m, jc, true, &sub_props);
}
}
"w:del" | "w:moveFrom" => {
let mut sub_ctx = ctx.clone();
sub_ctx.del = Some(if c.name == "w:moveFrom" {
self.move_revision(false, c, false)
} else {
self.revision(RevisionKind::Delete, c)
});
let sub_props = self.run_props_none(&sub_ctx);
for m in c.children("m:oMath") {
self.emit_omath(sc, pb, m, jc, true, &sub_props);
}
}
_ => {}
}
let linear = wordcraft_doc::math::to_linear(&math.nodes);
self.emit_obj(sc, pb, InlineObject::Equation { linear, display: true, math }, &props);
}
}
"mc:AlternateContent" => {
@@ -384,6 +400,17 @@ impl Reader<'_> {
}
}
/// Read an `m:oMath` element and emit its [`InlineObject::Equation`].
fn emit_omath(&mut self, sc: &mut StoryCtx, pb: &mut PB, m: &El, jc: MathJc, display: bool, props: &CharProps) {
let mut math = crate::read::math::read_omath(m, jc);
let mut props = props.clone();
if let Some(color) = crate::read::math::lift_color(&mut math) {
props.color = Some(TextColor::Rgb(color));
}
let linear = wordcraft_doc::math::to_linear(&math.nodes);
self.emit_obj(sc, pb, InlineObject::Equation { linear, display, math }, &props);
}
pub(super) fn emit_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps) {
match sink(sc) {
Some(f) => {
+3
View File
@@ -4,6 +4,7 @@ use super::section::comment_reference_style;
use super::{CharProps, Fill, InlineObject, NoteKind, PartRels, TextColor, W, Wrap, Writer, emu, n, rpr};
impl Writer<'_> {
/// Write an inline object (`Bookmark`, `Comment`, `Equation`, `Field`, `Shape`, etc.).
pub(super) fn object(&mut self, w: &mut W, o: &InlineObject, props: &CharProps, rels: &mut PartRels, depth: usize) {
match o {
// Written by `para_content` (inline) or `blocks` (around paragraphs).
@@ -51,10 +52,12 @@ impl Writer<'_> {
Some(TextColor::Rgb(c)) => Some(c),
_ => None,
};
self.rev_open(w, props);
match crate::write::math::resolve_numbers(math, *display, &mut self.eq_number) {
Some(m) => crate::write::math::write_equation(w, linear, *display, &m, color),
None => crate::write::math::write_equation(w, linear, *display, math, color),
}
self.rev_close(w, props);
}
InlineObject::Field { instr, result, locked, dirty, form, .. } => {
self.field(w, FieldData { instr, result, locked: *locked, dirty: *dirty, form }, props)
+31
View File
@@ -0,0 +1,31 @@
//! Line numbering (`w:lnNumType`, ECMA-376 Part 1 §17.6.8) and the built-in "line number"
//! character style that formats the numbers (#6018).
use std::sync::Arc;
use wordcraft_doc::section::{Columns, LineNumberRestart, LineNumbering};
use wordcraft_doc::{CharProps, Document, Rgb, Style, StyleKind, TextColor};
/// Every line-numbering option and a localised "line number" style (German Word calls its id
/// `Zeilennummer`) survive a save and reopen, beside two columns.
#[test]
fn line_numbering_options_and_style_round_trip() {
let mut d = Document::from_text("one\ntwo\nthree");
let numbering = LineNumbering { count_by: 5, start: 3, distance: 21.6, restart: LineNumberRestart::Continuous };
d.last_section.line_numbers = Some(numbering.clone());
d.last_section.columns = Columns { count: 2, space: 36.0, separator: false, widths: Vec::new() };
let red = TextColor::Rgb(Rgb(0xC0, 0, 0));
Arc::make_mut(&mut d.styles).upsert(Style {
id: "Zeilennummer".into(),
name: "Line Number".into(),
kind: StyleKind::Character,
chr: CharProps { size: Some(8.0), color: Some(red), italic: Some(true), ..Default::default() },
..Default::default()
});
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(back.last_section.line_numbers, Some(numbering));
assert_eq!(back.last_section.columns.count, 2);
let st = back.styles.find("line number").expect("the line number style");
assert_eq!(st.id, "Zeilennummer");
assert_eq!((st.chr.size, st.chr.color, st.chr.italic), (Some(8.0), Some(red), Some(true)));
}
+1
View File
@@ -20,6 +20,7 @@ mod glossary;
mod heading_levels;
mod legacy_forms;
mod ligatures;
mod line_numbering;
mod malformed;
mod missing_pictures;
mod nested_block_controls;
+74
View File
@@ -994,6 +994,80 @@ fn tracked_changes_round_trip() {
assert!(r.settings.track_changes);
}
#[test]
fn tracked_equations_round_trip() {
// Issue #6011: equations saved with tracked revision marks (w:ins / w:del) around math.
let mut d = Document::new();
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Alice".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None });
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Bob".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
d.settings.track_changes = true;
let ins = CharProps { ins: Some(0), ..Default::default() };
let del = CharProps { del: Some(1), ..Default::default() };
let plain = CharProps::default();
let mut p = Paragraph::with_text("Formula: ", CharProps::default());
let end = p.len();
p.insert_object(end, InlineObject::Equation { linear: "x+1".into(), display: false, math: Default::default() }, &ins).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::Equation { linear: "E=mc^2".into(), display: true, math: Default::default() }, &del).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::Equation { linear: "a^2+b^2=c^2".into(), display: true, math: Default::default() }, &ins).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::Equation { linear: "y=mx+b".into(), display: false, math: Default::default() }, &del).unwrap();
let end = p.len();
p.insert_object(end, InlineObject::Equation { linear: "z=1".into(), display: false, math: Default::default() }, &plain).unwrap();
d.body = vec![para_block(p.clone())];
let bytes = wordcraft_docx::write(&d).expect("write");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(r#"<w:ins w:id="1" w:author="Alice" w:date="2026-05-01T10:00:00Z"><m:oMath>"#), "ins directly encloses inline math: {xml}");
assert!(
xml.contains(r#"<w:del w:id="2" w:author="Bob" w:date="2026-05-02T10:00:00Z"><m:oMathPara>"#),
"del directly encloses display math: {xml}"
);
assert!(
xml.contains(r#"<w:ins w:id="3" w:author="Alice" w:date="2026-05-01T10:00:00Z"><m:oMathPara>"#),
"ins directly encloses display math: {xml}"
);
assert!(xml.contains(r#"<w:del w:id="4" w:author="Bob" w:date="2026-05-02T10:00:00Z"><m:oMath>"#), "del directly encloses inline math: {xml}");
assert!(xml.contains(r#"</w:del><m:oMath>"#), "plain equation follows closing tag without revision wrapper: {xml}");
let r = wordcraft_docx::read(&bytes).expect("read");
assert_eq!(r.revisions, d.revisions);
let got = &paras(&r)[0];
assert_eq!(got.objects.len(), 5);
let eq_summary: Vec<(bool, String)> = got
.objects
.iter()
.map(|o| match o {
InlineObject::Equation { display, linear, .. } => (*display, linear.clone()),
_ => panic!("expected equation object, got {o:?}"),
})
.collect();
assert_eq!(
eq_summary,
vec![(false, "x+1".into()), (true, "E=mc^2".into()), (true, "a^2+b^2=c^2".into()), (false, "y=mx+b".into()), (false, "z=1".into()),]
);
assert_eq!(got.runs, p.runs);
}
#[test]
fn nested_omath_para_revisions_read() {
// Defensive reader check: w:ins and w:del nested inside m:oMathPara.
let mut d = Document::new();
d.revisions.push(Revision { kind: RevisionKind::Insert, author: "Ada".into(), date: "2026-05-01T10:00:00Z".into(), move_name: None });
d.revisions.push(Revision { kind: RevisionKind::Delete, author: "Charles".into(), date: "2026-05-02T10:00:00Z".into(), move_name: None });
let doc_xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><w:body><w:p><m:oMathPara><w:ins w:id="1" w:author="Ada" w:date="2026-05-01T10:00:00Z"><m:oMath><m:r><m:t>x+1</m:t></m:r></m:oMath></w:ins><w:del w:id="2" w:author="Charles" w:date="2026-05-02T10:00:00Z"><m:oMath><m:r><m:t>y-1</m:t></m:r></m:oMath></w:del></m:oMathPara></w:p></w:body></w:document>"#;
let base = wordcraft_docx::write(&d).unwrap();
let bytes = replace_part(&base, "word/document.xml", doc_xml.as_bytes());
let r = wordcraft_docx::read(&bytes).expect("read");
let got = &paras(&r)[0];
assert_eq!(got.objects.len(), 2);
assert!(matches!(&got.objects[0], InlineObject::Equation { display: true, linear, .. } if linear == "x+1"));
assert!(matches!(&got.objects[1], InlineObject::Equation { display: true, linear, .. } if linear == "y-1"));
assert_eq!(got.runs[0].props.ins, Some(0));
assert_eq!(got.runs[1].props.del, Some(1));
}
#[test]
fn tracked_paragraph_marks_round_trip() {
// Issue #229: an inserted / deleted paragraph mark is `w:ins` / `w:del` in the mark's
+5
View File
@@ -51,6 +51,11 @@ chrono = { workspace = true, features = ["clock"] }
# O_NONBLOCK, so a path swapped for a named pipe can't block `open` (#3666).
libc = { workspace = true }
# A plain fsync where a file system refuses the full flush (`sync_file`, safe_save.rs).
# Already in Cargo.lock on Apple targets (craft-atomic), so this adds no new crate.
[target.'cfg(target_vendor = "apple")'.dependencies]
rustix = { version = "1.1", default-features = false, features = ["std", "fs"] }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
encoding_rs = { workspace = true }
+12 -4
View File
@@ -347,19 +347,24 @@ pub(crate) struct InsertEffect {
/// Insert typed text at the caret (replacing the selection), with pending formatting and track changes.
pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
insert_text(s, text, true).map(|_| ())
insert_text(s, text, true, None).map(|_| ())
}
/// [`type_text`] with the effect the insertion had.
pub(crate) fn type_text_effect(s: &mut Session, text: &str) -> Result<InsertEffect, CmdError> {
insert_text(s, text, true)
insert_text(s, text, true, None)
}
/// Insert pasted plain text like [`type_text`], except that its paragraph separators are text
/// being transferred, not Enter key presses: an empty list item between them stays (Enter on
/// one would end the list).
pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
insert_text(s, text, false).map(|_| ())
insert_text(s, text, false, None).map(|_| ())
}
/// Insert typed text in a chosen language without losing caret formatting or tracked changes.
pub fn type_text_with_lang(s: &mut Session, text: &str, language: Option<&str>) -> Result<(), CmdError> {
insert_text(s, text, true, language).map(|_| ())
}
/// Inserts typed or pasted text at the caret: resolves field ends and content-control placeholders,
@@ -368,7 +373,7 @@ pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
/// text; a one-line control suppresses typed breaks only. It gives an otherwise undirected paragraph
/// right-to-left direction when its first strong letter is RTL, and returns whether content or state
/// changed.
fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect, CmdError> {
fn insert_text(s: &mut Session, text: &str, enter: bool, language: Option<&str>) -> Result<InsertEffect, CmdError> {
let mut props = s.typing_props();
if s.sel.is_collapsed() {
s.sel = crate::Selection::caret(s.doc.past_field_ends(&s.sel.focus));
@@ -383,6 +388,9 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect,
if !enter {
props = props.without_symbol();
}
if let Some(language) = language {
props.lang = Some(language.to_string());
}
let deletion = delete_selection_effect(s)?;
// Typed text is new: never deleted, and not a formatting change of the text around it.
props.fmt_change = None;
+6 -3
View File
@@ -5,7 +5,7 @@ use wordcraft_doc::para::{Float, InlineObject, ShapeKind, Wrap};
use wordcraft_doc::props::{Align, Border, Borders, CharProps, Rgb, TabAlign, TabStop, TextColor};
use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, Table, para_block};
use super::{delete_selection, sel_result, split_para, type_text};
use super::{delete_selection, sel_result, split_para, type_text, type_text_with_lang};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
mod header_footer;
@@ -15,7 +15,9 @@ mod objects;
pub(crate) use header_footer::saved_copy;
use header_footer::{create_hf, edit_hf, header_footer, new_empty_headers, strip_headers};
pub use items::{DropCapState, drop_cap_state, format_date, format_date_at, format_date_parts};
pub use items::{
DropCapState, default_date_picture, drop_cap_state, format_date, format_date_at, format_date_in, format_date_parts, format_date_parts_in,
};
use items::{cover_page, date_time, drop_cap, drop_cap_off, field, page_number, symbol};
use links::{bookmark, delete_bookmark, link, link_targets};
pub use links::{bookmark_name_ok, bookmark_range, bookmark_span};
@@ -23,6 +25,7 @@ pub use objects::{base64_decode, base64_encode};
use objects::{icon, shape, table, text_box};
pub(crate) use objects::{image_ext, picture};
/// Command specifications for the Insert ribbon tab and menu.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("insert.pageBreak", "Page Break", "Insert › Pages", |s, _| {
@@ -113,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
s.doc.last_section.footers = Default::default();
sel_result(s)
}),
CommandSpec::new("insert.dateTime", "Date & Time", "Insert › Text", date_time).params(r#"{"format"?: "M/d/yyyy", "update"?: bool}"#),
CommandSpec::new("insert.dateTime", "Date & Time", "Insert › Text", date_time).params(r#"{"format"?: "M/d/yyyy", "update"?: bool, "language"?: "en-US|pt-BR"}"#),
CommandSpec::new("insert.symbol", "Symbol", "Insert › Symbols", symbol).params(r#"{"char": string, "font"?: family (the symbol's own font)}"#),
CommandSpec::new("insert.field", "Field", "Insert › Text › Quick Parts", field).key("Mod+F9").params(r#"{"instr": string, "result"?: string}"#),
// Word's Alt+Shift+D / T / P (Control+Shift+… in Word for Mac): DATE, TIME and PAGE fields.
+283 -32
View File
@@ -1,8 +1,72 @@
use super::PartKind;
use super::header_footer::hf_part;
use super::{
Align, Block, CharProps, CmdError, CmdResult, InlineObject, Paragraph, Rgb, Selection, Session, StoryRef, TabAlign, TabStop, TextColor, Value,
delete_selection, json, p, para_block, sel_result, type_text,
delete_selection, json, p, para_block, sel_result, type_text, type_text_with_lang,
};
use std::collections::BTreeSet;
fn story_has_page_field(doc: &wordcraft_doc::Document, story: StoryRef, except: Option<usize>) -> bool {
let Some(blocks) = doc.story(story) else { return false };
let mut seen = BTreeSet::from([story]);
let mut pending: Vec<&Block> = blocks.iter().enumerate().filter(|(i, _)| Some(*i) != except).map(|(_, b)| &**b).collect();
while let Some(block) = pending.pop() {
let para = match block {
Block::Para(para) => para,
Block::Table(table) => {
pending.extend(table.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).map(|b| &**b));
continue;
}
};
for object in &para.objects {
if matches!(object, InlineObject::Field { instr, .. } | InlineObject::FieldStart { instr, .. } if instr.split_whitespace().next().is_some_and(|name| name.eq_ignore_ascii_case("PAGE")))
{
return true;
}
for id in object.text_boxes() {
if let Some(part) = doc.parts.get(&id).filter(|part| part.kind == PartKind::TextBox)
&& seen.insert(StoryRef::Part(id))
{
pending.extend(part.blocks.iter().map(|b| &**b));
}
}
}
}
false
}
fn page_number_paragraph(style: &str, align: Align, x_of_y: bool) -> Result<Paragraph, CmdError> {
let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() };
let mut para = Paragraph::new().styled(style);
para.props.align = Some(align);
para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]);
if x_of_y {
para.insert_text(0, "Page of ", &CharProps::default())?;
para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?;
let end = para.len();
para.insert_object(
end,
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps { bold: Some(true), ..Default::default() },
)?;
} else {
para.insert_object(0, field, &CharProps::default())?;
}
Ok(para)
}
// Only cached display text is transferable; instructions, flags and formatting remain part of ownership.
fn copy_page_number_results(to: &mut Paragraph, from: &Paragraph) {
for object in &mut to.objects {
let InlineObject::Field { instr, result, .. } = object else { continue };
if let Some(cached) = from.objects.iter().find_map(|object| match object {
InlineObject::Field { instr: source, result, .. } if source.as_str() == instr.as_str() => Some(result),
_ => None,
}) {
result.clone_from(cached);
}
}
}
pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {
let position = p::str(v, "position").unwrap_or("bottom");
@@ -22,30 +86,86 @@ pub(super) fn page_number(s: &mut Session, v: &Value) -> CmdResult {
}
let header = position == "top";
let id = hf_part(s, header);
let mut para = Paragraph::new().styled(if header { "Header" } else { "Footer" });
para.props.align = Some(align);
para.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Clear, leader: Default::default() }]);
if x_of_y {
para.insert_text(0, "Page of ", &CharProps::default())?;
para.insert_object(5, field, &CharProps { bold: Some(true), ..Default::default() })?;
let end = para.len();
para.insert_object(
end,
InlineObject::Field { instr: "NUMPAGES".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps { bold: Some(true), ..Default::default() },
)?;
} else {
para.insert_object(0, field, &CharProps::default())?;
let story = StoryRef::Part(id);
let style = if header { "Header" } else { "Footer" };
let mut para = page_number_paragraph(style, align, x_of_y)?;
// Replace only a top-level paragraph matching our generated structure. User text,
// field switches/metadata and paragraph/run formatting must not be mistaken for a gallery number.
// Cached field results and paragraph-mark formatting survive gallery changes and identical requests.
if story_has_page_field(&s.doc, story, None) {
let generated = s.doc.story(story).and_then(|blocks| {
blocks.iter().enumerate().find_map(|(i, block)| {
let Block::Para(existing) = &**block else { return None };
let Some(align @ (Align::Left | Align::Center | Align::Right)) = existing.props.align else { return None };
[false, true]
.into_iter()
.any(|x_of_y| {
page_number_paragraph(style, align, x_of_y).is_ok_and(|mut p| {
if p.objects.len() != existing.objects.len() {
return false;
}
p.mark = existing.mark.clone();
copy_page_number_results(&mut p, existing);
*existing == p
})
})
.then_some((i, existing))
})
});
// More than one PAGE makes ownership ambiguous, including in tables or anchored text
// boxes. The traversal visits each story once so cycles cannot hang insertion.
if let Some((i, existing)) = generated
&& !story_has_page_field(&s.doc, story, Some(i))
{
para.mark = existing.mark.clone();
copy_page_number_results(&mut para, existing);
let replacement = para_block(para);
if s.doc.story(story).and_then(|blocks| blocks.get(i)) != Some(&replacement) {
let block = s.doc.story_mut(story)?.get_mut(i).ok_or_else(|| CmdError::Failed("page-number paragraph is missing".into()))?;
*block = replacement;
}
}
return Ok(json!({"story": id}));
}
s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?;
let blocks = s.doc.story_mut(story)?;
// A DOCX round trip can add paragraph-mark formatting to the otherwise untouched
// Header/Footer placeholder. Reuse it without losing that formatting or adding a blank line.
if let [block] = blocks.as_slice()
&& let Block::Para(existing) = &**block
{
let mut placeholder = Paragraph::new().styled(style);
placeholder.mark = existing.mark.clone();
if *existing == placeholder {
para.mark = existing.mark.clone();
blocks.clear();
}
}
blocks.push(para_block(para));
Ok(json!({"story": id}))
}
/// Insert date and time at the caret or selection, formatted with a Word picture or default format.
pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {
let fmt = p::str(v, "format").unwrap_or("M/d/yyyy");
let text = format_date(fmt);
let language = p::str(v, "language");
if language.is_some_and(|l| !matches!(l, "en-US" | "pt-BR")) {
return Err(CmdError::Params("language must be en-US or pt-BR".into()));
}
let mut props = s.typing_props();
if let Some(lang) = language {
props.lang = Some(lang.to_string());
}
// Resolve the same run language used by refresh_date_fields on subsequent updates.
// Resolving language from the selection start ordered().0 is intentional for the fixed-text path
// so the typed text takes the character/paragraph styling where the insertion begins.
let effective_language = s.doc.para_at(&s.sel.ordered().0).and_then(|p| s.doc.styles.resolve_char(p.props.style.as_deref(), &props).lang);
// A missing picture follows the selected or inherited language, just like a DATE field
// without a \\@ switch; an explicitly supplied picture is never overridden.
let fmt = p::str(v, "format").unwrap_or_else(|| default_date_picture(effective_language.as_deref()));
if fmt.is_empty() || fmt.len() > 120 || fmt.contains('"') || fmt.contains('\\') {
return Err(CmdError::Params("invalid date picture".into()));
}
let text = format_date_in(fmt, effective_language.as_deref());
if p::bool(v, "update").unwrap_or(false) {
let props = s.typing_props();
let at = delete_selection(s)?;
let end = s.doc.insert_object(
&at,
@@ -54,32 +174,87 @@ pub(super) fn date_time(s: &mut Session, v: &Value) -> CmdResult {
)?;
s.sel = Selection::caret(end);
} else {
type_text(s, &text)?;
type_text_with_lang(s, &text, language)?;
}
sel_result(s)
}
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…).
/// Format today's date with a Word picture (`M/d/yyyy`, `MMMM d, yyyy`, `yyyy-MM-dd`, `dddd`…),
/// in English.
pub fn format_date(fmt: &str) -> String {
// Word shows the computer's local date and time (#1707), not UTC's.
format_date_in(fmt, None)
}
/// Format today's date with a Word picture, month and weekday names in the language `lang` (a
/// tag such as `pt-BR`; English when `None` or a language without names here).
pub fn format_date_in(fmt: &str, lang: Option<&str>) -> String {
let now = crate::cmd::now_unix();
format_date_at(fmt, now, crate::cmd::local_offset_secs(now))
format_date_at_in(fmt, lang, now, crate::cmd::local_offset_secs(now))
}
/// Format the instant `unix` (seconds since the epoch, UTC) with a Word picture, in the zone
/// `offset_secs` east of UTC.
pub fn format_date_at(fmt: &str, unix: u64, offset_secs: i64) -> String {
format_date_at_in(fmt, None, unix, offset_secs)
}
/// Format the instant `unix` (seconds since the epoch, UTC) with a Word picture, in the zone
/// `offset_secs` east of UTC, with language `lang`.
pub fn format_date_at_in(fmt: &str, lang: Option<&str>, unix: u64, offset_secs: i64) -> String {
let (y, m, d, hms, days) = crate::cmd::local_civil(unix, offset_secs);
format_date_parts(fmt, y, m as usize, d, hms, days)
format_date_parts_in(fmt, lang, y, m as usize, d, hms, days)
}
/// Month and weekday names of a language, as Word writes them in date fields: full names, the
/// three-letter forms (`MMM`, `ddd`) being their first three letters, without a full stop.
/// Weekdays start on Thursday (1970-01-01).
struct DateNames {
/// Full month names (January through December).
months: [&'static str; 12],
/// Weekday names starting on Thursday (1970-01-01).
days: [&'static str; 7],
/// The picture of a `DATE` field without `\@`.
default: &'static str,
}
const ENGLISH: DateNames = DateNames {
months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
days: ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"],
default: "M/d/yyyy",
};
/// Portuguese (observed in Word for Mac with a pt-BR document): lower-case names, `MMM` = `out`,
/// `ddd` = `qui`, and `dd/MM/yyyy` for a field without a picture.
const PORTUGUESE: DateNames = DateNames {
months: ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"],
days: ["quinta-feira", "sexta-feira", "sábado", "domingo", "segunda-feira", "terça-feira", "quarta-feira"],
default: "dd/MM/yyyy",
};
/// Choose month and weekday names according to the primary language subtag.
fn date_names(lang: Option<&str>) -> &'static DateNames {
let primary = lang.and_then(|l| l.trim().split(['-', '_']).next()).unwrap_or("");
if primary.eq_ignore_ascii_case("pt") { &PORTUGUESE } else { &ENGLISH }
}
/// The picture Word gives a `DATE` field that has no `\@` switch, in the field's language.
pub fn default_date_picture(lang: Option<&str>) -> &'static str {
date_names(lang).default
}
/// Format a date and time with a Word picture; `days_since` (days since 1970-01-01) gives the weekday.
pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32, u32, u32), days_since: i64) -> String {
const MONTHS: [&str; 12] =
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const DAYS: [&str; 7] = ["Thursday", "Friday", "Saturday", "Sunday", "Monday", "Tuesday", "Wednesday"];
let month = MONTHS.get(m.saturating_sub(1)).copied().unwrap_or("January");
let day = DAYS.get(days_since.rem_euclid(7) as usize).copied().unwrap_or("Monday");
pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, hms: (u32, u32, u32), days_since: i64) -> String {
format_date_parts_in(fmt, None, y, m, d, hms, days_since)
}
/// [`format_date_parts`] with month and weekday names in `lang`. Text between single quotes is
/// copied as it is (`'de'`), while doubled apostrophes emit one literal quote anywhere.
#[allow(clippy::too_many_arguments)]
pub fn format_date_parts_in(fmt: &str, lang: Option<&str>, y: i64, m: usize, d: u32, (hh, mm, ss): (u32, u32, u32), days_since: i64) -> String {
let names = date_names(lang);
let month = names.months.get(m.saturating_sub(1)).copied().unwrap_or(names.months[0]);
let day = names.days.get(days_since.rem_euclid(7) as usize).copied().unwrap_or(names.days[0]);
let three = |s: &str| -> String { s.chars().take(3).collect() };
let mut out = String::new();
let chars: Vec<char> = fmt.chars().collect();
let mut i = 0;
@@ -121,11 +296,11 @@ pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32
('y' | 'Y', 4..) => out.push_str(&format!("{y:04}")),
('y' | 'Y', _) => out.push_str(&format!("{:02}", y % 100)),
('M', 4..) => out.push_str(month),
('M', 3) => out.push_str(month.get(..3).unwrap_or(month)),
('M', 3) => out.push_str(&three(month)),
('M', 2) => out.push_str(&format!("{m:02}")),
('M', 1) => out.push_str(&m.to_string()),
('d' | 'D', 4..) => out.push_str(day),
('d' | 'D', 3) => out.push_str(day.get(..3).unwrap_or(day)),
('d' | 'D', 3) => out.push_str(&three(day)),
('d' | 'D', 2) => out.push_str(&format!("{d:02}")),
('d' | 'D', 1) => out.push_str(&d.to_string()),
('H', 2..) => out.push_str(&format!("{hh:02}")),
@@ -337,3 +512,79 @@ pub fn drop_cap_state(s: &Session) -> DropCapState {
text_font,
}
}
#[cfg(test)]
mod date_time_locale_tests {
use super::*;
/// A DATE field without an explicit language initially uses the paragraph's and document's
/// inherited pt-BR language, just as the subsequent refresh will.
#[test]
fn date_field_inherits_document_default_locale_on_first_render() {
let mut s = Session::new(wordcraft_doc::Document::new());
std::sync::Arc::make_mut(&mut s.doc.styles).default_chr.lang = Some("pt-BR".into());
s.run(
"insert.dateTime",
&json!({
"format": "d 'de' MMMM 'de' yyyy", "update": true
}),
)
.unwrap();
let path = s.doc.para_paths(StoryRef::Body).remove(0);
let p = s.doc.para(StoryRef::Body, &path).unwrap();
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!("expected DATE field") };
let expected = format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR"));
assert_eq!(*result, expected);
assert!(result.contains(" de "));
let before = result.clone();
crate::cmd::references::refresh_date_fields(&mut s.doc);
let p = s.doc.para(StoryRef::Body, &path).unwrap();
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!("expected DATE field") };
assert_eq!(*result, before, "refresh must not switch languages");
}
/// Fixed date text uses the selected pt-BR editing language, without dropping the
/// caret's other character formatting or making the insertion refreshable.
#[test]
fn fixed_date_text_keeps_selected_language() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run(
"insert.dateTime",
&json!({
"format": "d 'de' MMMM 'de' yyyy", "language": "pt-BR", "update": false
}),
)
.unwrap();
let path = s.doc.para_paths(StoryRef::Body).remove(0);
let p = s.doc.para(StoryRef::Body, &path).unwrap();
assert_eq!(p.props_of_char(0).lang.as_deref(), Some("pt-BR"));
assert_eq!(p.plain_text(), format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR")));
assert!(p.objects.iter().all(|o| !matches!(o, InlineObject::Field { .. })));
}
/// Word's doubled apostrophe escapes one literal quote both before and inside a quoted run.
/// The existing three-character abbreviations must preserve accented Portuguese characters.
#[test]
fn word_date_picture_doubled_apostrophes_and_accented_abbreviations() {
let format = |pic, day| format_date_parts_in(pic, Some("pt-BR"), 2026, 10, day, (12, 0, 0), 2);
assert_eq!(format("d''MM", 20), "20'10");
assert_eq!(format("d 'd''água' MMMM", 20), "20 d'água outubro");
assert_eq!(format("ddd", 20), "sáb");
assert_eq!(format("MMM", 20), "out");
assert_eq!(format("MMMM", 20), "outubro");
}
/// A pt-BR dynamic field keeps its result after DOCX round-trip and refresh.
#[test]
fn pt_br_date_time_dialog_inserts_dynamic_field() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.dateTime", &json!({"format": "d 'de' MMMM 'de' yyyy", "language": "pt-BR", "update": true})).unwrap();
let bytes = wordcraft_docx::write(&s.doc).unwrap();
let mut doc = wordcraft_docx::read(&bytes).unwrap();
crate::cmd::references::refresh_date_fields(&mut doc);
let paths = doc.para_paths(StoryRef::Body);
let p = doc.para(StoryRef::Body, &paths[0]).unwrap();
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!("expected field") };
assert_eq!(result, &format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR")));
}
}
+16 -4
View File
@@ -378,8 +378,9 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
let name = wordcraft_layout::fields::field_name(instr);
match name.as_str() {
"DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => {
let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
updates.push((k, text_result(instr, crate::cmd::insert::format_date(&pic))));
let lang = field_lang(&s.doc, p, k);
let pic = date_picture(instr).unwrap_or_else(|| default_picture(&name, lang.as_deref()));
updates.push((k, text_result(instr, crate::cmd::insert::format_date_in(&pic, lang.as_deref()))));
}
"REF" | "PAGEREF" => {
let f = FieldCode::parse(instr);
@@ -505,8 +506,9 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
continue;
}
// Word's default picture when the code carries no `\@` switch.
let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
updates.push((k, text_result(instr, crate::cmd::insert::format_date(&pic))));
let lang = field_lang(doc, p, k);
let pic = date_picture(instr).unwrap_or_else(|| default_picture(&name, lang.as_deref()));
updates.push((k, text_result(instr, crate::cmd::insert::format_date_in(&pic, lang.as_deref()))));
}
if updates.is_empty() {
continue;
@@ -522,6 +524,16 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
}
}
/// Resolve a field object's language through the paragraph and document defaults.
fn field_lang(doc: &wordcraft_doc::Document, p: &wordcraft_doc::Paragraph, k: usize) -> Option<String> {
let off = p.object_offsets().get(k).copied()?;
doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(off)).lang
}
/// Word's default date or time picture when a field instruction carries no `\@` picture switch.
fn default_picture(name: &str, lang: Option<&str>) -> String {
if name == "TIME" { "h:mm am/pm".into() } else { crate::cmd::insert::default_date_picture(lang).into() }
}
/// Most names SET fields give values.
const MAX_SET: usize = 10_000;
+140 -53
View File
@@ -5,7 +5,7 @@ use super::{
use wordcraft_doc::para::OBJ;
/// Compare and Combine take the same settings.
pub(super) const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character", "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#;
pub(super) const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character" (character: paragraphs over 2000 characters fall back to word; see characterFallback/notHonoured), "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#;
/// What Compare / Combine compares (the More >> settings of Word's dialog).
struct CompareOptions {
@@ -27,6 +27,15 @@ impl CompareOptions {
}
}
/// Context and tracking counters for diffing paragraph contents.
struct DiffCtx<'a> {
ins: u32,
del: u32,
opts: &'a CompareOptions,
budget: &'a mut u64,
char_fallbacks: &'a mut usize,
}
/// Changed paragraphs longer than this (in characters) are compared word by word even at
/// character level, so the comparison table stays small.
const MAX_CHAR_DIFF: usize = 2000;
@@ -35,6 +44,9 @@ const MAX_CHAR_DIFF: usize = 2000;
/// result shows the differences as tracked insertions, deletions and formatting changes, labelled
/// with `author`. Options Word offers that the comparison can't honour yet (moves, comments,
/// headers and footers, footnotes, text boxes, fields) are accepted and listed in the result.
/// When character-level comparison is requested (`"level": "character"`) but any paragraph exceeds
/// [`MAX_CHAR_DIFF`] characters, it falls back to word-level diff for that paragraph, surfaces the
/// fallback count in `"characterFallback"`, and includes `"level"` in `"notHonoured"`.
pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
let revised = if let Some(t) = p::str(v, "text") {
Document::from_text(t)
@@ -61,7 +73,7 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
formatting: p::bool(v, "formatting").unwrap_or(true),
characters: matches!(level, "character" | "char"),
};
let not_honoured: Vec<&str> =
let mut not_honoured: Vec<&str> =
["moves", "comments", "headersFooters", "footnotes", "textBoxes", "fields"].into_iter().filter(|k| v.get(*k).is_some()).collect();
let author = p::str(v, "author").map(str::trim).filter(|a| !a.is_empty()).map(str::to_string).unwrap_or_else(|| s.author.clone());
let date = crate::cmd::now_iso();
@@ -83,7 +95,9 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
Block::Table(_) => format!("\u{0}table\u{0}{}", opts.key(&block_text(b, 0))),
};
let mut budget = MAX_COMPARE_CELLS;
let mut char_fallbacks = 0usize;
let ops = lcs_ops(&old.iter().map(key).collect::<Vec<_>>(), &new.iter().map(key).collect::<Vec<_>>(), &mut budget);
let mut ctx = DiffCtx { ins, del, opts: &opts, budget: &mut budget, char_fallbacks: &mut char_fallbacks };
let mut out: Vec<Arc<Block>> = Vec::new();
let (mut i, mut j) = (0usize, 0usize);
let mut changes = 0usize;
@@ -97,7 +111,7 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
if !same_objects(&s.doc, pa, pb) {
// Same text, but objects (text boxes, pictures…) differ: diffed, so the
// original's objects aren't lost.
out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget)));
out.push(para_block(word_diff(&s.doc, pa, pb, &mut ctx)));
changes += 1;
i += 1;
j += 1;
@@ -129,9 +143,7 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
for x in 0..dels.max(inss) {
let (a, b) = (if x < dels { old.get(i + x) } else { None }, if x < inss { new.get(j + x) } else { None });
match (a.map(|x| &**x), b.map(|x| &**x)) {
(Some(Block::Para(pa)), Some(Block::Para(pb))) => {
out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget)))
}
(Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(&s.doc, pa, pb, &mut ctx))),
_ => {
if let Some(x) = a {
out.push(Arc::new(mark_block(x, &|c| c.del = Some(del), 0)));
@@ -162,8 +174,21 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
// Compare creates an untitled result from the current document.
s.clear_versions();
}
if char_fallbacks > 0 {
log::warn!(
"compare: {char_fallbacks} paragraph(s) exceeded character diff limit of {MAX_CHAR_DIFF}; fell back from character to word level diff"
);
if !not_honoured.contains(&"level") {
not_honoured.push("level");
}
}
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
Ok(json!({"changes": changes, "author": author, "notHonoured": not_honoured}))
Ok(json!({
"changes": changes,
"author": author,
"notHonoured": not_honoured,
"characterFallback": char_fallbacks,
}))
}
/// A block's text (a table's cells in order), for matching blocks.
@@ -177,13 +202,14 @@ fn block_text(b: &Block, depth: usize) -> String {
}
}
/// A copy of the block with every run (in tables, every cell's) changed by `f`.
/// Returns a copy of `b` with all text runs and paragraph marks (or nested table cells) updated by `f`.
fn mark_block(b: &Block, f: &dyn Fn(&mut CharProps), depth: usize) -> Block {
match b {
Block::Para(p) => {
let mut p = p.clone();
let len = p.len();
let _ = p.format(0, len, f);
f(&mut p.mark);
Block::Para(p)
}
Block::Table(t) => {
@@ -205,59 +231,96 @@ pub(super) enum Op {
Ins,
}
/// Longest sequence the comparison table is built for; the rest of a longer sequence is
/// compared as deleted and inserted, never dropped.
/// Longest sequence window per LCS comparison table chunk; sequences longer than this are
/// aligned in successive chunked windows rather than truncated, preserving tail alignment.
const MAX_LCS: usize = 4000;
/// Comparison table cells one compare may fill over all its tables (blocks and every changed
/// paragraph pair); once spent, the remaining middles are compared as deleted and inserted.
const MAX_COMPARE_CELLS: u64 = 50_000_000;
/// LCS edit script between two sequences: the common start and end are matched first, then the
/// middle (bounded size). The middle's table is charged to `budget`; when it doesn't fit, the
/// middle is deleted and inserted whole.
/// Computes the longest common subsequence (LCS) edit script (`Op::Same`, `Op::Del`, `Op::Ins`)
/// between sequences `a` and `b`.
///
/// Strips common prefix and suffix first, then iterates over windows capped at [`MAX_LCS`],
/// charging comparison cells against `budget`. Between windows, intermediate common prefixes
/// are trimmed to re-anchor alignment without table allocations. When the remaining cells
/// exceed `budget`, the unaligned tail degrades honestly as whole deletions and insertions.
pub(super) fn lcs_ops<T: PartialEq>(a: &[T], b: &[T], budget: &mut u64) -> Vec<Op> {
let pre = a.iter().zip(b).take_while(|(x, y)| x == y).count();
let (ra, rb) = (a.get(pre..).unwrap_or_default(), b.get(pre..).unwrap_or_default());
let suf = ra.iter().rev().zip(rb.iter().rev()).take_while(|(x, y)| x == y).count();
let (a, b) = (ra.get(..ra.len() - suf).unwrap_or_default(), rb.get(..rb.len() - suf).unwrap_or_default());
let mut rem_a = ra.get(..ra.len().saturating_sub(suf)).unwrap_or_default();
let mut rem_b = rb.get(..rb.len().saturating_sub(suf)).unwrap_or_default();
let mut ops = vec![Op::Same; pre];
let (mut n, mut m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS));
let cells = (n as u64).saturating_mul(m as u64);
if cells > *budget {
(n, m) = (0, 0);
} else {
*budget -= cells;
}
let mut t = vec![vec![0u32; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
let v = if a.get(i) == b.get(j) {
t.get(i + 1).and_then(|r| r.get(j + 1)).copied().unwrap_or(0) + 1
} else {
t.get(i + 1).and_then(|r| r.get(j)).copied().unwrap_or(0).max(t.get(i).and_then(|r| r.get(j + 1)).copied().unwrap_or(0))
};
if let Some(c) = t.get_mut(i).and_then(|r| r.get_mut(j)) {
*c = v;
while !rem_a.is_empty() && !rem_b.is_empty() {
let p = rem_a.iter().zip(rem_b).take_while(|(x, y)| x == y).count();
if p > 0 {
ops.extend(std::iter::repeat_n(Op::Same, p));
rem_a = rem_a.get(p..).unwrap_or_default();
rem_b = rem_b.get(p..).unwrap_or_default();
if rem_a.is_empty() || rem_b.is_empty() {
break;
}
}
}
let (mut i, mut j) = (0, 0);
while i < n && j < m {
if a.get(i) == b.get(j) {
ops.push(Op::Same);
i += 1;
j += 1;
} else if t.get(i + 1).and_then(|r| r.get(j)) >= t.get(i).and_then(|r| r.get(j + 1)) {
ops.push(Op::Del);
i += 1;
} else {
ops.push(Op::Ins);
j += 1;
let n = rem_a.len().min(MAX_LCS);
let m = rem_b.len().min(MAX_LCS);
let cells = (n as u64).saturating_mul(m as u64);
if cells > *budget || n == 0 || m == 0 {
break;
}
*budget = budget.saturating_sub(cells);
let stride = m.saturating_add(1);
let table_len = n.saturating_add(1).saturating_mul(stride);
let mut t = vec![0u32; table_len];
for i in (0..n).rev() {
let row = i.saturating_mul(stride);
let next_row = i.saturating_add(1).saturating_mul(stride);
for j in (0..m).rev() {
let v = if rem_a.get(i) == rem_b.get(j) {
t.get(next_row.saturating_add(j).saturating_add(1)).copied().unwrap_or(0).saturating_add(1)
} else {
let down = t.get(next_row.saturating_add(j)).copied().unwrap_or(0);
let right = t.get(row.saturating_add(j).saturating_add(1)).copied().unwrap_or(0);
down.max(right)
};
if let Some(c) = t.get_mut(row.saturating_add(j)) {
*c = v;
}
}
}
let (mut i, mut j) = (0usize, 0usize);
while i < n && j < m {
let row = i.saturating_mul(stride);
let next_row = i.saturating_add(1).saturating_mul(stride);
if rem_a.get(i) == rem_b.get(j) {
ops.push(Op::Same);
i = i.saturating_add(1);
j = j.saturating_add(1);
} else {
let down = t.get(next_row.saturating_add(j)).copied().unwrap_or(0);
let right = t.get(row.saturating_add(j).saturating_add(1)).copied().unwrap_or(0);
if down >= right {
ops.push(Op::Del);
i = i.saturating_add(1);
} else {
ops.push(Op::Ins);
j = j.saturating_add(1);
}
}
}
rem_a = rem_a.get(i..).unwrap_or_default();
rem_b = rem_b.get(j..).unwrap_or_default();
}
ops.extend(std::iter::repeat_n(Op::Del, a.len() - i));
ops.extend(std::iter::repeat_n(Op::Ins, b.len() - j));
ops.extend(std::iter::repeat_n(Op::Del, rem_a.len()));
ops.extend(std::iter::repeat_n(Op::Ins, rem_b.len()));
ops.extend(std::iter::repeat_n(Op::Same, suf));
ops
}
@@ -410,15 +473,39 @@ fn put(p: &mut Paragraph, t: Option<&Tok<'_>>, props: &CharProps) {
}
}
fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions, budget: &mut u64) -> Paragraph {
let (at, bt) = (a.plain_text(), b.plain_text());
let chars = opts.characters && at.chars().count().max(bt.chars().count()) <= MAX_CHAR_DIFF;
/// Returns `true` if paragraph `p` contains more characters than `limit`.
///
/// Bounded to scan at most `limit + 1` characters without allocating unless inline objects
/// are present, avoiding O(n) traversal of large paragraphs.
fn para_exceeds_char_limit(p: &Paragraph, limit: usize) -> bool {
if p.objects.is_empty() {
p.text.chars().take(limit.saturating_add(1)).count() > limit
} else {
p.plain_text().chars().take(limit.saturating_add(1)).count() > limit
}
}
/// Diffs two matched paragraphs either at word granularity or at character granularity according
/// to `ctx.opts.characters`. When character-level diff is requested but either paragraph exceeds
/// [`MAX_CHAR_DIFF`] characters, falls back to word-level diff and increments `ctx.char_fallbacks`
/// so callers can discover the fallback.
fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ctx: &mut DiffCtx<'_>) -> Paragraph {
let chars = if ctx.opts.characters {
if para_exceeds_char_limit(a, MAX_CHAR_DIFF) || para_exceeds_char_limit(b, MAX_CHAR_DIFF) {
*ctx.char_fallbacks = ctx.char_fallbacks.saturating_add(1);
false
} else {
true
}
} else {
false
};
let (ta, tb) = (tokens(a, chars), tokens(b, chars));
let key = |t: &Tok<'_>| match t {
Tok::Text(t) => Key::Text(opts.key(t)),
Tok::Text(t) => Key::Text(ctx.opts.key(t)),
Tok::Obj(o) => obj_key(doc, o),
};
let ops = lcs_ops(&ta.iter().map(key).collect::<Vec<_>>(), &tb.iter().map(key).collect::<Vec<_>>(), budget);
let ops = lcs_ops(&ta.iter().map(key).collect::<Vec<_>>(), &tb.iter().map(key).collect::<Vec<_>>(), ctx.budget);
// Only at character granularity: a matched whole word almost never happens by coincidence,
// but a matched single character very often does (digits, punctuation), so only there does
// a short interior match need folding back into its surrounding edit.
@@ -436,11 +523,11 @@ fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ins: u32, del: u32, o
j += 1;
}
Op::Del => {
put(&mut p, ta.get(i), &CharProps { del: Some(del), ..base.clone() });
put(&mut p, ta.get(i), &CharProps { del: Some(ctx.del), ..base.clone() });
i += 1;
}
Op::Ins => {
put(&mut p, tb.get(j), &CharProps { ins: Some(ins), ..base.clone() });
put(&mut p, tb.get(j), &CharProps { ins: Some(ctx.ins), ..base.clone() });
j += 1;
}
}
+560
View File
@@ -66,6 +66,309 @@ fn collapse_noise_matches_merges_short_interior_runs_but_keeps_prefix_suffix() {
assert_eq!(collapse_noise_matches(&ops), ops);
}
/// Verifies that `lcs_ops` chunks sequence alignment across multiple `MAX_LCS` windows,
/// replaying operations to prove exact reconstruction and asserting specific edit indices.
#[test]
fn lcs_ops_chunks_across_multiple_windows() {
// Two sequences of 9,000 elements (spanning three 4,000-element MAX_LCS windows).
// Element at 50 moves to 8,500. Substitutions at 0 and 8,999 zero the outer prefix/suffix.
let total = 9000;
let mut a: Vec<usize> = (0..total).collect();
let mut b = a.clone();
a[0] = 99999;
b[0] = 88888;
a[total - 1] = 77777;
b[total - 1] = 66666;
let moved = a[50];
b.remove(50);
b.insert(8500, moved);
let mut budget = u64::MAX;
let ops = lcs_ops(&a, &b, &mut budget);
// Replay ops against `a` and `b` to verify exact sequence reconstruction and order.
let mut ia = 0;
let mut ib = 0;
let mut dels_seen = Vec::new();
let mut inss_seen = Vec::new();
let mut reconstructed = Vec::new();
for op in &ops {
match op {
Op::Same => {
assert_eq!(a.get(ia), b.get(ib), "Same op must match elements at ia={ia}, ib={ib}");
if let Some(&val) = a.get(ia) {
reconstructed.push(val);
}
ia += 1;
ib += 1;
}
Op::Del => {
if let Some(&val) = a.get(ia) {
dels_seen.push((ia, val));
}
ia += 1;
}
Op::Ins => {
if let Some(&val) = b.get(ib) {
inss_seen.push((ib, val));
reconstructed.push(val);
}
ib += 1;
}
}
}
assert_eq!(ia, a.len());
assert_eq!(ib, b.len());
assert_eq!(reconstructed, b);
// Truth: 2 substitutions (indices 0 and 8999), 1 deletion at 50, 1 insertion at 8500.
assert_eq!(dels_seen, vec![(0, 99999), (50, 50), (8999, 77777)]);
assert_eq!(inss_seen, vec![(0, 88888), (8500, 50), (8999, 66666)]);
}
/// Verifies that `lcs_ops` forces successive full 4,000-element windows without
/// intermediate prefix trimming collapsing them, asserting exact budget exhaustion.
#[test]
fn lcs_ops_forces_successive_full_windows_without_intermediate_prefix_trim() {
// Force successive full 4,000-element windows without intermediate prefix trimming
// collapsing the middle into fewer tables.
// Length: 9,000 elements across 3 windows (4,000 + 4,000 + 1,000).
// Boundary substitutions at indices 0, 4000, 8000, and 8999 guarantee rem_a[0] != rem_b[0]
// at the beginning of each window, forcing intermediate prefix trim to find p == 0 so
// all three full DP tables must execute.
let total = 9000;
let mut a: Vec<usize> = (0..total).collect();
let mut b = a.clone();
a[0] = 11111;
b[0] = 22222;
a[4000] = 33333;
b[4000] = 44444;
a[8000] = 55555;
b[8000] = 66666;
a[total - 1] = 77777;
b[total - 1] = 88888;
// Budget for 3 full windows:
// Window 1: 4,000 * 4,000 = 16,000,000 cells
// Window 2: 4,000 * 4,000 = 16,000,000 cells
// Window 3: 1,000 * 1,000 = 1,000,000 cells
// Total cells = 33,000,000.
let mut budget = 33_000_000u64;
let ops = lcs_ops(&a, &b, &mut budget);
// Exact budget is fully exhausted (budget == 0), proving all 3 full DP tables were built.
assert_eq!(budget, 0, "all 3 successive windows must execute their DP tables without collapsing");
let mut ia = 0;
let mut ib = 0;
let mut reconstructed = Vec::new();
let mut dels_seen = Vec::new();
let mut inss_seen = Vec::new();
for op in &ops {
match op {
Op::Same => {
assert_eq!(a.get(ia), b.get(ib));
if let Some(&val) = a.get(ia) {
reconstructed.push(val);
}
ia += 1;
ib += 1;
}
Op::Del => {
if let Some(&val) = a.get(ia) {
dels_seen.push((ia, val));
}
ia += 1;
}
Op::Ins => {
if let Some(&val) = b.get(ib) {
inss_seen.push((ib, val));
reconstructed.push(val);
}
ib += 1;
}
}
}
assert_eq!(ia, a.len());
assert_eq!(ib, b.len());
assert_eq!(reconstructed, b);
assert_eq!(dels_seen, vec![(0, 11111), (4000, 33333), (8000, 55555), (8999, 77777)]);
assert_eq!(inss_seen, vec![(0, 22222), (4000, 44444), (8000, 66666), (8999, 88888)]);
// Also verify completely disjoint sequences across multiple windows with zero common items.
// Window 1: 4,000 * 4,000 = 16,000,000 cells (deletes 4,000 of a_diff)
// Window 2: 4,000 * 4,000 = 16,000,000 cells (deletes next 4,000 of a_diff)
// Window 3: 1,000 of a_diff * 4,000 of b_diff = 4,000,000 cells
// Total cells = 36,000,000.
let a_diff: Vec<usize> = (0..9000).collect();
let b_diff: Vec<usize> = (10_000..19_000).collect();
let mut budget_diff = 36_000_000u64;
let ops_diff = lcs_ops(&a_diff, &b_diff, &mut budget_diff);
assert_eq!(budget_diff, 0, "all 3 windows in disjoint comparison must exhaust exact 36,000,000 budget");
assert_eq!(ops_diff.iter().filter(|o| **o == Op::Del).count(), 9000);
assert_eq!(ops_diff.iter().filter(|o| **o == Op::Ins).count(), 9000);
assert_eq!(ops_diff.iter().filter(|o| **o == Op::Same).count(), 0);
}
/// Tests `lcs_ops` budget behavior across edge cases: zero initial budget, exact-fit allocation,
/// one-short exhaustion, and multi-window exhaustion after the first chunk.
#[test]
fn lcs_ops_exhausted_budget_falls_back_whole() {
// 1. Budget 0 before any table: middle degrades whole, budget remains 0
let a = vec![1, 2, 3, 4, 5];
let b = vec![1, 8, 9, 5];
let mut budget = 0;
let ops = lcs_ops(&a, &b, &mut budget);
assert_eq!(ops, vec![Op::Same, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Same]);
assert_eq!(budget, 0, "budget must remain 0");
// 2. Exact-fit: cells == budget
// Middle has 3 elements in a (2, 3, 4) and 2 in b (8, 9) -> 3 * 2 = 6 cells.
let mut budget_exact = 6;
let ops_exact = lcs_ops(&a, &b, &mut budget_exact);
assert_eq!(budget_exact, 0, "budget exactly exhausted to 0");
assert_eq!(ops_exact, vec![Op::Same, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Same]);
// Exact-fit with shared elements in middle
let a_mid = vec![1, 2, 3, 5];
let b_mid = vec![1, 2, 9, 5];
// Middle is (3) vs (9): 1 * 1 = 1 cell
let mut budget_mid = 1;
let ops_mid = lcs_ops(&a_mid, &b_mid, &mut budget_mid);
assert_eq!(budget_mid, 0);
assert_eq!(ops_mid, vec![Op::Same, Op::Same, Op::Del, Op::Ins, Op::Same]);
// 3. One-short: budget == cells - 1
let mut budget_short = 5;
let ops_short = lcs_ops(&a, &b, &mut budget_short);
assert_eq!(budget_short, 5, "one-short budget must not be charged");
assert_eq!(ops_short, vec![Op::Same, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Same]);
// 4. Multi-window: Exhaustion after the first window
// Two windows: window 1 has 4,000 elements (16,000,000 cells), window 2 has 500 elements (250,000 cells).
let mut a_multi: Vec<usize> = (0..4500).collect();
let mut b_multi = a_multi.clone();
a_multi[0] = 99999;
b_multi[0] = 88888;
a_multi[4000] = 77777;
b_multi[4000] = 66666;
a_multi[4499] = 55555;
b_multi[4499] = 44444;
// Case 4A: budget fits window 1 exactly (16,000,000), exhausts for window 2
let mut budget_w1_only = 16_000_000u64;
let ops_w1_only = lcs_ops(&a_multi, &b_multi, &mut budget_w1_only);
assert_eq!(budget_w1_only, 0, "budget spent on window 1, 0 remaining for window 2");
// Window 1 aligned via DP table, window 2 fell back to 500 Dels and 500 Inss
let tail_dels = ops_w1_only.iter().rev().skip(500).take(500).filter(|o| **o == Op::Del).count();
let tail_inss = ops_w1_only.iter().rev().take(500).filter(|o| **o == Op::Ins).count();
assert_eq!(tail_dels, 500);
assert_eq!(tail_inss, 500);
// Case 4B: budget fits both windows exactly: 16,250,000
let mut budget_both = 16_250_000u64;
let ops_both = lcs_ops(&a_multi, &b_multi, &mut budget_both);
assert_eq!(budget_both, 0, "both windows charged exactly to 0");
assert_eq!(ops_both.iter().filter(|o| **o == Op::Del).count(), 3);
assert_eq!(ops_both.iter().filter(|o| **o == Op::Ins).count(), 3);
// Case 4C: one short on window 2: 16_249_999
let mut budget_both_short = 16_249_999u64;
let _ = lcs_ops(&a_multi, &b_multi, &mut budget_both_short);
assert_eq!(budget_both_short, 249_999, "remaining budget must reflect window 1 spent and window 2 unspent");
}
/// Tests boundary conditions for `lcs_ops`: empty slices, one-sided empty, exact `MAX_LCS`,
/// `MAX_LCS + 1`, `2 * MAX_LCS`, and prefix-trim loop break paths.
#[test]
fn lcs_ops_boundary_cases_and_break_paths() {
// 1. Empty sequences
let mut b = u64::MAX;
assert_eq!(lcs_ops::<i32>(&[], &[], &mut b), Vec::<Op>::new());
// 2. One-sided empty
let items = [1, 2, 3];
assert_eq!(lcs_ops(&[], &items, &mut b), vec![Op::Ins, Op::Ins, Op::Ins]);
assert_eq!(lcs_ops(&items, &[], &mut b), vec![Op::Del, Op::Del, Op::Del]);
// 3. Exact MAX_LCS (4,000)
let len_max = 4000;
let mut a_max: Vec<usize> = (0..len_max).collect();
let mut b_max = a_max.clone();
a_max[0] = 99999;
b_max[0] = 88888;
a_max[len_max - 1] = 77777;
b_max[len_max - 1] = 66666;
let mut b_budget = u64::MAX;
let ops_max = lcs_ops(&a_max, &b_max, &mut b_budget);
assert_eq!(ops_max.len(), len_max + 2);
assert_eq!(ops_max.iter().filter(|o| **o == Op::Same).count(), len_max - 2);
// 4. Exact MAX_LCS + 1 (4,001)
let len_plus1 = 4001;
let mut a_p1: Vec<usize> = (0..len_plus1).collect();
let mut b_p1 = a_p1.clone();
a_p1[0] = 99999;
b_p1[0] = 88888;
a_p1[len_plus1 - 1] = 77777;
b_p1[len_plus1 - 1] = 66666;
let mut b_budget2 = u64::MAX;
let ops_p1 = lcs_ops(&a_p1, &b_p1, &mut b_budget2);
assert_eq!(ops_p1.len(), len_plus1 + 2);
assert_eq!(ops_p1.iter().filter(|o| **o == Op::Same).count(), len_plus1 - 2);
// 5. Exact 2 * MAX_LCS (8,000)
let len_2m = 8000;
let mut a_2m: Vec<usize> = (0..len_2m).collect();
let mut b_2m = a_2m.clone();
a_2m[0] = 11111;
b_2m[0] = 22222;
a_2m[4000] = 33333;
b_2m[4000] = 44444;
a_2m[len_2m - 1] = 55555;
b_2m[len_2m - 1] = 66666;
let mut b_budget3 = u64::MAX;
let ops_2m = lcs_ops(&a_2m, &b_2m, &mut b_budget3);
assert_eq!(ops_2m.iter().filter(|o| **o == Op::Del).count(), 3);
assert_eq!(ops_2m.iter().filter(|o| **o == Op::Ins).count(), 3);
assert_eq!(ops_2m.iter().filter(|o| **o == Op::Same).count(), len_2m - 3);
// 6. Prefix-trim break paths inside the while loop:
// Case 6A: rem_a becomes empty after matching p elements
let mut a_break_a: Vec<usize> = (0..4005).collect();
let mut b_break_a: Vec<usize> = (0..4008).collect();
a_break_a[0] = 99999;
b_break_a[0] = 88888;
let mut b_break1 = u64::MAX;
let ops_break_a = lcs_ops(&a_break_a, &b_break_a, &mut b_break1);
assert_eq!(ops_break_a.iter().filter(|o| **o == Op::Del).count(), 1);
assert_eq!(ops_break_a.iter().filter(|o| **o == Op::Ins).count(), 4);
// Case 6B: rem_b becomes empty after matching p elements
let mut a_break_b: Vec<usize> = (0..4008).collect();
let mut b_break_b: Vec<usize> = (0..4005).collect();
a_break_b[0] = 99999;
b_break_b[0] = 88888;
let mut b_break2 = u64::MAX;
let ops_break_b = lcs_ops(&a_break_b, &b_break_b, &mut b_break2);
assert_eq!(ops_break_b.iter().filter(|o| **o == Op::Del).count(), 4);
assert_eq!(ops_break_b.iter().filter(|o| **o == Op::Ins).count(), 1);
// Case 6C: both become empty simultaneously after matching p elements
let mut a_break_both: Vec<usize> = (0..4005).collect();
let mut b_break_both: Vec<usize> = (0..4005).collect();
a_break_both[0] = 99999;
b_break_both[0] = 88888;
let mut b_break3 = u64::MAX;
let ops_break_both = lcs_ops(&a_break_both, &b_break_both, &mut b_break3);
assert_eq!(ops_break_both.iter().filter(|o| **o == Op::Del).count(), 1);
assert_eq!(ops_break_both.iter().filter(|o| **o == Op::Ins).count(), 1);
assert_eq!(ops_break_both.iter().filter(|o| **o == Op::Same).count(), 4004);
}
#[test]
fn compare_at_word_level_still_matches_a_whole_unchanged_word() {
// `collapse_noise_matches` only runs for character-level compares (wired up in
@@ -80,6 +383,145 @@ fn compare_at_word_level_still_matches_a_whole_unchanged_word() {
);
}
#[test]
fn compare_character_level_surfaces_fallback_on_long_paragraphs() {
// Under MAX_CHAR_DIFF (2,000 characters), character-level diff engages normally:
// sub-word fragment is marked, characterFallback is 0, and notHonoured does not contain "level".
let original_short = "The quick brown fox jumps over the lazy dog. ".repeat(10);
let revised_short = original_short.replacen("jumps", "jumped", 1);
assert!(original_short.chars().count() < 2000);
let mut s = Session::new(Document::from_text(&original_short));
let r = s.run("review.compare", &json!({"text": revised_short, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(0));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(!not_honoured.iter().any(|v| v == "level"), "level should be honoured for short paragraphs");
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "s", "character level marks only the sub-word suffix for short paragraphs");
assert_eq!(inserted, "ed", "character level marks only the sub-word suffix for short paragraphs");
// Above MAX_CHAR_DIFF (2,000 characters), character-level diff falls back to word-level diff.
// The fallback is surfaced in result metadata: characterFallback is incremented and
// notHonoured reports "level" rather than silently degrading without signal.
// In the output document, the fallback marks the whole word rather than sub-word fragments.
let original_long = "The quick brown fox jumps over the lazy dog. ".repeat(50);
let revised_long = original_long.replacen("jumps", "jumped", 1);
assert!(original_long.chars().count() > 2000);
let mut s = Session::new(Document::from_text(&original_long));
let r = s.run("review.compare", &json!({"text": revised_long, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(not_honoured.iter().any(|v| v == "level"), "level should be listed in notHonoured when fallback occurs");
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "jumps ", "long paragraph fallback marks the whole word");
assert_eq!(inserted, "jumped ", "long paragraph fallback marks the whole word");
}
#[test]
fn compare_character_level_exact_boundaries_at_limit() {
let prefix = "The quick brown fox jumps over the lazy dog. ".repeat(44); // 1980 chars
// Exactly MAX_CHAR_DIFF (2000) characters: character-level diff engages without fallback.
let original_2000 = format!("{prefix}1234567890123456789x");
let revised_2000 = format!("{prefix}1234567890123456789y");
assert_eq!(original_2000.chars().count(), 2000);
assert_eq!(revised_2000.chars().count(), 2000);
let mut s = Session::new(Document::from_text(&original_2000));
let r = s.run("review.compare", &json!({"text": revised_2000, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(0));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(!not_honoured.iter().any(|v| v == "level"), "2000 chars should not fall back");
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "x", "character level marks single changed character at exactly 2000 chars");
assert_eq!(inserted, "y", "character level marks single changed character at exactly 2000 chars");
// Exactly 2001 characters (MAX_CHAR_DIFF + 1): triggers fallback to word-level diff.
let original_2001 = format!("{prefix}12345678901234567890x");
let revised_2001 = format!("{prefix}12345678901234567890y");
assert_eq!(original_2001.chars().count(), 2001);
assert_eq!(revised_2001.chars().count(), 2001);
let mut s = Session::new(Document::from_text(&original_2001));
let r = s.run("review.compare", &json!({"text": revised_2001, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(not_honoured.iter().any(|v| v == "level"), "2001 chars must trigger fallback");
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "12345678901234567890x", "fallback marks whole word at 2001 chars");
assert_eq!(inserted, "12345678901234567890y", "fallback marks whole word at 2001 chars");
}
#[test]
fn compare_character_level_multiple_paragraphs_and_asymmetric_lengths() {
let short_a = "Short original text here.";
let short_b = "Short revised text here.";
let long_a = "The quick brown fox jumps over the lazy dog. ".repeat(50);
let long_b = long_a.replacen("jumps", "jumped", 1);
let long_c = "Another long paragraph that exceeds two thousand chars. ".repeat(40);
let long_d = long_c.replacen("Another", "Different", 1);
// Document with 1 short modified paragraph and 1 long modified paragraph: fallback count is 1.
let doc_mixed_a = format!("{short_a}\n{long_a}");
let doc_mixed_b = format!("{short_b}\n{long_b}");
let mut s = Session::new(Document::from_text(&doc_mixed_a));
let r = s.run("review.compare", &json!({"text": doc_mixed_b, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
// Document with 2 long modified paragraphs: fallback count is 2.
let doc_2long_a = format!("{long_a}\n{long_c}");
let doc_2long_b = format!("{long_b}\n{long_d}");
let mut s = Session::new(Document::from_text(&doc_2long_a));
let r = s.run("review.compare", &json!({"text": doc_2long_b, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(2));
// Asymmetric lengths: original short, revised long (> 2000 chars) -> fallback count is 1.
let mut s = Session::new(Document::from_text(short_a));
let r = s.run("review.compare", &json!({"text": long_b, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
// Asymmetric lengths: original long (> 2000 chars), revised short -> fallback count is 1.
let mut s = Session::new(Document::from_text(&long_a));
let r = s.run("review.compare", &json!({"text": short_b, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
}
#[test]
fn compare_word_level_default_and_explicit_never_flag_fallback() {
let original_long = "The quick brown fox jumps over the lazy dog. ".repeat(50);
let revised_long = original_long.replacen("jumps", "jumped", 1);
assert!(original_long.chars().count() > 2000);
// Explicit "level": "word" on long paragraph: characterFallback is 0 and "level" not in notHonoured.
let mut s = Session::new(Document::from_text(&original_long));
let r = s.run("review.compare", &json!({"text": revised_long, "level": "word"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(0));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(!not_honoured.iter().any(|v| v == "level"));
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "jumps ");
assert_eq!(inserted, "jumped ");
// Default (no "level" param provided): behaves as word level, characterFallback is 0 and "level" not in notHonoured.
let mut s = Session::new(Document::from_text(&original_long));
let r = s.run("review.compare", &json!({"text": revised_long})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(0));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(!not_honoured.iter().any(|v| v == "level"));
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "jumps ");
assert_eq!(inserted, "jumped ");
}
#[test]
fn compare_keeps_text_boxes_of_the_original() {
// Text boxes are anchored in body paragraphs: one after changed text, one alone in an
@@ -193,6 +635,124 @@ fn compare_bounds_total_diff_work() {
assert_eq!(s.doc.plain_text(StoryRef::Body), revised);
}
/// Verifies that moving a paragraph past `MAX_LCS` in a long document does not cascade into false
/// revisions in the tail, asserting exact changed block indices, text, and edit types.
#[test]
fn compare_lcs_window_move_past_cap_does_not_cascade_false_revisions() {
// Issue #6110: 4,600 blocks where prefix/suffix trim is zeroed (substitutions at blocks 0
// and 4599) and a record moves from index 100 to index 4400 (past MAX_LCS = 4000).
// Without chunking/continuation past the window, the tail misaligns and positional pairing
// marks ~202 paragraphs instead of the true 4 changes (substitutions at 0 and 4599,
// deletion at 100, insertion at 4400).
let total = 4600;
let mut orig_paras: Vec<String> = (0..total).map(|i| format!("Record {i} has alpha beta gamma payload.")).collect();
let mut rev_paras = orig_paras.clone();
// Zero out prefix trim: substitution at 0
orig_paras[0] = "Record 0 has alpha beta gamma original.".into();
rev_paras[0] = "Record 0 has alpha beta gamma revised.".into();
// Zero out suffix trim: substitution at 4599
orig_paras[total - 1] = format!("Record {} has alpha beta gamma original.", total - 1);
rev_paras[total - 1] = format!("Record {} has alpha beta gamma revised.", total - 1);
// Move record 100 to 4400
let moved = orig_paras[100].clone();
rev_paras.remove(100);
rev_paras.insert(4400, moved);
let original = orig_paras.join("\n");
let revised = rev_paras.join("\n");
let mut s = Session::new(Document::from_text(&original));
let r = s.run("review.compare", &json!({"text": revised})).unwrap();
// The true number of changed paragraphs is 4 (the two substitutions, the move's deletion,
// and the move's insertion). On unpatched code, window truncation misaligned the tail
// and marked ~202 paragraphs.
assert_eq!(r["changes"], 4, "move past MAX_LCS cap must not cascade into false revisions in the tail");
// In s.doc.body (total 4601 blocks: 4598 unchanged, 1 del at 100, 1 ins at 4401, 2 substituted at 0 and 4600):
let changed_blocks: Vec<(usize, String)> = s
.doc
.body
.iter()
.enumerate()
.filter_map(|(idx, b)| {
let p = b.as_para()?;
let has_change = p.mark.del.is_some() || p.mark.ins.is_some() || p.runs.iter().any(|r| r.props.del.is_some() || r.props.ins.is_some());
if has_change { Some((idx, p.plain_text())) } else { None }
})
.collect();
assert_eq!(changed_blocks.len(), 4);
assert_eq!(changed_blocks[0], (0, "Record 0 has alpha beta gamma original.revised.".into()));
assert_eq!(changed_blocks[1], (100, "Record 100 has alpha beta gamma payload.".into()));
assert_eq!(changed_blocks[2], (4401, "Record 100 has alpha beta gamma payload.".into()));
assert_eq!(changed_blocks[3], (4600, "Record 4599 has alpha beta gamma original.revised.".into()));
// Verify exact change types on those blocks:
let b0 = s.doc.body[0].as_para().unwrap();
assert!(b0.runs.iter().any(|r| r.props.del.is_some()) && b0.runs.iter().any(|r| r.props.ins.is_some()), "block 0 is text replacement");
let b100 = s.doc.body[100].as_para().unwrap();
assert!(b100.runs.iter().all(|r| r.props.del.is_some()) && b100.mark.del.is_some(), "block 100 is deleted whole");
let b4401 = s.doc.body[4401].as_para().unwrap();
assert!(b4401.runs.iter().all(|r| r.props.ins.is_some()) && b4401.mark.ins.is_some(), "block 4401 is inserted whole");
let b4600 = s.doc.body[4600].as_para().unwrap();
assert!(b4600.runs.iter().any(|r| r.props.del.is_some()) && b4600.runs.iter().any(|r| r.props.ins.is_some()), "block 4600 is text replacement");
s.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), revised);
}
/// Verifies that `mark_block` records paragraph mark revisions on whole-block deletions,
/// whole-block insertions, empty paragraphs, and ensures clean resolution via `review.acceptAll`.
#[test]
fn compare_mark_block_records_paragraph_mark_revisions_on_whole_block_changes() {
// Whole block delete/insert: test with differing paragraph count so whole-block paths trigger
let orig_multi = "Keep start.\nDelete me.\nKeep end.";
let rev_multi = "Keep start.\nKeep end.";
let mut s2 = Session::new(Document::from_text(orig_multi));
if let Some(Block::Para(p)) = s2.doc.body.get_mut(1).map(Arc::make_mut) {
p.mark.bold = Some(true);
}
s2.run("review.compare", &json!({"text": rev_multi})).unwrap();
// In s2.doc.body, block 1 was deleted whole.
// Verify that p.mark.del is recorded by mark_block and formatting is preserved.
let del_para = s2.doc.body[1].as_para().expect("block 1 must be paragraph");
assert!(del_para.mark.del.is_some(), "whole-block delete must record revision on paragraph mark");
assert_eq!(del_para.mark.bold, Some(true), "paragraph mark formatting must be preserved on deleted block");
// Accept all changes: deleted paragraph is removed
s2.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s2.doc.plain_text(StoryRef::Body), rev_multi);
// Test whole-block insertion
let mut s3 = Session::new(Document::from_text(rev_multi));
s3.run("review.compare", &json!({"text": orig_multi})).unwrap();
let ins_para = s3.doc.body[1].as_para().expect("block 1 must be paragraph");
assert!(ins_para.mark.ins.is_some(), "whole-block insert must record revision on paragraph mark");
s3.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s3.doc.plain_text(StoryRef::Body), orig_multi);
// Test empty paragraph deletion and insertion (len == 0, where only mark carries the revision)
let orig_empty = "Before.\n\nAfter.";
let rev_empty = "Before.\nAfter.";
let mut s4 = Session::new(Document::from_text(orig_empty));
s4.run("review.compare", &json!({"text": rev_empty})).unwrap();
let empty_del = s4.doc.body[1].as_para().expect("empty paragraph block");
assert_eq!(empty_del.len(), 0);
assert!(empty_del.mark.del.is_some(), "empty paragraph deletion must set mark.del");
s4.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s4.doc.plain_text(StoryRef::Body), rev_empty);
let mut s5 = Session::new(Document::from_text(rev_empty));
s5.run("review.compare", &json!({"text": orig_empty})).unwrap();
let empty_ins = s5.doc.body[1].as_para().expect("empty paragraph block");
assert_eq!(empty_ins.len(), 0);
assert!(empty_ins.mark.ins.is_some(), "empty paragraph insertion must set mark.ins");
s5.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s5.doc.plain_text(StoryRef::Body), orig_empty);
}
#[test]
fn macros_record_and_run() {
let mut s = Session::new(Document::new());
+13 -2
View File
@@ -8,12 +8,14 @@ use wordcraft_layout::{NumeralMode, ViewMode};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// Toggles or sets a boolean view property, returning the updated state.
fn toggle(s: &mut Session, v: &Value, f: fn(&mut crate::ViewState) -> &mut bool) -> CmdResult {
let cur = *f(&mut s.view);
*f(&mut s.view) = p::bool(v, "value").unwrap_or(!cur);
Ok(json!({"value": *f(&mut s.view)}))
}
/// Returns the command specifications for the View ribbon tab and window management commands.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("view.marks", "Show/Hide ¶", "Home › Paragraph", |s, v| {
@@ -143,7 +145,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
.pure(),
CommandSpec::new("view.switchWindows", "Switch Windows", "View › Window", switch_windows)
.params(r#"{"id"?: n (make this open document the active one)} → {"documents": [{"id", "title", "path", "dirty", "active"}]} in the order they were opened"#)
.params(r#"{"id"?: n (make this open document the active one), "window"?: n (make this open window the active one)} → {"documents": [{"id", "title", "path", "dirty", "active"}]} in the order they were opened"#)
.pure(),
CommandSpec::new("view.split", "Split", "View › Window", |s, _| {
s.push_ui_request(json!({"toggle": "split"}));
@@ -213,6 +215,8 @@ pub fn specs() -> Vec<CommandSpec> {
pub struct Windows {
/// The host can list and move its windows (the desktop app).
pub available: bool,
/// The active window, when known.
pub current: Option<u64>,
/// The other open windows, by id.
pub others: Vec<OtherWindow>,
/// View Side by Side is on, with this window.
@@ -431,6 +435,7 @@ pub(crate) fn restore_zoom(s: &mut Session, z: Zoom) {
s.view.single_column = fit == "onePage" || fit == "pageWidth";
}
/// Sets the view zoom level or fit mode, or requests opening the Zoom dialog.
fn zoom(s: &mut Session, v: &Value) -> CmdResult {
match v.get("value") {
Some(Value::String(f)) if ["pageWidth", "onePage", "multiplePages", "textWidth"].contains(&f.as_str()) => {
@@ -456,7 +461,13 @@ fn zoom(s: &mut Session, v: &Value) -> CmdResult {
/// View › Window › Switch Windows: the open documents, or make one of them active.
fn switch_windows(s: &mut Session, v: &Value) -> CmdResult {
if let Some(x) = v.get("id").filter(|x| !x.is_null()) {
if let Some(w) = v.get("window").filter(|x| !x.is_null()) {
let wid = w.as_u64().ok_or_else(|| CmdError::Params("`window` must be an open window's id".into()))?;
if s.windows.available && s.windows.current != Some(wid) && !s.windows.others.iter().any(|other| other.id == wid) {
return Err(CmdError::Params(format!("no open window {wid}")));
}
s.push_ui_request(json!({"windows": "switchWindow", "window": wid}));
} else if let Some(x) = v.get("id").filter(|x| !x.is_null()) {
let id = x.as_u64().ok_or_else(|| CmdError::Params("`id` must be an open document's id".into()))?;
s.activate(id)?;
}
+71 -1
View File
@@ -11,6 +11,9 @@ use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
/// Writes `bytes` safely to `path`, replacing an existing destination atomically via a
/// temporary file. If the destination's folder refuses the temporary file, a new file
/// is created directly and removed on failure; an existing file is left untouched.
pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
let permissions = match fs::metadata(path) {
Ok(meta) if meta.is_file() => Some(meta.permissions()),
@@ -28,11 +31,41 @@ pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
}
// Synchronize the prepared file before replacing the destination. Directory durability
// and network-filesystem/power-loss guarantees are outside this operation's contract.
file.sync_all()
sync_file(file)
});
written.map_err(explain)
}
/// Flushes `file` to stable storage (`sync_all`). On Apple platforms that is a full flush
/// (`F_FULLFSYNC`); where the file system can't do one (SMB shares and virtual file systems
/// refuse it with `ENOTSUP` #6155, `EINVAL`, or `ENOTTY`), this falls back to a plain `fsync`,
/// as Apple's own `removefile` and SQLite do. Plain `fsync` flushes to the drive controller but
/// does not guarantee disk cache durability; any other error is returned as it is.
fn sync_file(file: &File) -> io::Result<()> {
#[cfg(target_vendor = "apple")]
let plain = || rustix::fs::fsync(file).map_err(io::Error::from);
#[cfg(not(target_vendor = "apple"))]
let plain = || Ok(());
or_plain_fsync(file.sync_all(), plain)
}
/// Returns `full`, or calls `fsync()` when `full` failed only because the file system can't do a full flush.
fn or_plain_fsync(full: io::Result<()>, fsync: impl FnOnce() -> io::Result<()>) -> io::Result<()> {
match full {
Err(e) if full_flush_unsupported(&e) => fsync(),
other => other,
}
}
/// Whether `e` is a file system refusing a full flush (`F_FULLFSYNC`): `ENOTSUP` (45, SMB),
/// `EINVAL` (22), or `ENOTTY` (25) on Apple platforms. Never elsewhere: there `sync_all` is a plain `fsync`.
fn full_flush_unsupported(e: &io::Error) -> bool {
const EINVAL: i32 = 22;
const ENOTTY: i32 = 25;
const ENOTSUP: i32 = 45;
cfg!(target_vendor = "apple") && matches!(e.raw_os_error(), Some(ENOTSUP | EINVAL | ENOTTY))
}
/// A folder that keeps no Unix modes refuses to set one with "Operation not supported": a NAS
/// share mounted by GVFS (`smb://` in the Linux file manager) refuses every mode change (#3003).
/// There is no mode to keep there, so the save goes on; any other failure still stops it.
@@ -448,4 +481,41 @@ mod tests {
assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o640);
assert_eq!(fs::read(&path).unwrap(), b"Saved again.");
}
/// A share that refuses the full flush (`F_FULLFSYNC`, #6155) still saves through a plain fsync
/// on Apple platforms; every other sync error still fails the save.
#[test]
fn a_refused_full_flush_falls_back_to_a_plain_fsync_on_apple_only() {
use std::cell::Cell;
let refused = |errno| Err(io::Error::from_raw_os_error(errno));
for (errno, name) in [(45, "ENOTSUP, SMB"), (22, "EINVAL"), (25, "ENOTTY")] {
let fsynced = Cell::new(false);
let r = or_plain_fsync(refused(errno), || {
fsynced.set(true);
Ok(())
});
let apple = cfg!(target_vendor = "apple");
assert_eq!(r.is_ok(), apple, "{name}");
assert_eq!(fsynced.get(), apple, "{name}: the plain fsync runs only on Apple platforms");
}
// The plain fsync's own failure is reported.
if cfg!(target_vendor = "apple") {
let r = or_plain_fsync(refused(45), || Err(io::Error::from_raw_os_error(5)));
assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(5)));
}
// Real errors (EIO, ENOSPC, EACCES) never fall back, and success needs no fallback.
for errno in [5, 28, 13] {
let r = or_plain_fsync(refused(errno), || panic!("no fallback for errno {errno}"));
assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(errno)));
}
assert!(or_plain_fsync(Ok(()), || panic!("no fallback after a full flush")).is_ok());
}
#[test]
fn sync_file_flushes_a_local_file() {
let d = Scratch::new();
let path = d.0.join("f.bin");
let file = OpenOptions::new().write(true).create_new(true).open(&path).unwrap();
sync_file(&file).unwrap();
}
}
+14
View File
@@ -214,6 +214,7 @@ fn read_aloud_belongs_to_its_document() {
assert_eq!(s.read_aloud.document, Some(b));
}
/// Tests that `view.switchWindows` lists open documents, activates by document ID, and requests switching by window ID.
#[test]
fn switch_windows_lists_and_activates() {
let mut s = s();
@@ -228,10 +229,23 @@ fn switch_windows_lists_and_activates() {
assert!(list[0].get("path").is_some() && list[0].get("dirty").is_some());
run(&mut s, "view.switchWindows", json!({"id": a}));
assert_eq!(s.document_id(), a);
s.ui_requests.clear();
run(&mut s, "view.switchWindows", json!({"window": 1}));
assert_eq!(s.ui_requests, vec![json!({"windows": "switchWindow", "window": 1})]);
s.windows.available = true;
s.windows.current = Some(1);
s.windows.others = vec![crate::cmd::view::OtherWindow { id: 2, title: "Second".into() }];
s.ui_requests.clear();
run(&mut s, "view.switchWindows", json!({"window": 2}));
assert_eq!(s.ui_requests, vec![json!({"windows": "switchWindow", "window": 2})]);
assert!(s.run("view.switchWindows", &json!({"window": 3})).is_err(), "unknown window key fails when windows are available");
for bad in [json!({"id": 0}), json!({"id": u64::MAX}), json!({"id": -5}), json!({"id": "1"}), json!({"id": 1e300}), json!({"id": {}})] {
assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}");
assert_eq!(s.document_id(), a);
}
for bad in [json!({"window": "1"}), json!({"window": -1}), json!({"window": {}})] {
assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}");
}
let junk = [json!(null), json!([]), json!({"id": null}), json!({"text": 5})];
for id in ["view.switchWindows", "file.closeDocument", "file.new"] {
for j in &junk {
+33
View File
@@ -186,3 +186,36 @@ fn binary_doc_opened_by_extension_refreshes_date_fields() {
assert_eq!(got, expected, "{name}: DATE field stale after opening");
}
}
/// Regression from a synthetic Word document: a visible Brazilian long date is
/// stored in a TIME field and pt-BR is present only in docDefaults, not in field runs.
#[test]
fn a_word_time_field_inherits_pt_br_from_document_defaults() {
const PICTURE: &str = "d' de 'MMMM' de 'yyyy";
const CODE: &str = r#"TIME \@ "d' de 'MMMM' de 'yyyy""#;
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc_rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
let styles = r#"<?xml version="1.0" encoding="UTF-8"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:docDefaults><w:rPrDefault><w:rPr><w:lang w:val="pt-BR"/></w:rPr></w:rPrDefault></w:docDefaults><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style></w:styles>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Cidade, </w:t></w:r>{}</w:p><w:p><w:r><w:t>Cidade, </w:t></w:r>{}</w:p></w:body></w:document>"#,
field(CODE, "22 de setembro de 2026", false),
field(CODE, "22 de setembro de 2026", false)
);
let source = zip(&[("_rels/.rels", rels), ("word/document.xml", &doc), ("word/_rels/document.xml.rels", doc_rels), ("word/styles.xml", styles)]);
let original = wordcraft_engine::io::open_bytes("pt-br-word.docx", &source).unwrap();
let expected = wordcraft_engine::cmd::insert::format_date_in(PICTURE, Some("pt-BR"));
let assert_correct = |document: &wordcraft_doc::Document| {
let got = fields(document);
assert_eq!(got.len(), 2, "both original fields survive");
for (code, result) in got {
assert_eq!(code, CODE, "field instruction remains editable");
assert_eq!(result, expected, "quoted Portuguese words and month names");
assert!(!result.contains("October") && !result.contains('\''));
assert_eq!(result.matches(" de ").count(), 2);
}
};
assert_correct(&original);
let written = wordcraft_docx::write(&original).unwrap();
let reopened = wordcraft_engine::io::open_bytes("reopened.docx", &written).unwrap();
assert_correct(&reopened);
}
@@ -0,0 +1,398 @@
//! #2201: inserting page numbers must preserve the header/footer story.
use std::sync::Arc;
use serde_json::json;
use wordcraft_doc::para::{GroupChild, InlineObject};
use wordcraft_doc::props::{Align, CharProps};
use wordcraft_doc::{Block, Document, Paragraph, PartKind, Path, StoryRef, Table, para_block};
use wordcraft_engine::{Selection, Session};
fn fields(doc: &Document, story: StoryRef, name: &str) -> usize {
doc.para_paths(story)
.iter()
.filter_map(|path| doc.para(story, path))
.flat_map(|p| &p.objects)
.filter(|o| matches!(o, InlineObject::Field { instr, .. } | InlineObject::FieldStart { instr, .. } if instr.split_whitespace().next().is_some_and(|token| token.eq_ignore_ascii_case(name))))
.count()
}
fn field(instr: &str) -> InlineObject {
InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() }
}
#[test]
fn numbering_preserves_content_formatting_selection_and_undo_redo() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
for format in ["plain", "x of y"] {
let mut s = Session::new(Document::from_text("Body text"));
let id = s.run(command, &json!({"text": "Keep this\nSecond line"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
s.doc.para_mut(story, &Path::top(0)).unwrap().props.align = Some(Align::Right);
let mut table = Table::new(1, 2, 300.0);
table.rows[0].cells[0].blocks =
vec![para_block(Paragraph::with_text("Keep table", CharProps { bold: Some(true), ..Default::default() }))];
s.doc.story_mut(story).unwrap().push(Arc::new(Block::Table(table)));
let before = s.doc.clone();
let selection = s.sel.clone();
let blocks = s.doc.story(story).unwrap().clone();
let depth = s.undo_depth();
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
assert_eq!(&s.doc.story(story).unwrap()[..blocks.len()], blocks.as_slice());
assert_eq!(s.sel, selection);
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
assert_eq!(fields(&s.doc, story, "NUMPAGES"), usize::from(format == "x of y"));
assert_eq!(s.undo_depth(), depth + 1);
let after = s.doc.clone();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
assert_eq!(s.sel, selection);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc, after);
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
assert_eq!(s.doc, after, "repeated numbering must not duplicate or replace content");
let mut back = after.clone();
for _ in 0..2 {
back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap();
let id = if position == "top" { back.last_section.headers.default } else { back.last_section.footers.default }.unwrap();
let reopened = StoryRef::Part(id);
assert_eq!(back.plain_text(StoryRef::Body), "Body text");
assert!(back.plain_text(reopened).starts_with("Keep this\nSecond line\nKeep table"));
assert_eq!(back.para(reopened, &Path::top(0)).unwrap().props.align, Some(Align::Right));
let Block::Table(table) = &*back.story(reopened).unwrap()[2] else { panic!("lost table") };
let Block::Para(p) = &*table.rows[0].cells[0].blocks[0] else { panic!("lost cell") };
assert_eq!(p.props_at(0).bold, Some(true));
assert_eq!(fields(&back, reopened, "PAGE"), 1);
assert_eq!(fields(&back, reopened, "NUMPAGES"), usize::from(format == "x of y"));
}
}
}
}
#[test]
fn existing_page_field_in_a_table_is_preserved_with_its_switches() {
for depth in [1, 17] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run("insert.footer", &json!({"text": "Keep footer"})).unwrap()["story"].as_u64().unwrap() as u32;
let mut para = Paragraph::with_text("Existing page: ", CharProps::default());
para.insert_object(para.len(), field(" page \\* MERGEFORMAT "), &CharProps::default()).unwrap();
let mut blocks = vec![para_block(para)];
for _ in 0..depth {
let mut table = Table::new(1, 1, 300.0);
table.rows[0].cells[0].blocks = blocks;
blocks = vec![Arc::new(Block::Table(table))];
}
s.doc.story_mut(StoryRef::Part(id)).unwrap().extend(blocks);
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": "bottom", "format": "x of y"})).unwrap();
assert!(s.doc == before, "PAGE in a table nested {depth} deep must not be duplicated");
}
}
#[test]
fn existing_range_page_field_is_not_duplicated() {
let mut s = Session::new(Document::new());
let id = s.run("insert.header", &json!({"text": "Keep header"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let para = s.doc.para_mut(story, &Path::top(0)).unwrap();
para.insert_object(para.len(), InlineObject::FieldStart { instr: "PAGE".into(), locked: true, dirty: true }, &CharProps::default()).unwrap();
para.insert_text(para.len(), "7", &CharProps::default()).unwrap();
para.insert_object(para.len(), InlineObject::FieldEnd, &CharProps::default()).unwrap();
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": "top"})).unwrap();
assert_eq!(s.doc, before);
}
#[test]
fn unrelated_fields_do_not_prevent_numbering_and_formatted_empty_paragraphs_survive() {
let mut s = Session::new(Document::new());
let id = s.run("insert.header", &json!({"text": ""})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let para = s.doc.para_mut(story, &Path::top(0)).unwrap();
para.props.align = Some(Align::Right);
let empty = para.clone();
let mut para = Paragraph::new();
para.insert_object(0, field("PAGEREF bookmark"), &CharProps::default()).unwrap();
s.doc.story_mut(story).unwrap().push(para_block(para));
s.run("insert.pageNumber", &json!({"position": "top"})).unwrap();
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap(), &empty);
assert_eq!(fields(&s.doc, story, "PAGEREF"), 1);
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
}
#[test]
fn fresh_numbering_uses_one_paragraph_and_current_position_stays_inline() {
for position in ["top", "bottom"] {
let mut s = Session::new(Document::new());
let id = s.run("insert.pageNumber", &json!({"position": position})).unwrap()["story"].as_u64().unwrap() as u32;
assert_eq!(s.doc.story(StoryRef::Part(id)).unwrap().len(), 1);
}
let mut s = Session::new(Document::from_text("Before selected after"));
s.run("select.text", &json!({"text": "selected"})).unwrap();
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
assert!(s.doc.parts.is_empty());
assert_eq!(fields(&s.doc, StoryRef::Body, "PAGE"), 1);
assert!(!s.doc.plain_text(StoryRef::Body).contains("selected"));
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
}
#[test]
fn existing_page_field_in_nested_text_boxes_is_preserved() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
for levels in [1, 2] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let box_id = s.run("insert.textBox", &json!({"text": "Existing page: "})).unwrap()["story"].as_u64().unwrap() as u32;
if levels == 2 {
// Imported stories can contain a nested box, even though the insert command
// does not offer one while the caret is already in a text box.
let inner = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::new())]);
let mut anchor = s.doc.para(story, &Path::top(0)).unwrap().objects.iter().find(|o| o.text_box() == Some(box_id)).unwrap().clone();
if let InlineObject::Shape { story, .. } = &mut anchor {
*story = Some(inner);
}
s.doc.para_mut(StoryRef::Part(box_id), &Path::top(0)).unwrap().insert_object(0, anchor, &CharProps::default()).unwrap();
s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(inner)));
}
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
let before = s.doc.clone();
let selection = s.sel.clone();
s.run("insert.pageNumber", &json!({"position": position, "format": "x of y"})).unwrap();
assert_eq!(fields(&s.doc, story, "PAGE"), 0, "the text box already contains the page number");
assert_eq!(s.doc, before);
assert_eq!(s.sel, selection);
let mut back = s.doc.clone();
for _ in 0..2 {
back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap();
assert_eq!(back.parts.keys().map(|id| fields(&back, StoryRef::Part(*id), "PAGE")).sum::<usize>(), 1);
assert_eq!(back.parts.keys().map(|id| fields(&back, StoryRef::Part(*id), "NUMPAGES")).sum::<usize>(), 0);
}
}
}
}
#[test]
fn existing_page_field_in_grouped_text_boxes_is_preserved() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let box_id = s.run("insert.textBox", &json!({"text": "Existing page: "})).unwrap()["story"].as_u64().unwrap() as u32;
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
let anchor = s.doc.para_mut(story, &Path::top(0)).unwrap().objects.iter_mut().find(|o| o.text_box() == Some(box_id)).unwrap();
*anchor = InlineObject::Group {
w: 144.0,
h: 72.0,
float: Default::default(),
ch_w: 144.0,
ch_h: 72.0,
children: vec![GroupChild { x: 0.0, y: 0.0, obj: anchor.clone() }],
canvas: None,
};
let before = s.doc.clone();
let selection = s.sel.clone();
s.run("insert.pageNumber", &json!({"position": position})).unwrap();
assert_eq!(fields(&s.doc, story, "PAGE"), 0, "the grouped text box already contains the page number");
assert_eq!(s.doc, before);
assert_eq!(s.sel, selection);
let mut back = s.doc.clone();
for _ in 0..2 {
back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap();
assert_eq!(back.parts.keys().map(|id| fields(&back, StoryRef::Part(*id), "PAGE")).sum::<usize>(), 1);
}
}
}
#[test]
fn page_fields_in_unrelated_text_boxes_do_not_prevent_numbering() {
let mut s = Session::new(Document::from_text("Body"));
let box_id = s.run("insert.textBox", &json!({"text": "Body page: "})).unwrap()["story"].as_u64().unwrap() as u32;
s.run("insert.pageNumber", &json!({"position": "current"})).unwrap();
let footer = s.run("insert.footer", &json!({"text": "Keep footer"})).unwrap()["story"].as_u64().unwrap() as u32;
s.run("insert.pageNumber", &json!({"position": "bottom"})).unwrap();
assert_eq!(fields(&s.doc, StoryRef::Part(box_id), "PAGE"), 1);
assert_eq!(fields(&s.doc, StoryRef::Part(footer), "PAGE"), 1);
}
#[test]
fn cyclic_text_box_references_do_not_block_numbering() {
let mut s = Session::new(Document::from_text("Body"));
let footer = s.run("insert.footer", &json!({"text": "Keep footer"})).unwrap()["story"].as_u64().unwrap() as u32;
let box_id = s.run("insert.textBox", &json!({"text": "A box"})).unwrap()["story"].as_u64().unwrap() as u32;
let anchor = s.doc.para(StoryRef::Part(footer), &Path::top(0)).unwrap().objects.iter().find(|o| o.text_box() == Some(box_id)).unwrap().clone();
s.doc.para_mut(StoryRef::Part(box_id), &Path::top(0)).unwrap().insert_object(0, anchor, &CharProps::default()).unwrap();
s.run("insert.pageNumber", &json!({"position": "bottom"})).unwrap();
assert_eq!(fields(&s.doc, StoryRef::Part(footer), "PAGE"), 1);
}
#[test]
fn generated_numbers_switch_format_without_losing_neighboring_content() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let mut table = Table::new(1, 1, 300.0);
table.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Keep table", CharProps::default()))];
s.doc.story_mut(story).unwrap().push(Arc::new(Block::Table(table)));
let blocks = s.doc.story(story).unwrap().clone();
let selection = s.sel.clone();
for (format, align) in [("plain", "left"), ("x of y", "right"), ("plain", "center")] {
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": position, "format": format, "align": align})).unwrap();
assert_eq!(&s.doc.story(story).unwrap()[..blocks.len()], blocks.as_slice());
assert_eq!(s.sel, selection);
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
assert_eq!(fields(&s.doc, story, "NUMPAGES"), usize::from(format == "x of y"));
let expected = match align {
"left" => Align::Left,
"right" => Align::Right,
_ => Align::Center,
};
assert_eq!(s.doc.para(story, &Path::top(blocks.len())).unwrap().props.align, Some(expected));
let after = s.doc.clone();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc, after);
}
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let id = if position == "top" { back.last_section.headers.default } else { back.last_section.footers.default }.unwrap();
assert!(back.plain_text(StoryRef::Part(id)).starts_with("Keep this\nKeep table"));
assert_eq!(fields(&back, StoryRef::Part(id), "PAGE"), 1);
assert_eq!(fields(&back, StoryRef::Part(id), "NUMPAGES"), 0);
}
}
#[test]
fn repeated_numbering_keeps_clean_state_and_undo_depth() {
for position in ["top", "bottom"] {
for format in ["plain", "x of y"] {
let mut s = Session::new(Document::new());
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
s.dirty = false;
let doc = s.doc.clone();
let depth = s.undo_depth();
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
assert_eq!(s.doc, doc);
assert_eq!(s.undo_depth(), depth, "an identical request must not add an undo step");
assert!(!s.dirty, "an identical request must not dirty the document");
}
}
}
#[test]
fn reopened_empty_header_footer_mark_is_reused_without_a_blank_line() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": ""})).unwrap()["story"].as_u64().unwrap() as u32;
s.doc.para_mut(StoryRef::Part(id), &Path::top(0)).unwrap().mark = CharProps { bold: Some(true), italic: Some(true), ..Default::default() };
let doc = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let id = if position == "top" { doc.last_section.headers.default } else { doc.last_section.footers.default }.unwrap();
let story = StoryRef::Part(id);
let mark = doc.para(story, &Path::top(0)).unwrap().mark.clone();
assert_eq!(mark.bold, Some(true));
let mut s = Session::new(doc);
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": position})).unwrap();
assert_eq!(s.doc.story(story).unwrap().len(), 1, "reuse the imported empty paragraph rather than add a blank line");
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap().mark, mark);
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
let inserted = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": position, "format": "x of y", "align": "right"})).unwrap();
assert_eq!(s.doc.story(story).unwrap().len(), 1);
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap().mark, mark);
assert_eq!(s.doc.para(story, &Path::top(0)).unwrap().props.align, Some(Align::Right));
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
assert_eq!(fields(&s.doc, story, "NUMPAGES"), 1);
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, inserted);
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
}
}
#[test]
fn user_owned_page_number_lookalikes_are_preserved() {
for kind in ["formatting", "metadata", "another field"] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run("insert.pageNumber", &json!({"position": "bottom"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
match kind {
"formatting" => s.doc.para_mut(story, &Path::top(0)).unwrap().props.indent_left = Some(12.0),
"metadata" => {
let InlineObject::Field { locked, .. } = &mut s.doc.para_mut(story, &Path::top(0)).unwrap().objects[0] else {
panic!("no page field")
};
*locked = true;
}
_ => {
let mut para = Paragraph::with_text("User page: ", CharProps::default());
para.insert_object(para.len(), field("PAGE"), &CharProps::default()).unwrap();
s.doc.story_mut(story).unwrap().push(para_block(para));
}
}
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": "bottom", "format": "x of y"})).unwrap();
assert_eq!(s.doc, before, "preserve {kind} rather than claim the PAGE as generated");
}
}
#[test]
fn reopened_cached_page_numbers_keep_noops_and_gallery_changes() {
for (command, position) in [("insert.header", "top"), ("insert.footer", "bottom")] {
for format in ["plain", "x of y"] {
let mut s = Session::new(Document::from_text("Body"));
let id = s.run(command, &json!({"text": "Keep this"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
for object in &mut s.doc.para_mut(story, &Path::top(1)).unwrap().objects {
if let InlineObject::Field { instr, result, .. } = object {
*result = if instr == "PAGE" { "7" } else { "12" }.into();
}
}
let doc = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let id = if position == "top" { doc.last_section.headers.default } else { doc.last_section.footers.default }.unwrap();
let story = StoryRef::Part(id);
let mut s = Session::new(doc);
let cached = |s: &Session, name: &str| {
s.doc.para(story, &Path::top(1)).unwrap().objects.iter().find_map(|object| match object {
InlineObject::Field { instr, result, .. } if instr == name => Some(result.clone()),
_ => None,
})
};
assert_eq!(cached(&s, "PAGE").as_deref(), Some("7"), "the fixture must retain the refreshed PAGE cache after reopen");
assert_eq!(cached(&s, "NUMPAGES").as_deref(), (format == "x of y").then_some("12"));
let kept = s.doc.story(story).unwrap()[0].clone();
let mark = s.doc.para(story, &Path::top(1)).unwrap().mark.clone();
let selection = s.sel.clone();
s.dirty = false;
let before = s.doc.clone();
let depth = s.undo_depth();
s.run("insert.pageNumber", &json!({"position": position, "format": format})).unwrap();
assert_eq!(s.doc, before, "identical requests must retain refreshed results");
assert_eq!(s.undo_depth(), depth);
assert!(!s.dirty);
for (next_format, align) in [("x of y", "right"), ("plain", "left")] {
let before = s.doc.clone();
s.run("insert.pageNumber", &json!({"position": position, "format": next_format, "align": align})).unwrap();
assert_eq!(s.doc.story(story).unwrap()[0], kept);
assert_eq!(s.sel, selection);
assert_eq!(s.doc.para(story, &Path::top(1)).unwrap().mark, mark);
assert_eq!(s.doc.para(story, &Path::top(1)).unwrap().props.align, Some(if align == "right" { Align::Right } else { Align::Left }));
assert_eq!(fields(&s.doc, story, "PAGE"), 1);
assert_eq!(fields(&s.doc, story, "NUMPAGES"), usize::from(next_format == "x of y"));
assert_eq!(cached(&s, "PAGE").as_deref(), Some("7"));
if format == "x of y" && next_format == "x of y" {
assert_eq!(cached(&s, "NUMPAGES").as_deref(), Some("12"));
}
let after = s.doc.clone();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc, after);
}
}
}
}
+2 -1
View File
@@ -1177,7 +1177,8 @@ impl Styles {
"graphic-properties" if in_graphic_default && cur.is_none() => self.default_graphic = super::frames::Graphic::read(&a),
props if in_table_default == Some(props) && cur.is_none() => self.read_table_default(props, &a),
"text-properties" if in_default && cur.is_none() => {
self.default_font = default_font(&a, &self.faces);
// A later default style without a (usable) font keeps the earlier one (#4501).
self.default_font = default_font(&a, &self.faces).or(self.default_font.take());
if let Some(sz) = get(&a, "fo:font-size").and_then(length).filter(|v| v.is_finite() && *v >= 1.0) {
self.default_size = Some(sz.min(1638.0));
}
+30
View File
@@ -1289,6 +1289,36 @@ fn meta_creation_date_timezones_and_fallbacks() {
assert_eq!(m2.author, "Solo Creator");
}
#[test]
fn a_later_default_style_without_a_font_keeps_the_default_font() {
// #4501 follow-up: styles read twice (styles.xml, then content.xml) must not wipe the font.
let mut styles = styles::Styles::default();
let with_font = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="DejaVu Serif"/></style:default-style></office:styles></office:document-styles>"#
);
let without = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
);
styles.read(with_font.as_bytes(), false);
styles.read(without.as_bytes(), false);
assert_eq!(styles.doc_font().as_deref(), Some("DejaVu Serif"));
}
#[test]
fn a_later_monospaced_default_style_keeps_the_proportional_default_font() {
// #4501 follow-up: a monospaced default font is rejected and must not wipe the earlier one.
let mut styles = styles::Styles::default();
let with_font = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="DejaVu Serif"/></style:default-style></office:styles></office:document-styles>"#
);
let mono = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="Courier New"/></style:default-style></office:styles></office:document-styles>"#
);
styles.read(with_font.as_bytes(), false);
styles.read(mono.as_bytes(), false);
assert_eq!(styles.doc_font().as_deref(), Some("DejaVu Serif"));
}
/// The `content.xml` of a package.
pub(super) fn content_of(bytes: &[u8]) -> String {
let mut z = archive(bytes);
+8 -2
View File
@@ -7,8 +7,10 @@ use super::{
ParaLayout, Paragraph, Rect, SectionProps, StoryRef, ViewMode, Wrap, contour, math, para, styleref, textbox,
};
/// The character style Word formats line numbers with (`w:styleId="LineNumber"`).
/// The character style Word formats line numbers with: `w:styleId="LineNumber"` in English
/// Word; other languages localise the id (German `Zeilennummer`) but keep the built-in name.
const LINE_NUMBER_STYLE: &str = "LineNumber";
const LINE_NUMBER_STYLE_NAME: &str = "line number";
/// A layout pass: finished, or to be done again with the page totals it found, or with the
/// floats placed above their anchors it found ([`EarlyFloats`]).
@@ -199,7 +201,11 @@ impl Ctx<'_> {
// Word formats line numbers with the "Line Number" character style over the document's
// defaults (measured in Word for Mac: Aptos 12 pt black in a document whose defaults are
// Aptos 12 pt), not in a fixed small grey.
let style = self.doc.styles.get(LINE_NUMBER_STYLE).map(|s| s.id.clone());
let styles = &self.doc.styles;
let style = styles
.get(LINE_NUMBER_STYLE)
.or_else(|| styles.find(LINE_NUMBER_STYLE_NAME).filter(|s| s.kind == wordcraft_doc::StyleKind::Character))
.map(|s| s.id.clone());
let mut p = Paragraph::with_text(&n.to_string(), CharProps { style, ..Default::default() });
p.props.space_after = Some(0.0);
p.props.space_before = Some(0.0);
+3 -2
View File
@@ -777,7 +777,8 @@ fn add_notes(pb: &mut PageBuilder, mut new_notes: Vec<NoteBox>, notes: &[(usize,
}
}
/// Line numbers in the left margin for lines `l0..l1` of `pl` laid out from `y`.
/// Line numbers for lines `l0..l1` of `pl` laid out from `y`, left of the column they are in:
/// each column of a multi-column section gets its own (#6018), as in Word.
fn line_numbers(ctx: &mut Ctx, pb: &mut PageBuilder, pl: &ParaLayout, (l0, l1): (usize, usize), y: f32) -> Vec<Placed> {
let mut items = Vec::new();
if let Some(ln) = pb.sect.line_numbers.clone().filter(|_| !pb.web && pl.rp.style != "Header" && !pl.rp.suppress_line_numbers)
@@ -800,7 +801,7 @@ fn line_numbers(ctx: &mut Ctx, pb: &mut PageBuilder, pl: &ParaLayout, (l0, l1):
para: num.clone(),
l0: 0,
l1: 1,
x: pb.sect.margin_left + pb.sect.gutter - dist - w,
x: pb.col_x() - dist - w,
y: ly,
turn: TextDirection::Horizontal,
});
+28 -10
View File
@@ -270,12 +270,21 @@ fn section_tops(ctx: &mut Ctx, hf: &HfLinks, (si, sect): (usize, &SectionProps),
Tops { body, first, foot, first_foot }
}
/// Set up the walk state for a section: its grid, field values and footnote separators.
fn begin_section_state<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, sec: &Sec<'p>, totals: &Totals, web: bool) {
let (doc, sect, si) = (ctx.doc, sec.sect, sec.si);
/// Set up the walk state for a section before its header and footer room is measured: no grid yet
/// (the previous section's must not leak into headers, footers and separators), the field values
/// and the footnote separators.
fn begin_section_state<'p>(
ctx: &mut Ctx,
pb: &mut PageBuilder<'p>,
(si, sect, page_format): (usize, &'p SectionProps, wordcraft_doc::section::NumFormat),
totals: &Totals,
web: bool,
) {
let doc = ctx.doc;
ctx.grid = para::Grid::default();
pb.sect = sect;
pb.page_format = sec.page_format;
ctx.fields.page_format = sec.page_format;
pb.page_format = page_format;
ctx.fields.page_format = page_format;
pb.sect_idx = si;
pb.sect_beneath = doc.note_options(sect, false).pos == wordcraft_doc::section::NotePos::BeneathText;
if !web && !doc.footnote_separators.is_empty() {
@@ -283,7 +292,11 @@ fn begin_section_state<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, sec: &Sec<'p
}
ctx.fields.section = u32::try_from(si).unwrap_or(u32::MAX).saturating_add(1);
ctx.fields.section_pages = totals.section(si);
ctx.grid = para::Grid::of(doc, sect);
}
/// The section's own grid and line numbering, once its header and footer room is known.
fn apply_section_grid(ctx: &mut Ctx, pb: &mut PageBuilder<'_>, sect: &SectionProps) {
ctx.grid = para::Grid::of(ctx.doc, sect);
if sect.line_numbers.as_ref().is_some_and(|l| l.restart == wordcraft_doc::section::LineNumberRestart::Section) {
pb.line_no = 0;
}
@@ -541,17 +554,22 @@ pub(crate) fn layout_pass(
_ if mode.reflow => sect_ref,
_ => sect,
};
ctx.fields.page_format = page_format;
let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned());
let start = if si == 0 { SectionStart::NextPage } else { sect.start };
let sec = Sec { si, sect, page_format, start, tops };
if !mode.web && !pb.pages.is_empty() && (start != SectionStart::Continuous || turn != prev_turn) {
// The page the section before ends on is finished with its own margins and footnote
// separators, before this section's replace them: the footnotes pending on it
// belong to the section whose page it is (#5289).
pb.finish_page();
}
begin_section_state(&mut ctx, &mut pb, &sec, totals, mode.web);
// This section's fields and separators, without the previous section's grid, before its
// header and footer room is measured; its own grid once that room is known (#4501).
begin_section_state(&mut ctx, &mut pb, (si, sect, page_format), totals, mode.web);
let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned());
// Measuring the room took the (reflow) section's own number format; the body keeps the
// document section's.
ctx.fields.page_format = page_format;
apply_section_grid(&mut ctx, &mut pb, sect);
let sec = Sec { si, sect, page_format, start, tops };
if mode.web && si > 0 {
// one long page
} else if pb.pages.is_empty() || start != SectionStart::Continuous || turn != prev_turn {
+70
View File
@@ -4055,6 +4055,76 @@ fn line_numbers_follow_the_line_number_style() {
assert!(styled.iter().all(|(size, color)| *size == 8.0 && *color == red), "{styled:?}");
}
/// #6018: German Word saves the "line number" style as `Zeilennummer`; the numbers still take
/// its formatting, found by the built-in name.
#[test]
fn line_numbers_follow_a_localised_line_number_style() {
let mut d = Document::from_text("one\ntwo\nthree");
d.last_section.line_numbers = Some(Default::default());
let red = wordcraft_doc::TextColor::Rgb(wordcraft_doc::Rgb(0xC0, 0, 0));
Arc::make_mut(&mut d.styles).upsert(wordcraft_doc::Style {
id: "Zeilennummer".into(),
name: "Line Number".into(),
kind: wordcraft_doc::StyleKind::Character,
chr: CharProps { size: Some(8.0), color: Some(red), ..Default::default() },
..Default::default()
});
let l = lay(&d);
let runs: Vec<_> = l.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story, para, .. } if *story == DECOR_STORY => para.styles.first().map(|s| (s.size, s.rc.color)),
_ => None,
})
.collect();
assert_eq!(runs.len(), 3);
assert!(runs.iter().all(|(size, color)| *size == 8.0 && *color == red), "{runs:?}");
}
/// #6018: in two columns each column has its numbers at its own left edge, as in Word; the
/// second column's used to be drawn in the page margin over the first column's.
#[test]
fn line_numbers_stand_beside_each_column() {
crate::test_fonts();
let text = (1..=120).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
let mut d = Document::from_text(&text);
d.last_section.line_numbers = Some(Default::default());
d.last_section.columns = wordcraft_doc::section::Columns { count: 2, space: 36.0, separator: false, widths: Vec::new() };
let s = d.last_section.clone();
let col2 = s.margin_left + s.gutter + (s.text_width() - 36.0) / 2.0 + 36.0;
let l = lay(&d);
let p = &l.pages[0];
// Right edge and top of every number.
let numbers: Vec<(f32, f32)> = p
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story, para, x, y, .. } if *story == DECOR_STORY => {
Some((x + para.lines.first().and_then(|l| l.xs.last().copied()).unwrap_or(0.0), *y))
}
_ => None,
})
.collect();
let body_xs: Vec<f32> = p
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story: StoryRef::Body, x, .. } => Some(*x),
_ => None,
})
.collect();
assert!(body_xs.iter().any(|x| (x - col2).abs() < 0.5), "the text runs into the second column: {body_xs:?}");
let in_col2 = body_xs.iter().filter(|x| (*x - col2).abs() < 0.5).count();
let right: Vec<_> = numbers.iter().filter(|(r, _)| *r > s.margin_left + 1.0).collect();
assert_eq!(right.len(), in_col2, "one number per second-column line");
// Numbers end 18 pt (auto distance) left of their column.
assert!(right.iter().all(|(r, _)| (r - (col2 - 18.0)).abs() < 0.5), "{right:?}");
let left = numbers.len() - right.len();
assert!(numbers.iter().filter(|(r, _)| *r <= s.margin_left + 1.0).all(|(r, _)| (r - (s.margin_left - 18.0)).abs() < 0.5));
assert!(left > 0);
}
#[test]
fn line_numbers_borders_text_boxes() {
crate::test_fonts();
+55
View File
@@ -627,6 +627,61 @@ fn a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits() {
}
}
#[test]
fn a_sections_header_is_measured_without_the_previous_sections_grid() {
// #4501 follow-up: section 1 has a line grid, section 2 a tall header of its own. The header
// room is measured before section 2's grid is on, so section 1's pitch never reaches it.
let body_top = |grid: Option<wordcraft_doc::section::DocGrid>| {
let mut d = Document::from_text("First section\nSecond section");
let id = d.add_part(PartKind::Header, paras("Header line", 8));
d.last_section.headers.default = Some(id);
let first = SectionProps { doc_grid: grid, headers: Default::default(), ..d.last_section.clone() };
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first));
let l = lay(&d);
assert_eq!(l.pages.len(), 2);
l.pages[1].body.y
};
let grid = wordcraft_doc::section::DocGrid { kind: wordcraft_doc::section::DocGridType::Lines, line_pitch: 18.0, char_space: 0 };
let plain = body_top(None);
assert!(plain > 72.0, "the header reaches past the top margin: {plain}");
assert_eq!(body_top(Some(grid)), plain, "section 1's grid leaked into section 2's header");
}
#[test]
fn a_sections_header_is_measured_with_its_own_section_page_count() {
// #4501 follow-up: section 1 has one page ("one"), section 2 twenty-three ("twenty-three").
// Section 2's header room is measured with its own SECTIONPAGES, so its body starts below it.
// (The total in the body makes layout run again with the page totals found.)
let mut d = Document::from_text(&format!("First section\n{}", "Second section\n".repeat(22)));
for i in 1..d.body.len() {
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.page_break_before = Some(i > 1);
}
let field = InlineObject::Field {
instr: r"SECTIONPAGES \* CardText".into(),
result: String::new(),
locked: false,
dirty: true,
code: false,
form: String::new(),
};
let first_para = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
first_para.insert_object(first_para.len(), field.clone(), &Default::default()).unwrap();
let mut header = Paragraph::new();
for _ in 0..60 {
header.insert_object(header.len(), field.clone(), &Default::default()).unwrap();
}
let id = d.add_part(PartKind::Header, vec![para_block(header)]);
d.last_section.headers.default = Some(id);
let first = SectionProps { headers: Default::default(), ..d.last_section.clone() };
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first));
let l = lay(&d);
assert_eq!(l.pages.len(), 24);
let p = &l.pages[1];
let (_, header_bottom) = extent(&p.header, StoryRef::Part(id)).unwrap();
assert!(header_bottom > 72.0, "the header reaches past the top margin: {header_bottom}");
assert!(header_bottom <= p.body.y + 0.01, "the body starts at {} inside the header ending at {header_bottom}", p.body.y);
}
/// A body PAGE field shows the page number in the section's number format, as Word does (a
/// cardinal-text section shows "one"), and `\*` overrides it.
#[test]
+5 -2
View File
@@ -321,7 +321,9 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
let galley = ui.painter().layout_no_wrap(caption.to_string(), regular(12.5), ui.visuals().text_color());
let (tile, resp) = ui.allocate_exact_size(vec2(galley.size().x.max(150.0), 194.0 + galley.size().y), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), caption));
let r = Rect::from_min_size(tile.min, vec2(150.0, 194.0));
let flows = crate::rtl::flows(ui);
let r_min_x = if flows { tile.max.x - 150.0 } else { tile.min.x };
let r = Rect::from_min_size(pos2(r_min_x, tile.min.y), vec2(150.0, 194.0));
ui.painter().rect(
r,
2.0,
@@ -352,7 +354,8 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
if let Some(h) = tex {
ui.painter().image(h.id(), r.shrink(1.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE);
}
ui.painter().galley(pos2(r.min.x, r.max.y), galley, ui.visuals().text_color());
let x = if flows { r.max.x - galley.size().x } else { r.min.x };
ui.painter().galley(pos2(x, r.max.y), galley, ui.visuals().text_color());
if resp.clicked() {
let _ = app.run("file.new", json!({"template": template}));
app.ui.backstage = false;
+136
View File
@@ -1156,3 +1156,139 @@ fn the_file_page_scrolls_with_the_wheel_at_the_window_edge() {
assert!(after < before - 1.0, "expected doc0.docx to scroll up at the edge: before={before}, after={after}");
assert!((bar.right() - 1432.0).abs() < 1.0, "expected the scrollbar at the window edge: {bar:?}");
}
/// Recursively collects bounding rectangles and text entries from egui shapes.
fn collect_shapes(shape: &egui::Shape, rects: &mut Vec<egui::Rect>, texts: &mut Vec<(String, egui::Pos2, f32)>) {
match shape {
egui::Shape::Rect(r) => rects.push(r.rect),
egui::Shape::Text(t) => texts.push((t.galley.text().to_string(), t.pos, t.galley.size().x)),
egui::Shape::Vec(v) => {
for s in v {
collect_shapes(s, rects, texts);
}
}
_ => {}
}
}
/// #6151: in an RTL interface (Arabic), template titles on the Start/Home page
/// and New page are right-aligned to their preview thumbnails rather than left-aligned.
#[test]
fn rtl_template_titles_are_right_aligned() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default());
a.ui.language = "ar".into();
a.ui.backstage = true;
a.ui.backstage_page = "home".into();
let ctx = egui::Context::default();
let mut last_shapes = Vec::new();
for frame in 0..4 {
let input = egui::RawInput {
time: Some(f64::from(frame) * 0.1),
screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1440.0, 900.0))),
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
a.logic(&ctx.clone());
a.ui(ui);
});
out.textures_delta.clear();
if frame == 3 {
last_shapes = out.shapes;
}
}
let mut rects = Vec::new();
let mut texts = Vec::new();
for s in &last_shapes {
collect_shapes(&s.shape, &mut rects, &mut texts);
}
let blank_caption = crate::i18n::tc("template", "Blank document");
let (_, pos, width) = texts.iter().find(|(t, _, _)| t == blank_caption).expect("blank template caption drawn");
let thumbnail = rects
.iter()
.find(|r| (r.width() - 150.0).abs() < 1.0 && (r.height() - 194.0).abs() < 1.0 && (r.max.y - pos.y).abs() < 1.0)
.expect("blank template thumbnail rect");
let text_right = pos.x + width;
assert!(
(thumbnail.max.x - text_right).abs() < 1.0,
"template caption should be right-aligned with thumbnail in RTL: thumbnail.max.x={}, text_right={}, text_left={}, caption={}",
thumbnail.max.x,
text_right,
pos.x,
blank_caption,
);
// Verify the New page through the normal UI path in Arabic as well (#6151).
a.ui.backstage_page = "new".into();
let mut new_out = ctx.run_ui(
egui::RawInput {
time: Some(0.4),
screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1440.0, 900.0))),
..Default::default()
},
|ui| {
a.logic(&ctx.clone());
a.ui(ui);
},
);
new_out.textures_delta.clear();
let mut new_rects = Vec::new();
let mut new_texts = Vec::new();
for s in &new_out.shapes {
collect_shapes(&s.shape, &mut new_rects, &mut new_texts);
}
for (label, _) in super::TEMPLATES {
let caption = crate::i18n::tc("template", label);
if let Some((_, new_pos, new_width)) = new_texts.iter().find(|(t, _, _)| t == caption) {
let right = new_pos.x + new_width;
let has_matching_thumbnail = new_rects.iter().any(|r| {
(r.width() - 150.0).abs() < 1.0
&& (r.height() - 194.0).abs() < 1.0
&& (r.max.y - new_pos.y).abs() < 1.0
&& (r.max.x - right).abs() < 1.0
});
assert!(
has_matching_thumbnail,
"New page: caption '{caption}' must be right-aligned with its 150pt thumbnail (right={right}, y={})",
new_pos.y
);
}
}
// Also verify that a template tile with a wide caption (>150 pt) aligns both the thumbnail
// and caption to the right edge in RTL.
let wide_label = "A very long template title that exceeds 150 points width";
let mut wide_out = ctx.run_ui(
egui::RawInput {
time: Some(0.5),
screen_rect: Some(egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1440.0, 900.0))),
..Default::default()
},
|ui| {
crate::rtl::root(ui, |ui| {
super::template_tile(ui, &mut a, wide_label, "sample");
});
},
);
wide_out.textures_delta.clear();
let mut wide_rects = Vec::new();
let mut wide_texts = Vec::new();
for s in &wide_out.shapes {
collect_shapes(&s.shape, &mut wide_rects, &mut wide_texts);
}
let (_, wide_pos, wide_width) = wide_texts.iter().find(|(t, _, _)| t == wide_label).expect("wide template caption drawn");
assert!(*wide_width > 150.0, "wide caption should exceed 150 points: {wide_width}");
let wide_thumbnail = wide_rects
.iter()
.find(|r| (r.width() - 150.0).abs() < 1.0 && (r.height() - 194.0).abs() < 1.0 && (r.max.y - wide_pos.y).abs() < 1.0)
.expect("wide template thumbnail rect");
let wide_text_right = wide_pos.x + wide_width;
assert!(
(wide_thumbnail.max.x - wide_text_right).abs() < 1.0,
"wide template caption and thumbnail should share the same right edge in RTL: thumb.max.x={}, text_right={}",
wide_thumbnail.max.x,
wide_text_right,
);
}
+13 -9
View File
@@ -175,7 +175,9 @@ fn handle_page_pointer(app: &mut WordApp, pointer: PagePointer<'_>) {
return;
}
if pressed {
handle_primary_press(app, &pointer, mods, hf_zone, hf_enter);
if handle_primary_press(app, &pointer, mods, hf_zone, hf_enter) {
return;
}
} else if app.canvas.dragging
&& ui.input(|i| i.pointer.primary_down())
&& let Some((from, last)) = app.canvas.column_drag
@@ -229,13 +231,14 @@ fn handle_page_pointer(app: &mut WordApp, pointer: PagePointer<'_>) {
show_hover_link(app, &pointer);
}
/// A press. `true` when it was handled and the pointer is done (no hover tooltip this frame).
fn handle_primary_press(
app: &mut WordApp,
pointer: &PagePointer<'_>,
mods: egui::Modifiers,
hf_zone: Option<(Option<StoryRef>, bool)>,
hf_enter: bool,
) {
) -> bool {
let PagePointer { ui, layout, p, page, x, y, story, .. } = *pointer;
let now = ui.input(|i| i.time);
let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist));
@@ -269,14 +272,14 @@ fn handle_primary_press(
}
app.session.page_hint = page;
}
return;
return true;
}
// A press in the header/footer area from the body leaves the caret where it is, as in
// Word (#2103): moving it to the nearest body text scrolled the view there, away from the
// footer the double-click was aiming at.
if hf_switch && !mods.shift && hf_zone.is_some() && story == StoryRef::Body && app.session.sel.focus.story == StoryRef::Body {
app.canvas.column_drag = None;
return;
return true;
}
if !mods.shift
&& let Some((at, inner)) = layout.equation_hit(page, x, y, story)
@@ -284,7 +287,7 @@ fn handle_primary_press(
app.session.sel = wordcraft_engine::Selection::caret(at);
let _ = app.run("equation.edit", json!({"pos": serde_json::to_value(&inner).unwrap_or_default()}));
app.session.page_hint = page;
return;
return true;
}
if app.session.math.is_some() {
let _ = app.run("equation.exit", json!({}));
@@ -324,15 +327,15 @@ fn handle_primary_press(
};
app.canvas.unit_drag = Some(UnitDrag { unit, clicks, start, end, last: line });
app.canvas.dragging = true;
return;
return true;
}
let Some(pos) = layout.hit(page, x, y, story) else { return };
let Some(pos) = layout.hit(page, x, y, story) else { return true };
// Ctrl/⌘+click follows a hyperlink.
if mods.command
&& let Some(link) = link_under(app, layout, page, x, y, &pos)
{
follow_link(app, link);
return;
return true;
}
let pj = serde_json::to_value(&pos).unwrap_or_default();
// Alt+drag selects a column (block) of text.
@@ -341,7 +344,7 @@ fn handle_primary_press(
app.session.page_hint = page;
app.canvas.column_drag = Some(((page, x, y), (page, x, y)));
app.canvas.dragging = true;
return;
return true;
}
app.canvas.column_drag = None;
let _ = app.run("caret.set", json!({"pos": pj, "extend": mods.shift}));
@@ -362,6 +365,7 @@ fn handle_primary_press(
app.session.page_hint = page;
app.canvas.dragging = true;
// Format painter applies on mouse up
false
}
fn pointer_story(app: &WordApp, layout: &DocLayout, page: usize, x: f32, y: f32, story: StoryRef) -> StoryRef {
+34
View File
@@ -2887,6 +2887,40 @@ fn right_clicking_a_link_hides_the_hover_tooltip() {
assert_eq!(tips(&h), 0, "the link tooltip is gone, so it can't cover the menu");
}
/// #4501 follow-up: a press the canvas handles (here Ctrl+click following a link) is done with
/// the pointer, so the link tooltip stands down that frame; plain hovering shows it again.
#[test]
fn a_handled_press_skips_the_link_tooltip() {
use egui::{Event, Modifiers, PointerButton};
use egui_kittest::kittest::Queryable;
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("See here")), Default::default());
a.run("select.text", json!({"text": "here"})).unwrap();
a.run("insert.link", json!({"url": "https://example.com/"})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
steps(&mut h, 6);
let c = h.state_mut().session.layout().caret(&Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 5 }).unwrap();
let at = screen(&mut h, c.x, c.top + c.height / 2.0);
h.event(Event::PointerMoved(at));
steps(&mut h, 2);
let tips = |h: &egui_kittest::Harness<'_, WordApp>| h.query_all_by_label_contains("Click to follow link").count();
assert!(tips(&h) > 0, "hovering the link shows its target");
h.event(Event::ModifiersChanged(Modifiers::COMMAND));
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::COMMAND });
h.step();
assert_eq!(tips(&h), 0, "the Ctrl+click was handled: no tooltip that frame");
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::COMMAND });
h.event(Event::ModifiersChanged(Modifiers::NONE));
steps(&mut h, 2);
assert!(tips(&h) > 0, "hovering again shows the tooltip");
}
/// #688: the drag auto-scroll waits in the middle, ramps in from both edges and never leaves
/// the scrollable range.
#[test]
+9 -3
View File
@@ -67,6 +67,10 @@ pub(crate) struct CaptureGeometry {
pub(super) document: u64,
}
/// Explanation returned when a capture is attempted on a secondary viewport.
pub(crate) const UNSUPPORTED_SECONDARY_WINDOW_ERROR: &str =
"capturing a secondary document window is not supported by the viewport renderer; ui.screenshot captures only the window in the root viewport";
impl CaptureGeometry {
/// Returns the document id the capture was admitted for.
pub(crate) fn document(self) -> u64 {
@@ -85,10 +89,12 @@ impl CaptureGeometry {
}
}
/// Explains why the surface cannot be captured, when it is minimized or fully occluded, and
/// returns `None` when it can.
/// Explains why the surface cannot be captured: when it is in a secondary viewport that the
/// viewport renderer cannot capture, or when the window is minimized or fully occluded.
pub(crate) fn unavailable_reason(self) -> Option<&'static str> {
if self.surface.minimized {
if self.viewport != egui::ViewportId::ROOT {
Some(UNSUPPORTED_SECONDARY_WINDOW_ERROR)
} else if self.surface.minimized {
Some("the window is minimized")
} else if self.surface.occluded {
Some("the window is fully occluded")
+14 -1
View File
@@ -1,4 +1,4 @@
use super::geometry::CaptureGeometry;
use super::geometry::{CaptureGeometry, UNSUPPORTED_SECONDARY_WINDOW_ERROR};
use super::{CaptureSlot, Sender, Value, WordApp, json};
/// A page rendered by the engine, as base64 PNG in the reply (the same shape as the headless
@@ -128,6 +128,19 @@ pub(crate) fn admit_screenshot(app: &mut WordApp, ctx: &egui::Context, path: Opt
reject_shot(reply, token, "screenshot failed: the window geometry is not known yet");
return;
};
if geometry.viewport != egui::ViewportId::ROOT {
let msg = match app.root_window_info() {
Some((root_key, _doc_id, root_title)) if !root_title.is_empty() => format!(
"screenshot failed: {UNSUPPORTED_SECONDARY_WINDOW_ERROR}. Select the root window first (view.switchWindows {{\"window\": {root_key}}}) for {root_title:?}."
),
Some((root_key, ..)) => format!(
"screenshot failed: {UNSUPPORTED_SECONDARY_WINDOW_ERROR}. Select the root window first (view.switchWindows {{\"window\": {root_key}}})."
),
None => format!("screenshot failed: {UNSUPPORTED_SECONDARY_WINDOW_ERROR}."),
};
reject_shot(reply, token, msg);
return;
}
if let Some(reason) = geometry.unavailable_reason() {
reject_shot(reply, token, format!("screenshot failed: {reason}"));
return;
@@ -12,11 +12,16 @@ use crate::options::MeasureExt;
use crate::theme::{Tokens, regular, semibold};
mod columns;
mod date_time;
mod field;
mod symbol;
pub use columns::{ColumnsForm, DropCapForm};
use columns::{columns_ui, drop_cap_ui};
pub use date_time::DateTimeForm;
#[cfg(test)]
pub(crate) use date_time::date_time_preview_matches;
use date_time::date_time_ui;
use field::field_ui;
pub use field::{DATE_FORMATS, FIELD_CATEGORIES, FIELDS, FieldForm, FieldKind, NUMBER_FORMATS, STYLEREF_SWITCHES, field_kind};
use symbol::symbol_ui;
@@ -25,6 +30,8 @@ pub use symbol::{RECENT_SYMBOLS, RecentSymbol, SPECIAL, SUBSETS, SymbolForm, rem
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "form", rename_all = "camelCase")]
pub enum InsertDialog {
/// Date & Time insertion dialog.
DateTime(DateTimeForm),
Columns(ColumnsForm),
Symbol(SymbolForm),
Field(FieldForm),
@@ -35,8 +42,10 @@ pub enum InsertDialog {
}
impl InsertDialog {
/// The dialog name used in command dispatch and UI state.
pub fn name(&self) -> &'static str {
match self {
InsertDialog::DateTime(_) => "dateTime",
InsertDialog::Columns(_) => "columns",
InsertDialog::Symbol(_) => "symbol",
InsertDialog::Field(_) => "field",
@@ -51,6 +60,7 @@ impl InsertDialog {
/// The window title (English; the caller translates it).
pub fn title(&self) -> &'static str {
match self {
InsertDialog::DateTime(_) => "Date and Time",
InsertDialog::Columns(_) => "Columns",
InsertDialog::Symbol(_) => "Symbol",
InsertDialog::Field(_) => "Field",
@@ -68,6 +78,7 @@ impl InsertDialog {
/// `insertSmartArt`, or `dropCap` (Drop Cap Options…, where the paragraph can take one).
pub fn open(name: &str, app: &WordApp) -> Option<InsertDialog> {
Some(match name {
"dateTime" => InsertDialog::DateTime(DateTimeForm::new(app)),
"columns" => InsertDialog::Columns(ColumnsForm::read(app)),
"symbol" => InsertDialog::Symbol(SymbolForm::new(app, 0)),
"specialCharacters" => InsertDialog::Symbol(SymbolForm::new(app, 1)),
@@ -84,6 +95,7 @@ pub fn open(name: &str, app: &WordApp) -> Option<InsertDialog> {
/// Draws the dialog; returns true to close it.
pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut InsertDialog) -> bool {
match d {
InsertDialog::DateTime(f) => date_time_ui(app, ui, f),
InsertDialog::Columns(f) => columns_ui(app, ui, f),
InsertDialog::Symbol(f) => symbol_ui(app, ui, f),
InsertDialog::Field(f) => field_ui(app, ui, f),
@@ -0,0 +1,141 @@
//! Date & Time insertion dialog (Word-style format and language selection).
use egui::{Stroke, Ui};
use serde::Serialize;
use serde_json::{Value, json};
use super::buttons;
use super::field::DATE_FORMATS;
use crate::WordApp;
use crate::theme::Tokens;
/// Predefined Portuguese (Brazil) date and time format pictures.
const PT_DATE_FORMATS: &[&str] = &[
"dd/MM/yyyy",
"dddd, d 'de' MMMM 'de' yyyy",
"d 'de' MMMM 'de' yyyy",
"dd/MM/yy",
"yyyy-MM-dd",
"d-MMM-yy",
"dd.MM.yyyy",
"d MMMM yyyy",
"MMM-yy",
"dd/MM/yyyy HH:mm",
"dd/MM/yyyy HH:mm:ss",
"HH:mm",
"HH:mm:ss",
];
/// The date preview list consists of document values (not interface strings).
/// The i18n audit recognizes these dynamic values instead of treating month names as UI labels.
#[cfg(test)]
pub(crate) fn date_time_preview_matches(value: &str) -> bool {
PT_DATE_FORMATS.iter().chain(DATE_FORMATS.iter()).any(|picture| {
wordcraft_engine::cmd::insert::format_date_in(picture, Some("pt-BR")) == value
|| wordcraft_engine::cmd::insert::format_date_in(picture, Some("en-US")) == value
})
}
/// State of the Word-like Insert > Date & Time dialog, including an optional validation error.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DateTimeForm {
/// Selected Word field picture, not a Rust strftime format.
pub format: String,
/// Language of the resulting text or DATE field.
pub language: String,
/// Whether to insert a refreshable field rather than fixed text.
pub update: bool,
/// Last insertion failure; retain the window so the user can change the choice.
#[serde(skip)]
pub error: Option<String>,
/// Brief status confirmation message (e.g. after setting default preferences).
#[serde(skip)]
pub status: Option<&'static str>,
}
impl DateTimeForm {
/// Restore a saved format/language or choose a sensible default for the UI language.
pub fn new(app: &WordApp) -> Self {
let language = if app.ui.date_time_language.is_empty() {
if crate::i18n::current().code().to_ascii_lowercase().starts_with("pt") { "pt-BR" } else { "en-US" }.to_string()
} else if app.ui.date_time_language.to_ascii_lowercase().starts_with("pt") {
"pt-BR".to_string()
} else {
"en-US".to_string()
};
let list = if language == "pt-BR" { PT_DATE_FORMATS } else { DATE_FORMATS };
let format = if list.contains(&app.ui.date_time_format.as_str()) { app.ui.date_time_format.clone() } else { list[0].to_string() };
Self { format, language, update: app.ui.date_time_update, error: None, status: None }
}
/// Pictures available in the chosen language.
fn formats(&self) -> &'static [&'static str] {
if self.language == "pt-BR" { PT_DATE_FORMATS } else { DATE_FORMATS }
}
/// Engine command parameters corresponding to the current controls.
pub fn params(&self) -> Value {
json!({"format": self.format, "language": self.language, "update": self.update})
}
}
/// Draw the interactive format list, language picker and update checkbox; close on successful
/// insertion or Cancel, keeping errors visible rather than silently dismissing the dialog.
pub(super) fn date_time_ui(app: &mut WordApp, ui: &mut Ui, f: &mut DateTimeForm) -> bool {
let tokens = Tokens::get(ui.ctx());
ui.set_width(520.0);
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(350.0);
ui.label(tl!("Date formats:"));
egui::Frame::new().stroke(Stroke::new(1.0, tokens.border)).inner_margin(2.0).show(ui, |ui| {
egui::ScrollArea::vertical().id_salt("date_time_formats").max_height(245.0).auto_shrink([false, false]).show(ui, |ui| {
for picture in f.formats() {
let sample = wordcraft_engine::cmd::insert::format_date_in(picture, Some(&f.language));
if ui.selectable_label(f.format == *picture, sample).on_hover_text(*picture).clicked() {
f.format = (*picture).to_string();
}
}
});
});
});
ui.add_space(12.0);
ui.vertical(|ui| {
ui.label(tl!("Language"));
craft_ui::ComboBox::from_id_salt("date_time_lang")
.selected_text(if f.language == "pt-BR" { tl!("Portuguese (Brazil)") } else { tl!("English (United States)") })
.show_ui(ui, |ui| {
if ui.selectable_label(f.language == "pt-BR", tl!("Portuguese (Brazil)")).clicked() {
f.language = "pt-BR".into();
f.format = PT_DATE_FORMATS[0].into();
}
if ui.selectable_label(f.language == "en-US", tl!("English (United States)")).clicked() {
f.language = "en-US".into();
f.format = DATE_FORMATS[0].into();
}
});
});
});
ui.add_space(8.0);
ui.checkbox(&mut f.update, tl!("Update automatically"));
ui.add_space(6.0);
if ui.button(tl!("Set as Default")).clicked() {
app.ui.date_time_format = f.format.clone();
app.ui.date_time_language = f.language.clone();
app.ui.date_time_update = f.update;
f.status = Some(tl!("Default format saved."));
}
if let Some(status) = f.status {
ui.colored_label(tokens.text_dim, status);
}
if let Some(error) = &f.error {
ui.colored_label(ui.visuals().error_fg_color, error);
}
let (ok, cancel) = buttons(ui, "OK", "Cancel", true);
if ok {
match app.run("insert.dateTime", f.params()) {
Ok(_) => return true,
Err(error) => {
f.error = Some(error.to_string());
return false;
}
}
}
cancel
}
@@ -166,3 +166,155 @@ fn field_dialog_lists_the_document_information_fields() {
f.select("SUBJECT");
assert_eq!(f.instruction(), "SUBJECT");
}
/// The Date & Time dialog uses the saved pt-BR editing language when present in UI preferences.
#[test]
fn date_time_dialog_uses_saved_pt_br_language() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
a.ui.date_time_language = "pt-BR".into();
let InsertDialog::DateTime(mut f) = open("dateTime", &a).expect("dialog") else { panic!("not DateTime") };
assert_eq!(f.format, "dd/MM/yyyy");
assert_eq!(f.params()["language"], "pt-BR");
assert!(!f.update);
f.update = false;
assert_eq!(f.params()["update"], false);
}
/// Language tags with primary subtag `pt` (such as `pt_BR` or `pt-PT`) are normalized to `pt-BR`.
#[test]
fn date_time_dialog_normalizes_portuguese_language_tags() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
a.ui.date_time_language = "pt_BR".into();
let InsertDialog::DateTime(f) = open("dateTime", &a).expect("dialog") else { panic!("not DateTime") };
assert_eq!(f.language, "pt-BR");
assert_eq!(f.format, "dd/MM/yyyy");
a.ui.date_time_language = "pt-PT".into();
let InsertDialog::DateTime(f) = open("dateTime", &a).expect("dialog") else { panic!("not DateTime") };
assert_eq!(f.language, "pt-BR");
}
/// Click the real egui language menu, date picture, checkbox and OK; ensure the resulting
/// document contains pt-BR fixed text with the language on its run, not just JSON params.
#[test]
fn date_time_dialog_controls_insert_portuguese_fixed_text() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
app.ui.language = "en".into();
app.ui.date_time_language = "en-US".into();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.state_mut().run("ui.dialog", json!({"name": "dateTime"})).unwrap();
h.run_steps(4);
// Go through the actual language combo box and then select a long Portuguese date.
// Scope the click to this dialog: the ribbon has another English-language button.
// The popup choice has a plain accessible label; the main ribbon uses a different control.
h.get_by_role_and_label(Role::Window, "Date and Time").get_by_role(Role::ComboBox).click();
h.run_steps(3);
h.get_by_role_and_label(Role::Button, "Portuguese (Brazil)").click();
h.run_steps(3);
let sample = wordcraft_engine::cmd::insert::format_date_in("d 'de' MMMM 'de' yyyy", Some("pt-BR"));
h.get_by_label(&sample).click();
h.run_steps(2);
// By default, update is unchecked (false). Toggle it on and off to verify the checkbox.
assert!(matches!(
h.state().dialog.as_ref(),
Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if !f.update)
));
h.get_by_label("Update automatically").click();
h.run_steps(2);
assert!(matches!(
h.state().dialog.as_ref(),
Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if f.update)
));
h.get_by_label("Update automatically").click();
h.run_steps(2);
assert!(matches!(
h.state().dialog.as_ref(),
Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if f.language == "pt-BR" && f.format == "d 'de' MMMM 'de' yyyy" && !f.update)
));
h.get_by_role_and_label(Role::Button, "OK").click();
h.run_steps(3);
assert!(h.state().dialog.is_none(), "successful insertion closes the dialog");
let doc = &h.state().session.doc;
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body), sample);
let para = doc.para_at(&doc.start_of(wordcraft_doc::StoryRef::Body)).expect("inserted paragraph");
assert_eq!(para.props_of_char(0).lang.as_deref(), Some("pt-BR"));
assert!(para.objects.iter().all(|o| !matches!(o, wordcraft_doc::para::InlineObject::Field { .. })));
}
/// Checking 'Update automatically' inserts a refreshable dynamic DATE field.
#[test]
fn date_time_dialog_controls_insert_dynamic_field() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
app.ui.language = "en".into();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.state_mut().run("ui.dialog", json!({"name": "dateTime"})).unwrap();
h.run_steps(4);
h.get_by_label("Update automatically").click();
h.run_steps(2);
assert!(matches!(
h.state().dialog.as_ref(),
Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if f.update)
));
h.get_by_role_and_label(Role::Button, "OK").click();
h.run_steps(3);
assert!(h.state().dialog.is_none(), "successful insertion closes the dialog");
let doc = &h.state().session.doc;
let para = doc.para_at(&doc.start_of(wordcraft_doc::StoryRef::Body)).expect("inserted paragraph");
assert!(para.objects.iter().any(|o| matches!(o, wordcraft_doc::para::InlineObject::Field { .. })));
}
/// An invalid saved picture is reported in the modal, not silently dismissed when OK is clicked.
#[test]
fn date_time_dialog_keeps_open_and_displays_invalid_picture_error() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
app.ui.language = "en".into();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.state_mut().run("ui.dialog", json!({"name": "dateTime"})).unwrap();
if let Some(crate::dialogs::Dialog::Insert(d)) = h.state_mut().dialog.as_mut()
&& let InsertDialog::DateTime(f) = &mut **d
{
f.format = "invalid\\picture".into();
} else {
panic!("date/time dialog expected");
}
h.run_steps(3);
h.get_by_role_and_label(Role::Button, "OK").click();
h.run_steps(3);
assert!(matches!(
h.state().dialog.as_ref(),
Some(crate::dialogs::Dialog::Insert(d)) if matches!(&**d, InsertDialog::DateTime(f) if f.error.as_deref().is_some_and(|e| e.contains("invalid date picture")))
));
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "");
}
+1
View File
@@ -235,6 +235,7 @@ impl<'a> Exempt<'a> {
|| matches!(plain, "WordCraft" | "WordCraft User" | "Discord" | "https://" | "auto" | "I. II. III." | "i. ii. iii." | "I, II, III, …" | "i, ii, iii, …")
|| plain == "CONFIDENTIAL" // the watermark dialog's text: document content, kept as typed
|| plain == "Figure 1" // the caption dialog's sample: the label goes into the document as is
|| crate::dialogs_insert::date_time_preview_matches(plain)
}
}
+1 -1
View File
@@ -254,7 +254,7 @@ fn controls(tab: &str) -> Vec<Control> {
m("WordArt", "insert.wordArt"),
m("Drop Cap", "insert.dropCap"),
c("Signature Line", "insert.signatureLine"),
c("Date & Time", "insert.dateTime"),
d("Date & Time", "dateTime"),
c("Object", "insert.object"),
c("Equation", "insert.equation"),
m("Symbol", "insert.symbol"),
+10
View File
@@ -294,6 +294,12 @@ pub struct UiState {
pub recent: Vec<String>,
/// Symbols inserted from the Symbol dialog, most recent first (#321).
pub recent_symbols: Vec<dialogs_insert::RecentSymbol>,
/// Saved choices in Insert > Date & Time: format picture.
pub date_time_format: String,
/// Saved language tag in Insert > Date & Time.
pub date_time_language: String,
/// Saved preference for updating inserted date/time fields automatically (defaults to false).
pub date_time_update: bool,
/// Colours picked in the Colors dialog (More Colors…), most recent first (#606).
pub recent_colors: Vec<String>,
/// Fonts applied from the font box, the mini toolbar or the Font dialog, most recent first:
@@ -445,6 +451,7 @@ pub const MAX_RECENT_FONTS: usize = 10;
pub const MAX_FONT_NAME_BYTES: usize = 128;
impl Default for UiState {
/// Default user interface preferences matching Word defaults.
fn default() -> Self {
UiState {
tab: DEFAULT_TAB.into(),
@@ -455,6 +462,9 @@ impl Default for UiState {
quick_access_toolbar: true,
recent: Vec::new(),
recent_symbols: Vec::new(),
date_time_format: String::new(),
date_time_language: String::new(),
date_time_update: false,
recent_colors: Vec::new(),
recent_fonts: Vec::new(),
theme: theme::Appearance::default(),
+26 -6
View File
@@ -70,7 +70,10 @@ use shape::shape_format;
use table::{table_design, table_layout};
#[cfg(test)]
use tabs::MIN_TAB_W;
use tabs::{MORE_TABS_W, TAB_ENTRY_SECS, TAB_ENTRY_SLIDE, ease_out_cubic, note_contextual_tabs, reading_toolbar, tab_entry};
use tabs::{
CONTENT_SWITCH_SLIDE, MORE_TABS_W, TAB_ENTRY_SECS, TAB_ENTRY_SLIDE, ease_out_cubic, note_contextual_tabs, reading_toolbar, slide_underline,
tab_content_entry, tab_entry,
};
pub use tabs::{
StripPlan, TABS, TabSpec, contextual_tabs, fit_tabs, has_chart_selected, has_picture_selected, has_shape_selected, has_smart_art_selected,
in_table_public, plan_strip, resolve_tab,
@@ -277,8 +280,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
let active = app.ui.tab == tab && !app.ui.backstage;
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, shown));
if resp.hovered() && !active && ghost.is_none() {
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered() && !active && ghost.is_none());
if hover > 0.0 {
ui.painter().rect_filled(
r.shrink2(vec2(0.0, 4.0)),
4.0,
egui::Color32::TRANSPARENT.lerp_to_gamma(t.hover, hover),
);
}
// Entering and departing contextual tabs move and fade without changing layout.
let entry = match ghost {
@@ -316,7 +324,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
let resp = if cut && ghost.is_none() { resp.on_hover_text(shown) } else { resp };
if active {
let u = Rect::from_center_size(pos2(r.center().x + shift.x, r.max.y - 2.0), vec2(w - 16.0, 3.0));
ui.painter().rect_filled(u, 2.0, t.accent.gamma_multiply(entry));
slide_underline(ui, tab, u, t.accent.gamma_multiply(entry), now, animate);
}
if resp.clicked() {
let _ = app.run("ui.tab", json!({"tab": tab}));
@@ -337,8 +345,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
.collect();
if !left_out.is_empty() {
let (r, resp) = ui.allocate_exact_size(vec2(MORE_TABS_W, 30.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered());
if hover > 0.0 {
ui.painter().rect_filled(
r.shrink2(vec2(0.0, 4.0)),
4.0,
egui::Color32::TRANSPARENT.lerp_to_gamma(t.hover, hover),
);
}
// The active tab is in the menu: the button says so.
let holds_active = left_out.iter().any(|(tab, _)| *tab == app.ui.tab);
@@ -376,6 +389,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin { left: 8, right: 8, top: 4, bottom: 4 }).stroke(Stroke::new(1.0, t.border)),
)
.show(ui, |ui| {
// Bring newly selected ribbon content in from the side of its tab.
let index = tabs.iter().position(|(name, _)| name == &app.ui.tab).unwrap_or(0);
let (settled, direction) = tab_content_entry(ui.ctx(), &app.ui.tab, index, now, animate);
let shift = vec2(direction * (1.0 - settled) * CONTENT_SWITCH_SLIDE, 0.0);
let mut shifted = ui.new_child(egui::UiBuilder::new().max_rect(ui.max_rect().translate(shift)).layout(*ui.layout()));
shifted.set_opacity(0.2 + 0.8 * settled);
let ui = &mut shifted;
// Right-click on the ribbon (outside a button): Customize Ribbon. Registered first, so
// the buttons drawn over it keep their own clicks.
// No document open (#1129): every command is greyed out but Help's.
+2 -1
View File
@@ -44,6 +44,7 @@ pub(super) fn shape_gallery(ui: &mut Ui, app: &mut WordApp) {
});
}
/// Render the Insert ribbon tab controls.
pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Pages", None, app, |ui, app| {
stack(ui, |ui| {
@@ -189,7 +190,7 @@ pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) {
});
stack(ui, |ui| {
small(ui, app, "signature", None, "Signature Line", crate::widgets::ButtonAction::new("insert.signatureLine", json!({}), false));
small(ui, app, "dateTime", None, "Date & Time", crate::widgets::ButtonAction::new("insert.dateTime", json!({}), false));
small(ui, app, "dateTime", None, "Date & Time", crate::widgets::ButtonAction::new("ui.dialog", json!({"name": "dateTime"}), false));
small(ui, app, "object", None, "Object", crate::widgets::ButtonAction::new("insert.object", json!({}), false));
});
});
+108
View File
@@ -158,6 +158,78 @@ pub(super) fn ease_out_cubic(p: f32) -> f32 {
1.0 - (1.0 - p.clamp(0.0, 1.0)).powi(3)
}
const UNDERLINE_SLIDE_SECS: f64 = 0.2;
const CONTENT_SWITCH_SECS: f64 = 0.16;
pub(super) const CONTENT_SWITCH_SLIDE: f32 = 14.0;
#[derive(Clone, Debug, PartialEq)]
struct Underline {
tab: String,
from: (f32, f32),
to: (f32, f32),
start: f64,
}
fn underline_at(u: &Underline, now: f64) -> (f32, f32) {
let e = ease_out_cubic(((now - u.start) / UNDERLINE_SLIDE_SECS) as f32);
(egui::lerp(u.from.0..=u.to.0, e), egui::lerp(u.from.1..=u.to.1, e))
}
pub(super) fn slide_underline(ui: &egui::Ui, tab: &str, target: egui::Rect, color: egui::Color32, now: f64, animate: bool) {
let id = egui::Id::unique("tab_underline");
let prev: Option<Underline> = ui.ctx().data(|d| d.get_temp(id));
let snap = Underline { tab: tab.to_owned(), from: (target.center().x, target.width()), to: (target.center().x, target.width()), start: now };
let next = match prev {
Some(p) if animate && p.tab != tab => Underline { from: underline_at(&p, now), ..snap },
Some(p) if animate && now - p.start < UNDERLINE_SLIDE_SECS => Underline { to: (target.center().x, target.width()), ..p },
_ => snap,
};
let (x, width) = underline_at(&next, now);
if next.from != next.to && now - next.start < UNDERLINE_SLIDE_SECS {
ui.ctx().request_repaint();
}
ui.ctx().data_mut(|d| d.insert_temp(id, next));
ui.painter().rect_filled(egui::Rect::from_center_size(egui::pos2(x, target.center().y), egui::vec2(width, target.height())), 2.0, color);
}
#[derive(Clone, Debug, PartialEq)]
struct TabChange {
tab: String,
index: usize,
start: f64,
dir: f32,
}
pub(super) fn tab_content_entry(ctx: &egui::Context, tab: &str, index: usize, now: f64, animate: bool) -> (f32, f32) {
let id = egui::Id::unique("ribbon_tab_change");
let prev: Option<TabChange> = ctx.data(|d| d.get_temp(id));
let next = match prev {
Some(p) if p.tab != tab => TabChange {
tab: tab.to_owned(),
index,
start: now,
dir: if !animate || p.index == index {
0.0
} else if index > p.index {
1.0
} else {
-1.0
},
},
Some(p) => p,
None => TabChange { tab: tab.to_owned(), index, start: f64::MIN, dir: 0.0 },
};
let p = ((now - next.start) / CONTENT_SWITCH_SECS).clamp(0.0, 1.0) as f32;
let dir = next.dir;
ctx.data_mut(|d| d.insert_temp(id, next));
if p < 1.0 && dir != 0.0 {
ctx.request_repaint();
(ease_out_cubic(p), dir)
} else {
(1.0, 0.0)
}
}
/// Track contextual tabs between frames and return those still animating out.
pub(super) fn note_contextual_tabs(ctx: &egui::Context, shown: &[(&str, usize)], now: f64, animate: bool) -> Vec<(String, usize, f64)> {
let (shown_id, leaving_id) = (egui::Id::unique("contextual_tabs_shown"), egui::Id::unique("contextual_tabs_leaving"));
@@ -346,4 +418,40 @@ mod transition_tests {
note_contextual_tabs(&ctx, &both[..1], 7.0, true);
assert!(note_contextual_tabs(&ctx, &both, 7.1, true).is_empty(), "back again");
}
#[test]
fn tab_underline_slides_and_can_reverse_mid_motion() {
let ctx = egui::Context::default();
let draw = |tab: &str, x: f32, now: f64| {
ctx.run_ui(egui::RawInput { time: Some(now), ..Default::default() }, |ui| {
slide_underline(
ui,
tab,
egui::Rect::from_min_size(egui::pos2(x - 25.0, 10.0), egui::vec2(50.0, 3.0)),
egui::Color32::WHITE,
now,
true,
);
})
.drop_without_applying_deltas();
};
draw("Home", 40.0, 1.0);
draw("Insert", 140.0, 2.0);
let frame = ctx.run_ui(egui::RawInput { time: Some(2.1), ..Default::default() }, |ui| {
slide_underline(ui, "Draw", egui::Rect::from_min_size(egui::pos2(215.0, 10.0), egui::vec2(50.0, 3.0)), egui::Color32::WHITE, 2.1, true);
});
assert!(!frame.shapes.is_empty());
}
#[test]
fn tab_content_motion_comes_from_the_new_tab_side() {
let ctx = egui::Context::default();
assert_eq!(tab_content_entry(&ctx, "Home", 1, 1.0, true), (1.0, 0.0));
let (p, dir) = tab_content_entry(&ctx, "Insert", 2, 2.0, true);
assert_eq!((p, dir), (0.0, 1.0));
let (p, dir) = tab_content_entry(&ctx, "Home", 1, 2.05, true);
assert!(p < 1.0);
assert_eq!(dir, -1.0);
assert_eq!(tab_content_entry(&ctx, "Draw", 3, 3.0, false), (1.0, 0.0));
}
}
+16 -9
View File
@@ -35,12 +35,19 @@ pub fn enabled(app: &WordApp, id: &str) -> bool {
}
pub(crate) fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) {
let fill = if resp.is_pointer_button_down_on() {
bg_if(ui, r, resp, checked, t, true);
}
/// Paints a button background with an optional hover/press response. Disabled ribbon commands
/// pass `false` so their visual state cannot imply that they are interactive.
pub(crate) fn bg_if(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens, on: bool) {
let hover = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered() && on);
let fill = if resp.is_pointer_button_down_on() && on {
t.pressed
} else if checked {
t.checked
} else if resp.hovered() {
t.hover
} else if hover > 0.0 {
Color32::TRANSPARENT.lerp_to_gamma(t.hover, hover)
} else {
return;
};
@@ -105,7 +112,7 @@ fn big_button(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str,
let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click());
keytip_badge(ui, r, id, &label.replace('\n', " "));
let on = enabled(app, id);
bg(ui, r, &resp, checked, &t);
bg_if(ui, r, &resp, checked, &t, on);
let ic = Rect::from_center_size(pos2(r.center().x, r.min.y + BIG_ICON_Y), vec2(32.0, 32.0));
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
icons::paint(ui.painter(), ic, icon, c, a);
@@ -146,7 +153,7 @@ pub fn small(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, ti
let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w, 22.0), Sense::click());
keytip_badge(ui, r, id, tip);
let on = enabled(app, id);
bg(ui, r, &resp, checked, &t);
bg_if(ui, r, &resp, checked, &t, on);
let rtl = crate::rtl::flows(ui);
let ic = Rect::from_center_size(pos2(crate::rtl::lead_x(rtl, r, 12.0), r.center().y), vec2(17.0, 17.0));
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
@@ -181,8 +188,8 @@ pub fn split(
keytip_badge(ui, r, id, tip);
let (ar, aresp) = ui.allocate_exact_size(vec2(11.0, 22.0), Sense::click());
let on = enabled(app, id);
bg(ui, r, &resp, checked, &t);
bg(ui, ar, &aresp, false, &t);
bg_if(ui, r, &resp, checked, &t, on);
bg_if(ui, ar, &aresp, false, &t, on);
let ic = Rect::from_center_size(r.center(), vec2(17.0, 17.0));
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
icons::paint(ui.painter(), ic, icon, c, a);
@@ -252,7 +259,7 @@ pub fn menu_button(ui: &mut Ui, app: &mut WordApp, options: MenuButtonOptions<'_
let w = (galley_w + 22.0).max(44.0);
let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click());
keytip_badge(ui, r, "menu", tip);
bg(ui, r, &resp, false, &t);
bg_if(ui, r, &resp, false, &t, on);
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + BIG_ICON_Y), vec2(32.0, 32.0)), icon, c, a);
let lines: Vec<&str> = label.unwrap_or("").split('\n').collect();
let mut y = r.min.y + BIG_LABEL_Y;
@@ -265,7 +272,7 @@ pub fn menu_button(ui: &mut Ui, app: &mut WordApp, options: MenuButtonOptions<'_
let text_w = label.map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x) + 6.0).unwrap_or(0.0);
let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w + 10.0, 22.0), Sense::click());
keytip_badge(ui, r, "menu", tip);
bg(ui, r, &resp, false, &t);
bg_if(ui, r, &resp, false, &t, on);
let rtl = crate::rtl::flows(ui);
let lead = |inset: f32| crate::rtl::lead_x(rtl, r, inset);
icons::paint(ui.painter(), Rect::from_center_size(pos2(lead(12.0), r.center().y), vec2(17.0, 17.0)), icon, c, a);
+47 -1
View File
@@ -1,7 +1,7 @@
#[cfg(test)]
use super::TALL_GROUPS;
use super::buttons::{BIG_ICON_Y, BIG_LABEL_Y, CONTENT_H, DROPDOWN, LABEL_H, bg, label_line};
use super::{Align2, Rect, Response, Sense, Stroke, Tokens, Ui, WordApp, icons, pos2, regular, vec2};
use super::{Align2, Color32, Rect, Response, Sense, Stroke, Tokens, Ui, WordApp, icons, pos2, regular, vec2};
/// A ribbon group: content, a centred label below and a divider on the right.
pub fn group(ui: &mut Ui, title: &str, launcher: Option<&str>, app: &mut WordApp, add: impl FnOnce(&mut Ui, &mut WordApp)) {
@@ -26,6 +26,8 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&
// Not folded: a drop-down left open while it was folded doesn't come back by itself.
ui.data_mut(|d| d.remove::<bool>(collapsed_open_id(ui, id_title, position)));
let label_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.to_string(), regular(11.0), t.group_label).size().x);
// Reserve the paint slot before children so the glow stays beneath the controls.
let glow = ui.painter().add(egui::Shape::Noop);
let inner = crate::rtl::vertical(ui, |ui| {
ui.set_min_width(label_w + if launcher.is_some() { 22.0 } else { 10.0 });
crate::ribbon_fit::set_compact(ui.ctx(), level == crate::ribbon_fit::COMPACT);
@@ -42,6 +44,13 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&
let _ = content;
});
let r = inner.response.rect;
let area = group_area(rtl, start, r);
let over = ui.rect_contains_pointer(area);
// Position is part of identity because duplicate custom group names can appear in tabs.
let lit = ui.ctx().animate_bool_responsive(ui.scope_id().with(("group_glow", id_title, position)), over);
if lit > 0.0 {
ui.painter().set(glow, group_glow(area, t.accent, lit));
}
title_row(
ui,
Rect::from_min_max(pos2(if rtl { r.min.x } else { start.x }, start.y + CONTENT_H), pos2(r.max.x, start.y + CONTENT_H + LABEL_H)),
@@ -58,6 +67,13 @@ pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&
crate::ribbon_fit::record(ui.ctx(), id_title, level, group_width(rtl, start_cursor, ui.cursor()));
}
/// The hover area of a group. A right-to-left row has no finite left edge at the cursor, so the
/// area starts at the content's own left edge there.
fn group_area(rtl: bool, start: egui::Pos2, content: Rect) -> Rect {
let left = if rtl { content.min.x } else { start.x };
Rect::from_min_max(pos2(left, start.y), pos2(content.max.x, start.y + CONTENT_H + LABEL_H))
}
/// The width a group took: the cursor's way along the row, from `before` to `after`. A
/// right-to-left row fills from its right edge, where the cursor's left edge is negative infinity,
/// so there the width is how far the right edge moved.
@@ -65,6 +81,25 @@ fn group_width(rtl: bool, before: Rect, after: Rect) -> f32 {
if rtl { before.max.x - after.max.x } else { after.min.x - before.min.x }
}
/// A soft accent glow along the bottom of a hovered ribbon group.
fn group_glow(area: Rect, accent: Color32, strength: f32) -> egui::Shape {
let color = |alpha: f32| Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), (alpha * strength * 255.0) as u8);
let r = Rect::from_min_max(pos2(area.min.x, area.max.y - 7.0), pos2(area.max.x, area.max.y + 2.0));
let soft = (r.width() * 0.3).min(22.0);
let xs = [r.min.x, r.min.x + soft, r.max.x - soft, r.max.x];
let mut mesh = egui::epaint::Mesh::default();
for (y, alpha) in [(r.max.y, 0.3), (r.min.y, 0.0)] {
for (i, x) in xs.iter().enumerate() {
mesh.colored_vertex(pos2(*x, y), color(if i == 0 || i == 3 { 0.0 } else { alpha }));
}
}
for i in 0..3u32 {
mesh.add_triangle(i, i + 1, i + 5);
mesh.add_triangle(i, i + 5, i + 4);
}
egui::Shape::mesh(mesh)
}
/// A group's title row (`r`): the centred title and, with a `launcher`, its dialog launcher.
fn title_row(ui: &mut Ui, r: Rect, id_title: &str, title: &str, launcher: Option<&str>, app: &mut WordApp) {
let t = Tokens::get(ui.ctx());
@@ -264,4 +299,15 @@ mod tests {
let after = Rect::from_min_max(pos2(180.0, 0.0), pos2(f32::INFINITY, 90.0));
assert_eq!(group_width(false, before, after), 160.0);
}
#[test]
fn a_group_in_a_right_to_left_row_has_a_finite_hover_area() {
let start = pos2(f32::NEG_INFINITY, 10.0);
let content = Rect::from_min_max(pos2(600.0, 10.0), pos2(740.0, 80.0));
let area = group_area(true, start, content);
assert!(area.min.x.is_finite() && area.max.x.is_finite());
assert_eq!((area.min.x, area.max.x), (600.0, 740.0));
let next = group_area(true, start, Rect::from_min_max(pos2(450.0, 10.0), pos2(590.0, 80.0)));
assert!(!area.intersects(next), "neighbouring groups do not overlap");
}
}
+3
View File
@@ -261,6 +261,9 @@ impl WordApp {
quick_access_toolbar: _,
recent: _,
recent_symbols: _,
date_time_format: _,
date_time_language: _,
date_time_update: _,
recent_colors: _,
recent_fonts: _,
theme: _,
+21
View File
@@ -33,6 +33,7 @@ use craft_window::{Document, Frame, Viewports, WindowEvent, WindowId, WindowList
use egui::{ViewportCommand, ViewportId};
use serde_json::{Value, json};
use wordcraft_engine::WindowView;
use wordcraft_engine::cmd::view::OtherWindow;
use crate::{WordApp, documents, window_ui::WindowUi};
@@ -306,6 +307,17 @@ impl WordApp {
(viewport, crate::control::SurfaceGeometry::from_viewport(&info, screen_rect, max_texture_side, ctx))
}
/// Returns the window key, document ID and display title of the window currently occupying
/// the root viewport, if one exists and native viewports are in use.
pub(crate) fn root_window_info(&self) -> Option<(u64, u64, String)> {
let viewports = self.windows.viewports.as_ref()?;
let root_id = viewports.root()?;
let root_win = self.windows.open.iter().find(|w| w.id == Some(root_id))?;
let doc_id = root_win.doc;
let title = self.session.documents().iter().find(|d| d.id == doc_id).map(documents::display_title).unwrap_or_default();
Some((root_win.key, doc_id, title))
}
/// The viewport new windows start from: the host's size, icon and app id. Their title and
/// place are the app's.
pub fn set_window_builder(&mut self, builder: egui::ViewportBuilder) {
@@ -663,6 +675,15 @@ impl WordApp {
self.settle_windows();
if let Some(&active) = self.windows.recent_keys().first() {
self.enter_window(active);
self.session.windows.current = Some(active);
if self.session.windows.available {
self.session.windows.others = self
.switch_windows_entries()
.into_iter()
.filter(|(k, ..)| *k != active)
.map(|(id, title, _)| OtherWindow { id, title })
.collect();
}
}
}
+6
View File
@@ -71,6 +71,7 @@ pub(crate) fn poll(app: &mut WordApp, _ctx: &egui::Context) {
};
let w = &mut app.session.windows;
w.available = native;
w.current = Some(key);
w.others = others;
w.side_by_side = partner;
w.sync_scroll = sync;
@@ -99,6 +100,11 @@ pub(crate) fn request(app: &mut WordApp, req: &Value) {
let key = app.windows.shown;
let id = |k: &str| req.get(k).and_then(Value::as_u64);
match what {
"switchWindow" => {
if let Some(w) = id("window") {
app.show_window(w);
}
}
"sideBySide" => {
app.canvas.sync_in = 0.0;
if id("off").is_some() {
+93 -14
View File
@@ -386,11 +386,14 @@ fn control_typing_reaches_the_active_window() {
assert_eq!(text(&a), "First", "the other window got none of it");
}
/// `ui.screenshot` captures the active window: the capture is sized from its viewport and the
/// command goes to that viewport. A capture still pending when another window comes forward
/// fails with an error instead of capturing that window.
/// `ui.screenshot` captures the window in the root viewport: capturing a secondary document
/// window in a separate native viewport is not supported by the viewport renderer and is refused
/// immediately at admission with an error directing to `view.switchWindows` (#6159). A capture
/// still pending when another window comes forward fails with an error instead of capturing that
/// window. When a window is adopted into the root viewport after the first window closes, it can
/// be captured.
#[test]
fn screenshot_captures_the_active_window() {
fn screenshot_captures_the_root_window_and_refuses_secondary_windows() {
let ctx = backend();
let mut a = app("First");
frames(&ctx, &mut a, 2);
@@ -403,26 +406,102 @@ fn screenshot_captures_the_active_window() {
let (reply, replies) = std::sync::mpsc::channel();
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
assert!(replies.try_recv().is_err(), "the capture is admitted");
assert_eq!(a.shots.requests.iter().map(|s| (s.viewport, s.expected)).collect::<Vec<_>>(), [(child, [800, 600])]);
let v = replies.try_recv().expect("a secondary window capture is refused at admission");
assert_eq!(v["ok"], false, "{v}");
let err = v["error"].as_str().expect("error message");
assert!(err.contains("capturing a secondary document window is not supported by the viewport renderer"), "error explains why: {err}");
assert!(err.contains("view.switchWindows {\"window\": 1}"), "error tells how to activate the root window: {err}");
assert!(a.shots.requests.is_empty(), "refused capture is never queued");
focus(ViewportId::ROOT);
frames(&ctx, &mut a, 2);
assert_eq!(a.active_viewport(), ViewportId::ROOT, "the root window is now active");
let (reply, replies) = std::sync::mpsc::channel();
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
assert!(replies.try_recv().is_err(), "the capture of the root window is admitted");
assert_eq!(a.shots.requests.len(), 1);
assert_eq!(a.shots.requests[0].viewport, ViewportId::ROOT);
a.shots.requests.iter_mut().for_each(|s| s.ready_at = 0.0);
COMMANDS.with(|c| c.borrow_mut().clear());
frames(&ctx, &mut a, 4);
let shot_to =
|id: ViewportId| COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Screenshot(_)))));
assert!(shot_to(child), "the active window is captured");
assert!(!shot_to(ViewportId::ROOT), "the first window is not");
assert!(shot_to(ViewportId::ROOT), "the root window is captured");
assert!(!shot_to(child), "the child window is not");
let (reply, later) = std::sync::mpsc::channel();
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
focus(child);
frames(&ctx, &mut a, 2);
let v = later.try_recv().expect("the capture was answered");
assert_eq!(v["ok"], false);
assert!(v["error"].as_str().is_some_and(|e| e.contains("another window")), "{v}");
assert!(a.shots.requests.is_empty());
// When the first window closes, the child window is adopted into the root viewport:
let first_key = a.windows.open.iter().find(|w| w.id == a.windows.viewports.as_ref().unwrap().root()).unwrap().key;
let child_id = a.windows.open.iter().find(|w| w.key != first_key).unwrap().id;
a.close_window(first_key);
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
frames(&ctx, &mut a, 2);
assert_eq!(a.window_count(), 1);
assert_eq!(a.windows.viewports.as_ref().unwrap().root(), child_id);
assert_eq!(a.active_viewport(), ViewportId::ROOT, "the adopted window now occupies the root viewport");
let (reply, replies) = std::sync::mpsc::channel();
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
assert!(replies.try_recv().is_err(), "the adopted window's capture is admitted");
assert_eq!(a.shots.requests.len(), 1);
assert_eq!(a.shots.requests[0].viewport, ViewportId::ROOT);
}
/// #6159: when two windows are open on the same document (`view.newWindow`), `ui.screenshot`
/// while the secondary window is active is refused at admission. Using `view.switchWindows` with
/// `window` selects the root window, allowing a screenshot retry to be admitted and issued.
#[test]
fn screenshot_refuses_secondary_window_on_same_document_and_recovers_via_switch_windows() {
let ctx = backend();
let mut a = app("Shared Doc");
frames(&ctx, &mut a, 2);
a.run("view.newWindow", json!({})).unwrap();
let child = a.active_viewport();
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
INFOS.with(|i| i.borrow_mut().entry(child).or_default().inner_rect = Some(rect));
focus(child);
frames(&ctx, &mut a, 4);
assert_ne!(a.active_viewport(), ViewportId::ROOT);
let (reply, replies) = std::sync::mpsc::channel();
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
let v = replies.try_recv().expect("secondary window screenshot refused at admission");
assert_eq!(v["ok"], false, "{v}");
let err = v["error"].as_str().expect("error message");
assert!(err.contains("capturing a secondary document window is not supported by the viewport renderer"), "error: {err}");
assert!(err.contains("view.switchWindows {\"window\": 1}"), "error recommends root window selection: {err}");
// Recovery path: select the root window via view.switchWindows {"window": 1}
COMMANDS.with(|c| c.borrow_mut().clear());
a.run("view.switchWindows", json!({"window": 1})).unwrap();
assert_eq!(a.active_viewport(), ViewportId::ROOT, "switchWindows selected the root window");
// Simulate the desktop applying the focus request:
focus(ViewportId::ROOT);
frames(&ctx, &mut a, 2);
for r in [replies, later] {
let v = r.try_recv().expect("the capture was answered");
assert_eq!(v["ok"], false);
assert!(v["error"].as_str().is_some_and(|e| e.contains("another window")), "{v}");
}
assert!(a.shots.requests.is_empty());
let root_focused = COMMANDS.with(|c| c.borrow().get(&ViewportId::ROOT).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Focus))));
assert!(root_focused, "switchWindows requested focus on root viewport");
assert_eq!(a.active_viewport(), ViewportId::ROOT, "root window is now active");
let (reply, replies) = std::sync::mpsc::channel();
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
assert!(replies.try_recv().is_err(), "screenshot admitted on retry");
assert_eq!(a.shots.requests.len(), 1);
assert_eq!(a.shots.requests[0].viewport, ViewportId::ROOT);
a.shots.requests.iter_mut().for_each(|s| s.ready_at = 0.0);
COMMANDS.with(|c| c.borrow_mut().clear());
frames(&ctx, &mut a, 4);
let shot_to =
|id: ViewportId| COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Screenshot(_)))));
assert!(shot_to(ViewportId::ROOT), "root window screenshot was issued");
assert!(!shot_to(child), "secondary window was not issued");
}
/// Quitting with two documents of unsaved changes asks about each in its window, the window
+27
View File
@@ -0,0 +1,27 @@
//! Release and source Flatpaks must allow release checks and host update helpers.
fn finish_args(manifest: &str) -> Vec<&str> {
manifest
.split("finish-args:")
.nth(1)
.unwrap_or_default()
.lines()
.take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#'))
.filter_map(|line| line.trim().strip_prefix("- "))
.map(|arg| arg.trim_matches(['\'', '"']))
.collect()
}
#[test]
fn flatpak_update_permissions() {
let manifests = [
include_str!("../../../packaging/linux/flatpak/ai.storyteller.wordcraft.bundle.yml"),
include_str!("../../../packaging/linux/flatpak/ai.storyteller.wordcraft.yml"),
];
let args = manifests.map(finish_args);
assert_eq!(args[0], args[1], "source and release permissions must agree");
for permissions in args {
for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] {
assert!(permissions.contains(&required), "missing update permission: {required}");
}
}
}
+16 -6
View File
@@ -1,6 +1,6 @@
# Control protocol
> **Last updated:** 2026-10-31 · #2596: accept-thread startup failure disables the server and removes its key file; MCP retry policy distinguishes file exports from in-memory images.
> **Last updated:** 2026-11-09 · #6159: refuse ui.screenshot on secondary document windows and support root window selection with view.switchWindows.
Start the app with a control port: `wordcraft --control 7981` (or `WORDCRAFT_CONTROL_PORT=7981`).
A missing or invalid port after `--control` (not 1–65535) prints an error and exits with status 2.
@@ -72,9 +72,11 @@ Unauthenticated connections are limited to 64 while they wait for their first au
The app's native windows (one per document, or several on one document) take part in View ›
Window (#322): `view.arrangeAll` tiles them over the monitor, `view.sideBySide {value?, window?}`
places this window and another one in the left and right halves and turns on Synchronous
Scrolling, `view.syncScroll {value?}` turns that on or off, and `view.switchWindows {id?}` lists
and switches documents. `window` is a window id from `ui.inspect` → `windows.others`, the windows
other than the one the command runs in (optional when only one other window is open). The
Scrolling, `view.syncScroll {value?}` turns that on or off, and `view.switchWindows {id?, window?}`
lists documents and switches documents or windows. For Side by Side, `window` is an id from
`ui.inspect` → `windows.others` and is optional when only one other window is open. For Switch
Windows, `window` is an open window's key. A secondary-window screenshot refusal can include
the root window key in the suggested `view.switchWindows` command. The
commands are disabled where there is one window (web, CLI, MCP without the app). Wayland doesn't
let apps move windows, so there Arrange All and Side by Side only resize.
@@ -93,7 +95,7 @@ let apps move windows, so there Arrange All and Side by Side only resize.
| `ui.wheel` | `x`, `y`, `dy`, `dx?`, `unit?` (`point`/`line`/`page`), `phase?` (`start`/`move`/`end`), modifiers | mouse-wheel or trackpad scroll at window coordinates (`dy` < 0 scrolls down) |
| `ui.key` | `key` (`B`, `Enter`, `Left`…), `shift?`, `alt?`, `cmd?` | key press through egui (shortcuts apply) |
| `ui.text` | `text` | typed text through egui |
| `ui.screenshot` | `path?` | PNG of the window: written to `path`, or without one returned as base64 `png` in the reply |
| `ui.screenshot` | `path?` | PNG of the window in the root viewport: written to `path`, or without one returned as base64 `png` in the reply. Capturing a secondary document window is not supported by the viewport renderer; select the root window with `view.switchWindows {"window": root}` first |
| `ui.render` | `path?`, `page?` (1-based), `scale?`, `doc?` | PNG of a page, rendered by the engine: written to `path`, or without one returned as base64 `png` in the reply (never a fixed name in the shared temporary folder) |
| `ui.parity` | — | feature-catalog parity |
| `ui.resize` | `width`, `height` | asks for that window content size in points; sizes that aren't positive and finite are refused, the rest are clamped to the app's minimum (760 × 480) and to the largest surface the GPU can draw (at most 16384; that limit wins over the minimum), and the reply's `width` and `height` are the size asked for |
@@ -131,7 +133,7 @@ window); in the browser, the one the window shows. Commands work on it unless to
(`"alreadyOpen": true`). Its result carries the document's `id`.
- `view.switchWindows` lists the open documents (`{"documents": [{"id", "title", "path",
"dirty", "active"}]}`); with `{"id": n}` it makes that document active and brings its window to
the front.
the front. With `{"window": key}` it selects that window directly and brings it to the front.
- `file.closeDocument {"id"?, "force"?}` closes a document; its window closes with it unless it
is the last window.
- `engine.execute`, `document.inspect` and `ui.render` take `doc`, the id of the document to work
@@ -154,6 +156,14 @@ Input and window methods go to the active window, the one focused last: `ui.clic
`ui.screenshot` (a PNG of that window), `ui.inspect` (its size), `ui.resize` and `ui.focus`. To
work in another window, make its document active first with `view.switchWindows {"id": n}`.
`ui.screenshot` is the exception when multiple document windows are open: the desktop renderer
captures only the window in the root viewport (the first window opened, until that window closes and
another is adopted in its place). While the active window is a secondary document window in a separate
native viewport, `ui.screenshot` returns an error directing the caller to select the root window
instead of hanging for 8 seconds and timing out. Select the root window with
`view.switchWindows {"window": root}` and request the capture again. The error gives the root
window key when it is available.
The `CharProps` and `ParaProps` that `format.set`, `para.set` and `styles.modify` take are listed
in [MCP server](mcp.md#character-and-paragraph-properties).
File diff suppressed because one or more lines are too long
+2 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -1
View File
File diff suppressed because one or more lines are too long
@@ -17,6 +17,8 @@ runtime-version: '25.08'
sdk: org.freedesktop.Sdk
command: wordcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
@@ -18,6 +18,8 @@ sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: wordcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
+5 -1
View File
@@ -25,7 +25,8 @@ description: |-
# The binaries link only glibc and libgcc_s directly. Windowing (X11/Wayland, xkbcommon) and the
# GPU (Vulkan, EGL/GL) are loaded at runtime, so they are dependencies/recommendations here
# rather than ELF NEEDED entries. Read Aloud speaks through espeak-ng (or Speech Dispatcher), a
# program it starts, so that is a recommendation too.
# program it starts, so that is a recommendation too. Without the X11 libraries the app stays on
# native Wayland rather than starting through XWayland (see `uses_x11` in main.rs).
overrides:
deb:
depends:
@@ -37,6 +38,7 @@ overrides:
- libwayland-client0
- libx11-6
- libx11-xcb1
- libxkbcommon-x11-0
- libxcursor1
- libxi6
- libxrandr2
@@ -55,6 +57,8 @@ overrides:
recommends:
- libwayland-client
- libX11
- libX11-xcb
- libxkbcommon-x11
- libXcursor
- libXi
- libXrandr
+9 -1
View File
@@ -4,6 +4,14 @@ sonar.organization=storytold
# S4790 (weak hash): SHA-1 is a start-key and "SHA1/1K" checksum algorithm that ODF package
# encryption (ODF 1.3 Part 2 §4.16) names, and older office suites wrote it. It is needed to open
# those files, not chosen for new security: WordCraft writes a SHA-256 start key and checksum.
sonar.issue.ignore.multicriteria=odt_sha1
sonar.issue.ignore.multicriteria=odt_sha1,gha-lockfile
sonar.issue.ignore.multicriteria.odt_sha1.ruleKey=rust:S4790
sonar.issue.ignore.multicriteria.odt_sha1.resourceKey=crates/formats/src/odt/crypt.rs
# githubactions:S7637 asks for every action to be pinned to a commit SHA. A workflow covered by
# .github/workflows/actions.lock runs the commit the lockfile records, so its tag refs are pinned
# and the lockfile covers the actions those actions use too. Sonar does not read lockfiles yet, so
# the rule flags the tag refs anyway:
# https://community.sonarsource.com/t/githubactions-s7637-false-positives-for-actions-pinned-through-native-actions-lock/190814
sonar.issue.ignore.multicriteria.gha-lockfile.ruleKey=githubactions:S7637
sonar.issue.ignore.multicriteria.gha-lockfile.resourceKey=.github/workflows/**