Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 079f478bd8 Merge tested main for v0.18.1 2026-11-11 18:38:09 -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 7296cf64de Merge main into release for WordCraft v0.18.0 2026-11-11 19:36:24 +01: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
Dylan M. Taylor 92e0e67f3a Clipboard: size-check a Wayland clipboard picture before anything decodes it (#5441)
* fix(clipboard): size-check a Wayland clipboard picture before it is decoded

* address review: refuse a clipboard PNG the renderer cannot decode within its budget
2026-11-10 21:33:46 -05:00
Dylan M. Taylor a34c94e4c1 packaging: use [[ for bash tests (SonarCloud S7688) (#6224)
Behaviour-preserving: `[[ ]]` in place of `[ ]` for bash tests (SonarCloud shell:S7688). Every script has a bash shebang; no test uses `-a`/`-o`, escaped parentheses or an unquoted pattern on the right of `=`. `bash -n` passes on each script; the rest is left to CI.

- `packaging/freebsd/package.sh`
2026-11-10 18:45:02 -05:00
FredApps a42c9eaab3 Merge pull request #6187 from storytold/sonar/format-mandated-sha1
sonar: SHA-1 in ODT decryption is the format's, not a choice
2026-11-10 18:43:18 +01:00
FredApps 12869b8e02 Merge branch 'main' into sonar/format-mandated-sha1 2026-11-10 18:23:24 +01:00
8548b33be1 MacOS: the window moves by its title bar behind a modal dialog (#5124) (#5125)
* #5124: the window moves by its title bar behind a modal dialog

On macOS WordCraft draws its own title bar, and since modal dialogs
(#3859, #4076) the dialog's backdrop covers it, so the window couldn't be
dragged while a dialog was open. Word for Mac lets you drag the document
window by its title bar behind its Font dialog (and beeps on the click).

- The title bar (and the Backstage's top band on macOS) notes the part
  that drags the window each frame. When the modal backdrop's drag starts
  there, it moves the window; the press still shakes the dialog, as Word
  beeps.
- Only the backdrop's own drag counts, so a drag on the dialog, its tabs
  or its popups, where it reaches over the title bar, moves only the
  dialog.

Tested by the developer on a Mac.

Fixes #5124.

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

* #5124: only the macOS title bar moves the window behind a dialog

Review (CodeRabbit):
- Elsewhere the system's title bar already moves the window; WordCraft's
  own bar under it now doesn't, behind a modal dialog either, so nothing
  changes on Linux and Windows.
- A drag from just inside the dialog's edge, where it reaches over the
  title bar, moves only the dialog, as the nudge already tells it apart.

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

* #5124: the window moves by its title bar behind Colors too; modal_open tests

Colors is an egui Modal, not a window with the backdrop of block_behind, so
a drag on the macOS title bar did nothing behind it. It now shares the check
with the other modal windows.

The new tests pin modal_open with nothing open, a modeless dialog, a modal
dialog, the file browser and Colors, and that the title bar moves the window
in each of those while the dialog stays open.

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

* #5124: the modal drag test covers the top of File's side menu

On macOS File's side menu covers the title bar, and its top band moves the
window. The test now drags there too, so losing the band's note_title_drag
call fails it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-10 14:04:45 +01:00
FredApps abd5a6e957 sonar: cite the ODF section the SHA-1 algorithms come from 2026-11-10 11:21:57 +01:00
FredApps 8dac5c7802 sonar: SHA-1 in ODT decryption is the format's, not a choice 2026-11-10 11:21:35 +01:00
2922fe566b RTF: style-sheet names take Unicode escapes; a malformed \' escape leaks no bytes (#6165)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: galagersharp-code <319712501+galagersharp-code@users.noreply.github.com>
2026-11-10 01:24:23 +01:00
43f94a5dca Paste: composite CSS colours with alpha over the page instead of dropping it (#6164)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: galagersharp-code <319712501+galagersharp-code@users.noreply.github.com>
2026-11-10 01:09:36 +01:00
Martin BallandClaude Opus 5.5 d40d1b2718 CLI: --strict on convert and render refuses missing glyphs and pictures (#6039)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-09 20:16:05 +01:00
05f781cfda ODT: columns stating only their shares keep their proportions (gap 19) (#5722)
* ODT gap 19: columns stating only their shares keep their proportions

A table's columns giving only style:rel-column-width, as LibreOffice's CV
template does, were read as equal columns: wrong on screen, and written back
that way once anything rewrote the table. Divide the table's width by the
shares when no column states a width.

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

* ODT: table column shares divide the right width (gap 19)

An aligned table's stated width is what shares divide, however wide. A column with
only a width takes it first, and the shares divide the rest, as in LibreOffice. A
column with no share takes the default style's. From CodeRabbit on this PR, checked
in LibreOffice 26.8.2.2: a share on every column still goes before a stated width.

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

* ODT: a table in a cell shares out no page width (gap 19)

A share-only table with nothing to divide, or one inside a cell, frame or narrower column with widths stated too, was given the page's width: an inner table got two 306 pt columns in a 100 pt cell. It now splits equally as main does. Also puts a doc comment back on its function.

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

* Gap 19: read the column and row table defaults through one helper

Main's row default (keep-together) beside this PR's column default took the style
reader one over clippy's complexity limit; both now go through `read_table_default`.

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

* Gap 19: docs status lines are main's, with this change in front

Merging main had kept this branch's older status lines in ROADMAP.md and
docs/file-format-parity.md, which set main's 2026-11-08 date back and dropped
main's newer entries and the parity doc's CLI path note.

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

* Gap 19: an aligned table's shares go before stated widths; no column under 6 pt

LibreOffice 26.8 lays out an aligned table whose columns give both a width and a share
by the shares (measured). A share too small for a column gave a width the model
rejects, losing the whole grid to the equal split; it now gives the narrowest column.

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

* Gap 19: the progress log says which share-only tables keep their proportions

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 16:35:36 +01:00
8a97427f51 ODT: a font size reaches every script's text (gap 19) (#5728)
* ODT gap 19: a font size reaches every script's text

Only fo:font-size was written, so Asian and complex-script text kept the default
size in LibreOffice: Chinese text set to 24 pt stayed at 12. Write the Asian and
complex sizes with it, as bold and italic are, and read a complex size equal to
the text's as no size of its own.

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

* ODT: a complex size like the text's stays over a style or default with another (gap 19)

Dropped as no size of its own, the default's showed instead: a 24 pt Arabic run drew
at 12, and a heading saved its complex size as 12. It's now dropped only where that
changes nothing, and a default stating one size for all goes with that size.

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

* ODT: a complex size only goes when no run's complex size changes (gap 19)

The check looked at each style alone, so a parent's complex size went from under a child or run that states only a size, and the size stated alone then showed through. The default's and the styles' now go together, and stay as the file says if any run's complex size would change.

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

* Gap 19: docs status lines are main's, with this change in front

Merging main had kept this branch's older status lines in ROADMAP.md and
docs/file-format-parity.md, which set main's 2026-11-08 date back and dropped
main's newer entries and the parity doc's CLI path note.

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

* Gap 19: a size stated alone keeps the complex size above it when saved

A run or paragraph style stating only a size took that size as its complex-script size
on save, though the model takes it from what is above it. The writer now writes the
complex size the model resolves where it differs from the size.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 16:34:47 +01:00
92eac2aa7c ODT: text can turn off what its style turns on (gap 19) (#5724)
* ODT gap 19: text can turn off what its style turns on

A word not bold in a bold heading showed bold on screen, and came back bold in
LibreOffice after a save: an "off" on a run was dropped because the run's own
formatting said nothing, so the paragraph's or character style's showed through.
Keep it on the run for bold, italic, underline, strike-through, capitals and
small capitals, and write it back.

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

* ODT: an Asian or complex normal says nothing of Latin text (gap 19)

With "off" now kept on the text, `style:font-weight-complex="normal"` alone took a
word's bold in a bold heading, and saved it as `fo:font-weight="normal"`. Those
attributes stay fallbacks only to turn it on.

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

* ODT: strike-through turned off over a double one stays off (gap 19)

A style or span saying line-through none over a double strike still showed the double strike, on screen and after a save.

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

* Docs: a missing full stop in the ODT revision row (gap 19)

Docs only.

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

* ODT: a span's character style wins over its paragraph turning off (gap 19)

A paragraph saying not bold made a span in a bold character style not bold too, on screen and after a save. The span's style now wins, for bold, italic, underline, strike-through and capitals.

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

* Gap 19: docs status lines are main's, with this change in front

Merging main had kept this branch's older status lines in ROADMAP.md and
docs/file-format-parity.md, which set main's 2026-11-08 date back and dropped
main's newer entries and the parity doc's CLI path note.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 16:33:58 +01:00
29a5abf3e7 ODT: custom styles with an outline level keep their name when edited (gap 19) (#5770)
* ODT gap 19: custom styles with an outline level keep their name when edited

A paragraph in a custom style with style:default-outline-level was read as
WordCraft's Heading 1 or whichever level it named. Unedited, it was written as
read. Edited, it was saved as Heading_20_1, so LibreOffice showed WordCraft's
blue heading instead of the file's look, and WordCraft drew it that way too.

Read such a style as a custom style that carries the outline level. The
paragraph stays a heading at that level in WordCraft, is numbered with the
file's outline as LibreOffice numbers it, and is saved as
<text:h text:style-name="Head" text:outline-level="1">. The level comes from
the style, not the paragraph kind, because kinds stop at Heading 6.

The writer dropped a custom style's name on any heading, title, quotation or
code paragraph, so custom styles based on those lost their look when edited
too. It now keeps the name for every kind.

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

* ODT: a heading's own outline level gives its number (gap 19)

LibreOffice numbers a heading by its text:outline-level. WordCraft took the
number from the style's level, so a heading stating another showed the wrong
number, or one on a level the outline doesn't number. A heading in a list stays
the list's. Found by CodeRabbit on this PR, as was the Title-based style test.

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

* ODT: a heading in a list of its own keeps it, and an unnumbered level takes no number (gap 19)

Any list whose first level was linked to a style counted as the outline's, so a
heading deeper in one lost its list on save. Now only the number the outline
itself gives counts. A style based on a numbered heading, at a level the outline
doesn't number, took its parent's number. Found by CodeRabbit on this PR.

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

* ODT: a heading past level 6 keeps its list (gap 19)

The written outline has no number past Heading 6, so counting such a heading's
list as the outline's lost its number. Found by CodeRabbit on this PR.

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

* ODT: a style with a heading numbered by its own level stays on the outline (gap 19)

Writing it with no list style took the number from that heading in LibreOffice.
Written so, the numbers match the file's. Found by CodeRabbit on this PR.

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

* ODT: a heading past level six numbered through its style keeps its list (gap 19)

The written outline numbers only six levels, and the list was dropped for a heading numbered only through its style, so at levels 7 to 9 it lost its number once its style was rewritten.

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

* Gap 19: docs status lines are main's, with this change in front

Merging main had kept this branch's older status lines in ROADMAP.md and
docs/file-format-parity.md, which set main's 2026-11-08 date back and dropped
main's newer entries and the parity doc's CLI path note.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 16:16:06 +01:00
bd9e726cda ODT: pictures keep the size their file gives (gap 19) (#5721)
* ODT gap 19: pictures keep the size their file gives

Opening a file fitted a picture wider than the text to the text, and shrank any
picture wider than 6.5 in to that, even with its size stated. So a save with no
edit to it took its paragraph off being written as read, and LibreOffice laid
it out differently. Pictures in OpenDocument files keep the size they state.

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

* ODT: a picture keeps a size stated on one side, in proportion (gap 19)

Only a frame stating both sides kept them. On one side alone a picture past a
column's width was still fitted to it, and past 1,584 pt each side was cut
separately, which distorted it. A size past any page can no longer overflow the
sizing either. Found by CodeRabbit on this PR.

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

* ODT: an inline picture with no stated size is fitted to the text (gap 19)

Skipping the fit for OpenDocument files left one wider than the text on a small
page, which `main` fits. A size the file states still stands, and a test now fails
without that. The fit happens when it is read, so its paragraph stays as read.

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

* ODT: an object's picture is fitted too, and one side follows the picture's own shape (gap 19)

An inline object with no stated size kept its picture's width on a small page, which main fitted. A picture stated on one side took the other from the size already capped to the page: 10 by 1000 pixels at 500 pt wide came out 468 by 1584 pt, not 15.84 by 1584.

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

* ODT: a frame stating a size of nothing is fitted like one stating none (gap 19)

svg:width="0pt" counted as a stated size for fitting, though the picture then came at its own size, so it could run past the text on a small page.

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

* ODT: a picture in line that is raised, turned or mirrored is fitted too (gap 19)

Such a picture has a float record that is still in line, which the reader's fit skipped, so with no size stated it stayed 468 pt wide on a small page. main fitted it.

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

* Gap 19: docs status lines are main's, with this change in front

Merging main had kept this branch's older status lines in ROADMAP.md and
docs/file-format-parity.md, which set main's 2026-11-08 date back and dropped
main's newer entries and the parity doc's CLI path note.

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

* Order the parity header's change notes newest first

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 16:15:23 +01:00
15b608660f ODT: frames keep the page they are anchored to (gap 19) (#5705)
* ODT gap 19: frames keep the page they are anchored to

A frame or shape anchored to a page was written without its
text:anchor-page-number, so one on page 3 came back on page 1 in LibreOffice
after a save. Keep the number and write it back. WordCraft's own layout still
places such a frame on its anchor paragraph's page.

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

* ODT: a page number only counts on a page anchor, and groups keep theirs (gap 19)

A paragraph anchor with a text:anchor-page-number was saved as a page anchor,
which LibreOffice ignores the number on and so moved the drawing. A group
anchored to a page lost its page. Found by CodeRabbit on this PR.

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

* ODT: a frame moved in WordCraft loses its page number (gap 19)

Nothing cleared it, so the save put the frame back on its old page. One placed by
its paragraph is no page anchor any more, and one dropped on a page takes that page.

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

* ODT tests: put the moved frame's test with the other moves (gap 19)

At the end of the file it collided with other ODT PRs' tests.

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

* ODT: a frame moved onto its paragraph loses its page number (gap 19)

The number was only hidden while the frame was placed by its paragraph, so placing it by the page again (arrange.position) brought back the old page. The merge of main also lost the test attribute on a test from main, put back here.

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

* Gap 19: docs status lines are main's, with this change in front

Merging main had kept this branch's older status lines in ROADMAP.md and
docs/file-format-parity.md, which set main's 2026-11-08 date back and dropped
main's newer entries and the parity doc's CLI path note.

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

* Gap 19: parity row says when a moved frame keeps its page number

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 15:57:37 +01:00
52fbc7f068 ODT: a document keeps its language when styles are rewritten (gap 19) (#5704)
* ODT gap 19: a document keeps its language when styles are rewritten

The default language was only ever saved by copying the file's own styles
back. Adding numbering or creating a style writes them from the model, which
never stated it, so LibreOffice showed the document in its own default
language. Import ignored it too, so such a document was proofed as English and
the styles WordCraft adds said en-US.

- Read the language from Standard, else the default paragraph style
- Write it in the model-written default style
- A file stating none has none, rather than en-US from new documents

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

* ODT: a language keeps its script, and a style with no parent the default style's language (gap 19)

sr-Latn-RS was written as fo:country="Latn" with RS dropped, and fo:script
wasn't read, in all three of the language attributes. A style with no parent
took Standard's language when it differed from the default style's. Found by
CodeRabbit on this PR, as were the added assertions and doc comments.

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

* ODT: the language written is Normal's, as WordCraft has it (gap 19)

The default style took its language from the defaults, though WordCraft's document language is Normal's: a Word file with a French Normal over en-US defaults saved as English.

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

* Gap 19: docs status lines are main's, with this change in front

Merging main had kept this branch's older status lines in ROADMAP.md and
docs/file-format-parity.md, which set main's 2026-11-08 date back and dropped
main's newer entries and the parity doc's CLI path note.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-09 15:56:59 +01:00
Aleksander HelgakerandClaude Opus 5.5 d75364067d ODT: table and row backgrounds show behind their cells (gap 19) (#5727)
* ODT gap 19: table and row backgrounds show behind their cells

fo:background-color on a table or row wasn't read, so it was missing on screen
and gone in LibreOffice after a save. WordCraft's tables have their backgrounds
on their cells, so it goes on the cells with none of their own.

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

* ODT: a column's default cell style shows, and transparent clears a background (gap 19)

A cell with no style took the row's or table's colour over its column's default
cell style, and `transparent` on a table or row fell through to its parent style's.
From CodeRabbit on this PR, checked in LibreOffice 26.8.2.2: a row's default cell
style isn't applied there, so it isn't here.

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

* ODT: a column's default background is read once, not its style's name (gap 19)

The name was cloned for every repeat of a column, which a hostile file could make
use to run out of memory, and allocation failure can't be caught.

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

* ODT: a transparent cell style stops its parent's colour (gap 19)

A cell style saying transparent over a coloured parent took the parent's colour and hid the row behind it. LibreOffice 26.8 shows the row, from a column's default cell style or the cell's own.

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

* Gap 19: docs status lines are main's, with this change in front

Merging main had kept this branch's older status lines in ROADMAP.md and
docs/file-format-parity.md, which set main's 2026-11-08 date back and dropped
main's newer entries and the parity doc's CLI path note.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 15:56:09 +01:00
Aleksander HelgakerandClaude Opus 5.5 d102b2f08b Gap 19: ODT parity row says how tab stops are saved (#6094)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:41:02 +01:00
Dylan M. Taylor 5574b32600 Release 0.17.0 2026-11-09 06:34:22 -05:00
FredApps 8dc3f0cd5f Merge pull request #4481 from fluorine/dialog-tabs-remaining
Dialogs: draw real tabs in five more dialogs
2026-11-09 11:55:52 +01:00
FredApps f1b1077327 Merge pull request #3802 from fluorine/search-box-text-centred
Search box: the placeholder text is centred vertically (#3779)
2026-11-09 11:55:43 +01:00
Dylan M. Taylor 7b8c0ba5eb Merge pull request #6070 from dotCooCoo/chore/craft-update-1b34eba41
Updates: take craft-update from craft-libs main at 1b34eba41
2026-11-08 23:01:20 -05:00
dotCooCoo 0b61f8b426 Updates: take craft-update from craft-libs main at 1b34eba41 2026-11-08 19:43:58 -08:00
Dylan M. Taylor 0a5d5d29f8 Merge pull request #6038 from dotCooCoo/chore/craft-update-updater-fixes
Updates: the updater reports installing, ready, installed and failed, and installs at exit
2026-11-08 22:12:29 -05: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
dotCooCoo 642f5dd6bf Updates: the updater reports installing, ready, installed and failed, and installs at exit
Moves craft-update, craft-pdf and the krilla patch from storytold/craft-libs
c5a1cbfb to dotCooCoo/craft-libs c7c84230, which is craft-libs main at
0a9d1f48 with storytold/craft-libs#687 and #696 merged in.

- The result window opens whenever the updater's status is not idle: at
  the first start after an update it says the version that was installed
  or why the update failed, and Help > Check for Updates shows Installing,
  Ready and the launcher's choice. A check asked for from the ribbon now
  shows the window on the next frame.
- Restart to Update closes the window and asks the app to quit. An update
  that asks the app to close quits every window, after asking about each
  document with unsaved changes.
- on_exit and the macOS terminate path start the waiting update's helper.
- The update prompt and the result window draw their words through the
  interface catalog.
2026-11-08 10:56:53 -08: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
FredApps 4b0d399599 Merge pull request #3195 from vangel76/zoom-stretch
Pointer zoom stretches page rasters until it stops
2026-11-08 18:14:29 +01:00
FredApps fda8758ec9 Merge upstream text hinting and window scrolling 2026-11-08 17:59:05 +01:00
FredApps c5fe6c6816 Merge pull request #3197 from vangel76/ui-text-hinting
Dark-theme interface text on Linux no longer looks semi-bold
2026-11-08 17:55:10 +01:00
FredApps 438f82977a Merge pull request #2723 from vangel76/feat/recent-remove-2716
Remove documents from the recent list, one by one or all at once
2026-11-08 17:53:13 +01:00
vangel76andClaude Opus 5.5 72a7afd9b5 Merge upstream/main into PR #3195
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:37:24 +01:00
FredApps 36ed94a09e Merge upstream control delivery changes 2026-11-08 17:35:46 +01:00
FredApps 0655e651fb Merge upstream control delivery changes 2026-11-08 17:35:06 +01:00
FredApps 0e3f4dd315 Merge pull request #2596 from vangel76/control-mcp-delivery-1326-1331
Control/MCP: no replay of mutations with unknown outcome; queued requests honour timeouts (#1326, #1331)
2026-11-08 17:32:33 +01:00
FredApps e01a88b0be Fix paper color tests for hinted page keys 2026-11-08 17:29:14 +01:00
FredApps ae257ba784 Merge upstream font tests and window scrolling 2026-11-08 17:26:55 +01:00
FredApps 0495fbb789 Merge pull request #2783 from vangel76/test/fixed-layout-fonts-2729
#2729: Layout tests use pinned bundled fonts
2026-11-08 17:24:43 +01:00
vangel76andClaude Opus 5.5 e7e9bc2759 Merge upstream/main into PR #3195
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:24:03 +01:00
FredApps be75ee90e3 Merge upstream main and reconcile control documentation 2026-11-08 17:22:59 +01:00
FredApps 480511eaa5 Merge upstream main into control delivery changes 2026-11-08 17:15:38 +01:00
FredApps aa262cf7d8 Merge pull request #2595 from vangel76/mcp-insert-table-2242
MCP: insert_table tool for filled tables (#2242)
2026-11-08 17:11:08 +01:00
FredApps 2d97c8443e Merge pull request #1056 from vangel76/feat/1053-rtf-notes-comments
RTF: footnotes, endnotes and comments are read and written (#1053)
2026-11-08 16:38:59 +01:00
FredApps d5d2b22dac Merge pull request #331 from vangel76/feat/322-view-windows
View › Window: Switch Windows, Arrange All, View Side by Side and Synchronous Scrolling
2026-11-08 16:37:54 +01:00
FredApps 2794ae44e9 Merge pull request #5115 from vangel76/feat/paper-colors-3398
Add persistent screen-only paper color presets (#3398)
2026-11-08 16:36:10 +01:00
vangel76andClaude Opus 5.5 5125de6f1f Merge upstream/main into PR #1056
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:18:07 +01:00
vangel76andClaude Opus 5.5 59030b1bb5 Merge upstream/main into PR #331
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:18:07 +01:00
vangel76andClaude Opus 5.5 4050f1bf8b Merge upstream/main into PR #5115
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:18:07 +01:00
02e74a5d9c Add Delete Previous Sentence with Ctrl+Shift+Backspace (#5094)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-08 16:13:33 +01:00
FredApps d5a1c0e181 Merge pull request #5116 from vangel76/feat/paragraph-tools-5043
Home › Paragraph Tools: nine paragraph conversion and cleanup commands (#5043)
2026-11-08 16:12:57 +01:00
Valentin KleinandClaude Opus 5.5 69d2a2e131 docs/gaps: gap 16 RTL in HTML/ODT/RTF landed (#2696)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:57:36 +01:00
Dylan M. TaylorandMartin Ball 661a790540 Render: skip drawing paths far past every canvas before flattening (#5453)
* fix(render): skip drawing paths far past every canvas before flattening

* fix(render): refuse paths with non-finite points too

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 08:15:55 +01:00
vangel76andClaude Opus 5.5 833c0154aa Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 06:18:12 +01:00
vangel76andClaude Opus 5.5 0a3fb5828e Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 06:18:12 +01:00
vangel76andClaude Opus 5.5 e344bb4243 Merge upstream/main into PR #3197
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:19:14 +01:00
vangel76andClaude Opus 5.5 4a507de3d6 Merge upstream/main into PR #3195
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:19:14 +01:00
vangel76andClaude Opus 5.5 d07f50dd8b Merge upstream/main into PR #5116
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:19:14 +01:00
vangel76andClaude Opus 5.5 87e08051bf Merge upstream/main into PR #2596
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:19:14 +01:00
vangel76andClaude Opus 5.5 6367400215 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:21:51 +01:00
vangel76andClaude Opus 5.5 9655b2da50 Merge upstream/main into test/fixed-layout-fonts-2729
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:21:38 +01:00
vangel76andClaude Opus 5.5 66e169c343 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:21:35 +01:00
vangel76andClaude Opus 5.5 1c6c7c732e Merge upstream/main into feat/paper-colors-3398
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:20:39 +01:00
vangel76andClaude Opus 5.5 dba2b3b5e9 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:26:05 +01:00
vangel76andClaude Opus 5.5 beab4e8e9c Merge upstream/main into zoom-stretch
Upstream's new layout tests start with crate::test_fonts(), like the others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:25:18 +01:00
vangel76andClaude Opus 5.5 c9090ba95a Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:17:59 +01:00
vangel76andClaude Opus 5.5 c606533853 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:17:59 +01:00
vangel76andClaude Opus 5.5 4774749a05 Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 02:27:44 +01:00
vangel76andClaude Opus 5.5 20a10a5f33 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 02:26:30 +01:00
vangel76andClaude Sonnet 5.5 e4310ec5c7 Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 01:17:09 +01:00
vangel76 ecbabc327c Merge remote-tracking branch 'upstream/main' into m1056
# Conflicts:
#	docs/file-format-parity.md
2026-11-08 00:30:11 +01:00
vangel76 34b4caf317 Merge remote-tracking branch 'upstream/main' into m2596
# Conflicts:
#	ROADMAP.md
2026-11-08 00:29:41 +01:00
vangel76 8ab285e61d Merge remote-tracking branch 'upstream/main' into m3195
# Conflicts:
#	ROADMAP.md
2026-11-08 00:27:34 +01:00
vangel76 490437fa87 Merge remote-tracking branch 'upstream/main' into m3197
# Conflicts:
#	ROADMAP.md
2026-11-08 00:27:30 +01:00
vangel76 4925e06391 Merge remote-tracking branch 'upstream/main' into m1056
# Conflicts:
#	docs/file-format-parity.md
2026-11-08 00:19:35 +01:00
vangel76 5c2e5ce0d0 Merge remote-tracking branch 'upstream/main' into m2596
# Conflicts:
#	ROADMAP.md
2026-11-08 00:19:30 +01:00
vangel76 274d40fc4d Merge remote-tracking branch 'upstream/main' into m3195
# Conflicts:
#	ROADMAP.md
2026-11-08 00:19:26 +01:00
vangel76 570c39e287 Merge remote-tracking branch 'upstream/main' into m3197
# Conflicts:
#	ROADMAP.md
2026-11-08 00:19:22 +01:00
vangel76andClaude Sonnet 5.5 f1b5f0b4de Layout test: install the pinned fonts before the #4720 chart fallback lookup
The test asked the global font database for a fallback face before any
layout call, so the pinned fonts could no longer be installed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:24:08 +01:00
vangel76andClaude Sonnet 5.5 ddb1d37de3 Merge upstream/main into test/fixed-layout-fonts-2729
Keep upstream's split of layout_para and install the pinned fonts at its
start. Refit the main tests added since (#4250 widow and footnote length,
#4720 chart fallback font) to the pinned test fonts. Merge the two
wordcraft-layout dev-dependency tables.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:22:44 +01:00
vangel76andClaude Sonnet 5.5 d98a335ae8 Hinting test: render a bundled face at a fixed size; refit a #4250 footnote test to the pinned fonts
The hinting test took whatever default font the machine resolved, so its grey-pixel threshold
depended on the system. It now sets Source Sans 3 at 12 pt (4.7 percent fewer grey pixels).
The footnote test's note length was chosen against system fonts and is now the one that fits
the pinned test fonts, which every layout call now installs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:35:41 +01:00
vangel76andClaude Sonnet 5.5 bbc6d94dbe Layout tests: install the pinned fonts in the layout entry points and font helpers
layout, layout_para, layout_equation and mark_text_width call test_fonts() first in test
builds, so the shared lay helper and everything that goes through them is covered. Tests and
helpers that look up fonts directly (the Times New Roman substitute test, the Symbol bullet
test, the Arabic/Myanmar/Thai face probes and the nbsp fixture) call it themselves. Audited by
running every layout test in its own process with a temporary guard that panicked on any
global font access before the pinned fonts were installed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:32:22 +01:00
vangel76andClaude Sonnet 5.5 d0498254a2 insert_table: an explicit null values is a parameter error, not the CSV sample table
The CSV fallback is now only for an absent values field. A null values argument is rejected
before anything is inserted. Null cells inside a valid row still work. Engine and MCP
regressions check that the document and the undo history stay unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:31:38 +01:00
vangel76andClaude Sonnet 5.5 9fd9ce54b7 Side by Side keeps the scrolling choice for an unchanged partner; drop queued partner scroll on pairing changes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:27:10 +01:00
vangel76andClaude Sonnet 5.5 d94bbc8fe0 Layout tests: install the pinned fonts in the layout entry points and font helpers
layout, layout_para, layout_equation and mark_text_width call test_fonts() first in test
builds, so the shared lay helper and everything that goes through them is covered. Tests and
helpers that look up fonts directly (the Times New Roman substitute test, the Symbol bullet
test, the Arabic/Myanmar/Thai face probes and the nbsp fixture) call it themselves. Audited by
running every layout test in its own process with a temporary guard that panicked on any
global font access before the pinned fonts were installed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:16:20 +01:00
vangel76andClaude Sonnet 5.5 18a26b41ce layout tests: give a #4250 widow test a paragraph-length range that fits the pinned test fonts
With the fixed test fonts the paragraph with a widow needs fewer repeats
than the search range started at.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 12:22:51 +01:00
vangel76andClaude Sonnet 5.5 9f9190f0d7 layout tests: give two #4250 tests geometry that fits the pinned test fonts
The footnote text length and the paragraph-length search range in these two
tests were chosen against system fonts; with the fixed test fonts the page
breaks fall elsewhere.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 12:16:50 +01:00
vangel76andClaude Sonnet 5.5 1c7c4e66e0 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:38:09 +01:00
vangel76andClaude Sonnet 5.5 2d6da7a326 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:38:09 +01:00
vangel76andClaude Sonnet 5.5 30a439899b Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:37:52 +01:00
vangel76andClaude Sonnet 5.5 875a62043a Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:37:52 +01:00
vangel76andClaude Sonnet 5.5 7ca1dd1802 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:37:52 +01:00
vangel76andClaude Sonnet 5.5 a5b1518a10 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 01:10:51 +01:00
vangel76andClaude Sonnet 5.5 4e334166e5 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 01:10:42 +01:00
vangel76andClaude Sonnet 5.5 8df01e481f Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 01:10:42 +01:00
vangel76andClaude Sonnet 5.5 4ac7292d62 Merge upstream/main into feat/recent-remove-2716
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 01:09:58 +01:00
vangel76andClaude Sonnet 5.5 67c9b8ac31 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 01:06:04 +01:00
fluorineandClaude Sonnet 5.5 7834197fbc Tabs: literal labels for the string extractor, and a test for the tab strip's selected state
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 10:47:38 -04:00
vangel76andClaude Sonnet 5.5 401e7c9169 Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 04:13:02 +01:00
vangel76andClaude Sonnet 5.5 3daa6b1e78 Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 04:09:53 +01:00
vangel76andClaude Sonnet 5.5 e4ba85346d Merge upstream/main into mcp-insert-table-2242
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 04:06:29 +01:00
vangel76andClaude Sonnet 5.5 ebdca4feb8 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:51:36 +01:00
vangel76andClaude Sonnet 5.5 44eb54eef9 layout: one dev-dependencies table after the merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:36:34 +01:00
vangel76andClaude Sonnet 5.5 75a905802c Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:36:06 +01:00
vangel76andClaude Sonnet 5.5 2c17b04f9a Merge upstream/main into feat/paragraph-tools-5043
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:29:32 +01:00
vangel76andClaude Sonnet 5.5 951b255b80 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:26:18 +01:00
vangel76andClaude Sonnet 5.5 37a627c61e Merge upstream/main into feat/paper-colors-3398
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:23:54 +01:00
vangel76andClaude Sonnet 5.5 0ff06b9d86 backstage: fix Align2 path after merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:23:46 +01:00
vangel76andClaude Sonnet 5.5 d5495ea4f8 Merge upstream/main into feat/recent-remove-2716
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 00:12:16 +01:00
fluorine 938e13e9c7 Merge remote-tracking branch 'upstream/main' into tmp 2026-11-04 12:19:56 -04:00
fluorineandClaude Sonnet 5.5 1787766d6f Merge upstream main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 11:51:52 -04:00
vangel76andClaude Sonnet 5.5 afc01f8328 Paragraph tools: walk runs once for live characters; stronger tracked-change tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 02:14:50 +01:00
vangel76andClaude Sonnet 5.5 9e391e97f4 Paragraph tools: skip tracked-deleted characters; snapshot revisions once per command (review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 02:04:56 +01:00
vangel76 2113f52db4 Merge upstream/main into feat/paragraph-tools-5043 2026-11-04 02:00:59 +01:00
vangel76 098cf86dc8 Merge upstream/main into feat/paragraph-tools-5043 2026-11-04 01:01:08 +01:00
vangel76andCodex e99e28aebd M8: finish paragraph tools (#5043)
Co-Authored-By: Codex <noreply@openai.com>
2026-11-04 00:53:50 +01:00
vangel76andCodex 4abae255e0 M8: refresh screen paper colors (#3398)
Co-Authored-By: Codex <noreply@openai.com>
2026-11-03 19:57:50 +01:00
vangel76andClaude Sonnet 5.5 0b6e052d73 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 00:01:00 +01:00
vangel76andClaude Sonnet 5.5 45cfc55116 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:54:30 +01:00
vangel76andClaude Sonnet 5.5 a3b50342f9 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:53:36 +01:00
vangel76andClaude Sonnet 5.5 ce4c401c4a Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:53:35 +01:00
vangel76andClaude Sonnet 5.5 b8ae10e453 Merge upstream/main into feat/paragraph-tools-5043
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:47:13 +01:00
vangel76andClaude Sonnet 5.5 f4f9d28d99 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:34:13 +01:00
vangel76andClaude Sonnet 5.5 e17ad54b15 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:34:13 +01:00
vangel76andClaude Sonnet 5.5 9e2f6ad330 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:31:57 +01:00
vangel76andClaude Sonnet 5.5 6f125461dd Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:31:57 +01:00
vangel76andClaude Sonnet 5.5 31a37bcf00 Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:30:37 +01:00
vangel76andClaude Sonnet 5.5 2025c48e9b Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:30:37 +01:00
vangel76andClaude Sonnet 5.5 83b319e7c3 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:30:37 +01:00
vangel76andClaude Haiku 5.5 ca1f584ae0 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-01 14:52:50 +01:00
vangel76andClaude Sonnet 5.5 7378ac5063 Layout tests: install the fixed fonts in mark_text_width too
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 14:50:06 +01:00
vangel76andClaude Sonnet 5.5 7e967d4a3a Layout tests: initialize the fixed fonts centrally in the layout entry points
layout, layout_para and layout_equation call crate::test_fonts() first in test
builds, so no test can reach font lookup through them before the pinned fonts
are installed. Tests and helpers that look up fonts directly (word::resolve,
FontDb::global, the nbsp fixture) call it themselves. Fixes init_test_fonts()
returning false when an uninitialised test ran first in a shared process.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 14:46:27 +01:00
vangel76andClaude Sonnet 5.5 f5038a12e3 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 14:40:14 +01:00
vangel76andClaude Sonnet 5.5 1bbced51a4 Recent list: saved is null when no preferences were written
The desktop hook returns Ok(false) when preferences are switched off or there is
no settings folder; the commands report that like a host without a hook. Also
move add_saved out of works_without_document's doc comment.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 13:53:50 +01:00
vangel76andClaude Sonnet 5.5 9de2227af2 Report a failed preferences save from ui.recentRemove and ui.recentClear
The save_prefs hook now returns a Result; both commands add saved/saveError to
their result and the status line says when the write failed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 13:33:45 +01:00
vangel76andClaude Haiku 5.5 7d0ba2be63 Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-01 13:28:06 +01:00
vangel76andClaude Haiku 5.5 f69b003fe1 Merge upstream/main into mcp-insert-table-2242
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-01 13:20:55 +01:00
vangel76 c9046233a0 Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-01 13:13:36 +01:00
vangel76andCodex 0e7dc8da53 fix(control): prevent ambiguous mutation replay (#2596)
Propagate accept-thread startup errors and remove the key file. Keep image exports from being retried and continue stdio after invalid UTF-8.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-31 22:12:18 +01:00
vangel76 837d2b0748 Merge remote-tracking branch 'upstream/main' into fix/pr2596-delivery-review 2026-10-31 21:52:47 +01:00
vangel76 220bcf947a Home › Paragraph Tools: nine WPS-style paragraph commands (#5043)
Conversions: line breaks to paragraphs, blank paragraphs between, separate
paragraphs, spaces to indentation, indents to leading spaces, join lines.
Cleanup: delete blank paragraphs, remove leading spaces, collapse extra
spaces. Each command works over the selection, or the whole document with
scope "all"; one undo step per command; deletions are recorded under Track
Changes. Home ribbon drop-down with per-command entries, including whole-
document variants. 16 engine tests, 1 ribbon kittest.

Closes #5043
2026-10-31 17:29:23 +01:00
vangel76andCodex 7d27d8299a Merge upstream/main into screen paper presets (#3398)
Co-Authored-By: Codex <noreply@openai.com>
2026-10-31 17:17:07 +01:00
vangel76andCodex 71dba44a0a M8: add persistent screen-only paper color presets (#3398)
Co-Authored-By: Codex <noreply@openai.com>
2026-10-31 17:12:52 +01:00
vangel76andCodex 3a22b72132 Merge upstream/main into zoom-stretch
Keep the current upstream roadmap status entries and wording together with the pointer-zoom note. No feature code changes in the conflict resolution.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-31 11:10:09 +01:00
vangel76andCodex f342b6ff1f Merge upstream/main into ui-text-hinting
Keep the current upstream roadmap status entries and wording together with the dark-theme text note. No feature code changes in the conflict resolution.

Co-Authored-By: Codex <noreply@openai.com>
2026-10-31 11:09:08 +01:00
vangel76 95669b9eb9 Merge upstream/main into ui-text-hinting 2026-10-31 00:08:15 +01:00
vangel76 88ac0d7174 Merge upstream/main into zoom-stretch 2026-10-31 00:05:05 +01:00
vangel76 3b20ea46de Merge upstream/main into feat/1053-rtf-notes-comments 2026-10-30 23:35:18 +01:00
vangel76andClaude Sonnet 5.5 4524bc6ba9 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 22:41:52 +01:00
vangel76andClaude Sonnet 5.5 49a3cd223b Format canvas.rs after the merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 22:40:27 +01:00
vangel76andClaude Sonnet 5.5 061d40d5eb Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 22:39:52 +01:00
vangel76andClaude Sonnet 5.5 05a37f6fe2 Apply cargo fmt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 22:24:10 +01:00
vangel76andClaude Sonnet 5.5 d95cb46da5 Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 22:22:47 +01:00
vangel76andClaude Sonnet 5.5 8c44eb1b98 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 22:22:42 +01:00
vangel76andClaude Sonnet 5.5 933bccf052 Merge upstream/main into feat/1053-rtf-notes-comments
Re-apply the RTF notes and comments reader changes on the refactored rtf/word.rs and
rtf/parse.rs, fix the imports of the split modules and drop stray .rej files.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 22:18:50 +01:00
vangel76andClaude Sonnet 5.5 9aa3d93ecf Merge upstream/main into feat/recent-remove-2716
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 22:18:00 +01:00
vangel76 bffb6e6705 Merge upstream/main into mcp-insert-table-2242 2026-10-29 22:15:47 +01:00
vangel76andClaude Sonnet 5.5 a449c59232 Merge upstream/main into feat/322-view-windows
Main split the session, the control handler, the app and the ribbon into modules; the
View Window hooks are reapplied to the new locations.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 20:12:37 +01:00
vangel76andClaude Sonnet 5.5 a82d509793 Merge upstream/main into feat/1053-rtf-notes-comments
Main split rtf.rs into modules; the notes and comments code is reapplied to the new files.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 20:04:09 +01:00
vangel76andClaude Sonnet 5.5 4bfd84514c Merge upstream/main into test/fixed-layout-fonts-2729
Main split fontdb.rs, math.rs and the layout test files into modules; the pinned-font
changes are reapplied to the new locations.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 19:47:34 +01:00
Fluorine e34840a78a Merge main and port dialog tabs to modular files 2026-10-27 08:47:20 -04:00
fluorineandClaude Sonnet 5.5 066af3b620 Dialog tab tests: pin the UI language to English
The tab queries use English labels, so the host locale must not change them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 16:15:51 -04:00
fluorineandClaude Sonnet 5.5 d0f273bbfd Dialogs: tabs only; drop the size-keeping page wrapper
Keep the five tab conversions. Remove dialog_page and the Symbol button
move. The test now checks which page a tab shows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 15:59:14 -04:00
vangel76andClaude Sonnet 5.5 c40d5159b7 docs: list insert_table among the tools that take doc
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 20:35:50 +01:00
vangel76andClaude Sonnet 5.5 841dd538dc Merge upstream/main into feat/322-view-windows
Main now gives every document its own native window in one process, with Switch Windows and
New Window. Drop the one-process-per-window registry and peer channel and build Arrange All,
View Side by Side and Synchronous Scrolling on those windows: the drawn window sees the others
by window id, tiles and halves are placed through the windows' viewports, and the scrolling is
handed to the partner window in document points.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 19:46:57 +01:00
fluorineandClaude Sonnet 5.5 541483ce0b Dialogs: real tabs in Colors, Symbol, Borders and Shading, AutoCorrect, Customize Ribbon
Use dialog_tabs in five more dialogs. Add dialog_page: every page of one
dialog takes the size of the largest page shown so far, so the dialog
keeps its size when the tab changes. The Symbol dialog draws its Insert
and Close buttons below the page for the same reason.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 14:39:47 -04:00
vangel76andClaude Sonnet 5.5 364495deb5 Merge upstream/main into mcp-insert-table-2242
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 19:34:56 +01:00
vangel76andClaude Sonnet 5.5 c85d454e09 Merge upstream/main into feat/recent-remove-2716
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 19:27:54 +01:00
vangel76andClaude Sonnet 5.5 1c1ee15cff Fix FlipPen initialisers after the hinting-strike change on main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 19:22:52 +01:00
vangel76andClaude Sonnet 5.5 1a950a3631 Fix FlipPen initialisers after the hinting-strike change on main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 19:18:45 +01:00
vangel76 0c859e334d Merge upstream/main
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-26 19:14:40 +01:00
vangel76 f4ff1f842d Merge upstream/main
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-26 19:14:40 +01:00
vangel76 d8899ed345 Merge upstream/main 2026-10-26 19:13:12 +01:00
vangel76 c10df4e7bc Merge remote-tracking branch 'upstream/main' into w3-1056 2026-10-26 19:12:44 +01:00
vangel76andClaude Sonnet 5.5 2a82cc4a22 Fix test layout after merging upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 02:20:51 +01:00
vangel76 5ce611091e Merge upstream/main into feat/1053-rtf-notes-comments 2026-10-26 02:20:26 +01:00
vangel76 878e5e3457 Merge upstream/main into test/fixed-layout-fonts-2729 2026-10-26 02:19:00 +01:00
vangel76 3cfa9ca721 x 2026-10-26 02:18:45 +01:00
vangel76 ff4377bf7e Merge upstream/main into zoom-stretch 2026-10-26 02:16:56 +01:00
vangel76andClaude Sonnet 5.5 71aa12c076 Fix hinting tests and imports after merging upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 02:16:31 +01:00
vangel76 cfa99b45ce Merge upstream/main into ui-text-hinting 2026-10-26 02:15:38 +01:00
vangel76andClaude Sonnet 5.5 e11943f818 Read the canvas scroll offset from the frame's clamped state after the merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 01:00:10 +01:00
vangel76andClaude Sonnet 5.5 33070c575c Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:55:58 +01:00
vangel76andClaude Sonnet 5.5 86a1b000ef Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:55:06 +01:00
vangel76andClaude Sonnet 5.5 b6d89388c8 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:49:08 +01:00
vangel76andClaude Sonnet 5.5 914e1d5872 Use Ui::scope_id for the recent-row remove button
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:47:31 +01:00
vangel76andClaude Sonnet 5.5 ba62c2255e Merge upstream/main into feat/recent-remove-2716
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:44:35 +01:00
vangel76andClaude Sonnet 5.5 0ea610d0ff Merge upstream/main into test/fixed-layout-fonts-2729
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:26:16 +01:00
vangel76andClaude Sonnet 5.5 a4e9b07701 Fix egui MouseWheel source field in zoom tests after merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:10:57 +01:00
vangel76andClaude Sonnet 5.5 2fb626e548 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:04:27 +01:00
vangel76andClaude Sonnet 5.5 827fc6e07a Fix tests after merge: egui MouseWheel source field, keep upstream's pinned fixtures
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:04:01 +01:00
vangel76andClaude Sonnet 5.5 e2b92cde84 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 23:56:15 +01:00
Fluorine 678af4368d Merge remote-tracking branch 'upstream/main' into search-box-text-centred 2026-10-25 10:33:30 -04:00
vangel76andClaude Sonnet 5.5 ed3c3ea038 Pass hinted=true in the sepia/print view_key tests after merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:20:25 +01:00
vangel76andClaude Sonnet 5.5 4940d07dad Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:19:42 +01:00
vangel76andClaude Sonnet 5.5 dc2b990602 Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:19:03 +01:00
vangel76andClaude Sonnet 5.5 92683b6796 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:18:49 +01:00
vangel76andClaude Sonnet 5.5 d858066cf7 Pass hinted=true in the sepia/print view_key tests after merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:18:34 +01:00
vangel76andClaude Sonnet 5.5 006b6480ed Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:17:03 +01:00
vangel76andClaude Sonnet 5.5 402ee4893c Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:14:50 +01:00
vangel76andClaude Sonnet 5.5 21bba8e7de Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 21:01:39 +02:00
vangel76andClaude Sonnet 5.5 d5de454641 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 21:00:39 +02:00
vangel76andClaude Sonnet 5.5 11fce726e3 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 21:00:14 +02:00
vangel76andClaude Opus 5.5 ed97087907 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:04:32 +02:00
vangel76andClaude Opus 5.5 2b2398ae55 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:54:22 +02:00
vangel76andClaude Sonnet 5.5 d7c7889c82 Merge upstream/main into feat/recent-remove-2716
Interface catalogs now live in craft-i18n; the PR's tsv rows are dropped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 14:18:34 +02:00
vangel76andClaude Sonnet 5.5 0b1704677d Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 14:01:46 +02:00
vangel76andClaude Sonnet 5.5 ec1cb78a20 Merge upstream/main into mcp-insert-table-2242
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 13:58:11 +02:00
vangel76andClaude Sonnet 5.5 8b06dd8666 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 13:56:07 +02:00
vangel76andClaude Sonnet 5.5 28b5cba00c Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 13:53:17 +02:00
vangel76andClaude Sonnet 5.5 14526d0d81 Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 13:51:55 +02:00
vangel76andClaude Sonnet 5.5 97eea6e12f Hinting test: ask for 3% fewer grey pixels, not 5%
Hinting only snaps horizontal edges, so on the fonts the render tests
resolve to the grey-pixel drop is 3.6 to 4.7 %, never the 5 % the test
asked for. It failed the same way on CI and locally (3767 vs 3630).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:11:30 +02:00
vangel76andClaude Sonnet 5.5 8b679452eb Hinting test: ask for 3% fewer grey pixels, not 5%
Hinting only snaps horizontal edges, so on the fonts the render tests
resolve to the grey-pixel drop is 3.6 to 4.7 %, never the 5 % the test
asked for. It failed the same way on CI and locally (3767 vs 3630).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 21:07:07 +02:00
fluorineandClaude Sonnet 5.5 fd27713d20 Search box: the placeholder text is centred vertically (#3779)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 13:45:41 -04:00
vangel76 c488558a1c Merge upstream/main into feat/recent-remove-2716 2026-10-22 19:10:34 +02:00
vangel76 addf03751d Merge upstream/main into control-mcp-delivery-1326-1331 2026-10-22 19:06:45 +02:00
vangel76 8578d99ccd Merge upstream/main into mcp-insert-table-2242 2026-10-22 19:05:36 +02:00
vangel76 77bfbd6f11 Merge upstream/main into feat/1053-rtf-notes-comments 2026-10-22 19:03:25 +02:00
vangel76 38253611d2 Add View Window strings to the da, nl, ro and sl catalogs 2026-10-22 19:02:20 +02:00
vangel76 876ce9ab48 Merge remote-tracking branch 'upstream/main' into s1-ui-text-hinting
# Conflicts:
#	ROADMAP.md
#	crates/fonts/src/fontdb.rs
#	crates/layout/src/tests_pages.rs
#	crates/render/src/lib.rs
#	crates/ui-egui/src/canvas.rs
#	docs/ui-parity.md
2026-10-22 18:56:32 +02:00
vangel76 0a23c4febe Merge remote-tracking branch 'upstream/main' into zoom-stretch
# Conflicts:
#	ROADMAP.md
#	crates/fonts/src/fontdb.rs
#	crates/layout/src/tests_pages.rs
#	crates/render/src/lib.rs
#	crates/ui-egui/src/canvas.rs
#	docs/ui-parity.md
2026-10-22 18:53:36 +02:00
vangel76 65f36f9b8a Merge upstream/main into feat/322-view-windows 2026-10-22 18:53:05 +02:00
vangel76 f50a5cc5e9 Merge remote-tracking branch 'upstream/main' into test/fixed-layout-fonts-2729
# Conflicts:
#	ROADMAP.md
#	crates/layout/src/tests_pages.rs
#	crates/render/src/lib.rs
2026-10-22 18:48:49 +02:00
vangel76 12582bb529 render: count legacy effect copies past the glyph box, not the glyph's antialiasing
The grey band 120-136 also holds the glyph's own antialiasing pixels on
the vertical stem edges; on platforms with many of them (FreeBSD's
Mesa AA), their centroid sits at the glyph centre and hides the copy's
offset in x (0.95 instead of ~6). The copy is drawn fully past the
glyph's bounding box in its own direction, so count grey only outside
the box in the copy's quadrant. Fixes the FreeBSD failure of #2783.
2026-10-22 00:04:52 +02:00
vangel76 badd0c5ef9 render: count legacy effect copies past the glyph box, not the glyph's antialiasing
The grey band 120-136 also holds the glyph's own antialiasing pixels on
the vertical stem edges; on platforms with many of them (FreeBSD's
Mesa AA), their centroid sits at the glyph centre and hides the copy's
offset in x (0.95 instead of ~6). The copy is drawn fully past the
glyph's bounding box in its own direction, so count grey only outside
the box in the copy's quadrant. Fixes the FreeBSD failure of #2783.
2026-10-22 00:04:05 +02:00
vangel76 2f46f194a1 render: count legacy effect copies past the glyph box, not the glyph's antialiasing
The grey band 120-136 also holds the glyph's own antialiasing pixels on
the vertical stem edges; on platforms with many of them (FreeBSD's
Mesa AA), their centroid sits at the glyph centre and hides the copy's
offset in x (0.95 instead of ~6). The copy is drawn fully past the
glyph's bounding box in its own direction, so count grey only outside
the box in the copy's quadrant. Fixes the FreeBSD failure of #2783.
2026-10-21 23:48:49 +02:00
vangel76 6bc7810c8c fmt: rustfmt backstage recent list 2026-10-21 23:23:44 +02:00
vangel76andClaude Sonnet 5.5 f9c6ddba3a fix(ui): show the Remove x on a hovered recent row, not only on the x itself (#2716)
The x was painted only while the pointer was already on the 28 pt button, so a
hovered row gave no cue that a per-row Remove exists. Paint it dimmed when the
row (or the button) is hovered or focused, with the stronger fill only on the x.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:23:29 +02:00
vangel76 00d4e7a291 #2729: use pinned layout fixtures to fix FreeBSD CI 2026-10-20 01:46:00 +02:00
vangel76 bfbb3d05a4 #2729: use pinned layout fixtures to fix FreeBSD CI 2026-10-20 01:46:00 +02:00
vangel76 9b6d326730 #2729: initialize pinned fonts in current ligature and caret tests 2026-10-20 01:45:42 +02:00
vangel76 212df61bc7 #3197: gate text-ink fixture to the platforms that use it 2026-10-20 01:40:47 +02:00
vangel76 0abe06e236 Merge remote-tracking branch 'upstream/main' into control-mcp-delivery-1326-1331 2026-10-20 01:40:44 +02:00
vangel76 843bf26ae8 #322: merge upstream and preserve window-command progress 2026-10-20 01:39:10 +02:00
vangel76 44055678c5 #1053: merge upstream while retaining RTF notes and comments 2026-10-20 01:38:35 +02:00
vangel76 9ea1d93a8d #2729: merge upstream and retain pinned-font test progress 2026-10-20 01:37:56 +02:00
vangel76andClaude Sonnet 5.5 ab3ad718a0 feat(ui): remove documents from the recent list, one by one or all (#2716)
Hovering a row in File > Home/Open > Recent shows a Remove button at its
right end, and a Clear all button under the list empties it. The commands
behind them, ui.recentRemove and ui.recentClear, work the same for the
CLI, the control channel and MCP. On the desktop the new list is written
to the preferences at once, so a removal survives the app being killed
before a normal close.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 01:34:12 +02:00
Mikael Tollefsen ad7eea67c1 ui_shot: an out-of-range {"ppp": s} is clamped to 0.5–4, not ignored
A scale outside the range fell back to 2 instead of the nearest allowed
one (review on #3101): {"ppp": 5} now gives 4, {"ppp": 0.1} gives 0.5.
2026-10-20 01:07:36 +02:00
Mikael Tollefsen be492ddc98 Dark-theme interface text on Linux no longer looks semi-bold 2026-10-20 01:07:35 +02:00
Mikael Tollefsen e8c852d490 ui_shot: a {"ppp": s} line sets the window's pixels per point
Headless screenshots were always taken at 2x, so text could not be seen
as it is drawn on a fractional desktop scale (e.g. 1.45 on a 4K screen at
145 %). The scale is clamped to 0.5–4; 2 stays the default.
2026-10-20 01:07:31 +02:00
Mikael Tollefsen 7a3c3b40af Screen hinting: tricky fonts run their instructions with the Mono target
FreeType's "tricky" fonts (old CJK fonts that assemble their glyphs with
their instructions) were hinted with the light smooth target, whose
backward compatibility forbids the instructions' sideways moves, so their
glyphs could come apart. skrifa requires the interpreter with
Target::Mono for them (OutlineGlyphCollection::require_interpreter), as
the unhinted path already does; the screen hinter now does the same.

Test: a bundled font renamed into FreeType's list ("cpop") is hinted with
the Mono target and drawn as it hints; any other font keeps the light
target.
2026-10-20 01:07:31 +02:00
Mikael Tollefsen 5288fed0ea Zoom hinting test: the wheel's own frame is checked too
The frame the Ctrl+wheel event arrives in already changes the scale; the
test now checks it is unhinted, not only the eased frames after it.
2026-10-20 01:07:31 +02:00
Mikael Tollefsen 1e306ed5ab Pages drawn mid-zoom are unhinted, and hinted once the zoom stops
A Ctrl/Cmd+wheel notch eases in over several frames and a pinch changes
the scale every frame: each frame was a new size, so its pages were
hinted afresh (a new hinting instance per face and every glyph hinted
again, about 1.5 ms more per page) and the hint caches filled with sizes
nobody rests on, which could clear the one the zoom ends at.

While a pointer zoom changes the scale, pages are now drawn unhinted;
the hinting is part of the page's texture key, so the first frame at
rest draws the page hinted, also at the size the last zoom frame drew.
A zoom that leaves the scale as it is (at 10 % or 500 %, or over the
ribbon) keeps the pages hinted, and other scale changes (Zoom dialog,
slider, a fit zoom following the window) are drawn hinted at once.

Rounding sizes into buckets was considered instead: steps between
eased frames are far larger than any bucket fine enough to keep the
glyphs on the pixel grid at the size the zoom ends at.

Tests: an easing Ctrl+wheel zoom draws every in-between size unhinted
and the page hinted the frame it stops; the decision for a gesture,
the zoom limits and other scale changes; the page key holds the
hinting.
2026-10-20 01:07:31 +02:00
Mikael Tollefsen 9e25e978e5 Page text on Linux is hinted like FreeType's hintslight 2026-10-20 01:07:31 +02:00
Mikael Tollefsen 3248047b76 Zoom stretch: only a raster of what the page shows now stands in
A pointer zoom that reflows the pages (Read Mode's text size follows the
wheel; Web Layout's width follows the zoom) gives a page new content while
the zoom is still moving. The stretched raster failed the page key but
still stood in when its size fitted, so the page showed the previous
layout's text until the zoom stopped (review on #3111).

Each page raster now keeps what the page showed (its content and view,
without scale or hinting); a raster is stretched only when that matches
what the page shows now, else the page is rendered.

Tests: in Read Mode a wheel zoom renders the reflowed pages while it
moves (none rendered without the change, over 11 frames); the eased notch
is checked frame by frame: no raster while it moves, one the frame it
stops.
2026-10-20 01:01:19 +02:00
Mikael Tollefsen 5edcd6765b Pointer zoom stretches page rasters until it stops 2026-10-20 01:01:19 +02:00
Mikael Tollefsen 80bf6ed96b Screen hinting: tricky fonts run their instructions with the Mono target
FreeType's "tricky" fonts (old CJK fonts that assemble their glyphs with
their instructions) were hinted with the light smooth target, whose
backward compatibility forbids the instructions' sideways moves, so their
glyphs could come apart. skrifa requires the interpreter with
Target::Mono for them (OutlineGlyphCollection::require_interpreter), as
the unhinted path already does; the screen hinter now does the same.

Test: a bundled font renamed into FreeType's list ("cpop") is hinted with
the Mono target and drawn as it hints; any other font keeps the light
target.
2026-10-20 01:01:15 +02:00
Mikael Tollefsen 665af07277 Zoom hinting test: the wheel's own frame is checked too
The frame the Ctrl+wheel event arrives in already changes the scale; the
test now checks it is unhinted, not only the eased frames after it.
2026-10-20 01:01:15 +02:00
Mikael Tollefsen 2e7852fa4d Pages drawn mid-zoom are unhinted, and hinted once the zoom stops
A Ctrl/Cmd+wheel notch eases in over several frames and a pinch changes
the scale every frame: each frame was a new size, so its pages were
hinted afresh (a new hinting instance per face and every glyph hinted
again, about 1.5 ms more per page) and the hint caches filled with sizes
nobody rests on, which could clear the one the zoom ends at.

While a pointer zoom changes the scale, pages are now drawn unhinted;
the hinting is part of the page's texture key, so the first frame at
rest draws the page hinted, also at the size the last zoom frame drew.
A zoom that leaves the scale as it is (at 10 % or 500 %, or over the
ribbon) keeps the pages hinted, and other scale changes (Zoom dialog,
slider, a fit zoom following the window) are drawn hinted at once.

Rounding sizes into buckets was considered instead: steps between
eased frames are far larger than any bucket fine enough to keep the
glyphs on the pixel grid at the size the zoom ends at.

Tests: an easing Ctrl+wheel zoom draws every in-between size unhinted
and the page hinted the frame it stops; the decision for a gesture,
the zoom limits and other scale changes; the page key holds the
hinting.
2026-10-20 01:01:15 +02:00
Mikael Tollefsen 3ee5840d6d Page text on Linux is hinted like FreeType's hintslight 2026-10-20 01:01:15 +02:00
vangel76andClaude Sonnet 5.5 ee649d03da i18n: translate the View Side by Side disabled-reason strings
The two disabled reasons added by the Window commands ("only the desktop app has
several windows", "turn on View Side by Side first") had no catalog rows, so
i18n::audit::untranslated_interface_text and the cs/vi coverage tests failed.
Add rows to ru, hu, tr, bg, it, sv, hr, pl, cs and vi.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:28:02 +02:00
vangel76andClaude Sonnet 5.5 19094d4f3a Layout tests: start every test with test_fonts and refit three fixtures to the pinned fonts
Adds crate::test_fonts() to the 95 layout tests that lacked it (several arrived
with upstream merges). Three fixtures were sized for installed fonts and failed
with the pinned set: exact_row_moves_whole (one line less of filler),
an_at_least_row_at_the_top_of_a_page_splits_instead_of_moving_on (60 lines so
the split row does not spill an empty page) and the inline text box test
(FILLER 16).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:20:09 +02:00
vangel76andClaude Sonnet 5.5 fb3d600428 cargo fmt --all
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:12:04 +02:00
vangel76andClaude Sonnet 5.5 7b1c0402b0 Merge upstream/main into mcp-insert-table-2242
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:11:48 +02:00
vangel76andClaude Sonnet 5.5 365b8c003e Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:11:33 +02:00
vangel76andClaude Sonnet 5.5 1bec25c02d Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:11:16 +02:00
vangel76andClaude Sonnet 5.5 c3703ebc73 Regenerate parity checklist and headline coverage after merging main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:10:30 +02:00
vangel76andClaude Sonnet 5.5 ede933dcc2 Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:09:57 +02:00
vangel76andClaude Sonnet 5.5 d955ff2874 Merge upstream/main into test/fixed-layout-fonts-2729
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:00:30 +02:00
vangel76 bd320e65a5 M2.1: pin layout tests to bundled fonts (#2729) 2026-10-17 21:14:47 +02:00
vangel76 3445b7226d M-ctl: cargo fmt the MCP bridge test (long fake_app line broke cargo fmt --all --check in CI) 2026-10-17 11:50:34 +02:00
vangel76andClaude Sonnet 5.5 f2d7a4fe7d M-ctl: MCP bridge never replays a mutation of unknown outcome; queued control requests honour the caller's timeout (#1326, #1331)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 22:28:30 +02:00
vangel76andClaude Sonnet 5.5 25e4986998 MCP: insert_table tool; insert.spreadsheet takes values, header and style (#2242)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 22:27:46 +02:00
vangel76andClaude Sonnet 5.5 a912aacf73 Merge upstream/main into feat/322-view-windows; translations for hr, ko, sv, vi; catalog 493/504
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 23:11:06 +02:00
vangel76andClaude Sonnet 5.5 256d5d084d Merge upstream/main into feat/1053-rtf-notes-comments
Notes and comments now work with RTF tracked changes (#1516) and per-section
headers and footers (#1495); a \sect inside a note or comment no longer ends
a section. Adds a test for the combination.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 23:05:08 +02:00
vangel76andClaude Sonnet 5.5 f0c1b64bc8 RTF: footnotes, endnotes and comments are read and written (#1053)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 21:00:56 +02:00
vangel76andClaude Sonnet 5.5 0164ac86fa #322: translations for the other locales, catalog count 491/502, parity checklist after rebase
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 19:28:33 +02:00
vangel76andClaude Opus 5.5 12a1316f10 View › Window across WordCraft windows: Switch Windows, Arrange All, Side by Side, Synchronous Scrolling (#322)
Each desktop window is its own process with one document, so the window
commands work across processes. Every window joins a small registry in the
settings folder (windows/<id>.json, mode 0600, rewritten every 30 s, removed
on exit; stale and malformed entries are ignored or cleaned up) and serves a
minimal authenticated loopback channel that can only focus, place and scroll
the window. The full control channel stays opt-in.

- engine: view.switchWindows, view.arrangeAll, view.sideBySide and
  view.syncScroll record what to do in Session::windows and ask the UI;
  disabled where there is no window host (web, CLI, MCP).
- ui-egui: WindowHost service, tiling and half-screen geometry, sync-scroll
  plumbing in the canvas, ribbon buttons and keytips, strings in every
  catalog.
- desktop app: registry and peer channel (4 KiB lines, constant-time key
  check, 2 s first-line deadline, 16 connections at most).
- Parity 393/434 -> 397/434; docs updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:12:06 +02:00
237 changed files with 13680 additions and 747 deletions
+4
View File
@@ -27,6 +27,10 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+12 -2
View File
@@ -42,6 +42,10 @@ jobs:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: '0'
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 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@8dd607977d06adf4ed49b190381e74c17a9dd25f # 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@8871c8ba063416da7c32246932ea6ae08700453e # 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
+29 -23
View File
@@ -1081,7 +1081,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=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=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
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=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
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,30 +6839,35 @@ 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",
"sys-locale",
"wordcraft-doc",
"wordcraft-engine",
"wordcraft-fonts",
"wordcraft-mcp",
"wordcraft-render",
"wordcraft-zotero",
"zip 3.0.0",
]
[[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",
@@ -6875,7 +6881,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"cfb",
"chrono",
@@ -6892,7 +6898,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6917,7 +6923,7 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"cfb",
"chrono",
@@ -6932,6 +6938,7 @@ dependencies = [
"log",
"regex",
"regex-automata",
"rustix 1.1.5",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -6950,7 +6957,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"brotli-decompressor",
"harfrust 0.13.3",
@@ -6964,7 +6971,7 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6991,7 +6998,7 @@ dependencies = [
[[package]]
name = "wordcraft-geom"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"image",
"serde",
@@ -6999,7 +7006,7 @@ dependencies = [
[[package]]
name = "wordcraft-layout"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"craft-proof",
"image",
@@ -7019,7 +7026,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"log",
"serde_json",
@@ -7031,7 +7038,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -7039,7 +7046,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"chrono",
"craft-pdf",
@@ -7060,7 +7067,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"image",
"log",
@@ -7074,7 +7081,7 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"accesskit_consumer",
"cfb",
@@ -7107,7 +7114,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"craft-web-api",
"craft-web-host",
@@ -7128,7 +7135,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"log",
"proptest",
@@ -7291,7 +7298,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"serde_json",
]
@@ -7562,7 +7569,6 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
+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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", 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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", 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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
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" }
+18
View File
@@ -48,3 +48,21 @@ _Avoid_: User dictionary (in the interface)
A folder of Hunspell dictionaries you supply, used before the system's (an installed Language pack
comes after it).
_Avoid_: Custom dictionaries
## Export
**Strict export**:
An export that writes nothing, and reports why, when a character or picture the output is meant
to show would be missing from it. Text the output doesn't show (hidden text, deletions without
markup) isn't checked.
_Avoid_: Safe mode, validated export
**Missing glyph**:
A character the output draws that no face in the font fallback chain can draw, so it would appear
as an empty box.
_Avoid_: Tofu, box, notdef character
**Missing picture**:
A picture the document refers to that the output cannot include: its part is absent, its data
cannot be decoded, its format is unsupported, or it is linked from outside the file.
_Avoid_: Broken image, dropped image
+40
View File
@@ -157,6 +157,7 @@ Command line:
```sh
wordcraft-cli convert report.docx report.pdf # docx, pdf, odt, rtf, html, md, tex, txt, png
wordcraft-cli convert report.docx report.pdf --strict # exit 3, no file, if a character or picture would be left out
wordcraft-cli text report.docx # plain text
wordcraft-cli inspect report.docx # structure as JSON
wordcraft-cli run --template sample \
@@ -164,6 +165,40 @@ wordcraft-cli run --template sample \
wordcraft-cli run --script job.json # the same from a file: commands, then save to docx and pdf
```
`convert --strict` (a `.pdf` or `.png` output only) and `render --strict` (a PNG of one page) refuse a silently incomplete render: they exit 3
and write nothing if the output would draw a character no font has (an empty box) or leave out a
picture. Exit codes are 0 (written), 1 (unreadable input, usage error, or `--strict` with another
output type, checked before the input is read) and 3 (strict refusal). On exit 3, stderr has one
`wordcraft-cli: strict: …` line per kind, then one JSON line:
```sh
wordcraft-cli convert in.docx out.pdf --strict
# wordcraft-cli: strict: 1 character cannot be drawn: U+2D30 (×3)
# wordcraft-cli: strict: 1 picture(s) left out: word/media/image2.bin (header2.xml): unsupported format
# {"schema":1,"strict":"failed","missing_glyphs":[{"cp":"U+2D30","count":3}],"missing_pictures":[{"part":"word/media/image2.bin","source_part":"word/header2.xml","reason":"unsupported_format"}],"truncated":false,"total_missing_glyphs":1,"total_missing_pictures":1,"glyphs_truncated":false,"pictures_truncated":false}
```
The JSON line (schema 1, also what `file.strictReport` returns) has `schema`, `strict`
(`"passed"` or `"failed"`), `missing_glyphs` (entries `{"cp", "count"}`, sorted by code point),
`missing_pictures` (entries `{"part", "source_part", "reason"}`: the picture's part or target, the
story part it sits in such as `word/header2.xml`, and a reason code; `source_part` is also
`background` (the page picture), `body`, `header`, `footer`, `footnote`, `endnote`, `comment` or
`text box` where the part name is not known (a document not opened from a DOCX), or a chunk path such as
`word/afchunk1.docx > word/header1.xml` for a picture inside an embedded DOCX chunk), `truncated`, the two totals
`total_missing_glyphs` and `total_missing_pictures`, and `glyphs_truncated` and
`pictures_truncated` for each list. The reason codes are `missing_part`, `external_target`,
`link_only`, `undecodable` and `unsupported_format`; the stderr lines use plain words for them. When a list is cut, the stderr line gives the total
(`300 characters cannot be drawn (first 256 listed)`).
Each list stops at 256 entries separately; `truncated` is true when either was cut, and the totals
count every distinct character and picture found, those past the cap included. Only characters the output draws count (not hidden text, or deleted text
without `--markup`), and a character a fallback font draws is fine. Text inside a WMF or EMF picture counts too, shaped in the font the picture names with no fallback. A Private Use Area character
fails only if no installed font maps it. The result depends on the fonts installed on the host
(see #5624 for a fixed font set). An external picture target is never fetched: it is reported, not
loaded. Pictures dropped when a DOCX is opened (a missing part, an external target, or a `link only` picture with no embedded copy) are reported for DOCX input only. They have no page, so a PNG of any page is also refused for a missing or external picture anywhere in the document, unused headers included. `render --strict` checks only the page it
writes (`--page`; `--scale` doesn't matter), prints nothing on stdout when it refuses, and reports the
same way. The engine takes `strict: true` on `file.save`, `file.saveAs`, `file.exportPdf` and
`file.exportPng`, and `file.strictReport {markup?, page?}` returns the report without writing.
Web: `cd apps/wordcraft-web && trunk serve`, then open <http://127.0.0.1:8771/?sample>.
## For agents: CLI and MCP
@@ -319,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 |
+48 -1
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -21,3 +21,8 @@ wordcraft-mcp = { workspace = true }
wordcraft-zotero = { workspace = true }
serde_json = { workspace = true }
sys-locale = "0.3"
[dev-dependencies]
wordcraft-fonts = { workspace = true }
image = { workspace = true }
zip = { workspace = true }
+200 -57
View File
@@ -11,23 +11,30 @@ 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
USAGE:
wordcraft-cli convert <in> <out> [--markup] [--update-fields] [--format wordml2003|flatopc]
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
tracked changes in pdf/png, a comment list in html/md/txt
tracked changes in pdf/png, a comment list in html/md/txt;
--strict (pdf or png output only) writes nothing and exits 3
if a character can't be drawn or a picture is missing or
undecodable; the report is on stderr, with one JSON line
wordcraft-cli info <file> pages, words, paragraphs, properties (JSON)
wordcraft-cli text <file> plain text
wordcraft-cli inspect <file> document structure (JSON)
wordcraft-cli render <file> <out.png> [--page N] [--scale S]
wordcraft-cli render <file> <out.png> [--page N] [--scale S] [--strict]
--strict writes nothing and exits 3 if a character on the
page can't be drawn or a picture is missing or undecodable
(report on stderr, as for convert --strict)
wordcraft-cli run [--file F | --template T] [--script FILE|-] --cmd 'id={json}' [--cmd …]
[--save OUT] [--print] [--author NAME]
run commands headlessly, then save (--author names
@@ -77,6 +84,14 @@ fn wants_help(cmd: &str, rest: &[String]) -> bool {
SUBCOMMANDS.contains(&cmd) && rest.iter().any(|a| a == "--help" || a == "-h")
}
/// A failure whose message may carry text from a file or from the engine's reading of one (a
/// picture's part name, a parse error quoting the input): control, bidi and invisible characters
/// are escaped, so it can't forge or spoof a line (the CLI's own messages, which have line breaks
/// on purpose, are not routed through here). Backslashes stay: messages quote Windows paths.
fn engine_error(e: impl fmt::Display) -> Failure {
Failure::Error(escape_text(&e.to_string(), false))
}
/// The error every subcommand gives when a required path is not there.
fn missing_argument() -> Failure {
format!("missing argument\n\n{USAGE}").into()
@@ -85,11 +100,11 @@ fn missing_argument() -> Failure {
/// Open a document for a subcommand. A password-protected Word file needs its password
/// ([`password_from`]). As in the app, a document opened with one is saved with it again, so
/// `convert` does not quietly write a copy the password no longer protects.
fn open(path: &Path, password: Option<&str>) -> Result<Session, String> {
fn open(path: &Path, password: Option<&str>) -> Result<Session, Failure> {
// A file that gives no page size (HTML, Markdown, plain text) opens on the system region's
// paper, as in the app: A4 in Italy, Letter in the US (#1381).
let region = system_region();
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, &region)?;
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, &region).map_err(engine_error)?;
let mut s = session(doc);
s.path = Some(path.into());
if encrypted {
@@ -198,7 +213,7 @@ fn listed_text(doc: &wordcraft_doc::Document) -> Result<String, String> {
/// The command also checks whether the open document is protected: this name determines which
/// named editing exceptions the session can use, so the flag must not bypass that check.
fn set_author(s: &mut Session, name: &str) -> Result<(), String> {
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| format!("--author: {e}"))
s.run("file.setAuthor", &json!({"name": name})).map(|_| ()).map_err(|e| escape_text(&format!("--author: {e}"), false))
}
fn arg_value(args: &[String], name: &str) -> Option<String> {
@@ -310,10 +325,12 @@ const CONVERT_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--markup", takes_value: false },
OptionSpec { name: "--format", takes_value: true },
OptionSpec { name: "--update-fields", takes_value: false },
OptionSpec { name: "--strict", takes_value: false },
OptionSpec { name: "--password", takes_value: true },
OptionSpec { name: "--password-file", takes_value: true },
];
const RENDER_OPTIONS: &[OptionSpec] = &[
OptionSpec { name: "--strict", takes_value: false },
OptionSpec { name: "--page", takes_value: true },
OptionSpec { name: "--scale", takes_value: true },
OptionSpec { name: "--password", takes_value: true },
@@ -350,6 +367,9 @@ enum Failure {
/// Whoever read stdout closed it (`wordcraft-cli commands --json | head`): there is nobody left
/// to write to, so stop quietly and exit 0 like other command-line tools.
StdoutClosed,
/// `convert --strict` refused: the text for stderr (the report's lines, then its JSON line).
/// Exit status 3.
Strict(String),
}
impl fmt::Display for Failure {
@@ -357,6 +377,7 @@ impl fmt::Display for Failure {
match self {
Failure::Error(e) => f.write_str(e),
Failure::StdoutClosed => f.write_str("stdout closed"),
Failure::Strict(report) => f.write_str(report),
}
}
}
@@ -402,11 +423,104 @@ fn valid_connect_addr(addr: &str) -> bool {
!host.is_empty() && port.parse::<u16>().is_ok()
}
/// A failed `--strict` export: a strict refusal becomes [`Failure::Strict`] carrying what stderr
/// shows (each line of the report as `wordcraft-cli: …`, then its JSON on one line, from
/// `file.strictReport` for `page` of a page image, else the PDF); any other error is an ordinary
/// failure.
fn refusal(s: &mut Session, e: wordcraft_engine::CmdError, markup: bool, page: Option<u64>) -> Failure {
if !matches!(&e, wordcraft_engine::CmdError::Failed(m) if m.starts_with("strict: ")) {
return engine_error(e);
}
let mut params = json!({"markup": markup});
if let Some(page) = page {
params["page"] = json!(page);
}
Failure::Strict(match s.run("file.strictReport", &params).map_err(|e| e.to_string()) {
Ok(json) => {
let mut text = String::new();
for line in report_lines(&json) {
text += &format!("wordcraft-cli: {}\n", escape_peer(&line));
}
text + &json_line_safe(&json.to_string())
}
Err(e) => format!("wordcraft-cli: strict: refused, and the report could not be made: {}", escape_text(&e, false)),
})
}
/// The report's human lines, from its JSON (the engine's `StrictReport::lines`).
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)?;
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
let format = match find_option_arg(rest, "--format") {
OptionArg::Absent => None,
OptionArg::Duplicate => return Err("convert: duplicate --format option".into()),
OptionArg::MissingValue => return Err("convert: --format requires a value".into()),
OptionArg::Value(value) => Some(value),
};
let paths = positionals(rest, CONVERT_OPTIONS);
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
let markup = rest.iter().any(|a| a == "--markup");
let strict = rest.iter().any(|a| a == "--strict");
// Before the input is opened: a wrong output type is a usage error, not a file error.
let to_png = o.to_ascii_lowercase().ends_with(".png");
if strict && !(to_png || o.to_ascii_lowercase().ends_with(".pdf")) {
return Err("convert: --strict needs a .pdf or .png output".into());
}
let password = password_from(rest)?;
let mut s = open(&i, password.as_deref())?;
if rest.iter().any(|a| a == "--update-fields") {
update_fields(&mut s)?;
}
let r = if to_png {
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup, "strict": strict}))
} else {
let mut params = json!({"path": o, "markup": markup, "strict": strict});
if let Some(f) = format {
params["format"] = json!(f);
}
s.run("file.save", &params)
};
if let Err(e) = r {
return Err(if strict { refusal(&mut s, e, markup, to_png.then_some(1)) } else { engine_error(e) });
}
eprintln!("wrote {o}");
Ok(())
}
fn load_run_script(rest: &[String], script_path: Option<&Path>) -> Result<Option<script::Script>, Failure> {
match arg_value(rest, "--script") {
Some(path) if !path.starts_with("--") => {
let source = script_path.unwrap_or(Path::new(&path));
Ok(Some(script::load(source)?))
Ok(Some(script::load(source).map_err(engine_error)?))
}
_ if rest.iter().any(|arg| arg == "--script") => Err("run: --script requires a value".into()),
_ => Ok(None),
@@ -438,42 +552,13 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
return write_stdout(|o| write!(o, "{USAGE}"));
}
match cmd {
"convert" => {
validate_options("convert", &rest, CONVERT_OPTIONS)?;
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
let format = match find_option_arg(&rest, "--format") {
OptionArg::Absent => None,
OptionArg::Duplicate => return Err("convert: duplicate --format option".into()),
OptionArg::MissingValue => return Err("convert: --format requires a value".into()),
OptionArg::Value(value) => Some(value),
};
let paths = positionals(&rest, CONVERT_OPTIONS);
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
let markup = rest.iter().any(|a| a == "--markup");
let password = password_from(&rest)?;
let mut s = open(&i, password.as_deref())?;
if rest.iter().any(|a| a == "--update-fields") {
s.run("references.updateFields", &json!({})).map_err(|e| e.to_string())?;
}
let r = if o.to_ascii_lowercase().ends_with(".png") {
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup}))
} else {
let mut params = json!({"path": o, "markup": markup});
if let Some(f) = format {
params["format"] = json!(f);
}
s.run("file.save", &params)
};
r.map_err(|e| e.to_string())?;
eprintln!("wrote {o}");
Ok(())
}
"convert" => run_convert(&rest, open),
"info" => {
validate_options("info", &rest, PASSWORD_OPTIONS)?;
let paths = positionals(&rest, PASSWORD_OPTIONS);
let password = password_from(&rest)?;
let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
let info = serde_json::to_string_pretty(&s.run("file.info", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default();
let info = serde_json::to_string_pretty(&s.run("file.info", &json!({})).map_err(engine_error)?).unwrap_or_default();
write_stdout(|o| writeln!(o, "{info}"))
}
"text" => {
@@ -489,7 +574,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let paths = positionals(&rest, PASSWORD_OPTIONS);
let password = password_from(&rest)?;
let mut s = open(paths.first().ok_or_else(missing_argument)?, password.as_deref())?;
let tree = serde_json::to_string_pretty(&s.run("document.inspect", &json!({})).map_err(|e| e.to_string())?).unwrap_or_default();
let tree = serde_json::to_string_pretty(&s.run("document.inspect", &json!({})).map_err(engine_error)?).unwrap_or_default();
write_stdout(|o| writeln!(o, "{tree}"))
}
"render" => {
@@ -503,7 +588,17 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let page = parse_page_arg("render", &rest)?.unwrap_or(1);
let scale = parse_scale_arg("render", &rest)?.unwrap_or(2.0);
let mut s = open(&i, password.as_deref())?;
let r = s.run("file.exportPng", &json!({"path": o, "page": page, "scale": scale})).map_err(|e| e.to_string())?;
let strict = rest.iter().any(|a| a == "--strict");
let mut params = json!({"path": o, "page": page, "scale": scale, "strict": strict});
if strict {
// The check and its report must see the page as it is written.
params["markup"] = json!(false);
}
let r = match s.run("file.exportPng", &params) {
Ok(r) => r,
Err(e) if strict => return Err(refusal(&mut s, e, false, Some(page))),
Err(e) => return Err(engine_error(e)),
};
write_stdout(|o| writeln!(o, "{r}"))
}
"run" => {
@@ -522,8 +617,8 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let mut s = session(wordcraft_doc::Document::new());
let first = s.document_id();
let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
s.run("file.new", &params).map_err(|e| e.to_string())?;
s.close_document(first).map_err(|e| e.to_string())?;
s.run("file.new", &params).map_err(engine_error)?;
s.close_document(first).map_err(engine_error)?;
s
}
};
@@ -532,7 +627,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
}
let print = rest.iter().any(|a| a == "--print");
for (n, (id, params)) in script.iter().flat_map(|c| c.commands.iter()).enumerate() {
let r = s.run(id, params).map_err(|e| format!("command {} ({id}): {e}", n + 1))?;
let r = s.run(id, params).map_err(|e| engine_error(format!("command {} ({id}): {e}", n + 1)))?;
if print {
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
}
@@ -542,10 +637,12 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
if rest.get(i).map(String::as_str) == Some("--cmd") {
let spec = rest.get(i + 1).ok_or("--cmd needs a value")?;
let (id, params) = match spec.split_once('=') {
Some((id, p)) => (id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| format!("{id}: bad JSON params: {e}"))?),
Some((id, p)) => {
(id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| engine_error(format!("{id}: bad JSON params: {e}")))?)
}
None => (spec.clone(), json!({})),
};
let r = s.run(&id, &params).map_err(|e| format!("{id}: {e}"))?;
let r = s.run(&id, &params).map_err(|e| engine_error(format!("{id}: {e}")))?;
if print {
write_stdout(|o| writeln!(o, "{id}: {r}"))?;
}
@@ -565,7 +662,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
return Err(format!("{out}: is a folder, not a file").into());
}
if !out.to_ascii_lowercase().ends_with(".png") {
wordcraft_engine::io::save_bytes(out, &s.doc)?;
wordcraft_engine::io::save_bytes(out, &s.doc).map_err(engine_error)?;
}
}
for out in outputs {
@@ -581,7 +678,7 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
} else {
s.run("file.save", &json!({"path": out}))
};
r.map_err(|e| e.to_string())?;
r.map_err(engine_error)?;
eprintln!("wrote {out}");
}
Ok(())
@@ -680,10 +777,12 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let mut s = open(file, None)?;
for spec in rest.iter().zip(rest.iter().skip(1)).filter(|(a, _)| *a == "--cmd").map(|(_, v)| v) {
let (id, params) = match spec.split_once('=') {
Some((id, p)) => (id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| format!("{id}: bad JSON params: {e}"))?),
Some((id, p)) => {
(id.to_string(), serde_json::from_str::<Value>(p).map_err(|e| engine_error(format!("{id}: bad JSON params: {e}")))?)
}
None => (spec.clone(), json!({})),
};
s.run(&id, &params).map_err(|e| format!("{id}: {e}"))?;
s.run(&id, &params).map_err(|e| engine_error(format!("{id}: {e}")))?;
}
let mut opts = wordcraft_zotero::client::Options::default();
if let Some(p) = arg_value(&rest, "--port") {
@@ -700,13 +799,13 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
}
r
})
.map_err(|e| e.to_string())?;
.map_err(engine_error)?;
for a in &host.alerts {
eprintln!("Zotero: {}", escape_peer(a));
}
eprintln!("{} calls, {}", out.calls, if out.completed { "completed" } else { "ended without completing (cancelled?)" });
if let Some(o) = arg_value(&rest, "--save") {
s.run("file.save", &json!({"path": o})).map_err(|e| e.to_string())?;
s.run("file.save", &json!({"path": o})).map_err(engine_error)?;
eprintln!("wrote {o}");
}
Ok(())
@@ -730,22 +829,62 @@ fn clip(s: &str) -> String {
/// overrides are shown as escapes (`\\`, `\n`, `\u{1b}`), so it can't end its line, drive the
/// terminal or pass for other output.
fn escape_peer(s: &str) -> String {
escape_text(s, true)
}
/// Whether `c` must not reach a terminal as itself: a control character, a line or paragraph
/// separator, a bidi control, or one that shows nothing (soft hyphen, zero-width characters, word
/// joiners, the byte order mark, tag characters), which can hide or reorder what a line says.
fn is_unsafe_char(c: char) -> bool {
c.is_control()
|| matches!(
c,
'\u{00ad}'
| '\u{061c}'
| '\u{200b}'..='\u{200f}'
| '\u{2028}'..='\u{202e}'
| '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{2069}'
| '\u{feff}'
| '\u{e0000}'..='\u{e007f}'
)
}
/// [`escape_peer`], with backslashes escaped only when `backslash` (error messages quote Windows
/// paths, and a backslash can't forge or hide anything).
fn escape_text(s: &str, backslash: bool) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'\\' if backslash => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() || matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{2028}'..='\u{202e}' | '\u{2066}'..='\u{2069}') => {
out.extend(c.escape_unicode())
}
c if is_unsafe_char(c) => out.extend(c.escape_unicode()),
c => out.push(c),
}
}
out
}
/// A JSON line safe to print: serde escapes control characters below U+0020 but not the other
/// characters [`is_unsafe_char`] names, which a crafted file could put in a report. They become
/// `\uXXXX` escapes (a surrogate pair above U+FFFF), so the line parses to the same value and
/// can't drive or spoof a terminal.
fn json_line_safe(json: &str) -> String {
let mut out = String::with_capacity(json.len());
for c in json.chars() {
if is_unsafe_char(c) {
for unit in c.encode_utf16(&mut [0u16; 2]) {
out.push_str(&format!("\\u{unit:04x}"));
}
} else {
out.push(c);
}
}
out
}
fn parity_markdown(p: &Value) -> String {
let mut s = String::from(
"# WordCraft feature parity\n\nGenerated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.\n\n",
@@ -786,6 +925,10 @@ fn main() -> ExitCode {
eprintln!("wordcraft-cli: {e}");
ExitCode::FAILURE
}
Err(Failure::Strict(report)) => {
eprintln!("{report}");
ExitCode::from(3)
}
}
}
+16
View File
@@ -564,3 +564,19 @@ fn script_parse_resolves_paths_against_the_base() {
assert_eq!(s.save, vec!["o.docx".to_string()]);
assert_eq!(s.commands.len(), 1);
}
#[test]
fn invisible_characters_are_escaped_and_json_lines_still_parse_back() {
let hidden = "a\u{ad}b\u{200b}c\u{200d}d\u{2060}e\u{2064}f\u{feff}g\u{e0041}h\u{e007f}i\u{85}j\u{202e}k";
let shown = escape_peer(hidden);
assert!(!shown.chars().any(is_unsafe_char), "{shown}");
assert!(shown.contains("\\u{e0041}") && shown.contains("\\u{ad}"), "{shown}");
// Errors keep a Windows path readable.
assert_eq!(escape_text("C:\\dir\\a.docx", false), "C:\\dir\\a.docx");
let value = json!({"part": hidden, "plain": "ünïcode ok"});
let line = json_line_safe(&value.to_string());
assert!(!line.chars().any(is_unsafe_char), "{line}");
assert!(line.contains("\\ud800") || line.contains("\\udb40"), "{line}");
assert_eq!(serde_json::from_str::<Value>(&line).unwrap(), value);
}
@@ -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();
}
+1
View File
@@ -6,4 +6,5 @@ mod help;
#[cfg(all(unix, not(target_os = "macos")))]
mod non_utf8_args;
mod picture_then_typing;
mod strict;
mod text_tracked;
+495
View File
@@ -0,0 +1,495 @@
//! `convert --strict` (#6005): exit 3 and a report when a PDF or PNG would silently lack a
//! character or a picture, nothing written; exit 0 otherwise.
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use wordcraft_doc::Document;
use wordcraft_fonts::FontDb;
const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture""#;
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
const RELS_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("wc-cli-strict-{name}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
fn convert(input: &Path, output: &Path, strict: bool) -> Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
cmd.arg("convert").arg(input).arg(output);
if strict {
cmd.arg("--strict");
}
cmd.output().unwrap()
}
fn stderr(o: &Output) -> String {
String::from_utf8_lossy(&o.stderr).into_owned()
}
/// The report's JSON line: the last line of stderr.
fn report_json(o: &Output) -> serde_json::Value {
let text = stderr(o);
let last = text.lines().last().unwrap_or_default();
serde_json::from_str(last).unwrap_or_else(|e| panic!("last stderr line is not JSON ({e}): {text}"))
}
fn json_input(d: &Path, text: &str) -> PathBuf {
let path = d.join("in.json");
std::fs::write(&path, serde_json::to_vec(&Document::from_text(text)).unwrap()).unwrap();
path
}
/// Built without craft-fonts, the host's own fonts decide what is drawable: skip, as the layout
/// tests do.
fn without_fonts() -> bool {
let missing = option_env!("CRAFT_FONTS_DIR").is_none_or(str::is_empty);
if missing {
eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a checkout)");
}
missing
}
/// A code point in unassigned plane 4 that no face of the host covers.
fn uncovered() -> char {
let db = FontDb::global();
let primary = wordcraft_fonts::word::resolve(&Document::default().settings.minor_font, false, false).face;
(0x40000u32..0x4FFF0)
.filter_map(char::from_u32)
.find(|c| !primary.covers(*c) && db.fallback_styled(*c, primary.id(), false, false).is_none() && db.fallback_for(*c, primary.id()).is_none())
.expect("the host has faces covering all of plane 4")
}
fn png() -> Vec<u8> {
let mut out = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([20, 90, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}
fn pic(rid: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="508000" cy="508000"/><wp:docPr id="1" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="{rid}"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="508000" cy="508000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
}
/// A DOCX whose header holds a picture of `header_media` (`word/media/image2.bin`).
fn docx_with_header_picture(header_media: &[u8]) -> Vec<u8> {
let doc = format!(
r#"<w:document {NS}><w:body><w:p><w:r><w:t>text</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr></w:body></w:document>"#
);
let drels = format!(r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rIdH" Type="{REL}/header" Target="header2.xml"/></Relationships>"#);
let hrels = format!(r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rIdI" Type="{REL}/image" Target="media/image2.bin"/></Relationships>"#);
let hdr = format!(r#"<w:hdr {NS}><w:p>{}</w:p></w:hdr>"#, pic("rIdI"));
package(
&doc,
&drels,
&[("word/header2.xml", hdr.as_bytes()), ("word/_rels/header2.xml.rels", hrels.as_bytes()), ("word/media/image2.bin", header_media)],
)
}
/// A DOCX whose body picture points outside the file, at `target`.
fn docx_with_external_picture(target: &str) -> Vec<u8> {
let doc = format!(r#"<w:document {NS}><w:body><w:p>{}<w:r><w:t>text</w:t></w:r></w:p></w:body></w:document>"#, pic("rIdX"));
let drels = format!(
r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rIdX" Type="{REL}/image" Target="{target}" TargetMode="External"/></Relationships>"#
);
package(&doc, &drels, &[])
}
fn package(document: &str, document_rels: &str, extra: &[(&str, &[u8])]) -> Vec<u8> {
let rels = format!(
r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rId1" Type="{REL}/officeDocument" Target="word/document.xml"/></Relationships>"#
);
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/octet-stream"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut parts: Vec<(&str, &[u8])> = vec![
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", rels.as_bytes()),
("word/document.xml", document.as_bytes()),
("word/_rels/document.xml.rels", document_rels.as_bytes()),
];
parts.extend_from_slice(extra);
for (name, bytes) in parts {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap().into_inner()
}
#[test]
fn multilingual_names_pass_strict() {
if without_fonts() {
return;
}
let names = "Łukasz Żmijewski\nGheorghe Ștefănescu\nKrejčí\nŞahın Gül\nLlŷr Grŵn\n王小明\n김민수\nสมชาย ใจดี\nराम शर्मा";
let d = dir("names");
let input = json_input(&d, names);
for out in ["names.pdf", "names.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out.exists());
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn an_uncovered_code_point_fails_strict_with_a_report_and_no_file() {
let c = uncovered();
let cp = format!("U+{:04X}", c as u32);
let d = dir("uncovered");
let input = json_input(&d, &format!("a{c}b{c}{c}"));
for out in ["out.pdf", "out.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
let text = stderr(&r);
assert!(text.contains(&format!("wordcraft-cli: strict: 1 character cannot be drawn: {cp} (×3)")), "{text}");
let json = report_json(&r);
assert_eq!(json["schema"], 1);
assert_eq!(json["strict"], "failed");
assert_eq!(json["missing_glyphs"][0]["cp"], cp.as_str());
assert_eq!(json["missing_glyphs"][0]["count"], 3);
assert!(!out.exists(), "{} was written", out.display());
eprintln!("{text}");
// Without --strict the same input converts, as before.
let r = convert(&input, &out, false);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out.exists());
std::fs::remove_file(&out).unwrap();
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_real_png_in_a_header_passes_strict() {
let d = dir("header-png");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_header_picture(&png())).unwrap();
for out in ["out.pdf", "out.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out.exists());
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_corrupt_header_picture_fails_strict_naming_the_part_and_the_header() {
let d = dir("header-corrupt");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_header_picture(b"corrupt bytes, not a picture")).unwrap();
for out in ["out.pdf", "out.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
let text = stderr(&r);
assert!(text.contains("word/media/image2.bin (header2.xml): unsupported format"), "{text}");
let json = report_json(&r);
assert_eq!(json["strict"], "failed");
assert_eq!(
json["missing_pictures"][0],
serde_json::json!({"part": "word/media/image2.bin", "source_part": "word/header2.xml", "reason": "unsupported_format"})
);
assert!(!out.exists());
eprintln!("{text}");
}
// Without --strict the grey box is drawn, as before.
let out = d.join("lenient.pdf");
assert_eq!(convert(&input, &out, false).status.code(), Some(0));
assert!(out.exists());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn an_external_picture_target_is_never_fetched_and_is_reported_under_strict() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let target = format!("http://{}/canary.png", listener.local_addr().unwrap());
let d = dir("external");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_external_picture(&target)).unwrap();
let lenient = convert(&input, &d.join("lenient.pdf"), false);
assert_eq!(lenient.status.code(), Some(0), "{}", stderr(&lenient));
let out = d.join("strict.pdf");
let strict = convert(&input, &out, true);
assert_eq!(strict.status.code(), Some(3), "{}", stderr(&strict));
let json = report_json(&strict);
assert_eq!(json["missing_pictures"][0]["reason"], "external_target");
assert!(stderr(&strict).contains("external target"));
assert!(!out.exists());
match listener.accept() {
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Ok((_, from)) => panic!("the converter connected to the external target from {from}"),
Err(e) => panic!("{e}"),
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn bad_input_exits_1_with_the_usual_message_with_and_without_strict() {
let d = dir("bad-input");
let good = docx_with_header_picture(&png());
let truncated = d.join("truncated.docx");
std::fs::write(&truncated, &good[..good.len() / 2]).unwrap();
let not_zip = d.join("not-zip.docx");
std::fs::write(&not_zip, b"this is plainly not a zip archive").unwrap();
for input in [&truncated, &not_zip] {
let plain = convert(input, &d.join("a.pdf"), false);
let strict = convert(input, &d.join("b.pdf"), true);
assert_eq!(plain.status.code(), Some(1), "{}", stderr(&plain));
assert_eq!(strict.status.code(), Some(1), "{}", stderr(&strict));
assert_eq!(stderr(&plain), stderr(&strict));
assert!(stderr(&plain).starts_with("wordcraft-cli: "), "{}", stderr(&plain));
assert!(!d.join("a.pdf").exists() && !d.join("b.pdf").exists());
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn strict_with_another_output_type_exits_1_before_the_input_is_read() {
let d = dir("wrong-output");
let missing = d.join("does-not-exist.docx");
for out in ["out.docx", "out.txt", "out.odt"] {
let r = convert(&missing, &d.join(out), true);
assert_eq!(r.status.code(), Some(1));
let text = stderr(&r);
assert!(text.contains("--strict needs a .pdf or .png output"), "{text}");
assert!(!d.join(out).exists());
}
// Without --strict the same command fails on the missing input.
let r = convert(&missing, &d.join("out.docx"), false);
assert_eq!(r.status.code(), Some(1));
assert!(!stderr(&r).contains("--strict"));
std::fs::remove_dir_all(d).unwrap();
}
fn render(input: &Path, output: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("render").arg(input).arg(output).args(args).output().unwrap()
}
/// Page 1 has `a{c}b`, page 2 plain text.
fn two_page_input(d: &Path, text: &str) -> PathBuf {
let mut doc = Document::from_text(&format!("{text}\nSecond page"));
doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap().props.page_break_before = Some(true);
let path = d.join("two.json");
std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap();
path
}
#[test]
fn render_strict_refuses_an_uncovered_character_on_the_page_with_no_stdout_or_file() {
let c = uncovered();
let cp = format!("U+{:04X}", c as u32);
let d = dir("render-glyph");
let input = two_page_input(&d, &format!("a{c}b"));
let out = d.join("p1.png");
let r = render(&input, &out, &["--strict"]);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
assert!(r.stdout.is_empty(), "{}", String::from_utf8_lossy(&r.stdout));
assert!(!out.exists());
let text = stderr(&r);
assert!(text.contains(&format!("wordcraft-cli: strict: 1 character cannot be drawn: {cp} (×1)")), "{text}");
assert_eq!(report_json(&r)["missing_glyphs"][0]["cp"], cp.as_str());
eprintln!("{text}");
// The character is not on page 2, and --scale does not matter to the check.
let out2 = d.join("p2.png");
let r = render(&input, &out2, &["--page", "2", "--strict", "--scale", "0.5"]);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out2.exists());
assert!(String::from_utf8_lossy(&r.stdout).contains("\"path\""));
// And the same page 1 without --strict renders, with the usual stdout.
let out3 = d.join("lenient.png");
let r = render(&input, &out3, &[]);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(out3.exists());
let json: serde_json::Value = serde_json::from_slice(&r.stdout).unwrap();
assert_eq!(json["path"], out3.to_string_lossy().as_ref());
assert!(json["width"].as_u64().is_some() && json["bytes"].as_u64().is_some());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn render_strict_refuses_a_corrupt_header_picture_and_bad_input_still_exits_1() {
let d = dir("render-pic");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_header_picture(b"corrupt bytes, not a picture")).unwrap();
let out = d.join("out.png");
let r = render(&input, &out, &["--strict"]);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
assert!(r.stdout.is_empty());
assert!(stderr(&r).contains("word/media/image2.bin (header2.xml)"), "{}", stderr(&r));
assert!(!out.exists());
let bad = d.join("bad.docx");
std::fs::write(&bad, b"this is plainly not a zip archive").unwrap();
let (plain, strict) = (render(&bad, &out, &[]), render(&bad, &out, &["--strict"]));
assert_eq!((plain.status.code(), strict.status.code()), (Some(1), Some(1)));
assert_eq!(stderr(&plain), stderr(&strict));
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_picture_target_with_a_newline_and_a_bidi_override_cannot_forge_a_report_line() {
let d = dir("escape");
let input = d.join("in.docx");
// XML character references put a newline and U+202E in the target.
std::fs::write(&input, docx_with_external_picture("http://example.invalid/a&#10;wordcraft-cli: strict: forged&#x202E;gnp.png")).unwrap();
let out = d.join("out.pdf");
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
let text = stderr(&r);
let lines: Vec<&str> = text.lines().collect();
// One human line for the picture, then the JSON line.
assert_eq!(lines.len(), 2, "{text}");
assert!(lines[0].starts_with("wordcraft-cli: strict: 1 picture(s) left out: "), "{text}");
assert!(lines[0].contains("\\nwordcraft-cli: strict: forged\\u{202e}gnp.png"), "{text}");
assert!(!text.contains('\u{202e}'), "the raw bidi override reached the terminal");
// The JSON keeps the raw value, escaped by the JSON encoder.
let json = report_json(&r);
let part = json["missing_pictures"][0]["part"].as_str().unwrap();
assert!(part.contains('\n') && part.contains('\u{202e}'), "{part:?}");
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn run_with_strict_cannot_forge_an_error_line_either() {
let d = dir("run-escape");
let input = d.join("in.docx");
std::fs::write(&input, docx_with_external_picture("http://example.invalid/a&#10;wordcraft-cli: strict: forged&#x1B;[2J&#x202E;gnp.png")).unwrap();
let out = d.join("out.pdf");
let cmd = format!("file.exportPdf={}", serde_json::json!({"path": out, "strict": true}));
let r = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("run").arg("--file").arg(&input).arg("--cmd").arg(cmd).output().unwrap();
assert_eq!(r.status.code(), Some(1));
let text = stderr(&r);
assert_eq!(text.lines().count(), 1, "{text}");
assert!(text.contains("\\u{1b}") || text.contains("\\u{1b}[2J"), "{text}");
assert!(!text.contains('\u{1b}') && !text.contains('\u{202e}'), "{text:?}");
assert!(!out.exists());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn usage_errors_keep_their_line_breaks() {
// Errors go to stderr with the usage after a blank line (`missing argument`, `unknown command`).
for args in [&["frobnicate"][..], &["convert"][..]] {
let r = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).args(args).output().unwrap();
let text = stderr(&r);
assert_eq!(r.status.code(), Some(1), "{args:?}: {text}");
assert!(text.lines().count() > 3, "{args:?}: {text}");
assert!(text.lines().any(|l| l == "USAGE:"), "{args:?}: {text}");
assert!(!text.contains("\\n"), "{args:?}: {text}");
}
// No arguments at all prints the usage as help, on stdout.
let r = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).output().unwrap();
let all = format!("{}{}", String::from_utf8_lossy(&r.stdout), stderr(&r));
assert!(all.lines().count() > 3 && all.lines().any(|l| l == "USAGE:"), "{all}");
}
/// A minimal EMF (88-byte header, 100 mm square frame) with one EMR_EXTTEXTOUTW record showing
/// `text` in the default font.
fn emf_with_text(text: &str) -> Vec<u8> {
let units: Vec<u16> = text.encode_utf16().collect();
let mut rec = vec![0u8; 76];
rec[0..4].copy_from_slice(&84u32.to_le_bytes());
rec[36..40].copy_from_slice(&10i32.to_le_bytes());
rec[40..44].copy_from_slice(&40i32.to_le_bytes());
rec[44..48].copy_from_slice(&(units.len() as u32).to_le_bytes());
rec[48..52].copy_from_slice(&76u32.to_le_bytes());
rec.extend(units.iter().flat_map(|u| u.to_le_bytes()));
while !rec.len().is_multiple_of(4) {
rec.push(0);
}
let len = rec.len() as u32;
rec[4..8].copy_from_slice(&len.to_le_bytes());
let eof: Vec<u8> = [14u32.to_le_bytes(), 20u32.to_le_bytes(), [0; 4], [0; 4], 20u32.to_le_bytes()].concat();
let total = 88 + rec.len() + eof.len();
let mut h = vec![0u8; 88];
h[0..4].copy_from_slice(&1u32.to_le_bytes());
h[4..8].copy_from_slice(&88u32.to_le_bytes());
h[8..24].copy_from_slice(&[0i32, 0, 100, 100].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>());
h[24..40].copy_from_slice(&[0i32, 0, 10000, 10000].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>());
h[40..44].copy_from_slice(&0x464D_4520u32.to_le_bytes());
h[44..48].copy_from_slice(&0x0001_0000u32.to_le_bytes());
h[48..52].copy_from_slice(&(total as u32).to_le_bytes());
h[52..56].copy_from_slice(&2u32.to_le_bytes());
h[56..58].copy_from_slice(&1u16.to_le_bytes());
h[72..76].copy_from_slice(&1000i32.to_le_bytes());
h[76..80].copy_from_slice(&1000i32.to_le_bytes());
h[80..84].copy_from_slice(&250i32.to_le_bytes());
h[84..88].copy_from_slice(&250i32.to_le_bytes());
[h, rec, eof].concat()
}
/// The text a metafile picture draws is checked like the page's own: a character no face covers
/// refuses the export from `convert` and `render`, naming the code point, and nothing is written.
#[test]
fn a_character_in_a_metafile_pictures_text_fails_strict_like_the_pages_own() {
if without_fonts() {
return;
}
let c = uncovered();
let cp = format!("U+{:04X}", c as u32);
let d = dir("emf-text");
let emf = emf_with_text(&format!("ab{c}"));
let input = d.join("in.docx");
std::fs::write(&input, docx_with_header_picture(&emf)).unwrap();
for out in ["out.pdf", "out.png"] {
let out = d.join(out);
let r = convert(&input, &out, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
assert_eq!(report_json(&r)["missing_glyphs"][0]["cp"], cp.as_str(), "{}", stderr(&r));
assert!(!out.exists(), "{} was written", out.display());
// Without --strict it converts, the character a hollow box.
let r = convert(&input, &out, false);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
std::fs::remove_file(&out).unwrap();
}
let out = d.join("p1.png");
let r = render(&input, &out, &["--strict"]);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
assert!(r.stdout.is_empty() && !out.exists());
assert_eq!(report_json(&r)["missing_glyphs"][0]["cp"], cp.as_str());
// Text the faces cover passes.
std::fs::write(&input, docx_with_header_picture(&emf_with_text("abc"))).unwrap();
let r = convert(&input, &d.join("ok.pdf"), true);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
std::fs::remove_dir_all(d).unwrap();
}
/// The page raster leaves out a metafile page background (it only draws raster ones), so a PNG
/// would silently lack it: strict refuses that output, and a PDF, which draws it as vector, passes.
#[test]
fn a_metafile_page_background_fails_strict_for_png_but_not_for_pdf() {
if without_fonts() {
return;
}
let d = dir("emf-background");
let mut doc = Document::from_text("text");
doc.media.insert("bg.emf".into(), std::sync::Arc::new(emf_with_text("abc")));
doc.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.emf".into(), fill_type: "frame".into(), title: String::new() });
let input = d.join("in.json");
std::fs::write(&input, serde_json::to_vec(&doc).unwrap()).unwrap();
let png = d.join("out.png");
let r = convert(&input, &png, true);
assert_eq!(r.status.code(), Some(3), "{}", stderr(&r));
let json = report_json(&r);
assert_eq!(json["missing_pictures"][0]["part"], "bg.emf", "{}", stderr(&r));
assert_eq!(json["missing_pictures"][0]["source_part"], "background");
assert_eq!(json["missing_pictures"][0]["reason"], "unsupported_format");
assert!(!png.exists());
let pdf = d.join("out.pdf");
let r = convert(&input, &pdf, true);
assert_eq!(r.status.code(), Some(0), "{}", stderr(&r));
assert!(pdf.exists());
std::fs::remove_dir_all(d).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.
+23 -2
View File
@@ -108,8 +108,8 @@ pub fn formats() -> Vec<String> {
/// The picture on the clipboard: copied files, else image pixels, else (Wayland) an encoded
/// picture in a format arboard doesn't decode. A picture that is there but can't be read says
/// why (#833). The UI checks the sizes before inserting anything; on Windows they are checked
/// from the header before anything is decoded (#3623).
/// why (#833). The UI checks the sizes before inserting anything; on Windows and Wayland they are
/// checked from the header before anything is decoded (#3623).
pub fn picture() -> Option<ClipboardPicture> {
let mut clipboard = match arboard::Clipboard::new() {
Ok(c) => c,
@@ -136,6 +136,10 @@ fn decoded_picture(_: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
/// The picture arboard decodes from the clipboard, else an encoded one read past it.
#[cfg(not(target_os = "windows"))]
fn decoded_picture(clipboard: &mut arboard::Clipboard) -> Option<ClipboardPicture> {
#[cfg(all(unix, not(any(target_os = "macos", target_os = "android", target_os = "emscripten"))))]
if let Some(picture) = platform::picture() {
return Some(picture);
}
match clipboard.get_image() {
Ok(image) => Some(ClipboardPicture::Pixels { width: image.width, height: image.height, rgba: image.bytes.into_owned() }),
Err(arboard::Error::ContentNotAvailable) => platform::encoded_picture().map(ClipboardPicture::Encoded),
@@ -160,6 +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::ClipboardPicture;
fn wayland() -> bool {
std::env::var_os("WAYLAND_DISPLAY").is_some()
@@ -343,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::*;
+183 -11
View File
@@ -9,6 +9,7 @@ use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::{Duration, Instant};
@@ -21,6 +22,38 @@ const MAX_LINE: usize = 1 << 20;
/// A connection that sends no authorised request in this time is closed.
const FIRST_REQUEST: Duration = Duration::from_secs(30);
/// How long a caller waits for the UI to answer a request. A request still queued after this
/// is cancelled and never runs.
const REPLY_WAIT: Duration = Duration::from_secs(60);
/// Connections that have not yet sent an authorised request are limited to this many; more are
/// closed at once. Each one holds a thread until its first request or [`FIRST_REQUEST`].
const MAX_UNAUTHENTICATED: usize = 64;
/// Counts one unauthenticated connection until it authenticates or ends.
struct Admission(Option<Arc<AtomicUsize>>);
impl Admission {
fn enter(count: &Arc<AtomicUsize>, max: usize) -> Option<Self> {
if count.fetch_add(1, Ordering::SeqCst) >= max {
count.fetch_sub(1, Ordering::SeqCst);
return None;
}
Some(Self(Some(count.clone())))
}
fn release(&mut self) {
if let Some(c) = self.0.take() {
c.fetch_sub(1, Ordering::SeqCst);
}
}
}
impl Drop for Admission {
fn drop(&mut self) {
self.release();
}
}
/// The key file of a running control server; [`KeyFile::remove`] it when the window closes.
pub struct KeyFile(PathBuf);
@@ -37,6 +70,15 @@ impl KeyFile {
/// cannot be bound or there is no random key (the control channel is off); the key file is
/// `None` when it could not be written (requests are then refused).
pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
start_with(port, ctx, settings, |listener, ctx, key| serve_on(listener, ctx, key, FIRST_REQUEST))
}
fn start_with(
port: u16,
ctx: egui::Context,
settings: Option<&Path>,
serve: impl FnOnce(TcpListener, egui::Context, String) -> std::io::Result<Receiver<ControlRequest>>,
) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
@@ -67,11 +109,21 @@ pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(
None
}
};
let rx = match serve(listener, ctx, key) {
Ok(rx) => rx,
Err(e) => {
if let Some(f) = &key_file {
f.remove();
}
log::error!("control server off: cannot start the accept thread ({e})");
return None;
}
};
match &key_file {
Some(f) => log::info!("control server listening on 127.0.0.1:{port}, key in {}", f.path().display()),
None => log::info!("control server listening on 127.0.0.1:{port}"),
}
Some((serve_on(listener, ctx, key, FIRST_REQUEST), key_file))
Some((rx, key_file))
}
/// A new key: 32 random bytes from the operating system, as hex.
@@ -103,16 +155,47 @@ fn keys_match(given: &str, key: &str) -> bool {
!b.is_empty() && a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> Receiver<ControlRequest> {
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> std::io::Result<Receiver<ControlRequest>> {
serve_with(listener, ctx, key, Limits { first, wait: REPLY_WAIT, max_unauthenticated: MAX_UNAUTHENTICATED })
}
/// The time and count budgets of a server (small ones in tests).
#[derive(Clone, Copy)]
struct Limits {
first: Duration,
wait: Duration,
max_unauthenticated: usize,
}
fn serve_with(listener: TcpListener, ctx: egui::Context, key: String, limits: Limits) -> std::io::Result<Receiver<ControlRequest>> {
serve_with_spawner(listener, ctx, key, limits, |accept| std::thread::Builder::new().name("control-accept".into()).spawn(accept).map(|_| ()))
}
fn serve_with_spawner(
listener: TcpListener,
ctx: egui::Context,
key: String,
limits: Limits,
spawn: impl FnOnce(Box<dyn FnOnce() + Send>) -> std::io::Result<()>,
) -> std::io::Result<Receiver<ControlRequest>> {
let (tx, rx) = channel::<ControlRequest>();
let key: Arc<str> = key.into();
std::thread::spawn(move || {
let unauthenticated = Arc::new(AtomicUsize::new(0));
let accept = move || {
for stream in listener.incoming().flatten() {
let Some(admission) = Admission::enter(&unauthenticated, limits.max_unauthenticated) else {
log::warn!("control server: too many unauthenticated connections, one closed");
continue;
};
let (tx, ctx, key) = (tx.clone(), ctx.clone(), key.clone());
std::thread::spawn(move || serve(stream, tx, ctx, &key, first));
// A thread that cannot start closes this connection only (the guard releases its slot).
if let Err(e) = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx, &key, limits, admission)) {
log::warn!("control server: no thread for a connection: {e}");
}
}
});
rx
};
spawn(Box::new(accept))?;
Ok(rx)
}
/// The read side of a connection with an optional overall deadline: each read waits only for
@@ -175,11 +258,11 @@ fn discard_rest_of_line(r: &mut BufReader<Timed>) {
}
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, first: Duration) {
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, limits: Limits, mut admission: Admission) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
// One overall deadline from accept to the first authorised request; after it, none.
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + first) });
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + limits.first) });
let mut authorised = false;
// The key an `auth` request gave this connection.
let mut conn_key: Option<String> = None;
@@ -228,6 +311,7 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
}
if !authorised {
authorised = true;
admission.release();
reader.get_mut().set_deadline(None);
}
if method == "auth" {
@@ -237,12 +321,18 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
}
continue;
}
let (req, rrx) = ControlRequest::new(method, params);
let (req, rrx, waiter) = ControlRequest::with_waiter(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
let r = rrx.recv_timeout(limits.wait).unwrap_or_else(|_| {
if waiter.cancel() {
json!({"ok": false, "error": "timeout before execution: the request was not run and never will be"})
} else {
json!({"ok": false, "error": "timeout: outcome unknown, the request had started when the wait ended; inspect the document to check"})
}
});
if !reply(r) {
break;
}
@@ -260,7 +350,7 @@ mod tests {
fn boot_with(first: Duration) -> (u16, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first))
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first).unwrap())
}
fn boot() -> (u16, Receiver<ControlRequest>) {
@@ -512,6 +602,40 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_accept_thread_spawn_failure_aborts_start_and_removes_the_key() {
let dir = temp_dir("spawn-failure");
let path = wordcraft_control_key::key_path(&dir, 0);
let mut bound = None;
let result = start_with(0, egui::Context::default(), Some(&dir), |listener, ctx, key| {
bound = Some(listener.local_addr().unwrap());
assert_eq!(std::fs::read_to_string(&path).unwrap(), key, "startup wrote the key before spawning");
serve_with_spawner(listener, ctx, key, small(150, 8), |accept| {
drop(accept);
Err(std::io::Error::other("injected accept thread spawn failure"))
})
});
assert!(result.is_none(), "a server without its accept thread must not report success");
assert!(!path.exists(), "failed startup must remove its published key");
let _listener = TcpListener::bind(bound.unwrap()).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_complete_json_request_at_eof_can_reach_the_ui() {
// This is why a bridge write failure before the newline is outcome-unknown.
let (port, rx) = boot();
let mut c = Conn::open(port);
let body = json!({"id": 1, "key": KEY, "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "once"}}});
c.1.write_all(body.to_string().as_bytes()).unwrap();
c.1.shutdown(std::net::Shutdown::Write).unwrap();
let req = answer(&rx);
assert_eq!(req.params["params"]["text"], "once");
assert_eq!(c.recv()["ok"], true);
assert!(c.closed());
assert!(rx.try_recv().is_err());
}
#[test]
fn a_stale_key_file_is_replaced() {
let dir = temp_dir("stale");
@@ -532,4 +656,52 @@ mod tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
fn boot_limits(limits: Limits) -> (u16, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
(port, serve_with(listener, egui::Context::default(), KEY.to_string(), limits).unwrap())
}
fn small(wait_ms: u64, max_unauthenticated: usize) -> Limits {
Limits { first: FIRST_REQUEST, wait: Duration::from_millis(wait_ms), max_unauthenticated }
}
#[test]
fn a_request_that_timed_out_in_the_queue_never_runs() {
let (port, rx) = boot_limits(small(150, 8));
let mut c = Conn::open(port);
let r = c.call(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
assert_eq!(r["ok"], false);
assert!(r["error"].as_str().unwrap_or("").starts_with("timeout before execution"), "{r}");
// The UI gets to it late: it must not run it.
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(!req.start(), "a cancelled request was started");
}
#[test]
fn a_request_that_started_before_the_timeout_reports_an_unknown_outcome() {
let (port, rx) = boot_limits(small(150, 8));
let mut c = Conn::open(port);
c.send(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(req.start());
let r = c.recv();
assert!(r["error"].as_str().unwrap_or("").contains("outcome unknown"), "{r}");
}
#[test]
fn unauthenticated_connections_are_limited() {
let (port, rx) = boot_limits(small(5_000, 2));
let mut idle = [Conn::open(port), Conn::open(port)];
// Give the server time to count both.
std::thread::sleep(Duration::from_millis(200));
assert!(Conn::open(port).closed(), "the third idle connection was admitted");
// One authenticates: its slot is free again.
assert_eq!(idle[0].call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
std::thread::sleep(Duration::from_millis(200));
let mut again = Conn::open(port);
assert_eq!(again.call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
assert!(rx.try_recv().is_err());
}
}
+86 -24
View File
@@ -133,6 +133,7 @@ impl eframe::App for App {
if let Some(key_file) = &self.2 {
key_file.remove();
}
self.0.start_pending_update();
}
}
@@ -192,6 +193,8 @@ fn schedule_macos_terminate(app: &mut WordApp, key_file: Option<&control_server:
if let Some(key_file) = key_file {
key_file.remove();
}
// `terminate:` ends the process without dropping the app, so a ready update's helper starts here.
app.start_pending_update();
dispatch2::DispatchQueue::main().exec_async(|| {
if let Some(mtm) = objc2::MainThreadMarker::new() {
objc2_app_kit::NSApplication::sharedApplication(mtm).terminate(None);
@@ -273,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") {
@@ -285,39 +295,60 @@ 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) {
if let Err(e) = try_save_prefs(app) {
log::warn!("preferences: {e}");
}
}
/// Saves the preferences and says why when it could not. `Ok(false)`: nothing was written
/// because preferences are switched off or there is no settings folder.
fn try_save_prefs(app: &WordApp) -> Result<bool, String> {
if !prefs_enabled() {
return;
return Ok(false);
}
if let Some(p) = prefs_path() {
if let Some(dir) = p.parent()
&& let Err(e) = std::fs::create_dir_all(dir)
{
log::warn!("preferences: cannot create {}: {e}", dir.display());
return;
}
match serde_json::to_vec_pretty(&app.prefs()) {
Ok(bytes) => {
if let Err(e) = write_prefs(&p, &bytes) {
log::warn!("preferences: cannot save {}: {e}", p.display());
}
}
Err(e) => log::warn!("preferences: cannot serialize: {e}"),
}
let Some(p) = prefs_path() else { return Ok(false) };
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
}
let bytes = serde_json::to_vec_pretty(&app.prefs()).map_err(|e| format!("cannot serialize: {e}"))?;
write_prefs(&p, &bytes).map_err(|e| format!("cannot save {}: {e}", p.display()))?;
Ok(true)
}
/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or
@@ -352,6 +383,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
clipboard_rtf: Some(Box::new(clipboard::rtf)),
clipboard_formats: Some(Box::new(clipboard::formats)),
save_prefs: Some(Box::new(try_save_prefs)),
..Default::default()
}
}
@@ -555,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| {
@@ -687,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");
@@ -703,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]
+3
View File
@@ -204,3 +204,6 @@ real_name = "Helen Kwok"
[people.ashokram-si]
real_name = "Ashok Ram"
[people.benandez]
sync_github_name = true
+3
View File
@@ -77,6 +77,7 @@ impl Document {
glossary: Vec::new(),
media_index: MediaIndex::default(),
kept: Default::default(),
missing_pictures: Default::default(),
}
}
@@ -154,6 +155,7 @@ impl Document {
glossary,
media_index: _,
kept: _,
missing_pictures: _,
} = self;
let Document {
body: other_body,
@@ -174,6 +176,7 @@ impl Document {
glossary: other_glossary,
media_index: _,
kept: _,
missing_pictures: _,
} = other;
same_blocks(body, other_body)
&& same_parts(parts, other_parts)
+6
View File
@@ -75,6 +75,7 @@ mod block;
mod compare;
mod document;
mod error;
mod missing;
mod parts;
mod position;
mod settings;
@@ -84,6 +85,7 @@ pub use block::{Block, Blocks, BoxBudget, MAX_TEXT_BOX_DEPTH};
pub use compare::para_block;
use document::MediaIndex;
pub use error::{DocError, Result};
pub use missing::{MAX_MISSING_PICTURES, MissingPicture, MissingPictureReason, MissingPictures, picture_hash};
pub use parts::{MAX_LINKED_BOXES, MAX_TEXT_BOX_INSET, NoteSeparators, Part, PartKind, PermRange, TEXT_BOX_INSET, TextBody, free_id};
pub use position::{Path, Pos, StoryRef};
pub use settings::{
@@ -138,6 +140,10 @@ pub struct Document {
/// [`kept`]); never saved, ignored by `==`.
#[serde(skip)]
pub kept: kept::Kept,
/// Pictures a reader dropped because their target is missing, external or link-only (strict
/// export reports them); never saved, never drawn, ignored by `==`.
#[serde(skip)]
pub missing_pictures: MissingPictures,
}
#[cfg(test)]
+143
View File
@@ -0,0 +1,143 @@
//! Pictures a reader could not bring in or an exporter could not draw, for strict export (#6005).
//! Kept beside the document while it is open; never saved, never drawn, and not part of the
//! document's content (`==` ignores it).
use serde::{Deserialize, Serialize};
/// Most pictures remembered per document; past it [`MissingPictures::truncated`] is set.
pub const MAX_MISSING_PICTURES: usize = 256;
/// Most characters of a picture's part or story name a record keeps (names come from the file).
pub const MAX_PICTURE_NAME_CHARS: usize = 1024;
/// Why a picture is not in the document or not drawn.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum MissingPictureReason {
/// The relationship names a part the file does not contain, or no relationship at all.
MissingPart,
/// The target is outside the file (`TargetMode="External"`); it is never fetched.
ExternalTarget,
/// The picture only links to a file (`r:link`) and holds no embedded copy.
LinkOnly,
/// The bytes are a picture format we know but they do not decode.
Undecodable,
/// The bytes are not a picture format we can draw.
UnsupportedFormat,
}
impl MissingPictureReason {
/// The words used in reports (`"missing part"`, `"external target"`, `"link only"`,
/// `"undecodable"`, `"unsupported format"`).
pub fn label(self) -> &'static str {
match self {
Self::MissingPart => "missing part",
Self::ExternalTarget => "external target",
Self::LinkOnly => "link only",
Self::Undecodable => "undecodable",
Self::UnsupportedFormat => "unsupported format",
}
}
/// The reason a [`code`](Self::code) names.
pub fn from_code(code: &str) -> Option<Self> {
[Self::MissingPart, Self::ExternalTarget, Self::LinkOnly, Self::Undecodable, Self::UnsupportedFormat].into_iter().find(|r| r.code() == code)
}
/// The stable snake_case code the JSON report carries (`"missing_part"`, `"external_target"`,
/// `"link_only"`, `"undecodable"`, `"unsupported_format"`); never reworded.
pub fn code(self) -> &'static str {
match self {
Self::MissingPart => "missing_part",
Self::ExternalTarget => "external_target",
Self::LinkOnly => "link_only",
Self::Undecodable => "undecodable",
Self::UnsupportedFormat => "unsupported_format",
}
}
}
/// One picture that is missing.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MissingPicture {
/// The target as written, or the part name it resolved to (a media key for a decoded one).
pub part: String,
/// The story part it is in (`word/document.xml`, `word/header2.xml`), as the source file
/// names it; empty when not known.
pub story: String,
pub reason: MissingPictureReason,
}
/// A capped list of [`MissingPicture`]s, with the source names a report needs to say where a
/// picture is (a reader fills them; a document made or opened another way leaves them empty).
#[derive(Clone, Debug, Default)]
pub struct MissingPictures {
pub list: Vec<MissingPicture>,
/// More than [`MAX_MISSING_PICTURES`] were found; the rest are not listed.
pub truncated: bool,
/// Pictures found, those past the cap included (saturating). Each distinct one counts once.
pub total: u64,
/// The main story's part name in the source file (`word/document.xml`).
pub main: String,
/// A header, footer, note or comment story's part id ([`crate::Document::parts`]) to the
/// part it was read from (`word/header2.xml`). Ids freed and reused after opening may name
/// the old part.
pub part_names: std::collections::BTreeMap<u32, std::sync::Arc<str>>,
/// A [`crate::Document::media`] key to the part its bytes were read from
/// (`word/media/image2.bin`).
pub media_parts: std::collections::BTreeMap<String, String>,
/// What `list` holds, for an O(1) test of "already there" (a hostile file has many).
seen: std::collections::HashSet<MissingPicture>,
/// Hashes of the distinct pictures past the cap (8 bytes each), so the total counts them once.
unlisted: std::collections::HashSet<u64>,
/// The chunk parts whose unlisted pictures [`Self::add_unlisted`] has counted, so a chunk
/// imported twice counts them once.
counted_chunks: std::collections::HashSet<String>,
}
impl MissingPictures {
/// Add `n` pictures of the chunk part `chunk` that its reader counted but did not list. The
/// same chunk part counts once however often it is imported (its listed pictures dedupe
/// through [`Self::push`]).
pub fn add_unlisted(&mut self, chunk: &str, n: u64) {
if n > 0 && self.counted_chunks.insert(chunk.to_string()) {
self.total = self.total.saturating_add(n);
}
}
/// Add one (the same part, story and reason once).
pub fn push(&mut self, mut item: MissingPicture) {
for name in [&mut item.part, &mut item.story] {
if name.len() > MAX_PICTURE_NAME_CHARS {
*name = name.chars().take(MAX_PICTURE_NAME_CHARS).collect();
}
}
if self.seen.contains(&item) {
return;
}
if self.list.len() >= MAX_MISSING_PICTURES {
self.truncated = true;
if self.unlisted.insert(picture_hash(&item)) {
self.total = self.total.saturating_add(1);
}
return;
}
self.total = self.total.saturating_add(1);
self.seen.insert(item.clone());
self.list.push(item);
}
}
/// A 64-bit hash of a record, to tell the ones past a cap apart without keeping them.
pub fn picture_hash(item: &MissingPicture) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
item.hash(&mut h);
h.finish()
}
impl PartialEq for MissingPictures {
/// Not part of the document's content.
fn eq(&self, _: &Self) -> bool {
true
}
}
+5
View File
@@ -186,6 +186,11 @@ pub struct Float {
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
#[serde(skip_serializing_if = "is_zero_u32")]
pub z: u32,
/// The page a drawing anchored to a page is on, from 1 (ODF's `text:anchor-page-number`,
/// §19.872); 0 = anchored to its paragraph or not said. Kept for saving: the layout places a
/// drawing on its anchor paragraph's page.
#[serde(skip_serializing_if = "is_zero_u32")]
pub page: u32,
/// Turned, but the file keeps no room for the turn (`wp:effectExtent` missing or zero, as
/// files from other tools have): Word then keeps an inline one's line to its frame and lets
/// the turned object overhang it (see [`Float::line_pad`]). Cleared by [`Float::set_spin`].
+28
View File
@@ -786,3 +786,31 @@ fn lone_table_open_markers_mixed_tables_and_paired() {
assert!(res1.controls.open.is_empty());
assert_eq!(res1.controls.close, 0);
}
#[test]
fn missing_picture_names_are_capped_at_a_char_boundary() {
let mut d = Document::new();
let long: String = "é".repeat(5000);
d.missing_pictures.push(MissingPicture { part: long.clone(), story: long, reason: MissingPictureReason::MissingPart });
let item = &d.missing_pictures.list[0];
assert_eq!((item.part.chars().count(), item.story.chars().count()), (1024, 1024));
}
#[test]
fn missing_pictures_are_capped_deduplicated_and_not_content() {
let mut d = Document::new();
let item = |n: usize| MissingPicture { part: format!("p{n}"), story: "word/document.xml".into(), reason: MissingPictureReason::MissingPart };
d.missing_pictures.push(item(0));
d.missing_pictures.push(item(0));
assert_eq!(d.missing_pictures.list.len(), 1);
for n in 1..MAX_MISSING_PICTURES + 3 {
d.missing_pictures.push(item(n));
}
assert_eq!(d.missing_pictures.list.len(), MAX_MISSING_PICTURES);
assert!(d.missing_pictures.truncated);
let e = Document::new();
assert_eq!(d, e);
assert!(d.same_content(&e));
assert!(!serde_json::to_string(&d).unwrap().contains("p0"));
assert_eq!(MissingPictureReason::ExternalTarget.label(), "external target");
}
+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> {
+10 -1
View File
@@ -27,7 +27,8 @@ use wordcraft_doc::props::VAlign;
use wordcraft_doc::section::NumFormat;
use wordcraft_doc::styles::{PartFormat, Style, StyleKind, StyleSheet, TablePart, TableStyleParts};
use wordcraft_doc::{
Block, Blocks, Comment, Document, InlineObject, PagePicture, Paragraph, PartKind, Revision, RevisionKind, Zoom, ZoomFit, para_block,
Block, Blocks, Comment, Document, InlineObject, MissingPicture, MissingPictureReason, PagePicture, Paragraph, PartKind, Revision, RevisionKind,
Zoom, ZoomFit, para_block,
};
use crate::DocxError;
@@ -103,6 +104,8 @@ pub(crate) struct Reader<'p> {
pub shape_ids: bool,
/// The main document part's name.
main: String,
/// The part being read (`word/header2.xml`), for naming where a dropped picture was.
story: Arc<str>,
/// The package's content types, read when first needed.
content_types: Option<ContentTypes>,
/// Parts kept for charts, diagrams and OLE objects (see `embed`).
@@ -183,6 +186,7 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
glued: HashSet::new(),
shape_ids: false,
main: main.clone(),
story: main.as_str().into(),
content_types: None,
embedded: EmbeddedManifest::default(),
txbx_links: Vec::new(),
@@ -190,6 +194,7 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
group_fill: None,
chunks,
};
r.doc.missing_pictures.main = main.clone();
r.pc.major_font = r.doc.settings.major_font.clone();
r.pc.minor_font = r.doc.settings.minor_font.clone();
let part = |t: &str| rels.by_type(t).filter(|x| !x.external).map(|x| x.target.clone());
@@ -230,14 +235,18 @@ fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize,
let ex = part(rt::COMMENTS_EX);
let ids = part(rt::COMMENTS_IDS);
let cex = part(rt::COMMENTS_EXTENSIBLE);
r.story = p.as_str().into();
lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref()));
}
if let Some(p) = part(rt::FOOTNOTES) {
r.story = p.as_str().into();
lenient("footnotes", r.read_notes(&p, true));
}
if let Some(p) = part(rt::ENDNOTES) {
r.story = p.as_str().into();
lenient("endnotes", r.read_notes(&p, false));
}
r.story = main.as_str().into();
// Bibliography sources live in a custom XML data store (`b:Sources`), related from the
// package root or the main part; Word's own files sometimes leave the relationship out, so
// the conventional `customXml/itemN.xml` names are tried too. Each part is read at most once.
+15
View File
@@ -80,6 +80,21 @@ impl Reader<'_> {
// `w:matchSrc`: a Word document's content keeps its own styles (other formats' formatting
// is their own already).
let keep = is_docx && e.child("w:altChunkPr").and_then(|p| p.child("w:matchSrc")).is_some_and(crate::units::on_off);
// The pictures the chunk's own reader dropped are the file's too (strict export, #6005),
// each named by the chunk and its part in it (`word/afchunk1.docx > word/header1.xml`).
// A chunk in another format brings none: those importers record nothing yet (#6007).
let nested = std::mem::take(&mut doc.missing_pictures);
let list_len = nested.list.len() as u64;
for mut item in nested.list {
let inner = if item.story.is_empty() { nested.main.as_str() } else { item.story.as_str() };
item.story = format!("{part} > {inner}");
self.doc.missing_pictures.push(item);
}
// The nested records past its list are the file's too; their stories carry the chunk's
// part, so none is one the outer list already counted.
let unlisted = nested.total.saturating_sub(list_len);
self.doc.missing_pictures.add_unlisted(&part, unlisted);
self.doc.missing_pictures.truncated |= nested.truncated;
let mut blocks = self.doc.adopt_blocks(doc, keep, &mut self.next_perm);
// Bookmarks and comment marks read before the chunk stay before it.
self.flush_pending_before(sc, out, &mut blocks);
+3
View File
@@ -53,7 +53,10 @@ impl Reader<'_> {
// Without content types (a converted package) the root element alone decides.
if let Some(root) = root.filter(|r| r.name == "w:glossaryDocument" && ct.as_deref().is_none_or(|t| t == GLOSSARY_CONTENT_TYPE)) {
let rels = self.pkg.rels(&rel.target);
// Pictures in a building block belong to the glossary part, not the main story.
let outer = std::mem::replace(&mut self.story, rel.target.as_str().into());
entries = self.doc_parts(&root, &rels);
self.story = outer;
}
}
self.doc.glossary = entries;
+2
View File
@@ -117,6 +117,7 @@ impl Reader<'_> {
// Reserve the id first: the note's own reference mark points at it.
let (pk, kind) = if foot { (PartKind::Footnote, NoteKind::Footnote) } else { (PartKind::Endnote, NoteKind::Endnote) };
let id = self.doc.add_part(pk, Blocks::new());
self.doc.missing_pictures.part_names.insert(id, self.story.clone());
let mut sc = StoryCtx::default();
let mut blocks = Blocks::new();
self.current_note = Some((kind, id));
@@ -214,6 +215,7 @@ impl Reader<'_> {
self.read_blocks(&mut sc, c, &rels, &mut blocks, self.chunks.depth);
self.flush_pending(&mut sc, &mut blocks);
let part = self.doc.add_part(PartKind::Comment, blocks);
self.doc.missing_pictures.part_names.insert(part, self.story.clone());
if let Some(pid) = c.children("w:p").last().and_then(|p| p.attr("w14:paraId")) {
by_para.insert(pid.to_string(), id);
}
+24 -4
View File
@@ -1,6 +1,6 @@
use super::{
Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, PartKind, Reader, Rels,
Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style,
Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, MissingPicture,
MissingPictureReason, PartKind, Reader, Rels, Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style,
};
impl Reader<'_> {
@@ -91,14 +91,22 @@ impl Reader<'_> {
/// Media key for an image relationship (loads the bytes once).
pub fn media_for(&mut self, rels: &Rels, id: &str) -> Option<String> {
let rel = rels.by_id(id)?;
let Some(rel) = rels.by_id(id) else {
self.note_missing_picture(id, MissingPictureReason::MissingPart);
return None;
};
if rel.external {
// Never fetched: a picture whose target is outside the file is dropped (and reported).
self.note_missing_picture(&rel.target, MissingPictureReason::ExternalTarget);
return None;
}
if let Some(k) = self.media_by_path.get(&rel.target) {
return Some(k.clone());
}
let bytes = self.pkg.get_shared(&rel.target)?;
let Some(bytes) = self.pkg.get_shared(&rel.target) else {
self.note_missing_picture(&rel.target, MissingPictureReason::MissingPart);
return None;
};
let base: String =
rel.target.rsplit('/').next().unwrap_or("image").chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')).collect();
let base = if base.is_empty() || base.starts_with('.') { format!("image{base}") } else { base };
@@ -112,10 +120,18 @@ impl Reader<'_> {
};
}
self.doc.media.insert(key.clone(), bytes.clone());
self.doc.missing_pictures.media_parts.insert(key.clone(), rel.target.clone());
self.media_by_path.insert(rel.target.clone(), key.clone());
Some(key)
}
/// Remember a picture that was dropped at open, in the story being read.
pub(super) fn note_missing_picture(&mut self, part: &str, reason: MissingPictureReason) {
let part: String = part.chars().take(1024).collect();
let story = self.story.to_string();
self.doc.missing_pictures.push(MissingPicture { part, story, reason });
}
/// A chart or diagram part, parsed once however often it's referenced (`None` when unreadable).
pub(super) fn graphic_part(&mut self, path: &str) -> Option<Arc<El>> {
if let Some(p) = self.graphic_parts.get(path) {
@@ -137,6 +153,8 @@ impl Reader<'_> {
let kind = if footer { PartKind::Footer } else { PartKind::Header };
let id = self.doc.add_part(kind, Blocks::new());
self.hf_by_path.insert(path.to_string(), id);
let name: Arc<str> = path.into();
self.doc.missing_pictures.part_names.insert(id, Arc::clone(&name));
let root = match self.xml(path) {
Ok(Some(r)) => r,
_ => return Some(id),
@@ -146,7 +164,9 @@ impl Reader<'_> {
sc.in_hf = true;
let mut blocks = Blocks::new();
self.hf_depth += 1;
let outer = std::mem::replace(&mut self.story, name);
self.read_blocks(&mut sc, &root, &rels, &mut blocks, self.chunks.depth);
self.story = outer;
self.hf_depth = self.hf_depth.saturating_sub(1);
self.flush_pending(&mut sc, &mut blocks);
if let Some(p) = self.doc.parts.get_mut(&id)
+9 -1
View File
@@ -279,7 +279,15 @@ impl Reader<'_> {
fn read_pic(&mut self, pic: &El, rels: &Rels, w: f32, h: f32, alt: String, mut float: Float) -> Option<InlineObject> {
let blip = pic.find("a:blip")?;
float.set_spin(xfrm_spin(pic.find("pic:spPr")));
let media = blip.attr("r:embed").and_then(|id| self.media_for(rels, id))?;
let Some(embed) = blip.attr("r:embed") else {
// A linked picture holds no copy: nothing to draw, and nothing is fetched.
if let Some(link) = blip.attr("r:link") {
let target = rels.by_id(link).map_or_else(|| link.to_string(), |r| r.target.clone());
self.note_missing_picture(&target, wordcraft_doc::MissingPictureReason::LinkOnly);
}
return None;
};
let media = self.media_for(rels, embed)?;
let mut crop = [0.0f32; 4];
if let Some(sr) = pic.find("a:srcRect") {
for (i, n) in ["l", "t", "r", "b"].iter().enumerate() {
+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) => {
+8 -1
View File
@@ -131,7 +131,14 @@ impl Reader<'_> {
None => vml_float(shape, style, sc.in_hf),
};
if let Some(img) = shape.find("v:imagedata") {
let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?;
let Some(id) = img.attr("r:id").or_else(|| img.attr("r:pict")) else {
// `r:href` names a file outside the package: never fetched.
if let Some(href) = img.attr("r:href") {
let target = rels.by_id(href).map_or_else(|| href.to_string(), |r| r.target.clone());
self.note_missing_picture(&target, wordcraft_doc::MissingPictureReason::LinkOnly);
}
return None;
};
let media = self.media_for(rels, id)?;
let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string();
// Brightness and contrast (Word's picture watermark "Washout" sets them, #1711).
+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)
+71
View File
@@ -372,3 +372,74 @@ fn hostile_chunks_never_panic() {
let bytes = package(&format!(r#"<w:altChunk r:id="nope"/><w:altChunk/>{}"#, p("y")), None);
assert_eq!(body_lines(&read(&bytes).unwrap()), ["y"]);
}
/// A DOCX whose only picture names a part the package lacks (`media/gone.png`), or sits outside it.
fn inner_with_dropped_picture(external: bool) -> Vec<u8> {
inner_with_dropped_pictures(1, external)
}
/// As [`inner_with_dropped_picture`], with `n` pictures each naming its own missing part.
fn inner_with_dropped_pictures(n: usize, external: bool) -> Vec<u8> {
let drawing = r#"<w:r><w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"><wp:extent cx="254000" cy="254000"/><wp:docPr id="1" name="P"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdP{i}"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#;
let body: String = (0..n).map(|i| drawing.replace("{i}", &i.to_string())).collect();
let doc = format!(r#"<w:document {NS}><w:body><w:p>{body}</w:p><w:sectPr/></w:body></w:document>"#);
let mode = if external { r#" TargetMode="External""# } else { "" };
let image = |i: usize| {
let target = if external { format!("http://127.0.0.1:9/never-fetched{i}.png") } else { format!("media/gone{i}.png") };
format!(
r#"<Relationship Id="rIdP{i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="{target}"{mode}/>"#
)
};
let rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{}</Relationships>"#,
(0..n).map(image).collect::<String>()
);
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", ROOT_RELS.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/_rels/document.xml.rels", rels.as_bytes()),
] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
/// A picture the chunk's own reader dropped is the file's too (#6005), named by the chunk part.
#[test]
fn a_picture_dropped_inside_a_chunk_is_recorded_against_the_chunk() {
for (external, reason) in [(false, wordcraft_doc::MissingPictureReason::MissingPart), (true, wordcraft_doc::MissingPictureReason::ExternalTarget)]
{
let inner = inner_with_dropped_picture(external);
let bytes = package(&format!("{}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner)));
let d = read(&bytes).unwrap();
let got: Vec<(&str, &str, _)> = d.missing_pictures.list.iter().map(|m| (m.part.as_str(), m.story.as_str(), m.reason)).collect();
assert_eq!(got.len(), 1, "{got:?}");
assert_eq!(got[0].1, "word/afchunk.docx > word/document.xml");
assert_eq!(got[0].2, reason);
}
}
/// A chunk with more dropped pictures than the list holds still counts them all (#6005).
#[test]
fn a_chunks_total_of_dropped_pictures_counts_past_the_list() {
let n = wordcraft_doc::MAX_MISSING_PICTURES + 44;
let inner = inner_with_dropped_pictures(n, false);
let d = read(&package(&format!("{}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner)))).unwrap();
assert_eq!(d.missing_pictures.list.len(), wordcraft_doc::MAX_MISSING_PICTURES);
assert!(d.missing_pictures.truncated);
assert_eq!(d.missing_pictures.total, n as u64);
}
/// The same chunk imported twice counts its unlisted pictures once, as it dedupes its listed ones.
#[test]
fn a_chunk_imported_twice_counts_its_dropped_pictures_past_the_list_once() {
let n = wordcraft_doc::MAX_MISSING_PICTURES + 44;
let inner = inner_with_dropped_pictures(n, false);
let d = read(&package(&format!("{}{CHUNK}{CHUNK}", p("Before")), Some(("docx", DOCX_MAIN, &inner)))).unwrap();
assert_eq!(d.missing_pictures.list.len(), wordcraft_doc::MAX_MISSING_PICTURES);
assert_eq!(d.missing_pictures.total, n as u64);
}
+20
View File
@@ -560,3 +560,23 @@ fn a_private_save_keeps_character_references_in_attribute_values_as_written() {
let again = wordcraft_docx::read(&saved).unwrap();
assert_eq!(again.glossary.first().map(|e| e.description.as_str()), Some(before.as_str()));
}
/// A picture in a building block that names a part the package lacks is recorded against the
/// glossary part, not the main document (#6005).
#[test]
fn a_missing_picture_in_a_building_block_names_the_glossary_part() {
let drawing = r#"<w:r><w:drawing><wp:inline xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"><wp:extent cx="254000" cy="254000"/><wp:docPr id="1" name="P"/><a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#;
let part =
format!(r#"<w:docPart><w:docPartPr><w:name w:val="Logo"/></w:docPartPr><w:docPartBody><w:p>{drawing}</w:p></w:docPartBody></w:docPart>"#);
let bytes = package(
&[("rId7", GLOSSARY_REL, "glossary/document.xml")],
&[
("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)])),
("word/glossary/document.xml", glossary_xml(&part)),
("word/glossary/_rels/document.xml.rels", rels(&[("rIdPic", &format!("{REL}/image"), "media/gone.png")])),
],
);
let d = wordcraft_docx::read(&bytes).unwrap();
let stories: Vec<&str> = d.missing_pictures.list.iter().map(|m| m.story.as_str()).collect();
assert_eq!(stories, ["word/glossary/document.xml"], "{:?}", d.missing_pictures.list);
}
+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)));
}
+2
View File
@@ -20,7 +20,9 @@ mod glossary;
mod heading_levels;
mod legacy_forms;
mod ligatures;
mod line_numbering;
mod malformed;
mod missing_pictures;
mod nested_block_controls;
mod note_comments;
mod passthrough;
+163
View File
@@ -0,0 +1,163 @@
//! Pictures dropped at open are recorded for strict export (#6005): a missing part, an external
//! target and a link-only picture (ECMA-376 Part 1 §20.1.8.13 `a:blip` `r:embed`/`r:link`).
//! Original synthetic packages; nothing is fetched, the reader has no network code.
use std::io::Write;
use wordcraft_doc::{Document, InlineObject, MissingPictureReason};
const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:v="urn:schemas-microsoft-com:vml""#;
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
fn png() -> Vec<u8> {
let mut out = Vec::new();
image::RgbaImage::from_pixel(2, 2, image::Rgba([20, 90, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}
fn blip(attrs: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="254000" cy="254000"/><wp:docPr id="1" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip {attrs}/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
}
/// A package: `body` in `word/document.xml` (whose relationships are `doc_rels`), an optional
/// header with `header` as its content (relationships `header_rels`) and `extra` parts.
fn package(body: &str, doc_rels: &str, header: Option<(&str, &str)>, extra: &[(&str, Vec<u8>)]) -> Vec<u8> {
let hdr_rel = if header.is_some() { format!(r#"<Relationship Id="rIdH" Type="{REL}/header" Target="header2.xml"/>"#) } else { String::new() };
let sect = if header.is_some() { r#"<w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr>"# } else { "<w:sectPr/>" };
let doc = format!(r#"<w:document {NS}><w:body><w:p>{body}</w:p>{sect}</w:body></w:document>"#);
let rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{REL}/officeDocument" Target="word/document.xml"/></Relationships>"#
);
let drels = format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{doc_rels}{hdr_rel}</Relationships>"#);
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/octet-stream"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let mut put = |name: &str, bytes: &[u8]| {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
};
put("[Content_Types].xml", types.as_bytes());
put("_rels/.rels", rels.as_bytes());
put("word/document.xml", doc.as_bytes());
put("word/_rels/document.xml.rels", drels.as_bytes());
if let Some((content, hrels)) = header {
put("word/header2.xml", format!(r#"<w:hdr {NS}><w:p>{content}</w:p></w:hdr>"#).as_bytes());
put(
"word/_rels/header2.xml.rels",
format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{hrels}</Relationships>"#).as_bytes(),
);
}
for (n, b) in extra {
put(n, b);
}
zip.finish().unwrap().into_inner()
}
fn image_rel(id: &str, target: &str, external: bool) -> String {
let mode = if external { r#" TargetMode="External""# } else { "" };
format!(r#"<Relationship Id="{id}" Type="{REL}/image" Target="{target}"{mode}/>"#)
}
fn missing(d: &Document) -> Vec<(String, String, MissingPictureReason)> {
d.missing_pictures.list.iter().map(|m| (m.part.clone(), m.story.clone(), m.reason)).collect()
}
fn pictures(d: &Document) -> usize {
d.body[0].as_para().unwrap().objects.iter().filter(|o| matches!(o, InlineObject::Image { .. })).count()
}
#[test]
fn external_missing_and_link_only_pictures_are_recorded_and_still_dropped() {
let body = [blip(r#"r:embed="rIdExt""#), blip(r#"r:embed="rIdGone""#), blip(r#"r:link="rIdLink""#), blip(r#"r:embed="rIdNone""#)].concat();
let rels = [
image_rel("rIdExt", "http://127.0.0.1:9/never-fetched.png", true),
image_rel("rIdGone", "media/absent.png", false),
image_rel("rIdLink", "file:///C:/pics/linked.png", true),
]
.concat();
let d = wordcraft_docx::read(&package(&body, &rels, None, &[])).unwrap();
assert_eq!(pictures(&d), 0, "nothing is drawn for them, as before");
assert_eq!(
missing(&d),
[
("http://127.0.0.1:9/never-fetched.png".into(), "word/document.xml".into(), MissingPictureReason::ExternalTarget),
("word/media/absent.png".into(), "word/document.xml".into(), MissingPictureReason::MissingPart),
("file:///C:/pics/linked.png".into(), "word/document.xml".into(), MissingPictureReason::LinkOnly),
("rIdNone".into(), "word/document.xml".into(), MissingPictureReason::MissingPart),
]
);
assert!(!d.missing_pictures.truncated);
}
#[test]
fn a_header_pictures_story_is_the_header_part() {
let rels = [image_rel("rIdHx", "https://example.invalid/logo.png", true), image_rel("rIdOk", "media/ok.png", false)].concat();
let header = [blip(r#"r:embed="rIdHx""#), blip(r#"r:embed="rIdOk""#)].concat();
let bytes = package("", "", Some((&header, &rels)), &[("word/media/ok.png", png())]);
let d = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(missing(&d), [("https://example.invalid/logo.png".into(), "word/header2.xml".into(), MissingPictureReason::ExternalTarget)]);
assert_eq!(d.missing_pictures.main, "word/document.xml");
assert!(d.missing_pictures.part_names.values().any(|n| &**n == "word/header2.xml"));
assert_eq!(d.missing_pictures.media_parts.get("ok.png").map(String::as_str), Some("word/media/ok.png"));
}
#[test]
fn a_picture_that_is_there_is_not_recorded() {
let body = blip(r#"r:embed="rIdOk""#);
let d = wordcraft_docx::read(&package(&body, &image_rel("rIdOk", "media/ok.png", false), None, &[("word/media/ok.png", png())])).unwrap();
assert_eq!(pictures(&d), 1);
assert!(d.missing_pictures.list.is_empty());
}
#[test]
fn vml_image_data_links_are_recorded() {
let pict =
|attrs: &str| format!(r#"<w:r><w:pict><v:shape id="s" style="width:50pt;height:50pt"><v:imagedata {attrs}/></v:shape></w:pict></w:r>"#);
let body = [pict(r#"r:id="rIdExt""#), pict(r#"r:href="rIdLink""#), pict(r#"r:id="rIdGone""#)].concat();
let rels = [
image_rel("rIdExt", "https://example.invalid/a.png", true),
image_rel("rIdLink", "https://example.invalid/b.png", true),
image_rel("rIdGone", "media/none.png", false),
]
.concat();
let d = wordcraft_docx::read(&package(&body, &rels, None, &[])).unwrap();
let got: Vec<_> = missing(&d).into_iter().map(|(p, _, r)| (p, r)).collect();
assert_eq!(
got,
[
("https://example.invalid/a.png".to_string(), MissingPictureReason::ExternalTarget),
("https://example.invalid/b.png".to_string(), MissingPictureReason::LinkOnly),
("word/media/none.png".to_string(), MissingPictureReason::MissingPart),
]
);
}
#[test]
fn the_list_is_capped_and_the_document_is_still_equal() {
let n = wordcraft_doc::MAX_MISSING_PICTURES + 5;
let body: String = (0..n).map(|i| blip(&format!(r#"r:embed="r{i}""#))).collect();
let rels: String = (0..n).map(|i| image_rel(&format!("r{i}"), &format!("media/gone{i}.png"), false)).collect();
let d = wordcraft_docx::read(&package(&body, &rels, None, &[])).unwrap();
assert_eq!(d.missing_pictures.list.len(), wordcraft_doc::MAX_MISSING_PICTURES);
assert!(d.missing_pictures.truncated);
// Not part of the document: `==` and the clone ignore it, and a save doesn't carry it.
let mut e = d.clone();
e.missing_pictures = Default::default();
assert_eq!(d, e);
assert!(!serde_json::to_string(&d).unwrap().contains("gone"));
}
/// Every note of a part shares the one allocation of its source name, so a file with many notes
/// in a part with a long name does not keep a copy of the name per note.
#[test]
fn the_notes_of_a_part_share_one_copy_of_its_name() {
let notes: String = (1..=3).map(|i| format!(r#"<w:footnote w:id="{i}"><w:p><w:r><w:t>n{i}</w:t></w:r></w:p></w:footnote>"#)).collect();
let rels = format!(r#"<Relationship Id="rIdF" Type="{REL}/footnotes" Target="footnotes.xml"/>"#);
let part = format!(r#"<w:footnotes {NS}>{notes}</w:footnotes>"#);
let d = wordcraft_docx::read(&package("x", &rels, None, &[("word/footnotes.xml", part.into_bytes())])).unwrap();
let names: Vec<_> = d.missing_pictures.part_names.values().collect();
assert!(names.len() >= 3, "{names:?}");
assert!(names.iter().all(|n| &***n == "word/footnotes.xml"));
assert!(names.windows(2).all(|w| std::sync::Arc::ptr_eq(w[0], w[1])), "each note holds its own copy of the part name");
}
+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;
+46 -6
View File
@@ -94,9 +94,9 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("file.save", "Save", "File", save)
.key("Mod+S / Shift+F12 / Alt+Shift+F2")
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format)}"#)
.params(r#"{"path"?: string, "password"?: string (encrypt this and later saves with it, as file.encrypt does), "markup"?: bool (Print Markup for a PDF, PNG, HTML, Markdown or text copy: comments shown; default file.printMarkup), "format"?: "wordml2003" (an .xml file as a Word 2003 XML Document) | "flatopc" (as a Word XML Document, the default; saving over a Word 2003 XML file keeps that format), "strict"?: bool (a .pdf or .png only: refuse, writing nothing, if a character can't be drawn or a picture is missing; see file.strictReport)}"#)
.pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key(super::mac_or("F12 / Mod+Shift+S", "F12 / Alt+F2")).params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save)}"#).pure(),
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key(super::mac_or("F12 / Mod+Shift+S", "F12 / Alt+F2")).params(r#"{"path": string, "password"?: string, "markup"?: bool, "format"?: string (as file.save), "strict"?: bool (as file.save)}"#).pure(),
// Pure: the password must not land in Repeat or a recorded macro, and it isn't an edit
// to undo; it marks the document as changed itself.
CommandSpec::new("file.encrypt", "Encrypt with Password", "File › Info › Protect Document", encrypt)
@@ -114,10 +114,13 @@ pub fn specs() -> Vec<CommandSpec> {
crate::io::save_path_with(std::path::Path::new(&path), &s.doc, None, &opts).map_err(CmdError::Failed)?;
Ok(json!({"path": path, "markup": opts.markup}))
})
.params(r#"{"path": string, "markup"?: bool (Print Markup: tracked changes marked up and each page scaled down beside the comments in balloons; default file.printMarkup)}"#)
.params(r#"{"path": string, "markup"?: bool (Print Markup: tracked changes marked up and each page scaled down beside the comments in balloons; default file.printMarkup), "strict"?: bool (refuse, writing nothing, if a character can't be drawn or a picture is missing; see file.strictReport)}"#)
.pure(),
CommandSpec::new("file.exportPng", "Export Page as PNG", "File › Export", export_png)
.params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt, "markup"?: bool (the page with its comments in balloons beside it; default file.printMarkup)}"#)
.params(r#"{"path": string, "page"?: n (1-based), "scale"?: px per pt, "markup"?: bool (the page with its comments in balloons beside it; default file.printMarkup), "strict"?: bool (refuse, writing nothing, if a character on the page can't be drawn or a picture is missing; see file.strictReport)}"#)
.pure(),
CommandSpec::new("file.strictReport", "Strict Export Report", "File › Export", strict_report)
.params(r#"{"markup"?: bool (as file.exportPdf), "page"?: n (1-based: check that page of the page image file.exportPng writes; omit for every page of a PDF)} → {"schema": 1, "strict": "passed" | "failed", "missing_glyphs": [{"cp": "U+2D30", "count": n}], "missing_pictures": [{"part", "source_part": the story part it is in (word/header2.xml; background, body, header, footer, text box or "" where unknown), "reason": "missing_part" | "external_target" | "link_only" | "undecodable" | "unsupported_format"}], "truncated": bool (either list was cut at 256 entries), "total_missing_glyphs": n (distinct, past the cap too), "total_missing_pictures": n (past the cap too), "glyphs_truncated": bool, "pictures_truncated": bool}"#)
.pure(),
// A print setting, like Word's: not part of the document, so no undo step.
CommandSpec::new("file.printMarkup", "Print Markup", "File › Print", |s, v| {
@@ -369,6 +372,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
return Ok(json!({"saved": false}));
}
};
check_strict_output(p::bool(v, "strict").unwrap_or(false), &path.to_string_lossy())?;
if let Some(pw) = v.get("password").filter(|pw| !pw.is_null()) {
let Some(pw) = pw.as_str().filter(|pw| !pw.is_empty()) else {
return Err(CmdError::Params("`password` must be a non-empty string (file.encrypt removes a password)".into()));
@@ -450,9 +454,39 @@ pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
comments: markup && s.prefs.markup.comments,
word2003: p::str(v, "format") == Some(FORMAT_WORD_2003),
numeral: s.view.numeral.resolve(crate::session::system_locale()),
strict: p::bool(v, "strict").unwrap_or(false),
}
}
/// `strict: true` is for a PDF or a page image only: any other output is a parameter error.
fn check_strict_output(strict: bool, path: &str) -> Result<(), CmdError> {
if strict && !crate::strict::is_strict_output(path) {
return Err(CmdError::Params("`strict` applies to a .pdf or .png output only".into()));
}
Ok(())
}
/// The strict report of a PDF export of the document (every page), or with `page` of the page
/// image `file.exportPng` writes. Pure: it reads the document and writes nothing.
fn strict_report(s: &mut Session, v: &Value) -> CmdResult {
let opts = export_options(s, v);
let report = match p::u64(v, "page") {
Some(n) => {
let page = crate::p::page_index(n);
let l = s.export_layout_with(opts.markup);
if l.pages.get(page).is_none() {
return Err(CmdError::Params(format!("no page {}", page + 1)));
}
crate::strict::strict_report(&s.doc, &l, &crate::strict::png_display(), Some(page..=page), wordcraft_layout::StrictOutput::Png)
}
None => {
let out = super::references::output_fields(&s.doc);
crate::strict::pdf_report(out.as_ref().unwrap_or(&s.doc), &opts)
}
};
Ok(report.to_json())
}
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
// No path: the app asks where to save, as file.exportPdf does (#2313).
let Some(path) = p::str(v, "path") else {
@@ -460,16 +494,22 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
return Ok(json!({"exported": false}));
};
let page = crate::p::page_index(p::u64(v, "page").unwrap_or(1));
let strict = p::bool(v, "strict").unwrap_or(false);
let scale = p::f32(v, "scale").unwrap_or(2.0).clamp(0.1, 8.0);
let l = s.export_layout_with(export_options(s, v).markup);
let pg = l.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {}", page + 1)))?;
// A page image is the printed page: headers and footers at full strength. Dimming them is
// the on-screen cue while editing the body, not part of the page.
let mut opts = wordcraft_render::RenderOptions::default();
opts.display.dim_header = false;
let opts = wordcraft_render::RenderOptions { display: crate::strict::png_display(), ..Default::default() };
if !path.to_ascii_lowercase().ends_with(".png") {
return Err(CmdError::Params("path must end in .png".into()));
}
if strict {
let report = crate::strict::strict_report(&s.doc, &l, &opts.display, Some(page..=page), wordcraft_layout::StrictOutput::Png);
if !report.passed() {
return Err(CmdError::Failed(report.refusal()));
}
}
let img = wordcraft_render::render_page(&s.doc, pg, scale, &opts);
let png = img.to_png();
crate::io::write_file(path, &png).map_err(CmdError::Failed)?;
+26
View File
@@ -212,6 +212,32 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
}
}
/// Builds the tracked paragraph-property change for a command that also changes the paragraph's
/// text. The ordinary after-command recorder deliberately compares only paragraphs with identical
/// text, so mixed text/paragraph-format transformations use this before assigning their props.
/// Made once per command, before it creates revisions: it keeps the revisions as they were then
/// (all `Recorder::own` needs), instead of copying the list for every paragraph.
pub(crate) struct ParaPropTracker {
before: Vec<Revision>,
}
impl ParaPropTracker {
pub(crate) fn new(s: &Session) -> Self {
let tracking = s.doc.settings.track_changes && s.prefs.markup.track_formatting;
ParaPropTracker { before: if tracking { s.doc.revisions.clone() } else { Vec::new() } }
}
pub(crate) fn changed(&self, s: &mut Session, before: &ParaProps, after: &ParaProps) -> Option<Box<PropChange<ParaProps>>> {
if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting {
return after.fmt_change.clone();
}
let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &self.before, revisions: &mut s.doc.revisions, rid: None };
r.change(before, after, ParaProps::formatting, |p| &p.fmt_change)
.map(|Replacement(change)| change)
.unwrap_or_else(|| after.fmt_change.clone())
}
}
/// Record the paragraph formatting a command changed on the paragraph at `path` whose text it also
/// changed, which [`record`] doesn't compare: `before` is the paragraph's properties before the
/// command. As in [`record`], the author's own earlier change keeps the formatting before it and
+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")));
}
}
+6
View File
@@ -185,6 +185,10 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
if f.wrap == Wrap::Inline {
f.wrap = Wrap::Square;
}
// A drawing anchored to a page is on this one now.
if f.page > 0 {
f.page = u32::try_from(page).map_or(f.page, |p| p.saturating_add(1));
}
f.h_rel = Anchor::Page;
f.v_rel = Anchor::Page;
f.h_align = None;
@@ -199,6 +203,8 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
s.touch();
let origin = s.layout().object(&at, page).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?.origin;
edit_float(s, &at, |f| {
// On its paragraph now: a page it was anchored to is gone, not only hidden.
f.page = 0;
f.h_rel = Anchor::Column;
f.v_rel = Anchor::Paragraph;
f.x = (x - origin.x).clamp(-MAX_OFFSET, MAX_OFFSET);
+25
View File
@@ -16,6 +16,7 @@ mod lists;
mod outline;
mod styles;
mod tabs_borders;
mod tools;
use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog};
pub use format::{cur as selection_para, fmt, indent, outdent};
@@ -26,6 +27,10 @@ use outline::{move_paras, outline_step, sort};
use styles::{apply_style, apply_styles, create_style, delete_style, modify_style, set_visibility, style_id, update_to_match};
pub use tabs_borders::border_sides;
use tabs_borders::{borders, tabs};
use tools::{
blank_between, convert_breaks, indent_to_spaces, remove_blank, remove_extra_spaces, remove_line_breaks, separate_paragraphs, spaces_to_indent,
trim_leading,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -270,6 +275,26 @@ pub fn specs() -> Vec<CommandSpec> {
}
sel_result(s)
}),
// Advanced paragraph tools (#5043, as in WPS Office): conversions and cleanup over the
// selection, or the whole document with `scope: "all"`. One undo step per command.
CommandSpec::new("para.convertBreaks", "Convert Line Breaks to Paragraphs", "Home › Paragraph › Paragraph Tools", convert_breaks)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.blankBetween", "Insert Blank Paragraphs Between", "Home › Paragraph › Paragraph Tools", blank_between)
.params(r#"{"scope"?: "selection|all", "after"?: bool (also after the last paragraph)}"#),
CommandSpec::new("para.separateParagraphs", "Separate Paragraphs", "Home › Paragraph › Paragraph Tools", separate_paragraphs)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeBlank", "Delete Blank Paragraphs", "Home › Paragraph › Paragraph Tools", remove_blank)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.trimLeadingSpaces", "Remove Leading Spaces", "Home › Paragraph › Paragraph Tools", trim_leading)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeExtraSpaces", "Remove Extra Spaces", "Home › Paragraph › Paragraph Tools", remove_extra_spaces)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.spacesToIndent", "Convert Spaces to Indentation", "Home › Paragraph › Paragraph Tools", spaces_to_indent)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.indentToSpaces", "Convert Indents to Spaces", "Home › Paragraph › Paragraph Tools", indent_to_spaces)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeLineBreaks", "Remove Line Breaks", "Home › Paragraph › Paragraph Tools", remove_line_breaks)
.params(r#"{"scope"?: "selection|all"}"#),
]
}
+332
View File
@@ -0,0 +1,332 @@
//! Advanced paragraph tools (#5043): converting and cleaning up paragraphs, like WPS Office's
//! paragraph tools. Each command works on the paragraphs the selection covers, or on the whole
//! document with `scope: "all"`, and runs as one undo step. Deletions respect Track Changes.
use serde_json::{Value, json};
use wordcraft_doc::para::LINE_BREAK;
use wordcraft_doc::{Path, Pos, RevisionKind, StoryRef};
use super::{CmdError, CmdResult, Session, p, sel_result};
use crate::cmd::{has_next_para, new_revision, split_para, track_delete};
/// The maximum number of paragraphs one call may touch: the work, and the blank paragraphs a
/// command can add, stay bounded whatever the document says.
const MAX_TOOL_PARAS: usize = 10_000;
/// Leading spaces one paragraph may gain from an indent, whatever the file's indent says.
const MAX_INDENT_SPACES: usize = 1_000;
/// The scope of a tools command: `{"scope": "selection|all"}`, `selection` by default.
/// `all` selects the whole document first, so the command, its undo step and the result all
/// follow the full selection like any other command.
fn scope_all(s: &mut Session, v: &Value) -> Result<(), CmdError> {
match p::str(v, "scope").unwrap_or("selection") {
"selection" => Ok(()),
"all" => {
s.run("select.all", &json!({"whole": true}))?;
Ok(())
}
x => Err(CmdError::Params(format!("unknown scope `{x}` (selection|all)"))),
}
}
/// Refuse a paragraph rewrite that would touch content locked against editing. Commands operate
/// on whole paragraphs, so checking each full paragraph also covers controls outside the exact
/// selection offsets in a selected paragraph.
fn check_target_edits(s: &mut Session, story: StoryRef, paths: &[Path]) -> Result<(), CmdError> {
let ranges: Vec<(Pos, Pos)> = paths
.iter()
.filter_map(|path| {
let len = s.doc.para(story, path)?.len();
Some((Pos { story, path: path.clone(), off: 0 }, Pos { story, path: path.clone(), off: len }))
})
.collect();
for (a, b) in ranges {
crate::cmd::controls::check_edit(s, &a, &b)?;
}
Ok(())
}
/// The paragraphs a command runs over, in document order, and the story they are in.
fn targets(s: &Session) -> Result<(StoryRef, Vec<Path>), CmdError> {
let (a, b) = s.sel.ordered();
let paths = s.doc.para_paths_in(&a, &b);
if paths.is_empty() {
return Err(CmdError::Failed("no paragraphs selected".into()));
}
if paths.len() > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("select at most {MAX_TOOL_PARAS} paragraphs to process them in one command")));
}
Ok((a.story, paths))
}
/// Deletes `a..b` inside one paragraph, recording a tracked deletion when Track Changes is on.
/// Offsets past the paragraph's end are clamped, so the document is never half-edited.
fn delete_in_para(s: &mut Session, story: StoryRef, path: &Path, a: usize, b: usize) -> Result<(), CmdError> {
let len = s.doc.para(story, path).ok_or_else(|| CmdError::Failed("no paragraph here".into()))?.len();
let (a, b) = (a.min(b).min(len), b.min(len));
if a >= b {
return Ok(());
}
let at = |off: usize| Pos { story, path: path.clone(), off };
crate::cmd::controls::check_delete(s, &at(a), &at(b))?;
if s.doc.settings.track_changes {
track_delete(s, &at(a), &at(b))?;
} else {
s.doc.delete_range(&at(a), &at(b))?;
}
Ok(())
}
/// The deletions to make in one paragraph, as byte ranges, merged where they touch and applied
/// last first so the earlier offsets stay valid.
fn apply_deletions(s: &mut Session, story: StoryRef, path: &Path, mut ranges: Vec<(usize, usize)>) -> Result<(), CmdError> {
ranges.sort();
let mut merged: Vec<(usize, usize)> = Vec::new();
for (a, b) in ranges {
match merged.last_mut() {
Some(last) if a <= last.1 => last.1 = last.1.max(b),
_ => merged.push((a, b)),
}
}
for (a, b) in merged.into_iter().rev() {
delete_in_para(s, story, path, a, b)?;
}
Ok(())
}
/// The characters of the paragraph that are not tracked-deleted, as (byte offset, char). Under
/// Track Changes deleted text stays in `para.text`, so every scan that decides what to delete or
/// count goes through this.
fn live_chars(para: &wordcraft_doc::Paragraph) -> impl Iterator<Item = (usize, char)> + '_ {
para.run_ranges().filter(|(_, c)| c.del.is_none()).flat_map(|(r, _)| para.text[r.clone()].char_indices().map(move |(i, c)| (r.start + i, c)))
}
/// The byte ranges of every manual line break in the paragraph that is not already deleted.
fn break_ranges(para: &wordcraft_doc::Paragraph) -> Vec<(usize, usize)> {
live_chars(para).filter(|(_, c)| *c == LINE_BREAK).map(|(i, _)| (i, i + 1)).collect()
}
/// `para.convertBreaks`: every manual line break (Shift+Enter) in the selection becomes a
/// paragraph break. Paragraphs are handled last first: a split only shifts the paths after it.
pub(super) fn convert_breaks(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let breaks: usize = paths.iter().filter_map(|path| s.doc.para(story, path)).map(|p| break_ranges(p).len()).sum();
if breaks > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("convert at most {MAX_TOOL_PARAS} line breaks at once")));
}
for path in paths.iter().rev() {
let Some(para) = s.doc.para(story, path) else { continue };
for (i, _) in break_ranges(para).into_iter().rev() {
let new = split_para(s, &Pos { story, path: path.clone(), off: i })?;
// The break stays at the head of the new paragraph; the paragraph break replaces it.
delete_in_para(s, story, &new.path, 0, 1)?;
}
}
sel_result(s)
}
/// `para.blankBetween`: insert one empty paragraph between the selected paragraphs; `after`
/// adds one blank paragraph after the last selected paragraph too.
pub(super) fn blank_between(s: &mut Session, v: &Value) -> CmdResult {
let after = p::bool(v, "after").unwrap_or(false);
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let count = paths.len().saturating_sub(1) + usize::from(after);
if count > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("insert at most {MAX_TOOL_PARAS} blank paragraphs at once")));
}
// Last first, each split at a paragraph's end: a split only shifts the paths after it.
if after {
let last = paths.last().ok_or_else(|| CmdError::Failed("no paragraph selected".into()))?;
let len = s.doc.para(story, last).map(|p| p.len()).unwrap_or(0);
split_para(s, &Pos { story, path: last.clone(), off: len })?;
}
for path in paths[..paths.len().saturating_sub(1)].iter().rev() {
let len = s.doc.para(story, path).map(|p| p.len()).unwrap_or(0);
split_para(s, &Pos { story, path: path.clone(), off: len })?;
}
sel_result(s)
}
/// `para.separateParagraphs`: every selected paragraph becomes a paragraph of its own with a
/// blank line between them.
pub(super) fn separate_paragraphs(s: &mut Session, v: &Value) -> CmdResult {
blank_between(s, &json!({"scope": p::str(v, "scope").unwrap_or("selection"), "after": true}))
}
/// `para.removeBlank`: delete every empty paragraph in the selection.
pub(super) fn remove_blank(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
// Last first: removing a block leaves the paths before it as they are.
for path in paths.iter().rev() {
let Some(para) = s.doc.para(story, path) else { continue };
// A paragraph must be empty, carry no section break and have a paragraph after it in
// its container: a story's, part's or cell's last mark can't be removed, and a paragraph
// before a table can't be joined across it.
if !para.is_empty() || para.section.is_some() || !has_next_para(s, story, path) {
continue;
}
let next = path.with_last(path.last() + 1);
let (a, b) = (Pos { story, path: path.clone(), off: 0 }, Pos { story, path: next, off: 0 });
crate::cmd::controls::check_delete(s, &a, &b)?;
if s.doc.settings.track_changes {
track_delete(s, &a, &b)?;
} else {
s.doc.remove_block(story, path)?;
}
}
sel_result(s)
}
/// The number of leading spaces in the paragraph's text, deleted ones included (the range
/// `trimLeadingSpaces` deletes).
fn leading_spaces(para: &wordcraft_doc::Paragraph) -> usize {
para.text.chars().take_while(|c| *c == ' ').count()
}
/// The byte offsets of the paragraph's leading spaces that are not tracked-deleted.
fn live_leading_spaces(para: &wordcraft_doc::Paragraph) -> Vec<usize> {
live_chars(para).take_while(|(_, c)| *c == ' ').map(|(i, _)| i).collect()
}
/// `para.trimLeadingSpaces`: delete the spaces at the start of each selected paragraph.
pub(super) fn trim_leading(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let n = leading_spaces(para);
apply_deletions(s, story, &path, vec![(0, n)])?;
}
sel_result(s)
}
/// `para.removeExtraSpaces`: every run of spaces outside the paragraph's start becomes one
/// space. Tabs and page breaks are document structure, not redundant spaces, and stay in place.
/// Leading spaces stay (that is `para.trimLeadingSpaces`).
pub(super) fn remove_extra_spaces(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
// Runs of live spaces (deleted characters are skipped, as if absent). A run at the
// paragraph's start stays; a run inside the text collapses to its last space, and one
// that reaches the paragraph's end to its first.
let mut runs = Vec::new();
let mut run: Vec<usize> = Vec::new();
let mut leading = true;
for (i, c) in live_chars(para) {
if c == ' ' {
run.push(i);
continue;
}
if !leading && run.len() > 1 {
runs.extend(run[..run.len() - 1].iter().map(|&a| (a, a + 1)));
}
run.clear();
leading = false;
}
if !leading && run.len() > 1 {
runs.extend(run[1..].iter().map(|&a| (a, a + 1)));
}
apply_deletions(s, story, &path, runs)?;
}
sel_result(s)
}
/// `para.spacesToIndent`: each paragraph's leading spaces become a first-line indent of 72 pt
/// per space (Word's point-based indent from typing spaces), and are deleted.
pub(super) fn spaces_to_indent(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let live = live_leading_spaces(para);
let spaces = live.len();
if spaces == 0 {
continue;
}
let current = para.props.indent_first.unwrap_or(0.0).clamp(-1584.0, 1584.0);
let represented = spaces.min(((1584.0 - current).max(0.0) / 72.0).floor() as usize);
if represented == 0 {
continue;
}
let before = para.props.clone();
let mut after = before.clone();
after.indent_first = Some(current + represented as f32 * 72.0);
after.cancel_char_indents(false, false, true);
after.fmt_change = tracker.changed(s, &before, &after);
{
let p = s.doc.para_mut(story, &path)?;
p.props = after;
p.touch();
}
// A space is one byte; deleted spaces in between stay as they are.
apply_deletions(s, story, &path, live[..represented].iter().map(|&a| (a, a + 1)).collect())?;
}
sel_result(s)
}
/// `para.indentToSpaces`: each paragraph's first-line and left indents become leading spaces
/// (72 pt per space, as typing spaces indents), and the indents go to zero.
pub(super) fn indent_to_spaces(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let pts = para.props.indent_left.unwrap_or(0.0) + para.props.indent_first.unwrap_or(0.0);
let n = (pts / 72.0).round().clamp(0.0, MAX_INDENT_SPACES as f32) as usize;
if n == 0 && para.props.indent_left.unwrap_or(0.0) == 0.0 && para.props.indent_first.unwrap_or(0.0) == 0.0 {
continue;
}
let mut props = s.doc.para(story, &path).map(|p| p.props_at(0)).cloned().unwrap_or_default();
// The revision first: `new_revision` edits `s.doc`, so it runs before the paragraph borrow.
// The spaces are marked as a tracked insertion through `props.ins`, as typing is.
props.fmt_change = None;
props.del = None;
if s.doc.settings.track_changes && n > 0 {
props.ins = Some(new_revision(s, RevisionKind::Insert));
} else {
props.ins = None;
}
let before = s.doc.para(story, &path).map(|p| p.props.clone()).unwrap_or_default();
let mut after = before.clone();
after.indent_first = Some(0.0);
after.indent_left = Some(0.0);
after.cancel_char_indents(true, false, true);
after.fmt_change = tracker.changed(s, &before, &after);
let p = s.doc.para_mut(story, &path)?;
if n > 0 {
p.insert_text(0, &" ".repeat(n), &props)?;
}
p.props = after;
p.touch();
}
sel_result(s)
}
/// `para.removeLineBreaks`: delete every manual line break in the selection, so the lines
/// join into one paragraph.
pub(super) fn remove_line_breaks(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
apply_deletions(s, story, &path, break_ranges(para))?;
}
sel_result(s)
}
+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;
+2 -1
View File
@@ -14,7 +14,7 @@ mod enter_tab;
mod unicode;
use autoformat::insert;
use delete::{backspace, delete, delete_word_back, delete_word_fwd};
use delete::{backspace, delete, delete_sentence_back, delete_word_back, delete_word_fwd};
pub use enter_tab::MAX_TYPED_PARAGRAPHS;
use enter_tab::{back_tab, new_paragraph, tab};
pub use unicode::{alt_code_char, oem_code_page, system_code_pages, system_oem_code_page, windows_code_pages};
@@ -36,6 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"),
CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"),
CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"),
CommandSpec::new("text.deleteSentenceBack", "Delete Previous Sentence", "Editing", delete_sentence_back).key("Mod+Shift+Backspace"),
CommandSpec::new("text.deleteWordForward", "Delete Next Word", "Editing", delete_word_fwd).key("Mod+Delete / Alt+Delete"),
CommandSpec::new("text.nbsp", "Nonbreaking Space", "Insert › Symbols", |s, _| ins_char(s, NBSP)).key("Mod+Shift+Space"),
CommandSpec::new("text.nbHyphen", "Nonbreaking Hyphen", "Insert › Symbols", |s, _| ins_char(s, NB_HYPHEN)).key("Mod+Shift+-"),
+17
View File
@@ -237,3 +237,20 @@ pub(super) fn delete_word_fwd(s: &mut Session, v: &Value) -> CmdResult {
crate::cmd::controls::restore_placeholder(s)?;
sel_result(s)
}
/// Delete to the current sentence start, or the preceding sentence when exactly at its boundary.
/// Sentence boundaries match Select Sentence. Never remove a paragraph or cell boundary.
pub(super) fn delete_sentence_back(s: &mut Session, v: &Value) -> CmdResult {
if !s.sel.is_collapsed() {
return backspace(s, v);
}
let mut f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
f.off = para.clamp(f.off);
let Some((previous, _)) = para.text.get(..f.off).and_then(|text| text.char_indices().next_back()) else { return sel_result(s) };
let start = para.sentence_at(previous).0;
s.sel = Selection { anchor: Pos { off: start, ..f.clone() }, focus: f };
delete_selection(s)?;
crate::cmd::controls::restore_placeholder(s)?;
sel_result(s)
}
+39
View File
@@ -341,3 +341,42 @@ pub(super) fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult {
}
Ok(Value::Object(out))
}
/// The rows of an `insert.spreadsheet` call: `values` (a rectangular array of cells) or `csv`.
pub(super) fn spreadsheet_rows(v: &Value) -> Result<Vec<Vec<String>>, CmdError> {
// The CSV fallback is for an absent `values`; an explicit null is a malformed argument.
let Some(values) = v.get("values") else {
return Ok(crate::cmd::mailings::parse_csv(p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6")));
};
let list = values.as_array().ok_or_else(|| CmdError::Params("`values` must be an array of rows".into()))?;
if list.is_empty() || list.len() > 1000 {
return Err(CmdError::Params("`values` needs 1 to 1000 rows".into()));
}
let mut rows: Vec<Vec<String>> = Vec::with_capacity(list.len());
for (r, row) in list.iter().enumerate() {
let cells = row.as_array().ok_or_else(|| CmdError::Params(format!("`values` row {} is not an array", r + 1)))?;
if cells.is_empty() || cells.len() > 63 {
return Err(CmdError::Params(format!("`values` row {} needs 1 to 63 cells", r + 1)));
}
if rows.first().is_some_and(|f| f.len() != cells.len()) {
return Err(CmdError::Params(format!(
"`values` row {} has {} cells, row 1 has {}",
r + 1,
cells.len(),
rows.first().map_or(0, Vec::len)
)));
}
let mut out = Vec::with_capacity(cells.len());
for cell in cells {
out.push(match cell {
Value::String(x) => x.clone(),
Value::Number(x) => x.to_string(),
Value::Bool(x) => x.to_string(),
Value::Null => String::new(),
_ => return Err(CmdError::Params(format!("`values` row {} holds a cell that is not text, a number or a boolean", r + 1))),
});
}
rows.push(out);
}
Ok(rows)
}
+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;
}
}
+10 -4
View File
@@ -1,5 +1,6 @@
use super::actions::{
customize_keyboard, customize_ribbon, hf_nav, language, macros, preferred_language, quick_parts, search_commands, select_similar,
spreadsheet_rows,
};
use super::autocorrect::autocorrect_options;
use super::compare::{COMPARE_PARAMS, compare};
@@ -213,10 +214,10 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"path": string}"#),
CommandSpec::new("insert.spreadsheet", "Spreadsheet Table", "Insert › Tables", |s, v| {
let csv = p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6");
let rows = crate::cmd::mailings::parse_csv(csv);
let rows = spreadsheet_rows(v)?;
let cols = rows.iter().map(Vec::len).max().unwrap_or(1).clamp(1, 63);
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": "GridTable1Light"}))?;
let style = p::str(v, "style").unwrap_or("GridTable1Light");
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": style}))?;
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return sel_result(s) };
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
for (r, row) in rows.iter().enumerate() {
@@ -226,9 +227,14 @@ pub fn specs() -> Vec<CommandSpec> {
}
}
}
if p::bool(v, "header").unwrap_or(false)
&& let Some(first) = t.rows.first_mut()
{
first.props.header = true;
}
sel_result(s)
})
.params(r#"{"csv"?: string}"#),
.params(r#"{"csv"?: string, "values"?: [[cell, …], …] (rows of equal length, at most 1000 rows × 63 columns; wins over csv), "header"?: bool (first row repeats on each page), "style"?: table style name (default GridTable1Light)}"#),
CommandSpec::new("layout.linkToPrevious", "Link to Previous", "Header & Footer › Navigation", |s, v| {
let header = p::bool(v, "footer").map(|f| !f).unwrap_or(true);
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
+613
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());
@@ -255,6 +815,59 @@ fn proofing_language_options() {
assert!(s.run("review.language", &json!({"lang": "<script>"})).is_err());
}
/// #2242: `insert.spreadsheet` fills a table from `values`, with a repeating header and a style.
#[test]
fn spreadsheet_takes_values_header_and_style() {
let mut s = Session::new(Document::from_text(""));
s.run("insert.spreadsheet", &json!({"values": [["Month", "Cases"], ["Jan", 12], ["Feb", null]], "header": true, "style": "TableGrid"})).unwrap();
let Some(Block::Table(t)) = s.doc.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!("no table") };
assert_eq!((t.rows.len(), t.rows[0].cells.len()), (3, 2));
assert!(t.rows[0].props.header && !t.rows[1].props.header);
assert_eq!(t.rows[1].cells[1].blocks[0].as_para().unwrap().text, "12");
assert_eq!(t.props.style.as_deref(), Some("TableGrid"));
}
/// An explicit `values: null` is a parameter error, not the legacy CSV sample table: the document
/// and the undo history stay as they were. Only an absent `values` falls back to `csv`.
#[test]
fn spreadsheet_rejects_an_explicit_null_values_without_changing_anything() {
let mut s = Session::new(Document::from_text("keep"));
let depth = s.undo_depth();
for args in [json!({"values": null}), json!({"values": null, "csv": "a,b\n1,2"})] {
assert!(s.run("insert.spreadsheet", &args).is_err(), "{args}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "keep");
assert!(!s.doc.body.iter().any(|b| matches!(&**b, Block::Table(_))));
assert_eq!(s.undo_depth(), depth, "{args}: no undo step");
}
s.run("insert.spreadsheet", &json!({"csv": "a,b\n1,2"})).unwrap();
let Some(Block::Table(t)) = s.doc.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!("no table") };
assert_eq!((t.rows.len(), t.rows[0].cells.len()), (2, 2), "an absent `values` still takes the csv");
}
/// #2242: malformed `values` is an error, never a panic, and leaves the document alone.
#[test]
fn spreadsheet_rejects_hostile_values() {
let wide: Vec<Value> = (0..64).map(|i| json!(i)).collect();
let many: Vec<Value> = (0..1001).map(|_| json!(["x"])).collect();
for bad in [
json!("x"),
json!(5),
json!([]),
json!(["a"]),
json!([["a", "b"], ["c"]]),
json!([wide]),
json!(many),
json!([[{"a": 1}]]),
json!([[[1]]]),
json!([[]]),
] {
let mut s = Session::new(Document::from_text("keep"));
assert!(s.run("insert.spreadsheet", &json!({"values": bad})).is_err(), "{bad}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "keep");
assert!(!s.doc.body.iter().any(|b| matches!(&**b, Block::Table(_))));
}
}
/// The inspector's removals delete straight from the document, so they aren't tracked: on a
/// document that tracks changes the text would go with no revision to show for it, and the
/// revision trail would be broken behind the user's back. The removal is refused instead,
+158 -2
View File
@@ -1,5 +1,6 @@
//! View tab and status bar: views, zoom, show/hide, panes.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use wordcraft_doc::{Zoom, ZoomFit};
use wordcraft_layout::{NumeralMode, ViewMode};
@@ -7,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| {
@@ -40,6 +43,15 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("view.readPageColor", "Reading Page Color", "Reading › Layout", read_page_color)
.params(r#"{"value": "none" | "sepia" | "inverse"}"#)
.pure(),
CommandSpec::new("view.paperColor", "Screen Paper Color", "View › Display", |s, v| {
let color: PaperColor = serde_json::from_value(v.get("value").cloned().unwrap_or(Value::Null))
.map_err(|_| CmdError::Params("screen paper color must be document, green, cream, or gray".into()))?;
s.prefs.paper_color = color;
if color != PaperColor::Document {
s.view.dark_mode = false;
}
Ok(json!({"paperColor": color}))
}).params(r#"{"value": "document" | "green" | "cream" | "gray"}"#).pure(),
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
.params(r#"{"value"?: bool}"#)
@@ -133,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"}));
@@ -141,6 +153,30 @@ pub fn specs() -> Vec<CommandSpec> {
})
.key("Mod+Alt+S")
.pure(),
// View › Window across WordCraft windows (#322): the desktop app's native windows
// perform these (see `Windows`), elsewhere they are disabled.
CommandSpec::new("view.arrangeAll", "Arrange All", "View › Window", |s, _| {
s.ui_requests.push(json!({"windows": "arrange"}));
Ok(json!({"windows": s.windows.others.len() + 1}))
})
.when(windows_available)
.pure(),
CommandSpec::new("view.sideBySide", "View Side by Side", "View › Window", side_by_side)
.params(r#"{"value"?: bool, "window"?: id of the other window (optional when one other window is open)}"#)
.when(|s| match windows_available(s) {
None if s.windows.side_by_side.is_none() => has_other_window(s),
r => r,
})
.pure(),
CommandSpec::new("view.syncScroll", "Synchronous Scrolling", "View › Window", |s, v| {
let Some(with) = s.windows.side_by_side else { return Err(CmdError::Disabled("turn on View Side by Side first".into())) };
s.windows.sync_scroll = p::bool(v, "value").unwrap_or(!s.windows.sync_scroll);
s.ui_requests.push(json!({"windows": "syncScroll", "with": with, "value": s.windows.sync_scroll}));
Ok(json!({"value": s.windows.sync_scroll}))
})
.params(r#"{"value"?: bool}"#)
.when(|s| if s.windows.side_by_side.is_some() { None } else { Some("turn on View Side by Side first") })
.pure(),
// The window fills the screen, OS-level full screen (#980). F11 as in browsers and most
// desktop apps; ⌃⌘F first on macOS, where Ctrl is a key of its own (elsewhere Mod is Ctrl,
// so "Mod+Ctrl+F" would read Ctrl+Ctrl+F). Escape also leaves it. The front end applies it.
@@ -170,6 +206,85 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// View › Window (#322): the other native windows of the app as the window being drawn sees them
/// (by window id, the ids of `ui.inspect`'s `windows`), and its Side by Side pairing. The front
/// end keeps these current as each window draws; elsewhere (web, CLI, MCP without the app) they
/// stay empty and the window commands are disabled.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
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.
pub side_by_side: Option<u64>,
/// Synchronous Scrolling (only while Side by Side is on).
pub sync_scroll: bool,
}
/// Another open WordCraft window.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OtherWindow {
pub id: u64,
pub title: String,
}
fn windows_available(s: &Session) -> Option<&'static str> {
if s.windows.available { None } else { Some("only the desktop app has several windows") }
}
fn has_other_window(s: &Session) -> Option<&'static str> {
windows_available(s).or(if s.windows.others.is_empty() { Some("no other WordCraft window is open") } else { None })
}
/// The window a command means: `window`, or the only other one.
fn pick_window(s: &Session, v: &Value) -> Result<u64, CmdError> {
let others = &s.windows.others;
match p::u64(v, "window") {
Some(id) if others.iter().any(|w| w.id == id) => Ok(id),
Some(id) => Err(CmdError::Params(format!("no open window {id}"))),
None => match others.as_slice() {
[only] => Ok(only.id),
[] => Err(CmdError::Disabled("no other WordCraft window is open".into())),
several => {
let list: Vec<String> = several.iter().map(|w| format!("{} ({})", w.id, w.title)).collect();
Err(CmdError::Params(format!("pass `window`: one of {}", list.join(", "))))
}
},
}
}
/// View Side by Side: on with a window (left this one, right that one, scrolling together), or
/// off.
fn side_by_side(s: &mut Session, v: &Value) -> CmdResult {
let on = p::bool(v, "value").unwrap_or(s.windows.side_by_side.is_none() || v.get("window").is_some());
if !on {
if let Some(id) = s.windows.side_by_side.take() {
s.ui_requests.push(json!({"windows": "sideBySide", "off": id}));
}
s.windows.sync_scroll = false;
return Ok(json!({"value": false}));
}
let id = match (s.windows.side_by_side, v.get("window")) {
(Some(id), None) => id,
_ => pick_window(s, v)?,
};
if let Some(old) = s.windows.side_by_side.filter(|old| *old != id) {
s.ui_requests.push(json!({"windows": "sideBySide", "off": old}));
}
// The partner it already has keeps the user's Synchronous Scrolling choice and is not asked
// again; a new pairing starts with scrolling on.
if s.windows.side_by_side != Some(id) {
s.windows.side_by_side = Some(id);
s.windows.sync_scroll = true;
s.ui_requests.push(json!({"windows": "sideBySide", "with": id}));
}
Ok(json!({"value": true, "window": id, "syncScroll": s.windows.sync_scroll}))
}
/// The browser keeps full screen to itself (its own F11); the web app can't ask for it.
fn full_screen_available(_: &Session) -> Option<&'static str> {
cfg!(target_arch = "wasm32").then_some("Use the browser's full screen (F11)")
@@ -234,6 +349,40 @@ fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"spacing": spacing}))
}
/// Screen-only paper presets, saved with user preferences, never in the document.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PaperColor {
#[default]
Document,
Green,
Cream,
Gray,
}
impl PaperColor {
pub const ALL: [Self; 4] = [Self::Document, Self::Green, Self::Cream, Self::Gray];
pub fn label(self) -> &'static str {
match self {
Self::Document => "Document background",
Self::Green => "Soft green",
Self::Cream => "Warm cream",
Self::Gray => "Soft gray",
}
}
pub fn color(self) -> wordcraft_doc::props::Rgb {
use wordcraft_doc::props::Rgb;
match self {
Self::Document => Rgb::WHITE,
Self::Green => Rgb(0xCC, 0xE8, 0xCF),
Self::Cream => Rgb(0xFA, 0xF0, 0xDC),
Self::Gray => Rgb(0xE8, 0xEA, 0xED),
}
}
}
/// Read Mode › View › Page Color, as in Word. Screen only: the document and its own page color
/// are unchanged.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
@@ -286,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()) => {
@@ -311,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)?;
}
+4
View File
@@ -66,6 +66,10 @@ pub struct ExportOptions {
pub word2003: bool,
/// How digits look in PDFs and page images (the Numeral setting, `System` resolved).
pub numeral: wordcraft_layout::NumeralMode,
/// A PDF or page image is written only if nothing in it is silently missing (a character no
/// face can draw, a picture that is missing or can't be decoded); else the save fails with
/// `strict: …` (#6005). Ignored for other formats.
pub strict: bool,
}
/// A document password: wiped from memory when dropped, never printed (`Debug` shows `***`).
+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 -9
View File
@@ -10,10 +10,15 @@ pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Opti
"doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())),
// Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element.
"xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())),
// HTML, Markdown, ODT, RTF…: pictures fit the text width (see `io::fit_pictures`).
// HTML, Markdown, RTF…: pictures fit the text width (see `io::fit_pictures`). An
// OpenDocument file says how big they are, and one wider than the text is meant to be;
// changing it would take its paragraph off being written as read. Its reader fits those
// that state no size.
other => wordcraft_formats::import_on(other, bytes, page).map(|r| {
r.map(|mut doc| {
crate::io::fit_pictures(&mut doc);
if !matches!(other, "odt" | "ott" | "fodt") {
crate::io::fit_pictures(&mut doc);
}
draw_charts(&mut doc);
doc
})
@@ -60,13 +65,7 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
// Output shows the fields Word fills in as it prints (see `output_fields`).
let out = crate::cmd::references::output_fields(doc);
let doc = out.as_ref().unwrap_or(doc);
let pdf = wordcraft_pdf::PdfOptions {
include_markup: opts.markup,
comments: opts.markup && opts.comments,
numeral: opts.numeral,
..Default::default()
};
Some(wordcraft_pdf::export(doc, &pdf).map_err(|e| e.to_string()))
Some(crate::strict::export_pdf(doc, opts))
}
other => wordcraft_formats::export_with(other, doc, &wordcraft_formats::ExportOptions { comments: opts.markup && opts.comments }),
}
@@ -80,6 +79,12 @@ pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::Exp
let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() };
let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo);
let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
if opts.strict {
let report = crate::strict::strict_report(doc, &l, &crate::strict::png_display(), Some(page..=page), wordcraft_layout::StrictOutput::Png);
if !report.passed() {
return Err(report.refusal());
}
}
let img = wordcraft_render::render_page(doc, p, scale, &Default::default());
Ok(img.to_png())
}
+1
View File
@@ -25,6 +25,7 @@ pub mod sample;
mod session;
pub(crate) mod snapshot;
pub mod speech;
pub mod strict;
pub(crate) mod view;
use std::collections::HashMap;
+9
View File
@@ -7,6 +7,9 @@ use serde::{Deserialize, Serialize};
pub struct Prefs {
/// Word Count includes text boxes, footnotes and endnotes (Word's default).
pub count_notes: bool,
/// On-screen paper for pages without a document background; excluded from saved documents.
#[serde(deserialize_with = "lenient_paper_color")]
pub paper_color: crate::cmd::view::PaperColor,
/// Track Changes Options: what markup shows and how revisions are drawn (per user, as in
/// Word).
pub markup: wordcraft_layout::display::MarkupOptions,
@@ -64,6 +67,7 @@ pub struct Prefs {
impl Default for Prefs {
fn default() -> Self {
Prefs {
paper_color: Default::default(),
count_notes: true,
markup: Default::default(),
print_markup: false,
@@ -105,6 +109,11 @@ fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Op
Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag))
}
fn lenient_paper_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::PaperColor, D::Error> {
let value = serde_json::Value::deserialize(d)?;
Ok(serde_json::from_value(value).unwrap_or_default())
}
/// Ignore an invalid saved Page Color without discarding other preferences.
fn lenient_read_page_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::ReadPageColor, D::Error> {
let value = serde_json::Value::deserialize(d)?;
+2
View File
@@ -183,6 +183,8 @@ pub struct Session {
pub merge: crate::cmd::mailings::MergeState,
/// Requests from commands to the UI (open a dialog, scroll…), drained by the front end.
pub ui_requests: Vec<Value>,
/// View › Window: the other WordCraft windows and Side by Side (#322).
pub windows: crate::cmd::view::Windows,
/// Read Aloud player (Review › Speech).
pub read_aloud: crate::speech::ReadAloud,
/// Preferences the front end saves between runs.
+2
View File
@@ -250,6 +250,8 @@ impl Session {
author: _,
initials: _,
painter: _,
// What the drawn window knows of the others: its front end sets it as it draws.
windows: _,
status: _,
last_command: _,
recording: _,
+1
View File
@@ -157,6 +157,7 @@ impl Session {
ribbon: crate::RibbonLayout::default(),
editing_lang: None,
read_aloud: Default::default(),
windows: Default::default(),
password,
#[cfg(not(target_arch = "wasm32"))]
recovery: None,
+2
View File
@@ -139,6 +139,8 @@ impl Session {
pending_ime_application_change: _,
ime_origin: _,
read_aloud: _,
// The other windows, like `view`: not part of the document or its history.
windows: _,
prefs: _,
system_region: _,
keymap: _,
+233
View File
@@ -0,0 +1,233 @@
//! Strict export (#6005): the report of what a PDF or PNG would leave out silently (characters no
//! face can draw, pictures that are missing or can't be decoded), and the refusal built from it.
//!
//! The check runs on the layout and display options the export itself uses, so what it reports is
//! what the output lacks.
use std::ops::RangeInclusive;
use serde_json::{Value, json};
use wordcraft_doc::{Document, MAX_MISSING_PICTURES, MissingPicture};
use wordcraft_layout::display::DisplayOptions;
use wordcraft_layout::{DocLayout, LayoutCache, StrictOutput};
use crate::io::ExportOptions;
/// The report's JSON schema version; fields may be added without raising it.
pub const SCHEMA: u32 = 1;
/// What a strict export found missing.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct StrictReport {
/// (code point, times drawn), sorted by code point.
pub glyphs: Vec<(char, u64)>,
pub pictures: Vec<MissingPicture>,
/// More distinct characters were missing than are listed.
pub glyphs_truncated: bool,
/// More pictures were missing than are listed.
pub pictures_truncated: bool,
/// Distinct characters found missing, those past the list's cap included.
pub total_glyphs: usize,
/// Pictures found missing, those past the list's cap included.
pub total_pictures: u64,
}
impl StrictReport {
/// Nothing is missing: the strict export passes.
pub fn passed(&self) -> bool {
self.glyphs.is_empty() && self.pictures.is_empty() && !self.truncated()
}
/// Either list was cut at its cap.
pub fn truncated(&self) -> bool {
self.glyphs_truncated || self.pictures_truncated
}
/// The schema-1 JSON of the report.
pub fn to_json(&self) -> Value {
json!({
"schema": SCHEMA,
"strict": if self.passed() { "passed" } else { "failed" },
"missing_glyphs": self.glyphs.iter().map(|(c, n)| json!({"cp": code_point(*c), "count": n})).collect::<Vec<_>>(),
"missing_pictures": self.pictures.iter().map(|p| json!({"part": p.part, "source_part": p.story, "reason": p.reason.code()})).collect::<Vec<_>>(),
"truncated": self.truncated(),
"total_missing_glyphs": self.total_glyphs,
"total_missing_pictures": self.total_pictures,
"glyphs_truncated": self.glyphs_truncated,
"pictures_truncated": self.pictures_truncated,
})
}
/// The human lines (without the `strict: ` prefix): one for the characters, one for the
/// pictures; none when nothing is missing.
fn parts(&self) -> Vec<String> {
let glyphs: Vec<(String, u64)> = self.glyphs.iter().map(|(c, n)| (code_point(*c), *n)).collect();
let pictures: Vec<(String, String, String)> =
self.pictures.iter().map(|p| (p.part.clone(), story_label(&p.story).to_string(), p.reason.label().to_string())).collect();
human_parts(&glyphs, &pictures, (self.glyphs_truncated, self.pictures_truncated), (self.total_glyphs as u64, self.total_pictures))
}
/// The human lines, each `strict: …`.
pub fn lines(&self) -> Vec<String> {
self.parts().into_iter().map(|p| format!("strict: {p}")).collect()
}
/// The refusal's message: `strict: …`, the lines joined.
pub fn refusal(&self) -> String {
format!("strict: {}", self.parts().join("; "))
}
}
/// The human text of a report: (code point text, count) and (part, story label, reason words).
fn human_parts(glyphs: &[(String, u64)], pictures: &[(String, String, String)], cut: (bool, bool), totals: (u64, u64)) -> Vec<String> {
let (glyphs_cut, pictures_cut) = cut;
let mut out = Vec::new();
if !glyphs.is_empty() {
let list: Vec<String> = glyphs.iter().map(|(cp, n)| format!("{cp} (×{n})")).collect();
let more = if glyphs_cut { ", …" } else { "" };
let listed = glyphs.len() as u64;
let n = if glyphs_cut { totals.0.max(listed) } else { listed };
let first = if n > listed { format!(" (first {listed} listed)") } else { String::new() };
out.push(format!("{n} character{} cannot be drawn{first}: {}{more}", if n == 1 { "" } else { "s" }, list.join(", ")));
}
if !pictures.is_empty() {
let list: Vec<String> = pictures
.iter()
.map(|(part, at, reason)| if at.is_empty() { format!("{part}: {reason}") } else { format!("{part} ({at}): {reason}") })
.collect();
let more = if pictures_cut { "; …" } else { "" };
let listed = pictures.len() as u64;
let n = if pictures_cut { totals.1.max(listed) } else { listed };
let first = if n > listed { format!(" (first {listed} listed)") } else { String::new() };
out.push(format!("{n} picture(s) left out{first}: {}{more}", list.join("; ")));
}
out
}
/// The `strict: …` human lines of a report in its JSON form (what `file.strictReport` returns).
/// Anything the JSON lacks is left out.
pub fn lines_from_json(report: &Value) -> Vec<String> {
let text = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string();
let glyphs: Vec<(String, u64)> = report
.get("missing_glyphs")
.and_then(Value::as_array)
.map(|a| a.iter().map(|g| (text(g, "cp"), g.get("count").and_then(Value::as_u64).unwrap_or(0))).collect())
.unwrap_or_default();
let pictures: Vec<(String, String, String)> = report
.get("missing_pictures")
.and_then(Value::as_array)
.map(|a| a.iter().map(|p| (text(p, "part"), story_label(&text(p, "source_part")).to_string(), reason_label(&text(p, "reason")))).collect())
.unwrap_or_default();
let flag = |k: &str| report.get(k).and_then(Value::as_bool).unwrap_or(false);
// A report without the per-list flags (the top-level one alone) marks both lists.
let (glyphs_cut, pictures_cut) = match (report.get("glyphs_truncated"), report.get("pictures_truncated")) {
(None, None) => (flag("truncated"), flag("truncated")),
_ => (flag("glyphs_truncated"), flag("pictures_truncated")),
};
let total = |k: &str, listed: usize| report.get(k).and_then(Value::as_u64).unwrap_or(listed as u64);
let totals = (total("total_missing_glyphs", glyphs.len()), total("total_missing_pictures", pictures.len()));
human_parts(&glyphs, &pictures, (glyphs_cut, pictures_cut), totals).into_iter().map(|p| format!("strict: {p}")).collect()
}
/// The words for a JSON reason code; a code this version does not know is shown as it is.
fn reason_label(code: &str) -> String {
wordcraft_doc::MissingPictureReason::from_code(code).map_or_else(|| code.to_string(), |r| r.label().to_string())
}
/// `U+2D30`: upper-case hex, at least four digits.
fn code_point(c: char) -> String {
format!("U+{:04X}", c as u32)
}
/// The part a picture is in, as the report names it: `header2.xml` for `word/header2.xml`.
fn story_label(story: &str) -> &str {
story.strip_prefix("word/").unwrap_or(story)
}
/// The report of `doc` as drawn by `lay` with `display` into `output`, on `pages` (0-based; `None`
/// is every page). The watermark is checked with the page, and the pictures dropped when the
/// document was opened are added to those that can't be decoded now, capped like each.
pub fn strict_report(
doc: &Document,
lay: &DocLayout,
display: &DisplayOptions,
pages: Option<RangeInclusive<usize>>,
output: StrictOutput,
) -> StrictReport {
let mut missing = lay.missing_glyphs(doc, display, pages.clone());
let any_page = lay.pages.iter().enumerate().any(|(n, _)| pages.as_ref().is_none_or(|r| r.contains(&n)));
if any_page && let Some(wm) = &doc.settings.watermark {
let max = match output {
StrictOutput::Pdf => wordcraft_pdf::WATERMARK_MAX_CHARS,
StrictOutput::Png => usize::MAX,
};
missing.merge(wordcraft_layout::missing_watermark_glyphs(wm, max));
}
let (undecodable, mut pictures_truncated, undecodable_total) = wordcraft_layout::undecodable_pictures(doc, lay, pages, display, output);
pictures_truncated |= doc.missing_pictures.truncated;
// The two lists overlap where the same picture is in both; those are counted once.
let mut overlap: u64 = 0;
let mut pictures: Vec<MissingPicture> = Vec::new();
for p in doc.missing_pictures.list.iter().cloned().chain(undecodable) {
if pictures.contains(&p) {
overlap = overlap.saturating_add(1);
continue;
}
if pictures.len() >= MAX_MISSING_PICTURES {
pictures_truncated = true;
break;
}
pictures.push(p);
}
let total_pictures = doc.missing_pictures.total.saturating_add(undecodable_total).saturating_sub(overlap).max(pictures.len() as u64);
StrictReport {
glyphs: missing.chars,
pictures,
glyphs_truncated: missing.truncated,
pictures_truncated,
total_glyphs: missing.total,
total_pictures,
}
}
/// The PDF export's options for `opts`: the one place they are built, shared by the export and
/// its strict check.
pub(crate) fn pdf_options(opts: &ExportOptions) -> wordcraft_pdf::PdfOptions {
wordcraft_pdf::PdfOptions { include_markup: opts.markup, comments: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() }
}
/// What a page image shows: the display options `file.exportPng` renders with.
pub(crate) fn png_display() -> DisplayOptions {
// A page image is the printed page: headers and footers at full strength.
let mut opts = wordcraft_render::RenderOptions::default();
opts.display.dim_header = false;
opts.display
}
/// The strict report of a PDF export of `doc`: every page, laid out as the export lays it out.
pub(crate) fn pdf_report(doc: &Document, opts: &ExportOptions) -> StrictReport {
let pdf = pdf_options(opts);
let lay = wordcraft_layout::layout(doc, &mut LayoutCache::new(), &pdf.layout_options());
strict_report(doc, &lay, &pdf.display_options(), None, StrictOutput::Pdf)
}
/// The PDF of `doc`; with `opts.strict`, only if nothing is missing, laid out once for the check
/// and the export.
pub(crate) fn export_pdf(doc: &Document, opts: &ExportOptions) -> Result<Vec<u8>, String> {
let pdf = pdf_options(opts);
if !opts.strict {
return wordcraft_pdf::export(doc, &pdf).map_err(|e| e.to_string());
}
let lay = wordcraft_layout::layout(doc, &mut LayoutCache::new(), &pdf.layout_options());
let report = strict_report(doc, &lay, &pdf.display_options(), None, StrictOutput::Pdf);
if !report.passed() {
return Err(report.refusal());
}
wordcraft_pdf::export_layout(doc, &lay, &pdf).map_err(|e| e.to_string())
}
/// Is `path` a PDF or PNG output (the only outputs a strict export applies to)?
pub(crate) fn is_strict_output(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
lower.ends_with(".pdf") || lower.ends_with(".png")
}
+224 -1
View File
@@ -16,6 +16,7 @@ mod find_apostrophes;
mod font_sizes;
mod fonts;
mod ime_noop;
mod para_tools;
mod revision_checks;
mod typing;
mod wordart;
@@ -4682,7 +4683,7 @@ fn typed_paragraphs_are_capped_for_every_caller() {
assert!(!body_texts(&s).iter().any(|p| p.contains('y')));
}
fn hostile_junk() -> [serde_json::Value; 8] {
fn hostile_junk() -> [serde_json::Value; 10] {
[
json!(null),
json!({}),
@@ -4692,6 +4693,9 @@ fn hostile_junk() -> [serde_json::Value; 8] {
json!({"page": 0, "x": 1e30, "y": -1e30, "extend": 1}),
json!({"width": 1e-9, "height": 1e30, "top": -1e9, "left": 1e9}),
json!({"section": {"pageW": -1e308, "pageH": 0, "marginTop": 1e30, "header": -5, "columns": {"count": 1000000, "space": -1e300, "widths": [[0, 0]]}}}),
// Strict export (#6005): the flag, and file.strictReport's `page`, with junk.
json!({"strict": true, "page": 0, "scale": 0, "markup": true}),
json!({"strict": "x", "page": 1e30, "scale": 1e-9, "markup": "x"}),
]
}
@@ -4788,6 +4792,9 @@ fn hostile_file_params_never_panic() {
json!({"path": path, "password": "x", "format": "wordml2003", "markup": "x", "confirmDamaged": 1}),
json!({"path": path, "password": 5, "format": 5, "markup": true}),
json!({"path": path, "page": 0, "scale": 0, "width": -1e308, "alt": 5}),
json!({"path": path, "strict": true, "page": 1, "scale": 0.1}),
json!({"path": path, "strict": true, "page": 1e30, "markup": true, "format": "flatopc"}),
json!({"path": path, "strict": 5, "page": -1}),
json!({"path": path, "data": "not base64!"}),
json!({"path": path, "data": "AAAA"}),
json!({"path": path, "pagesPerSheet": 7, "range": "x", "pages": "9-1,,", "parity": "x", "copies": 3, "paper": "x"}),
@@ -5217,6 +5224,42 @@ fn arrange_bounds_resizes_and_moves_objects() {
assert_eq!(box_area(&mut s).rect, n);
}
/// A frame anchored to a page and moved in WordCraft is placed by its paragraph from then on: the
/// file's page number goes, or the save would put it back. Only resized, it stays on that page.
#[test]
fn an_odt_frame_moved_from_its_page_loses_the_page_number() {
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
let content = format!(
r#"<office:document-content {ns}><office:body><office:text><draw:frame text:anchor-type="page" text:anchor-page-number="3" svg:x="5cm" svg:y="5cm" svg:width="4cm" svg:height="2cm"><draw:text-box><text:p>On it</text:p></draw:text-box></draw:frame><text:p>One</text:p><text:p>Two</text:p></office:text></office:body></office:document-content>"#
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str())] {
z.start_file(name, o).unwrap();
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
}
let odt = z.finish().unwrap().into_inner();
let frame_tag = |s: &Session| {
let mut a = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&s.doc).unwrap())).unwrap();
let mut c = String::new();
std::io::Read::read_to_string(&mut a.by_name("content.xml").unwrap(), &mut c).unwrap();
c.split("<draw:frame").nth(1).and_then(|t| t.split('>').next()).unwrap_or_default().to_string()
};
let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap());
select_box(&mut s);
run(&mut s, "arrange.bounds", json!({"width": 150, "height": 80}));
assert!(frame_tag(&s).contains(r#"text:anchor-page-number="3""#), "resized: {}", frame_tag(&s));
let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap());
select_box(&mut s);
run(&mut s, "arrange.bounds", json!({"page": 0, "x": 200, "y": 300}));
let tag = frame_tag(&s);
assert!(!tag.contains("anchor-page-number") && !tag.contains(r#"text:anchor-type="page""#), "moved: {tag}");
// Moved, then placed by the margins: the file's page number is gone, not only hidden.
run(&mut s, "arrange.position", json!({"preset": "topLeft"}));
let tag = frame_tag(&s);
assert!(!tag.contains("anchor-page-number"), "moved, then placed: {tag}");
}
/// An object dropped near the bottom of a page lands there, though at the top of the paragraph
/// it anchors to it would push that paragraph onto the next page (#3169).
#[test]
@@ -10788,6 +10831,44 @@ fn insert_picture_takes_a_windows_metafile() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn window_commands_need_the_desktop_and_name_a_window() {
use crate::cmd::view::OtherWindow;
let mut s = s();
// Web, CLI, MCP: one window, so View › Window is disabled.
for id in ["view.arrangeAll", "view.sideBySide", "view.syncScroll"] {
assert!(s.run(id, &json!({})).is_err(), "{id} without a window host");
}
s.windows.available = true;
run(&mut s, "view.arrangeAll", json!({}));
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "arrange"})));
assert!(s.run("view.sideBySide", &json!({})).is_err(), "no other window");
s.windows.others = vec![OtherWindow { id: 7, title: "Notes".into() }, OtherWindow { id: 9, title: "Draft".into() }];
let e = s.run("view.sideBySide", &json!({})).unwrap_err().to_string();
assert!(e.contains("7 (Notes)") && e.contains("9 (Draft)"), "several windows: say which: {e}");
assert!(s.run("view.sideBySide", &json!({"window": 8})).is_err(), "an unknown window");
assert!(s.run("view.sideBySide", &json!({"window": "9"})).is_err());
// Side by Side pairs with one window and scrolls together until turned off.
assert!(s.run("view.syncScroll", &json!({})).is_err(), "only while Side by Side is on");
let r = run(&mut s, "view.sideBySide", json!({"window": 7}));
assert_eq!((r["value"].as_bool(), s.windows.side_by_side, s.windows.sync_scroll), (Some(true), Some(7), true));
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "with": 7})));
run(&mut s, "view.syncScroll", json!({}));
assert!(!s.windows.sync_scroll);
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "syncScroll", "with": 7, "value": false})));
// Asking again for the partner it already has keeps scrolling off and pairs nothing anew.
let r = run(&mut s, "view.sideBySide", json!({"window": 7, "value": true}));
assert_eq!((r["syncScroll"].as_bool(), s.windows.sync_scroll), (Some(false), false));
assert!(s.ui_requests.is_empty());
// Another partner replaces the first; the bare button turns it off.
run(&mut s, "view.sideBySide", json!({"window": 9}));
assert_eq!(std::mem::take(&mut s.ui_requests), [json!({"windows": "sideBySide", "off": 7}), json!({"windows": "sideBySide", "with": 9})]);
run(&mut s, "view.sideBySide", json!({}));
assert_eq!((s.windows.side_by_side, s.windows.sync_scroll), (None, false));
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "off": 9})));
assert!(!s.dirty, "window commands never touch the document");
}
/// Change Picture must keep the replacement's own format: the part's name and content type
/// come from the extension, so a JPEG stored as `imageN.png` saves declared `image/png` (Word
/// and LibreOffice then show a broken picture).
@@ -12568,6 +12649,40 @@ fn export_png_rejects_a_page_number_that_does_not_fit() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn screen_paper_color_is_a_persistent_preference_not_document_formatting() {
use crate::cmd::view::PaperColor;
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Screen paper test", "raw": true}));
let before = serde_json::to_value(&s.doc).unwrap();
let docx_before = wordcraft_docx::write(&s.doc).unwrap();
s.dirty = false;
let dirty = s.dirty;
s.view.dark_mode = true;
for (value, expected) in
[("green", PaperColor::Green), ("cream", PaperColor::Cream), ("gray", PaperColor::Gray), ("document", PaperColor::Document)]
{
run(&mut s, "view.paperColor", json!({"value": value}));
assert_eq!(s.prefs.paper_color, expected);
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
assert_eq!(s.dirty, dirty);
assert_eq!(wordcraft_docx::write(&s.doc).unwrap(), docx_before);
assert!(!s.view.dark_mode, "choosing a tint turns off dark paper");
let saved = serde_json::to_value(&s.prefs).unwrap();
let restored: crate::Prefs = serde_json::from_value(saved).unwrap();
assert_eq!(restored.paper_color, expected);
}
for v in [json!({}), json!({"value": "red"}), json!({"value": 42})] {
assert!(s.run("view.paperColor", &v).is_err());
assert_eq!(s.prefs.paper_color, PaperColor::Document);
}
let legacy: crate::Prefs = serde_json::from_value(json!({})).unwrap();
assert_eq!(legacy.paper_color, PaperColor::Document);
let future: crate::Prefs = serde_json::from_value(json!({"paperColor":"future", "countNotes":false})).unwrap();
assert_eq!(future.paper_color, PaperColor::Document);
assert!(!future.count_notes);
}
/// Font › Emphasis mark puts a mark over (or under) each selected character, and the layout
/// draws one per character; Format state reports it, and none takes it off.
#[test]
@@ -12615,6 +12730,73 @@ fn an_odt_charts_paragraph_is_written_as_read_after_drawing_it() {
assert!(content.contains(r#"draw:name="Chart1""#) && !content.contains("wcChart"), "{content}");
}
/// A picture an OpenDocument file makes wider than the text (a rule across the whole page) stays
/// that wide, and its paragraph is written as read: opening a file changes nothing in it.
#[test]
fn an_odts_picture_wider_than_the_text_keeps_its_size() {
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#;
let styles = format!(
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-left="3cm" fo:margin-right="3cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {ns}><office:body><office:text><text:p><draw:frame draw:name="Rule" text:anchor-type="paragraph" svg:x="-1cm" svg:width="18cm" svg:height="0.2cm"><draw:image xlink:href="Pictures/r.png"/></draw:frame>Text</text:p></office:text></office:body></office:document-content>"#
);
let png = [b"\x89PNG\r\n\x1a\n".as_slice(), &[7u8; 64]].concat();
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in [
("mimetype", b"application/vnd.oasis.opendocument.text".as_slice()),
("content.xml", content.as_bytes()),
("styles.xml", styles.as_bytes()),
("Pictures/r.png", &png),
] {
z.start_file(name, o).unwrap();
std::io::Write::write_all(&mut z, data).unwrap();
}
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
let width = doc.body[0]
.as_para()
.unwrap()
.objects
.iter()
.find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, .. } = o { Some(*w) } else { None });
let want = 18.0 * 72.0 / 2.54;
assert!(width.is_some_and(|w| (w - want).abs() < 0.1), "{width:?}, not fitted to the text");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&doc).unwrap())).unwrap();
let mut saved = String::new();
std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut saved).unwrap();
assert!(saved.contains(r#"draw:name="Rule""#) && saved.contains(r#"svg:width="18cm""#), "written as read: {saved}");
}
/// Numbering or a new style makes the styles be written from the model on save: a document in
/// German stays in German, rather than taking the default language of what opens it next.
#[test]
fn an_odts_language_survives_a_save_after_numbering_or_a_new_style() {
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
let styles = format!(
r#"<office:document-styles {ns}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:language="de" fo:country="DE"/></style:default-style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {ns}><office:body><office:text><text:p>Hallo</text:p></office:text></office:body></office:document-content>"#
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str()), ("styles.xml", styles.as_str())]
{
z.start_file(name, o).unwrap();
std::io::Write::write_all(&mut z, data.as_bytes()).unwrap();
}
let odt = z.finish().unwrap().into_inner();
for (command, params) in [("para.numbering", json!({})), ("styles.create", json!({"name": "Notiz"}))] {
let mut s = Session::new(crate::io::open_bytes("t.odt", &odt).unwrap());
assert_eq!(cmd::default_lang(&s).as_deref(), Some("de-DE"), "the document's, not English");
run(&mut s, command, params);
let saved = wordcraft_formats::odt::export(&s.doc).unwrap();
let back = crate::io::open_bytes("t.odt", &saved).unwrap();
assert_eq!(back.styles.default_chr.lang.as_deref(), Some("de-DE"), "after {command}");
}
}
/// A Word package whose body holds `body` and, as `rIdC`, the chunk part `word/afchunk.{ext}`.
#[cfg(not(target_arch = "wasm32"))]
fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> Vec<u8> {
@@ -12859,6 +13041,47 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
}
/// A picture an OpenDocument file gives no size is shown at its own, fitted to the text as other
/// formats' are; one with a stated size keeps it, in line too.
#[test]
fn an_odts_picture_is_fitted_only_when_it_states_no_size() {
let ns = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#;
let styles = format!(
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
);
let mut png = std::io::Cursor::new(Vec::new());
image::RgbImage::new(1000, 500).write_to(&mut png, image::ImageFormat::Png).unwrap();
let png = png.into_inner();
let open = |attrs: &str| {
let content = format!(
r#"<office:document-content {ns}><office:body><office:text><text:p><draw:frame text:anchor-type="as-char"{attrs}><draw:image xlink:href="Pictures/a.png"/></draw:frame></text:p></office:text></office:body></office:document-content>"#
);
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
for (name, data) in [
("mimetype", b"application/vnd.oasis.opendocument.text".as_slice()),
("content.xml", content.as_bytes()),
("styles.xml", styles.as_bytes()),
("Pictures/a.png", &png),
] {
z.start_file(name, o).unwrap();
std::io::Write::write_all(&mut z, data).unwrap();
}
let doc = crate::io::open_bytes("t.odt", &z.finish().unwrap().into_inner()).unwrap();
doc.body[0]
.as_para()
.unwrap()
.objects
.iter()
.find_map(|o| if let wordcraft_doc::para::InlineObject::Image { w, h, .. } = o { Some((*w, *h)) } else { None })
.unwrap()
};
let (w, h) = open("");
assert!((w - 306.14).abs() < 0.5 && (h - 153.07).abs() < 0.5, "fitted to the text: {w} {h}");
let (w, _) = open(r#" svg:width="18cm" svg:height="9cm""#);
assert!((w - 18.0 * 72.0 / 2.54).abs() < 0.5, "as the file says: {w}");
}
/// Issue #5667's document: `Intro`, then an inserted `Appendix` paragraph whose mark carries a
/// section break (Normal, indented), then `Body` in Heading 1. The whole of `Appendix` is
/// tracked as an insertion by `rev`, or as a deletion when `delete`.
+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 {
+484
View File
@@ -0,0 +1,484 @@
//! Advanced paragraph tools (#5043): conversion and cleanup over the selection or the whole
//! document, one undo step, Track Changes respected.
use serde_json::json;
use wordcraft_doc::{InlineObject, Path, Pos, RevisionKind, SectionProps, StoryRef};
use crate::Session;
fn s() -> Session {
Session::new(wordcraft_doc::Document::new())
}
fn run(s: &mut Session, id: &str, v: serde_json::Value) {
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"));
}
fn text(s: &Session) -> String {
s.doc.plain_text(StoryRef::Body)
}
/// Select the whole document (Mod+A's command).
fn select_all(s: &mut Session) {
run(s, "select.all", json!({}));
}
/// One paragraph with `\n` as manual line breaks, typed as text.
fn type_breaks(s: &mut Session, t: &str) {
run(s, "text.insert", json!({"text": t}));
}
#[test]
fn line_breaks_become_paragraphs() {
let mut s = s();
type_breaks(&mut s, "one\ntwo\nthree");
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 1);
select_all(&mut s);
run(&mut s, "para.convertBreaks", json!({"scope": "all"}));
assert_eq!(text(&s), "one\ntwo\nthree", "the breaks are paragraph marks, not text");
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 3);
// One undo step brings the manual line breaks back.
assert!(s.undo());
let p0 = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!(p0.text, "one\ntwo\nthree", "back to one paragraph with its breaks");
}
#[test]
fn blank_paragraphs_go_in_between() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
select_all(&mut s);
run(&mut s, "para.blankBetween", json!({"scope": "all"}));
assert_eq!(text(&s), "a\n\nb\n\nc");
}
#[test]
fn blank_paragraphs_go_after_too() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
select_all(&mut s);
run(&mut s, "para.blankBetween", json!({"scope": "all", "after": true}));
assert_eq!(text(&s), "a\n\nb\n\nc\n");
}
#[test]
fn separate_paragraphs_blanks_after_every_one() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\nb\nc"}));
select_all(&mut s);
run(&mut s, "para.separateParagraphs", json!({"scope": "all"}));
assert_eq!(text(&s), "a\n\nb\n\nc\n");
}
#[test]
fn blank_paragraphs_are_deleted() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\n\nb\n\n\nc"}));
select_all(&mut s);
run(&mut s, "para.removeBlank", json!({"scope": "all"}));
assert_eq!(text(&s), "a\nb\nc");
}
#[test]
fn a_section_break_paragraph_stays() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a\nb"}));
// The second paragraph carries a section break and is emptied: removing it would drop the
// section, so it stays.
let second = Path::top(1);
let p = s.doc.para_mut(StoryRef::Body, &second).unwrap();
p.text.clear();
p.runs.clear();
p.section = Some(Box::new(SectionProps::default()));
p.touch();
select_all(&mut s);
run(&mut s, "para.removeBlank", json!({"scope": "all"}));
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "the section-break paragraph stays");
}
#[test]
fn leading_spaces_are_removed() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": " a\nb\n c"}));
select_all(&mut s);
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), "a\nb\nc");
}
#[test]
fn extra_spaces_collapse_to_one() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b c d \n e"}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), "a b c d \n e", "inner runs collapse; the leading spaces of the second paragraph stay");
}
#[test]
fn a_space_run_at_the_end_collapses() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a "}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), "a ");
}
#[test]
fn extra_spaces_collapse_without_removing_tabs_or_page_breaks() {
use wordcraft_doc::para::PAGE_BREAK;
let mut s = s();
run(&mut s, "document.setText", json!({"text": format!("a \t b {PAGE_BREAK} c\t ")}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), format!("a \t b {PAGE_BREAK} c\t "), "only repeated spaces collapse");
}
#[test]
fn indented_spaces_become_an_indent() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": " a\nb"}));
select_all(&mut s);
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(text(&s), "a\nb");
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!(p.props.indent_first, Some(216.0), "three spaces are 3 x 72 pt");
assert_eq!(s.doc.para(StoryRef::Body, &Path::top(1)).unwrap().props.indent_first, None, "b has no spaces");
}
#[test]
fn spaces_to_indent_keeps_spaces_the_indent_limit_cannot_represent() {
let mut many = s();
run(&mut many, "document.setText", json!({"text": format!("{}a", " ".repeat(24))}));
select_all(&mut many);
run(&mut many, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(text(&many), " a", "22 spaces fit in the 1584 pt maximum; two stay as text");
assert_eq!(many.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1584.0));
let mut nearly_full = s();
run(&mut nearly_full, "document.setText", json!({"text": " a"}));
run(&mut nearly_full, "para.indents", json!({"firstLine": 1548.0}));
select_all(&mut nearly_full);
run(&mut nearly_full, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(text(&nearly_full), " a", "less than 72 pt of room cannot replace one space");
assert_eq!(nearly_full.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(1548.0));
}
#[test]
fn indents_become_leading_spaces() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a"}));
run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": 144.0}));
select_all(&mut s);
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), " a", "72 + 144 pt become three spaces");
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0)));
}
#[test]
fn a_hanging_indent_that_sums_to_zero_is_still_removed() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a"}));
run(&mut s, "para.indents", json!({"left": 72.0, "firstLine": -72.0}));
select_all(&mut s);
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
assert_eq!(text(&s), "a", "the cancelling indents need no leading spaces");
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.props.indent_left, p.props.indent_first), (Some(0.0), Some(0.0)));
let mut tracked = Session::new(wordcraft_doc::Document::new());
run(&mut tracked, "document.setText", json!({"text": "a"}));
run(&mut tracked, "para.indents", json!({"left": 72.0, "firstLine": -72.0}));
run(&mut tracked, "review.trackChanges", json!({"value": true}));
select_all(&mut tracked);
run(&mut tracked, "para.indentToSpaces", json!({"scope": "all"}));
assert!(!tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Insert), "no inserted text means no insert revision");
assert!(tracked.doc.revisions.iter().any(|r| r.kind == RevisionKind::Format), "the removed hanging indent is tracked as formatting");
}
#[test]
fn cleanup_keeps_fields_footnote_anchors_and_mixed_formatting() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "aa bb "}));
s.sel = crate::Selection { anchor: Pos::body(0, 4), focus: Pos::body(0, 6) };
run(&mut s, "format.bold", json!({"value": true}));
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
run(&mut s, "insert.field", json!({"instr": "AUTHOR", "result": "Ada"}));
let note = s.run("references.footnote", &json!({"text": "keep this note"})).unwrap()["id"].as_u64().unwrap() as u32;
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
run(&mut s, "text.insert", json!({"text": " tail"}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
let p = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.props_of_char(3).bold, p.props_of_char(4).bold), (Some(true), Some(true)), "the bold run moved but kept its formatting");
assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::Field { .. }))), "the field stays");
assert!(p.object_offsets().iter().any(|off| matches!(p.object_at(*off), Some(InlineObject::NoteRef { .. }))), "the footnote anchor stays");
assert!(s.doc.parts.contains_key(&note), "the anchored footnote story stays");
}
#[test]
fn cleanup_inside_a_table_keeps_the_table_and_its_cell() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
run(&mut s, "text.insert", json!({"text": "a b"}));
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays");
let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell paragraph stays");
assert_eq!(cell.text, "a b");
}
#[test]
fn whole_document_scope_from_a_table_cell_reaches_the_body() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "body text"}));
s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body));
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
let table_index = s.doc.body.iter().position(|block| block.as_table().is_some()).expect("the table was inserted") as u32;
s.sel = crate::Selection::caret(Pos { story: StoryRef::Body, path: Path(vec![table_index, 0, 0, 0]), off: 0 });
run(&mut s, "text.insert", json!({"text": "cell text"}));
assert!(s.sel.focus.path.depth() > 0, "the caret is inside the table");
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert!(text(&s).contains("body text"), "body text outside the table was cleaned too: {}", text(&s));
let table = s.doc.body.iter().find_map(|block| block.as_table()).expect("the table stays");
let cell = table.rows[0].cells[0].blocks.iter().find_map(|block| block.as_para()).expect("the cell stays");
assert_eq!(cell.text, "cell text");
}
#[test]
fn paragraph_tools_refuse_content_locked_controls() {
for (text, command) in [("a b", "para.removeExtraSpaces"), ("a\nb", "para.convertBreaks")] {
let mut s = s();
type_breaks(&mut s, text);
select_all(&mut s);
run(&mut s, "developer.richText", json!({"tag": "locked"}));
run(&mut s, "developer.properties", json!({"control": "locked", "lockEdit": true}));
select_all(&mut s);
let before = s.doc.clone();
let error = s.run(command, &json!({"scope": "all"})).unwrap_err();
assert!(error.to_string().contains("can't be edited"), "{command}: {error}");
assert_eq!(s.doc, before, "{command} changed locked content");
}
}
#[test]
fn line_breaks_are_removed() {
let mut s = s();
type_breaks(&mut s, "one\ntwo\nthree");
select_all(&mut s);
run(&mut s, "para.removeLineBreaks", json!({"scope": "all"}));
assert_eq!(text(&s), "onetwothree");
}
#[test]
fn the_selection_is_the_scope() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b\nc d"}));
// Only the first paragraph: select from its start to its end.
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 4) };
run(&mut s, "para.removeExtraSpaces", json!({}));
assert_eq!(text(&s), "a b\nc d", "the second paragraph is untouched");
}
#[test]
fn structural_tools_do_not_cross_the_selection() {
let mut breaks = s();
type_breaks(&mut breaks, "one\ntwo");
run(&mut breaks, "text.newParagraph", json!({}));
run(&mut breaks, "text.insert", json!({"text": "outside"}));
breaks.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 7) };
run(&mut breaks, "para.convertBreaks", json!({}));
let last = breaks.doc.para_paths(StoryRef::Body).last().cloned().unwrap();
assert_eq!(breaks.doc.para(StoryRef::Body, &last).unwrap().text, "outside");
let mut between = s();
run(&mut between, "document.setText", json!({"text": "a\nb\nc"}));
between.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) };
run(&mut between, "para.blankBetween", json!({}));
assert_eq!(text(&between), "a\n\nb\nc", "only a and b get a blank between them");
let mut separate = s();
run(&mut separate, "document.setText", json!({"text": "a\nb\nc"}));
separate.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(1, 1) };
run(&mut separate, "para.separateParagraphs", json!({}));
assert_eq!(text(&separate), "a\n\nb\n\nc", "the blank after b stays before the unselected c");
let mut blank = s();
run(&mut blank, "document.setText", json!({"text": "a\n\nb\n\nc"}));
blank.sel = crate::Selection::caret(Pos::body(1, 0));
run(&mut blank, "para.removeBlank", json!({}));
assert_eq!(text(&blank), "a\nb\n\nc", "the other blank paragraph is outside the selection");
}
#[test]
fn indent_space_conversions_are_reversible_track_changes() {
let mut to_indent = s();
run(&mut to_indent, "document.setText", json!({"text": " a"}));
run(&mut to_indent, "review.trackChanges", json!({"value": true}));
select_all(&mut to_indent);
run(&mut to_indent, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props.indent_first, Some(144.0));
run(&mut to_indent, "review.rejectAll", json!({}));
let p = to_indent.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.text.as_str(), p.props.indent_first), (" a", None));
let mut to_spaces = s();
run(&mut to_spaces, "document.setText", json!({"text": "a"}));
run(&mut to_spaces, "para.indents", json!({"firstLine": 144.0}));
run(&mut to_spaces, "review.trackChanges", json!({"value": true}));
select_all(&mut to_spaces);
run(&mut to_spaces, "para.indentToSpaces", json!({"scope": "all"}));
run(&mut to_spaces, "review.rejectAll", json!({}));
let p = to_spaces.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.text.as_str(), p.props.indent_first), ("a", Some(144.0)));
let mut accepted = s();
run(&mut accepted, "document.setText", json!({"text": "a"}));
run(&mut accepted, "para.indents", json!({"firstLine": 144.0}));
run(&mut accepted, "review.trackChanges", json!({"value": true}));
select_all(&mut accepted);
run(&mut accepted, "para.indentToSpaces", json!({"scope": "all"}));
run(&mut accepted, "review.acceptAll", json!({}));
let p = accepted.doc.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!((p.text.as_str(), p.props.indent_first), (" a", Some(0.0)));
}
#[test]
fn indent_to_spaces_does_not_inherit_revision_marks() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) };
run(&mut s, "format.bold", json!({"value": true}));
run(&mut s, "text.delete", json!({}));
run(&mut s, "review.trackChanges", json!({"value": false}));
run(&mut s, "para.indents", json!({"firstLine": 72.0}));
select_all(&mut s);
run(&mut s, "para.indentToSpaces", json!({"scope": "all"}));
let space = s.doc.para(StoryRef::Body, &Path::top(0)).unwrap().props_of_char(0);
assert_eq!((space.ins, space.del, space.fmt_change.as_ref().map(|_| ())), (None, None, None));
}
#[test]
fn converting_too_many_line_breaks_is_atomic() {
let original = format!("{}end", "x\n".repeat(10_001));
let mut s = s();
type_breaks(&mut s, &original);
select_all(&mut s);
let before = s.doc.clone();
let error = s.run("para.convertBreaks", &json!({"scope": "all"})).unwrap_err();
assert!(error.to_string().contains("at most"), "{error}");
assert_eq!(s.doc, before, "the refused conversion is all-or-nothing");
}
#[test]
fn repeating_a_tracked_deletion_creates_no_empty_revision() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": " a"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
select_all(&mut s);
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
let revisions = s.doc.revisions.len();
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
assert_eq!(s.doc.revisions.len(), revisions, "already-deleted spaces create no revision");
}
#[test]
fn deletions_are_recorded_under_track_changes() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b\n c"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
select_all(&mut s);
run(&mut s, "para.trimLeadingSpaces", json!({"scope": "all"}));
// The text stays marked deleted, not gone, as Word records the deletion.
assert_eq!(text(&s), "a b\n c", "tracked deletions keep their text");
assert!(s.doc.revisions.iter().any(|r| r.kind == RevisionKind::Delete));
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(text(&s), "a b\nc", "accepting removes the leading space");
}
/// Tracked-delete the bytes `a..b` of the first body paragraph (Track Changes must be on).
fn track_delete_in_first(s: &mut Session, a: usize, b: usize) {
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
let at = |off: usize| Pos { story: StoryRef::Body, path: path.clone(), off };
crate::cmd::track_delete(s, &at(a), &at(b)).expect("tracked delete");
}
fn first_indent(s: &Session) -> Option<f32> {
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
s.doc.para(StoryRef::Body, &path).expect("paragraph").props.indent_first
}
fn first_deleted(s: &Session, off: usize) -> bool {
let path = s.doc.para_paths(StoryRef::Body)[0].clone();
s.doc.para(StoryRef::Body, &path).expect("paragraph").props_of_char(off).del.is_some()
}
#[test]
fn remove_extra_spaces_skips_tracked_deleted_spaces() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
track_delete_in_first(&mut s, 2, 3); // the second space is already deleted
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
run(&mut s, "review.acceptAll", json!({}));
assert_eq!(text(&s), "a b", "the one live space survives");
// Three spaces, the middle one already deleted: of the two live ones the last is kept.
let mut s = self::s();
run(&mut s, "document.setText", json!({"text": "a b"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
track_delete_in_first(&mut s, 2, 3);
select_all(&mut s);
run(&mut s, "para.removeExtraSpaces", json!({"scope": "all"}));
assert!(first_deleted(&s, 1) && first_deleted(&s, 2), "the first and the already deleted space are deleted");
assert!(!first_deleted(&s, 3), "the last live space stays");
}
#[test]
fn spaces_to_indent_twice_does_not_count_deleted_spaces_again() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": " x"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
select_all(&mut s);
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(first_indent(&s), Some(144.0));
run(&mut s, "para.spacesToIndent", json!({"scope": "all"}));
assert_eq!(first_indent(&s), Some(144.0), "the deleted spaces are not indented a second time");
}
#[test]
fn convert_breaks_leaves_a_tracked_deleted_break_alone() {
let mut s = s();
type_breaks(&mut s, "a\nb\nc");
run(&mut s, "review.trackChanges", json!({"value": true}));
track_delete_in_first(&mut s, 1, 2);
select_all(&mut s);
run(&mut s, "para.convertBreaks", json!({"scope": "all"}));
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2, "only the live break becomes a paragraph break");
}
#[test]
fn junk_scope_is_refused() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "a b"}));
select_all(&mut s);
let e = s.run("para.removeExtraSpaces", &json!({"scope": "junk"})).unwrap_err();
assert!(format!("{e}").contains("scope"), "{e}");
assert_eq!(text(&s), "a b", "the document is unchanged");
}
#[test]
fn too_many_paragraphs_are_refused_before_any_change() {
let text = "x\n".repeat(15_000);
let mut s = Session::new(wordcraft_doc::Document::from_text(&text));
select_all(&mut s);
let e = s.run("para.blankBetween", &json!({"scope": "all"})).unwrap_err();
assert!(format!("{e}").contains("at most"), "{e}");
let kept = s.doc.para_paths(StoryRef::Body).len();
assert!((15_000..=15_001).contains(&kept), "nothing changed, {kept}");
}
+82
View File
@@ -3876,6 +3876,88 @@ fn backspace_cannot_join_into_locked_content_control() {
assert_eq!(s.doc, before);
}
// #3693 is an additional editing shortcut, not a claim of Word's default key behaviour.
#[test]
fn delete_previous_sentence_respects_caret_boundaries_and_unicode() {
for (text, before, expected) in [
("First sentence. Second sentence.", "First sentence. Second sentence.", "First sentence. "),
("First sentence. Second sentence.", "First sentence. Sec", "First sentence. ond sentence."),
("First sentence. Second sentence.", "First sentence. ", "Second sentence."),
("Grüße! Überall café.", "Grüße! Überall café.", "Grüße! "),
("No punctuation here", "No punctuation", " here"),
("One. Two.", "", "One. Two."),
("", "", ""),
] {
let mut s = s();
run(&mut s, "text.insert", json!({"text": text, "raw": true}));
let mut pos = s.sel.focus.clone();
pos.off = before.len();
s.sel = crate::Selection::caret(pos);
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected, "{text:?} at {before:?}");
if expected != text {
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, text);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, expected);
}
}
}
#[test]
fn delete_previous_sentence_keeps_paragraph_boundaries_and_deletes_selections() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "First.\nSecond sentence.", "raw": true, "paragraphs": true}));
let end = s.sel.focus.clone();
s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() });
let count = s.doc.body.len();
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.body.len(), count);
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence.");
s.sel = crate::Selection { anchor: Pos { off: 0, ..end.clone() }, focus: Pos { off: 6, ..end } };
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, " sentence.");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Second sentence.");
}
#[test]
fn delete_previous_sentence_tracks_changes_and_respects_read_only() {
for accept in [true, false] {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "One. Two.", "raw": true}));
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "text.deleteSentenceBack", json!({}));
let changes = s.run("review.changes", &json!({})).unwrap();
assert!(changes.as_array().unwrap().iter().any(|c| c["kind"] == "delete"));
run(&mut s, if accept { "review.acceptAll" } else { "review.rejectAll" }, json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, if accept { "One. " } else { "One. Two." });
}
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Keep this.", "raw": true}));
run(&mut s, "review.restrict", json!({"mode": "readOnly"}));
assert!(s.run("text.deleteSentenceBack", &json!({})).is_err());
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "Keep this.");
}
#[test]
fn delete_previous_sentence_does_not_cross_a_table_cell_boundary() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 2}));
run(&mut s, "text.insert", json!({"text": "First cell.", "raw": true}));
run(&mut s, "text.tab", json!({}));
run(&mut s, "text.insert", json!({"text": "Second cell.", "raw": true}));
let end = s.sel.focus.clone();
s.sel = crate::Selection::caret(Pos { off: 0, ..end.clone() });
let before = s.doc.plain_text(StoryRef::Body);
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), before);
s.sel = crate::Selection::caret(end);
run(&mut s, "text.deleteSentenceBack", json!({}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().text, "");
assert!(s.doc.plain_text(StoryRef::Body).contains("First cell."));
}
// English spelling from SCOWL 2026 (CP2): each English reads its own SCOWL dictionary, with
// `en-variants.txt` deciding the other dialect's spellings. Typed text, as a person types it.
+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);
}
}
}
}
+322
View File
@@ -0,0 +1,322 @@
//! Strict export (#6005): `strict: true` on the PDF and PNG exports refuses an output that would
//! silently lack something, before any byte is written; `file.strictReport` says what.
use serde_json::{Value, json};
use wordcraft_doc::Document;
use wordcraft_engine::{CmdError, Session};
use wordcraft_fonts::FontDb;
/// Code points in unassigned plane 4 that no face of the host covers (as the layout tests find them).
fn uncovered_many(n: usize) -> Vec<char> {
let db = FontDb::global();
let primary = wordcraft_fonts::word::resolve(&Document::default().settings.minor_font, false, false).face;
let out: Vec<char> = (0x40000u32..0x4FFF0)
.filter_map(char::from_u32)
.filter(|c| {
!primary.covers(*c) && db.fallback_styled(*c, primary.id(), false, false).is_none() && db.fallback_for(*c, primary.id()).is_none()
})
.take(n)
.collect();
assert_eq!(out.len(), n, "the host has faces covering all of plane 4");
out
}
fn uncovered() -> char {
uncovered_many(1)[0]
}
fn dir(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("wc-strict-export-{name}-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d
}
fn refused(r: Result<Value, CmdError>) -> String {
match r {
Err(CmdError::Failed(m)) => {
assert!(m.starts_with("strict: "), "{m}");
m
}
other => panic!("expected a strict refusal, got {other:?}"),
}
}
#[test]
fn strict_refuses_an_uncovered_character_and_writes_nothing() {
let c = uncovered();
let d = dir("refuse");
let mut s = Session::new(Document::from_text(&format!("a{c}b")));
let pdf = d.join("out.pdf");
let png = d.join("out.png");
let msg = refused(s.run("file.exportPdf", &json!({"path": pdf, "strict": true})));
assert!(msg.contains(&format!("U+{:04X} (×1)", c as u32)), "{msg}");
refused(s.run("file.save", &json!({"path": pdf, "strict": true})));
refused(s.run("file.saveAs", &json!({"path": pdf, "strict": true})));
refused(s.run("file.exportPng", &json!({"path": png, "strict": true})));
refused(s.run("file.save", &json!({"path": png, "strict": true})));
assert!(!pdf.exists() && !png.exists());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_refusal_leaves_an_existing_output_untouched() {
let c = uncovered();
let d = dir("keep");
let pdf = d.join("out.pdf");
std::fs::write(&pdf, b"previous").unwrap();
let mut s = Session::new(Document::from_text(&format!("a{c}")));
refused(s.run("file.exportPdf", &json!({"path": pdf, "strict": true})));
assert_eq!(std::fs::read(&pdf).unwrap(), b"previous");
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_refused_save_leaves_the_stamps_path_and_dirty_flag_alone() {
let c = uncovered();
let d = dir("stamp");
let mut s = Session::new(Document::from_text(&format!("a{c}")));
s.dirty = true;
let before =
(s.doc.core.modified.clone(), s.doc.core.created.clone(), s.doc.core.revision, s.doc.core.last_modified_by.clone(), s.doc.settings.zoom);
refused(s.run("file.save", &json!({"path": d.join("out.pdf"), "strict": true})));
let after =
(s.doc.core.modified.clone(), s.doc.core.created.clone(), s.doc.core.revision, s.doc.core.last_modified_by.clone(), s.doc.settings.zoom);
assert_eq!(before, after);
assert!(s.dirty);
assert_eq!(s.path, None);
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn strict_passes_and_writes_when_nothing_is_missing() {
let d = dir("pass");
let mut s = Session::new(Document::from_text("Hello, world."));
for (cmd, file, magic) in [
("file.exportPdf", "a.pdf", &b"%PDF-"[..]),
("file.save", "b.pdf", &b"%PDF-"[..]),
("file.exportPng", "a.png", &b"\x89PNG"[..]),
("file.save", "b.png", &b"\x89PNG"[..]),
] {
let path = d.join(file);
s.run(cmd, &json!({"path": path, "strict": true})).unwrap();
assert!(std::fs::read(&path).unwrap().starts_with(magic), "{file}");
}
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn strict_passes_a_document_the_same_pdf_as_without_it() {
let d = dir("pdfsame");
let mut s = Session::new(Document::from_text("Plain text"));
s.run("file.exportPdf", &json!({"path": d.join("a.pdf")})).unwrap();
s.run("file.exportPdf", &json!({"path": d.join("b.pdf"), "strict": true})).unwrap();
let (a, b) = (std::fs::read(d.join("a.pdf")).unwrap(), std::fs::read(d.join("b.pdf")).unwrap());
assert_eq!(a.len(), b.len());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn strict_is_for_pdf_and_png_outputs_only() {
let d = dir("other");
let mut s = Session::new(Document::from_text("Hello"));
for file in ["a.docx", "a.txt", "a.odt", "a.html"] {
let path = d.join(file);
let r = s.run("file.save", &json!({"path": path, "strict": true}));
assert!(matches!(r, Err(CmdError::Params(_))), "{file}: {r:?}");
assert!(!path.exists(), "{file}");
}
let r = s.run("file.exportPng", &json!({"path": d.join("a.jpg"), "strict": true}));
assert!(matches!(r, Err(CmdError::Params(_))), "{r:?}");
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn strict_report_has_the_schema_1_shape() {
let c = uncovered();
let mut s = Session::new(Document::from_text(&format!("a{c}b{c}")));
let r = s.run("file.strictReport", &json!({})).unwrap();
assert_eq!(
r,
json!({
"schema": 1,
"strict": "failed",
"missing_glyphs": [{"cp": format!("U+{:04X}", c as u32), "count": 2}],
"missing_pictures": [],
"truncated": false,
"total_missing_glyphs": 1,
"total_missing_pictures": 0,
"glyphs_truncated": false,
"pictures_truncated": false,
})
);
let png = s.run("file.strictReport", &json!({"page": 1})).unwrap();
assert_eq!(png, r);
assert!(matches!(s.run("file.strictReport", &json!({"page": 99})), Err(CmdError::Params(_))));
let mut ok = Session::new(Document::from_text("fine"));
let r = ok.run("file.strictReport", &json!({})).unwrap();
assert_eq!(
r,
json!({"schema": 1, "strict": "passed", "missing_glyphs": [], "missing_pictures": [], "truncated": false, "total_missing_glyphs": 0, "total_missing_pictures": 0, "glyphs_truncated": false, "pictures_truncated": false})
);
}
#[test]
fn strict_report_changes_nothing() {
let c = uncovered();
let mut s = Session::new(Document::from_text(&format!("a{c}")));
let before = serde_json::to_string(&s.doc).unwrap();
s.run("file.strictReport", &json!({})).unwrap();
assert_eq!(serde_json::to_string(&s.doc).unwrap(), before);
assert!(!s.dirty);
}
fn garbage_background(d: &mut Document) {
d.media.insert("bg.bin".into(), std::sync::Arc::new(b"this is not a picture".to_vec()));
d.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.bin".into(), fill_type: "stretch".into(), title: String::new() });
}
#[test]
fn an_undecodable_page_background_picture_is_refused_for_pdf_and_png() {
let d = dir("background");
let mut doc = Document::from_text("Hello");
garbage_background(&mut doc);
let mut s = Session::new(doc);
let r = s.run("file.strictReport", &json!({})).unwrap();
assert_eq!(r["strict"], "failed");
assert_eq!(r["missing_pictures"][0], json!({"part": "bg.bin", "source_part": "background", "reason": "unsupported_format"}));
let msg = refused(s.run("file.exportPdf", &json!({"path": d.join("a.pdf"), "strict": true})));
assert!(msg.contains("bg.bin (background)"), "{msg}");
refused(s.run("file.exportPng", &json!({"path": d.join("a.png"), "strict": true})));
refused(s.run("file.save", &json!({"path": d.join("b.png"), "strict": true})));
assert!(!d.join("a.pdf").exists() && !d.join("a.png").exists() && !d.join("b.png").exists());
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn a_decodable_page_background_picture_passes() {
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 200, 255, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let mut doc = Document::from_text("Hello");
doc.media.insert("bg.png".into(), std::sync::Arc::new(png));
doc.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "bg.png".into(), fill_type: "stretch".into(), title: String::new() });
let mut s = Session::new(doc);
assert_eq!(s.run("file.strictReport", &json!({})).unwrap()["strict"], "passed");
}
#[test]
fn a_picture_wider_than_the_pdf_draws_is_refused_for_pdf_only() {
// 40000 x 10: past the PDF's decode limit (30000), within what the page raster draws.
let mut png = Vec::new();
image::RgbaImage::from_pixel(40_000, 10, image::Rgba([9, 9, 9, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let mut doc = Document::from_text("Hello");
doc.media.insert("wide.png".into(), std::sync::Arc::new(png));
doc.settings.page_picture = Some(wordcraft_doc::PagePicture { media: "wide.png".into(), fill_type: "stretch".into(), title: String::new() });
let mut s = Session::new(doc);
assert_eq!(s.run("file.strictReport", &json!({})).unwrap()["strict"], "failed", "PDF");
assert_eq!(s.run("file.strictReport", &json!({"page": 1})).unwrap()["strict"], "passed", "PNG");
}
fn watermark(text: &str) -> Document {
let mut doc = Document::from_text("Hello");
doc.settings.watermark = Some(wordcraft_doc::Watermark { text: text.into(), ..Default::default() });
doc
}
#[test]
fn watermark_text_no_font_can_draw_is_refused_and_latin_passes() {
let cs = uncovered_many(2);
let d = dir("watermark");
let mut s = Session::new(watermark(&cs.iter().collect::<String>()));
let r = s.run("file.strictReport", &json!({})).unwrap();
assert_eq!(r["strict"], "failed");
let cps: Vec<String> = cs.iter().map(|c| format!("U+{:04X}", *c as u32)).collect();
let got: Vec<String> = r["missing_glyphs"].as_array().unwrap().iter().map(|g| g["cp"].as_str().unwrap().to_string()).collect();
assert_eq!(got, cps);
refused(s.run("file.exportPdf", &json!({"path": d.join("w.pdf"), "strict": true})));
refused(s.run("file.exportPng", &json!({"path": d.join("w.png"), "strict": true})));
assert!(!d.join("w.pdf").exists() && !d.join("w.png").exists());
let mut ok = Session::new(watermark("CONFIDENTIAL"));
assert_eq!(ok.run("file.strictReport", &json!({})).unwrap()["strict"], "passed");
ok.run("file.exportPdf", &json!({"path": d.join("ok.pdf"), "strict": true})).unwrap();
ok.run("file.exportPng", &json!({"path": d.join("ok.png"), "strict": true})).unwrap();
std::fs::remove_dir_all(d).unwrap();
}
#[test]
fn wordart_text_effects_keep_their_glyphs_in_the_check() {
let c = uncovered();
let props = wordcraft_doc::CharProps { shadow: Some(true), outline: Some(true), ..Default::default() };
let mut doc = Document::from_text("");
doc.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("a{c}b"), props))].into_iter().collect();
let mut s = Session::new(doc);
let r = s.run("file.strictReport", &json!({})).unwrap();
assert_eq!(r["missing_glyphs"][0]["cp"], format!("U+{:04X}", c as u32), "{r}");
}
#[test]
fn each_list_says_for_itself_that_it_was_cut() {
let many = uncovered_many(300);
let mut doc = Document::from_text(&many.iter().collect::<String>());
garbage_background(&mut doc);
let mut s = Session::new(doc);
let r = s.run("file.strictReport", &json!({})).unwrap();
assert_eq!(r["missing_glyphs"].as_array().unwrap().len(), 256);
assert_eq!((r["truncated"].clone(), r["glyphs_truncated"].clone(), r["pictures_truncated"].clone()), (json!(true), json!(true), json!(false)));
let lines = wordcraft_engine::strict::lines_from_json(&r);
assert!(lines[0].ends_with(", …"), "{}", lines[0]);
assert!(lines[0].starts_with("strict: 300 characters cannot be drawn (first 256 listed): "), "{}", lines[0]);
assert!(!lines[1].contains('…'), "{}", lines[1]);
assert_eq!((r["total_missing_glyphs"].clone(), r["total_missing_pictures"].clone()), (json!(300), json!(1)));
}
#[test]
fn a_truncated_report_carries_totals_past_the_cap() {
let many = uncovered_many(300);
let mut doc = Document::from_text(&many.iter().collect::<String>());
for n in 0..300 {
doc.missing_pictures.push(wordcraft_doc::MissingPicture {
part: format!("word/media/gone{n}.png"),
story: "word/document.xml".into(),
reason: wordcraft_doc::MissingPictureReason::MissingPart,
});
// The same record again is one picture.
doc.missing_pictures.push(wordcraft_doc::MissingPicture {
part: format!("word/media/gone{n}.png"),
story: "word/document.xml".into(),
reason: wordcraft_doc::MissingPictureReason::MissingPart,
});
}
let mut s = Session::new(doc);
let r = s.run("file.strictReport", &json!({})).unwrap();
assert_eq!(r["missing_glyphs"].as_array().unwrap().len(), 256);
assert_eq!(r["missing_pictures"].as_array().unwrap().len(), 256);
assert_eq!(r["truncated"], true);
assert_eq!(r["total_missing_glyphs"], 300);
assert_eq!(r["total_missing_pictures"], 300);
assert_eq!(r["missing_pictures"][0]["reason"], "missing_part");
assert_eq!(r["missing_pictures"][0]["source_part"], "word/document.xml");
}
#[test]
fn strict_off_gives_the_same_png_as_before_even_with_a_missing_character() {
let c = uncovered();
let d = dir("pngsame");
let mut s = Session::new(Document::from_text(&format!("Hello {c}")));
let png: Vec<Vec<u8>> = [json!({}), json!({"strict": false})]
.into_iter()
.enumerate()
.map(|(i, mut p)| {
p["path"] = json!(d.join(format!("{i}.png")));
s.run("file.exportPng", &p).unwrap();
std::fs::read(d.join(format!("{i}.png"))).unwrap()
})
.collect();
assert!(png[0].starts_with(b"\x89PNG"));
assert_eq!(png[0], png[1]);
std::fs::remove_dir_all(d).unwrap();
}
+100
View File
@@ -0,0 +1,100 @@
//! Pictures a layout draws that the exporters can't decode (#6005): a DOCX opened, laid out, and
//! asked which of its pictures would be grey boxes, and in which story.
use std::io::Write;
use wordcraft_doc::{Document, MissingPictureReason};
use wordcraft_layout::display::DisplayOptions;
use wordcraft_layout::{LayoutCache, LayoutOptions, StrictOutput, layout, media_check, undecodable_pictures};
const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture""#;
const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
fn png() -> Vec<u8> {
let mut out = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([20, 90, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}
fn pic(rid: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="508000" cy="508000"/><wp:docPr id="1" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="p"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="{rid}"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="508000" cy="508000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
}
/// A document with one header picture (`header_media`) and one body picture (`body_media`), the
/// two parts' bytes as given.
fn docx(header_media: &[u8], body_media: &[u8]) -> Vec<u8> {
let doc = format!(
r#"<w:document {NS}><w:body><w:p>{}<w:r><w:t>text</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr></w:body></w:document>"#,
pic("rIdB")
);
let rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{REL}/officeDocument" Target="word/document.xml"/></Relationships>"#
);
let drels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdH" Type="{REL}/header" Target="header2.xml"/><Relationship Id="rIdB" Type="{REL}/image" Target="media/body.png"/></Relationships>"#
);
let hrels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdI" Type="{REL}/image" Target="media/image2.bin"/></Relationships>"#
);
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/octet-stream"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let hdr = format!(r#"<w:hdr {NS}><w:p>{}</w:p></w:hdr>"#, pic("rIdI"));
for (name, bytes) in [
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", rels.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/_rels/document.xml.rels", drels.as_bytes()),
("word/header2.xml", hdr.as_bytes()),
("word/_rels/header2.xml.rels", hrels.as_bytes()),
("word/media/image2.bin", header_media),
("word/media/body.png", body_media),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap().into_inner()
}
fn report(d: &Document, pages: Option<std::ops::RangeInclusive<usize>>) -> Vec<(String, String, MissingPictureReason)> {
let l = layout(d, &mut LayoutCache::new(), &LayoutOptions::default());
let (list, truncated, total) = undecodable_pictures(d, &l, pages, &DisplayOptions::default(), StrictOutput::Pdf);
assert!(!truncated);
assert_eq!(total, list.len() as u64);
list.into_iter().map(|m| (m.part, m.story, m.reason)).collect()
}
#[test]
fn a_header_picture_of_corrupt_bytes_is_reported_with_its_part_and_story() {
let d = wordcraft_docx::read(&docx(b"corrupt bytes, not a picture", &png())).unwrap();
assert_eq!(report(&d, None), [("word/media/image2.bin".into(), "word/header2.xml".into(), MissingPictureReason::UnsupportedFormat)]);
}
#[test]
fn a_truncated_picture_is_undecodable_not_unsupported() {
let mut cut = png();
cut.truncate(cut.len() - 20);
let d = wordcraft_docx::read(&docx(&png(), &cut)).unwrap();
assert_eq!(report(&d, None), [("word/media/body.png".into(), "word/document.xml".into(), MissingPictureReason::Undecodable)]);
}
#[test]
fn a_real_png_in_a_header_is_not_reported() {
let d = wordcraft_docx::read(&docx(&png(), &png())).unwrap();
assert_eq!(report(&d, None), []);
}
#[test]
fn only_the_selected_pages_are_checked() {
let d = wordcraft_docx::read(&docx(b"corrupt", &png())).unwrap();
assert_eq!(report(&d, Some(0..=0)).len(), 1);
assert_eq!(report(&d, Some(5..=9)), []);
}
#[test]
fn the_check_names_what_the_exporters_draw() {
assert_eq!(media_check(&png(), StrictOutput::Pdf), Ok(()));
assert!(media_check(b"", StrictOutput::Pdf).is_err());
assert!(media_check(&[1, 0, 0, 0, 88, 0, 0, 0], StrictOutput::Pdf).is_err(), "a metafile that doesn't parse is a grey box");
}
+4
View File
@@ -8,6 +8,10 @@ description = "WordCraft fonts: font database (bundled OFL + system), metrics, g
[lints]
workspace = true
[features]
# Opt-in test harness support; enabling this alone never changes the application database.
test-support = []
[dependencies]
kurbo = { workspace = true }
skrifa = { workspace = true }
+37 -5
View File
@@ -7,7 +7,9 @@ use std::sync::{Arc, Mutex, RwLock};
use kurbo::BezPath;
use skrifa::instance::{Location, LocationRef, Size};
use skrifa::outline::{DrawSettings, Engine, Hinting, HintingInstance, HintingOptions, OutlinePen, Target};
use skrifa::outline::{
DrawSettings, Engine, GlyphStyles, Hinting, HintingInstance, HintingOptions, OutlineGlyphFormat, OutlinePen, SmoothMode, Target,
};
use skrifa::raw::FileRef;
use skrifa::string::StringId;
use skrifa::{GlyphId, MetadataProvider};
@@ -39,10 +41,16 @@ use names::{MAX_NAME_CHARS, file_face_names, font_faces, mini_font, name};
#[cfg(test)]
pub(crate) use names::{last_resort_face, legacy_family};
static GLOBAL: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
/// Process-wide font database.
pub struct FontDb {
faces: RwLock<Vec<Arc<FontFace>>>,
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
/// Screen-hinted outlines per (face, glyph, ppem bits); `None` where the face can't be hinted.
hinted: Mutex<HashMap<(u32, u32, u32), Option<Arc<BezPath>>>>,
/// Light hinting instances per (face, ppem bits); `None` where the face can't be hinted.
hinters: Mutex<HashMap<(u32, u32), Option<Arc<HintingInstance>>>>,
/// Fallback faces found per (character, excluded face), valid while the face count is the
/// one stored (faces are only ever added). Layout asks once per uncovered character, and the
/// search sorts every loaded face, so without this text in a script the requested font lacks
@@ -90,10 +98,14 @@ impl FontDb {
/// Arabic, so Arabic and Persian text draws without system fonts (#1652) (all empty without
/// `CRAFT_FONTS_DIR`), whose system font scan reads `font_dirs`.
pub fn with_font_dirs(font_dirs: Vec<std::path::PathBuf>) -> Self {
let mut faces = Vec::new();
let others = crate::CRAFT_FONTS.iter().filter(|f| !f.scripts.contains(&"Jpan"));
let craft = crate::japanese_document_fonts().into_iter().chain(others).map(|f| f.bytes);
for data in BUNDLED.iter().copied().chain(craft) {
Self::with_static_fonts(font_dirs, BUNDLED.iter().copied().chain(craft))
}
fn with_static_fonts(font_dirs: Vec<std::path::PathBuf>, fonts: impl Iterator<Item = &'static [u8]>) -> Self {
let mut faces = Vec::new();
for data in fonts {
for f in enumerate_faces(data, MAX_FILE_FACES) {
if let Some(mut face) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) {
face.local_names = f.local;
@@ -104,6 +116,8 @@ impl FontDb {
Self {
faces: RwLock::new(faces),
outlines: Mutex::new(HashMap::new()),
hinted: Mutex::new(HashMap::new()),
hinters: Mutex::new(HashMap::new()),
fallbacks: RwLock::new(FallbackCache::default()),
run_fallbacks: RwLock::new(RunFallbackCache::default()),
#[cfg(not(target_arch = "wasm32"))]
@@ -144,8 +158,26 @@ impl FontDb {
/// first time a lookup by family name needs them (or ahead of time by
/// [`FontDb::scan_in_background`]).
pub fn global() -> &'static FontDb {
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
DB.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
GLOBAL.get_or_init(|| FontDb::with_font_dirs(system_font_dirs()))
}
/// Initialize a test process with only the repository's pinned bundled fonts: no optional
/// craft-fonts, user fonts or system font directories. Call before any global font lookup.
/// Returns false if a normal global database was already initialized; never replaces it.
/// Repeated/concurrent calls are safe. Merely enabling `test-support` changes nothing.
#[cfg(feature = "test-support")]
pub fn init_test_fonts() -> bool {
static INIT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*INIT.get_or_init(|| {
let mut installed = false;
GLOBAL.get_or_init(|| {
installed = true;
let db = Self::with_static_fonts(Vec::new(), BUNDLED.iter().copied());
db.set_system_fallback(false);
db
});
installed
})
}
fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
+47 -4
View File
@@ -1,7 +1,7 @@
use super::names::{LocalName, east_asian_font};
use super::{
Arc, AtomicU32, BezPath, DrawSettings, Engine, GlyphId, Hinting, HintingInstance, HintingOptions, Location, LocationRef, MetadataProvider,
Ordering, OutlinePen, RwLock, Size, Target,
Arc, AtomicU32, BezPath, DrawSettings, Engine, GlyphId, GlyphStyles, Hinting, HintingInstance, HintingOptions, Location, LocationRef,
MetadataProvider, Ordering, OutlineGlyphFormat, OutlinePen, RwLock, Size, SmoothMode, Target,
};
/// The family used when a requested family is unknown (the UI sans).
@@ -77,6 +77,8 @@ pub struct FontFace {
pub(super) bmp: std::sync::OnceLock<Box<[u64]>>,
// Some legacy CJK fonts assemble their contours with TrueType instructions.
pub(super) outline_hinter: std::sync::OnceLock<Option<HintingInstance>>,
/// The autohinter's analysis of the font for screen hinting, made once for every size.
pub(super) glyph_styles: std::sync::OnceLock<GlyphStyles>,
/// Hinted draws so far, those that ran too long, and whether that turned hinting off
/// ([`FontFace::count_hinted`]).
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
@@ -288,6 +290,42 @@ impl FontFace {
})
.as_ref()
}
/// Glyph `gid`'s outline hinted for screen text at `ppem` device pixels per em, in device
/// pixels, y-down (flipped), or `None` when the face can't be hinted. The hinting is light,
/// as FreeType's `hintslight`: horizontal edges (baseline, x-height, cap height) move onto
/// whole pixels, nothing moves sideways, so advances and layout stay as they are.
pub(super) fn hinted_glyph_outline(&self, gid: u32, hinter: &HintingInstance) -> Option<BezPath> {
let f = self.skrifa()?;
let g = f.outline_glyphs().get(GlyphId::new(gid))?;
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
g.draw(DrawSettings::hinted(hinter, false), &mut pen).map_err(|e| log::warn!("Cannot hint glyph {gid} in {:?}: {e}", self.family)).ok()?;
Some(pen.path)
}
/// A light hinting instance for `ppem` device pixels per em, with the engine FreeType's light
/// hinting picks: the autohinter for TrueType outlines (their own instructions are ignored;
/// measured against Firefox on Linux, the autohinter matches its rows of pixels and the
/// TrueType instructions don't), the font's own hints for PostScript (CFF) outlines, and for
/// fonts whose glyphs need their instructions to be assembled at all their instructions with
/// the monochrome target, as skrifa requires for them.
pub(super) fn screen_hinter(&self, ppem: f32) -> Option<HintingInstance> {
let f = self.skrifa()?;
let outlines = f.outline_glyphs();
let light = Target::Smooth { mode: SmoothMode::Light, symmetric_rendering: true, preserve_linear_metrics: true };
let (engine, target) = if outlines.require_interpreter() {
// FreeType's "tricky" fonts (old CJK fonts that assemble glyphs with their
// instructions): skrifa asks for the interpreter with the monochrome target, under
// which the instructions may move points sideways, or glyphs come apart.
(Engine::Interpreter, Target::Mono)
} else if outlines.format() == Some(OutlineGlyphFormat::Glyf) {
(Engine::Auto(Some(self.glyph_styles.get_or_init(|| GlyphStyles::new(&outlines)).clone())), light)
} else {
(Engine::Interpreter, light)
};
HintingInstance::new(&outlines, Size::new(ppem), self.location(), HintingOptions { engine, target })
.map_err(|e| log::warn!("Cannot hint {:?} at {ppem} px: {e}", self.family))
.ok()
}
/// Is `family` this face's typographic, legacy, full or PostScript name?
pub fn named(&self, family: &str) -> bool {
self.family.eq_ignore_ascii_case(family)
@@ -358,13 +396,18 @@ impl FontFace {
pub(super) static NEXT_ID: AtomicU32 = AtomicU32::new(1);
pub(super) const OUTLINE_CACHE_MAX: usize = 50_000;
/// Hinting instances kept (one per face and text size on screen).
pub(super) const HINTER_CACHE_MAX: usize = 256;
/// Sizes hinted, in device pixels per em: below, text is too small to read anyway; above, the
/// half-pixel hinting moves is invisible (and each size would cost a cache entry).
pub(super) const HINT_PPEM: std::ops::RangeInclusive<f32> = 4.0..=96.0;
/// Collects an outline flipped to y-down, keeping at most `room` path elements (`over` once it
/// had to drop one).
pub(super) struct FlipPen {
pub(super) path: BezPath,
room: usize,
over: bool,
pub(super) room: usize,
pub(super) over: bool,
/// Drawn with the face's TrueType interpreter.
pub(super) hinted: bool,
}
+38 -1
View File
@@ -1,4 +1,4 @@
use super::face::{FALLBACK_FAMILY, FontBytes, FontFace, OUTLINE_CACHE_MAX};
use super::face::{FALLBACK_FAMILY, FontBytes, FontFace, HINT_PPEM, HINTER_CACHE_MAX, OUTLINE_CACHE_MAX};
#[cfg(not(target_arch = "wasm32"))]
use super::fallback_data::read_fallback_file;
#[cfg(not(target_arch = "wasm32"))]
@@ -227,4 +227,41 @@ impl FontDb {
cache.insert(key, p.clone());
p
}
/// Glyph outline hinted for the screen at `ppem` device pixels per em ([`HINT_PPEM`]), in
/// device pixels, y-down, for drawing at a baseline on a whole pixel; cached per (face, glyph,
/// size). `None` for sizes out of range and faces that can't be hinted: draw [`Self::outline`].
pub fn hinted_outline(&self, face: &FontFace, gid: u32, ppem: f32) -> Option<Arc<BezPath>> {
if !HINT_PPEM.contains(&ppem) {
return None;
}
let key = (face.id, gid, ppem.to_bits());
if let Some(p) = self.hinted.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return p.clone();
}
let hinter = {
let hkey = (face.id, ppem.to_bits());
let known = self.hinters.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&hkey).cloned();
match known {
Some(h) => h,
None => {
// Built outside the lock: running a font's hinting programs takes a while.
let h = face.screen_hinter(ppem).map(Arc::new);
let mut cache = self.hinters.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= HINTER_CACHE_MAX {
cache.clear();
}
cache.insert(hkey, h.clone());
h
}
}
};
let p = hinter.and_then(|h| face.hinted_glyph_outline(gid, &h)).map(Arc::new);
let mut cache = self.hinted.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();
}
cache.insert(key, p.clone());
p
}
}
+1
View File
@@ -394,6 +394,7 @@ pub(super) fn make_face(
index,
bmp: std::sync::OnceLock::new(),
outline_hinter: std::sync::OnceLock::new(),
glyph_styles: std::sync::OnceLock::new(),
hint_draws: Default::default(),
hint_slow: Default::default(),
hinting_off: Default::default(),
+62
View File
@@ -38,6 +38,7 @@ fn kai_contours_include_complete_character_body() {
}
}
use super::face::FlipPen;
use super::*;
#[test]
@@ -92,6 +93,67 @@ fn run_fallback_is_cached_per_char_set() {
assert!(db.run_fallbacks.read().unwrap().map.is_empty());
}
#[test]
fn screen_hinting_puts_horizontal_edges_on_whole_pixels_and_moves_nothing_sideways() {
let db = FontDb::global();
let face = db.face(FALLBACK_FAMILY, "Regular");
let x = face.glyph_for('x');
// 11 pt at 100 % zoom on a 145 % desktop: a fractional size, as on most screens.
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
let hinted = db.hinted_outline(&face, x, ppem).expect("Source Sans 3 can be hinted");
let plain = db.outline(&face, x);
let k = f64::from(ppem) / face.upem;
let (h, u) = (kurbo::Shape::bounding_box(&*hinted), kurbo::Shape::bounding_box(&*plain));
// The x-height (the top, y-down) and the baseline sit on the pixel grid…
assert!((h.y0 - h.y0.round()).abs() < 0.02, "x-height at {}", h.y0);
assert!((h.y1 - h.y1.round()).abs() < 0.02, "baseline at {}", h.y1);
assert!((u.y0 * k - (u.y0 * k).round()).abs() > 0.02, "the unhinted x-height is off the grid: {}", u.y0 * k);
// …and the sides stay where the font puts them, so advances and line breaks don't change.
assert!((h.x0 - u.x0 * k).abs() < 0.01 && (h.x1 - u.x1 * k).abs() < 0.01, "{h:?} vs {:?}", u.scale_from_origin(k));
// Cached per size.
assert!(Arc::ptr_eq(&hinted, &db.hinted_outline(&face, x, ppem).expect("cached")));
}
#[test]
fn screen_hinting_uses_the_autohinter_even_for_fonts_with_their_own_instructions() {
// FreeType's light hinting (`hintslight`, Firefox on Linux) runs its autohinter on
// TrueType outlines and ignores their instructions: measured against Firefox, the
// instructions drew Arial's rows elsewhere and heavier.
let db = FontDb::global();
let face = db.face(FALLBACK_FAMILY, "Regular");
let font = face.skrifa().unwrap();
let outlines = font.outline_glyphs();
assert!(outlines.prefer_interpreter(), "Source Sans 3 carries TrueType instructions (ttfautohint)");
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
let target = Target::Smooth { mode: SmoothMode::Light, symmetric_rendering: true, preserve_linear_metrics: true };
let drawn = |engine: Engine, gid: u32| {
let h = HintingInstance::new(&outlines, Size::new(ppem), face.location(), HintingOptions { engine, target }).unwrap();
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
outlines.get(GlyphId::new(gid)).unwrap().draw(DrawSettings::hinted(&h, false), &mut pen).unwrap();
pen.path
};
let mut differs = false;
for c in "Every Thursday".chars().filter(|c| !c.is_whitespace()) {
let gid = face.glyph_for(c);
let ours = db.hinted_outline(&face, gid, ppem).unwrap();
assert_eq!(*ours, drawn(Engine::Auto(None), gid), "{c}: the autohinter's outline");
differs |= *ours != drawn(Engine::Interpreter, gid);
}
assert!(differs, "the font's own instructions would draw it differently");
}
#[test]
fn screen_hinting_refuses_hostile_sizes() {
let db = FontDb::global();
let face = db.face(FALLBACK_FAMILY, "Regular");
let x = face.glyph_for('x');
for ppem in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -12.0, 0.0, 1.0, 3.99, 96.01, 1e9] {
assert!(db.hinted_outline(&face, x, ppem).is_none(), "{ppem}");
}
// A glyph id past the font's glyphs has no outline to hint.
assert!(db.hinted_outline(&face, u32::MAX, 20.0).is_none_or(|o| o.elements().is_empty()));
}
/// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign.
const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50";
+34
View File
@@ -2,6 +2,7 @@
#[allow(unused_imports)]
use std::path::{Path, PathBuf};
use crate::fontdb::face::FlipPen;
use crate::fontdb::*;
const FAMILY: &str = "Sysfont Sans3";
@@ -420,6 +421,39 @@ fn a_family_partly_loaded_by_a_character_fallback_is_loaded_whole_for_a_run() {
assert_eq!(db.read_faces().iter().filter(|f| f.family == "Sysfont Famly").count(), 3);
}
#[test]
fn screen_hinting_runs_a_tricky_fonts_instructions_with_the_monochrome_target() {
// FreeType's "tricky" fonts (old CJK fonts listed by name, such as DynaLab's "cpop") assemble
// their glyphs with their instructions; skrifa requires the interpreter with `Target::Mono`
// for them. A bundled font renamed into the list stands in for one.
let dir = font_dir("tricky");
std::fs::write(dir.join("Tricky.ttf"), renamed_to("SourceSans3-Regular.ttf", "Sysfont cpop1")).unwrap();
let db = FontDb::with_font_dirs(vec![dir]);
let face = db.face("Sysfont cpop1", "Regular");
assert_eq!(face.family, "Sysfont cpop1");
let font = face.skrifa().unwrap();
let outlines = font.outline_glyphs();
assert!(outlines.require_interpreter(), "the name makes it tricky");
let ppem = 11.0 * 96.0 / 72.0 * 1.45;
let drawn = |target: Target, gid: u32| {
let h = HintingInstance::new(&outlines, Size::new(ppem), face.location(), HintingOptions { engine: Engine::Interpreter, target }).unwrap();
let mut pen = FlipPen { path: BezPath::new(), room: usize::MAX, over: false, hinted: true };
outlines.get(GlyphId::new(gid)).unwrap().draw(DrawSettings::hinted(&h, false), &mut pen).unwrap();
pen.path
};
// The target decides whether the instructions may move points sideways (the light target's
// backward compatibility forbids it): this font's instructions only move points vertically,
// so the target itself is checked, and the outlines drawn with it.
assert!(matches!(face.screen_hinter(ppem).unwrap().target(), Target::Mono));
for c in "Every Thursday".chars().filter(|c| !c.is_whitespace()) {
let gid = face.glyph_for(c);
assert_eq!(*db.hinted_outline(&face, gid, ppem).unwrap(), drawn(Target::Mono, gid), "{c}: the monochrome target");
}
// Any other font keeps the light target.
let plain = db.face(FALLBACK_FAMILY, "Regular");
assert!(matches!(plain.screen_hinter(ppem).unwrap().target(), Target::Smooth { mode: SmoothMode::Light, preserve_linear_metrics: true, .. }));
}
#[test]
fn coverage_ranges_hold_exactly_the_mapped_characters() {
let data = renamed("SourceSans3-Regular.ttf");
+31
View File
@@ -0,0 +1,31 @@
//! Separate process: verifies the global test fixture before any other lookup initializes it.
#![cfg(feature = "test-support")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use wordcraft_fonts::FontDb;
#[test]
fn fixed_fonts_exclude_system_and_optional_faces() {
let threads: Vec<_> = (0..8).map(|_| std::thread::spawn(FontDb::init_test_fonts)).collect();
assert!(threads.into_iter().all(|t| t.join().unwrap()));
let db = FontDb::global();
let families = [
"Inter",
"Inter Medium",
"Inter SemiBold",
"JetBrains Mono",
"Source Sans 3",
"Source Sans 3 Semibold",
"Source Serif 4",
"Source Serif 4 Semibold",
];
assert_eq!(db.families(), families);
assert_eq!(db.load_system_fonts(), 0);
assert_eq!(db.families(), families);
for family in ["Arial", "Calibri", "Carlito", "Noto Sans Arabic", "BIZ UDPGothic"] {
assert!(!db.has_family(family), "external family {family} leaked into the test fixture");
}
assert!(db.fallback_for('你', 0).is_none(), "missing glyph lookup must not consult system fonts");
assert_eq!(db.loaded_font_bytes(), 0);
assert!(FontDb::init_test_fonts());
}
+8
View File
@@ -19,3 +19,11 @@ fn a_fresh_session_finds_installed_fonts_by_name() {
assert_eq!(&FontDb::global().face(family, "Regular").family, family);
assert!(FontDb::global().families().contains(family));
}
#[cfg(feature = "test-support")]
#[test]
fn test_initialization_cannot_replace_an_application_database() {
let db = FontDb::global();
assert!(!FontDb::init_test_fonts());
assert!(std::ptr::eq(db, FontDb::global()));
}
+5 -3
View File
@@ -123,13 +123,15 @@ mod tests {
assert_eq!(parse_color("#f00"), Some(Rgb(255, 0, 0)));
assert_eq!(parse_color("#f00f"), Some(Rgb(255, 0, 0)));
assert_eq!(parse_color("#0080FF"), Some(Rgb(0, 128, 255)));
assert_eq!(parse_color("#0080ff80"), Some(Rgb(0, 128, 255)));
assert_eq!(parse_color("#0080FFff"), Some(Rgb(0, 128, 255)));
// A colour with alpha is composited over the white page it lands on (#0080ff at 50%).
assert_eq!(parse_color("#0080ff80"), Some(Rgb(127, 191, 255)));
assert_eq!(parse_color("rgb(1, 2, 3)"), Some(Rgb(1, 2, 3)));
assert_eq!(parse_color("rgb(1 2 3)"), Some(Rgb(1, 2, 3)));
assert_eq!(parse_color("rgb(1 2 3 / 50%)"), Some(Rgb(1, 2, 3)));
assert_eq!(parse_color("rgb(1 2 3 / 50%)"), Some(Rgb(128, 129, 129)));
assert_eq!(parse_color("hsl(0, 100%, 50%)"), Some(Rgb(255, 0, 0)));
assert_eq!(parse_color("hsl(120deg 100% 50%)"), Some(Rgb(0, 255, 0)));
assert_eq!(parse_color("hsla(240, 100%, 50%, 0.8)"), Some(Rgb(0, 0, 255)));
assert_eq!(parse_color("hsla(240, 100%, 50%, 0.8)"), Some(Rgb(51, 51, 255)));
assert_eq!(parse_color("navy"), Some(Rgb(0, 0, 128)));
assert_eq!(parse_color("#zz"), None);
assert_eq!(parse_color("rgb(1)"), None);
+73 -9
View File
@@ -34,24 +34,59 @@ fn hsl_to_rgb(h: f64, s: f64, l: f64) -> Rgb {
Rgb(r, g, b)
}
/// Composite `rgb` over the white page a paste lands on, at CSS alpha `a` (0..1): `rgba(0, 0, 0,
/// 0.45)` reads like the rgb(140, 140, 140) it looks like on the site, instead of solid black.
fn over_white(r: u8, g: u8, b: u8, a: f32) -> Rgb {
let mix = |c: u8| (c as f32 * a + 255.0 * (1.0 - a)).round().clamp(0.0, 255.0) as u8;
Rgb(mix(r), mix(g), mix(b))
}
/// The alpha of a CSS colour's `/ alpha` or 4th-value part, where present: 1.0 (opaque) otherwise.
fn css_alpha(after: Option<&str>) -> f32 {
let Some(a) = after else { return 1.0 };
let a = a.trim();
// CSS Colour 4's `none` is a missing component, and a missing alpha is transparent.
if a.eq_ignore_ascii_case("none") {
return 0.0;
}
// A value that is not a finite number (`nan`, `inf`) is no alpha at all: clamp would keep
// NaN, and `over_white` would turn the colour black.
let parse = |t: &str| t.parse::<f32>().ok().filter(|f| f.is_finite());
if let Some(p) = a.strip_suffix('%') {
return parse(p.trim()).map(|f| f.clamp(0.0, 100.0) / 100.0).unwrap_or(1.0);
}
parse(a).map(|f| f.clamp(0.0, 1.0)).unwrap_or(1.0)
}
/// Parse a CSS / HTML colour: `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb(...)`, `hsl(...)` or a basic name.
/// A colour with alpha is composited over white: the model keeps opaque colours, and a paste lands
/// on a white page.
pub fn parse_color(s: &str) -> Option<Rgb> {
let s = s.trim().to_ascii_lowercase();
if let Some(h) = s.strip_prefix('#') {
if (h.len() == 3 || h.len() == 4) && h.is_ascii() {
let d = |i: usize| h.get(i..i + 1).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| v * 17);
return Some(Rgb(d(0)?, d(1)?, d(2)?));
let (r, g, b) = (d(0)?, d(1)?, d(2)?);
// A 4th digit is the alpha, composited over the page like `rgba(...)`'s is.
let a = if h.len() == 4 { d(3)? as f32 / 255.0 } else { 1.0 };
return Some(over_white(r, g, b, a));
}
if h.len() == 8 && h.bytes().all(|b| b.is_ascii_hexdigit()) {
let p = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok());
return Some(Rgb(p(0)?, p(2)?, p(4)?));
return Some(over_white(p(0)?, p(2)?, p(4)?, p(6)? as f32 / 255.0));
}
return Rgb::parse(h);
}
if let Some(inner) = s.strip_prefix("rgb(").or_else(|| s.strip_prefix("rgba(")) {
let inner = inner.trim_end_matches(')').trim();
let before_slash = inner.split('/').next().unwrap_or(inner);
let parts: Vec<&str> = if before_slash.contains(',') { before_slash.split(',').collect() } else { before_slash.split_whitespace().collect() };
let (rgb_part, alpha_part) = inner.split_once('/').map_or((inner, None), |(a, b)| (a, Some(b)));
// Legacy comma syntax carries the alpha as a 4th value (`rgba(0, 0, 0, 0.45)`).
let parts: Vec<&str> = if rgb_part.contains(',') { rgb_part.split(',').collect() } else { rgb_part.split_whitespace().collect() };
let alpha = match alpha_part {
Some(a) => css_alpha(Some(a)),
None if parts.len() >= 4 => css_alpha(Some(parts[3])),
_ => 1.0,
};
let v: Vec<u8> = parts
.iter()
.take(3)
@@ -65,14 +100,19 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
})
.collect();
if let [r, g, b] = v[..] {
return Some(Rgb(r, g, b));
return Some(over_white(r, g, b, alpha));
}
return None;
}
if let Some(inner) = s.strip_prefix("hsl(").or_else(|| s.strip_prefix("hsla(")) {
let inner = inner.trim_end_matches(')').trim();
let before_slash = inner.split('/').next().unwrap_or(inner);
let parts: Vec<&str> = if before_slash.contains(',') { before_slash.split(',').collect() } else { before_slash.split_whitespace().collect() };
let (hsl_part, alpha_part) = inner.split_once('/').map_or((inner, None), |(a, b)| (a, Some(b)));
let parts: Vec<&str> = if hsl_part.contains(',') { hsl_part.split(',').collect() } else { hsl_part.split_whitespace().collect() };
let alpha = match alpha_part {
Some(a) => css_alpha(Some(a)),
None if parts.len() >= 4 => css_alpha(Some(parts[3])),
_ => 1.0,
};
if parts.len() < 3 {
return None;
}
@@ -96,7 +136,8 @@ pub fn parse_color(s: &str) -> Option<Rgb> {
};
let s = parse_pct(parts[1])?;
let l = parse_pct(parts[2])?;
return Some(hsl_to_rgb(h, s, l));
let Rgb(r, g, b) = hsl_to_rgb(h, s, l);
return Some(over_white(r, g, b, alpha));
}
Some(match s.as_str() {
"black" => Rgb(0, 0, 0),
@@ -127,6 +168,29 @@ mod tests {
#[test]
fn eight_digit_hex_rejects_signs() {
assert_eq!(parse_color("#+1+2+3+4"), None);
assert_eq!(parse_color("#11223344"), Some(Rgb(0x11, 0x22, 0x33)));
// #11223344 is alpha 0x44 over white.
assert_eq!(parse_color("#11223344"), Some(Rgb(192, 196, 201)));
}
#[test]
fn colors_with_alpha_composite_over_white() {
// The secondary text colour of dark-theme sites: solid black would read as the site's
// own emphasis, white is invisible on the page; the composite is what the eye saw.
assert_eq!(parse_color("rgba(0, 0, 0, 0.45)"), Some(Rgb(140, 140, 140)));
assert_eq!(parse_color("rgb(0 0 0 / 0.45)"), Some(Rgb(140, 140, 140)));
assert_eq!(parse_color("rgba(0, 0, 0, 0)"), Some(Rgb(255, 255, 255)));
assert_eq!(parse_color("rgba(255, 0, 0, 1)"), Some(Rgb(255, 0, 0)));
assert_eq!(parse_color("rgba(255, 0, 0, 45%)"), Some(Rgb(255, 140, 140)));
assert_eq!(parse_color("hsla(120, 100%, 50%, 0.5)"), Some(Rgb(128, 255, 128)));
// A 4-digit hex's last digit is the alpha, too: #0008 is black at 8/15 over white.
assert_eq!(parse_color("#0008"), Some(Rgb(119, 119, 119)));
// `none` is a missing alpha, and a missing alpha is transparent, not the opaque default
// an omitted alpha takes.
assert_eq!(parse_color("rgb(0 0 0 / none)"), Some(Rgb(255, 255, 255)));
assert_eq!(parse_color("rgb(0 0 0)"), Some(Rgb(0, 0, 0)));
// An alpha that is not a finite number is no alpha: the colour stays opaque, not black.
assert_eq!(parse_color("rgba(0, 0, 0, nan)"), Some(Rgb(0, 0, 0)));
assert_eq!(parse_color("rgba(0, 0, 0, inf)"), Some(Rgb(0, 0, 0)));
assert_eq!(parse_color("rgba(0, 0, 0, nan%)"), Some(Rgb(0, 0, 0)));
}
}
+39 -11
View File
@@ -78,7 +78,7 @@ fn fmt_of(doc: &Document, cp: &CharProps, in_code: bool) -> Fmt {
/// The flow formatting of a run with properties `props`: with `opts.chars`, its extra formatting,
/// its custom character style by name (only its direct formatting then its own) and a
/// monospaced font by its name.
fn run_fmt(doc: &Document, props: &CharProps, in_code: bool, opts: FlowOpts) -> Fmt {
fn run_fmt(doc: &Document, para_style: Option<&str>, props: &CharProps, in_code: bool, opts: FlowOpts) -> Fmt {
let mut f = fmt_of(doc, props, in_code);
if !opts.chars {
return f;
@@ -104,6 +104,16 @@ fn run_fmt(doc: &Document, props: &CharProps, in_code: bool, opts: FlowOpts) ->
if let Some(id) = custom {
f.extra.get_or_insert_with(Default::default).style = Some(id.to_string());
}
// A size stated without a complex-script size leaves the complex size to what is above it
// (a style's, or this size where none says one): said where it isn't this size, since a
// writer that gives every script the one size would otherwise change it.
let sized = props.size.is_some() || custom.is_some_and(|id| doc.styles.chain(id).iter().any(|s| s.chr.size.is_some()));
if props.size_cs.is_none() && sized && f.extra.as_ref().is_none_or(|x| x.size_cs.is_none()) {
let cs = doc.styles.resolve_char(para_style, props).size_cs;
if f.size != Some(cs) {
f.extra.get_or_insert_with(Default::default).size_cs = Some(cs);
}
}
if f.code {
f.code = false;
f.font = e.shown_font().map(Arc::from);
@@ -116,11 +126,15 @@ fn run_fmt(doc: &Document, props: &CharProps, in_code: bool, opts: FlowOpts) ->
fn extra_of(e: &CharProps) -> Option<Box<CharProps>> {
let set = |v: Option<bool>| v.filter(|b| *b);
let x = CharProps {
underline: e.underline.filter(|u| !matches!(u, Underline::None | Underline::Single)),
// What it turns off, over what the style of its paragraph or span turns on, too.
bold: e.bold.filter(|b| !*b),
italic: e.italic.filter(|b| !*b),
strike: e.strike.filter(|b| !*b),
underline: e.underline.filter(|u| *u != Underline::Single),
underline_color: e.underline_color,
double_strike: set(e.double_strike),
caps: set(e.caps),
small_caps: set(e.small_caps),
caps: e.caps,
small_caps: e.small_caps,
hidden: set(e.hidden),
spacing: e.spacing.filter(|v| *v != 0.0 && v.is_finite()),
scale: e.scale.filter(|v| *v != 100.0 && v.is_finite()),
@@ -182,6 +196,9 @@ fn para_extra(doc: &Document, p: &wordcraft_doc::props::ParaProps) -> Option<Box
tabs
});
let style = p.style.clone().filter(|id| is_custom_style(doc, id));
// Its own, else its custom style's: the kind doesn't say (it stops at Heading 6, and is the
// heading style's where the style is based on one).
let outline_level = p.outline_level.or_else(|| style.as_ref().and_then(|_| doc.styles.resolve_para(p).outline_level));
let x = wordcraft_doc::props::ParaProps {
tabs,
shading: p.shading,
@@ -191,7 +208,7 @@ fn para_extra(doc: &Document, p: &wordcraft_doc::props::ParaProps) -> Option<Box
drop_cap: p.drop_cap.filter(|l| *l > 1),
drop_cap_distance: p.drop_cap_distance.filter(|_| p.drop_cap.is_some_and(|l| l > 1)),
keep_lines: p.keep_lines.filter(|k| *k),
outline_level: p.outline_level,
outline_level,
widow_control: p.widow_control.filter(|w| !*w),
// Hyphenated (the document hyphenates and the paragraph doesn't opt out): `Some(false)`.
suppress_hyphens: (doc.settings.auto_hyphenation && p.suppress_hyphens != Some(true)).then_some(false),
@@ -413,14 +430,25 @@ fn flow_comment(doc: &Document, c: &wordcraft_doc::Comment, parent: Option<u32>,
FComment { author: c.author.clone(), initials: c.initials.clone(), date: c.date.clone(), resolved: c.resolved, parent, blocks }
}
/// Whether `li`, the list of heading `p`, is the number its outline level has from its heading
/// style's list, not a list of its own: it states that level, and the heading style of that level
/// numbers with that list there. Past Heading 6 no such style gives the written outline a number.
fn numbered_by_outline(doc: &Document, p: &Paragraph, li: &ListInfo) -> bool {
use wordcraft_doc::props::NumRef;
p.props.outline_level == Some(li.level)
&& li.level < 6
&& doc.styles.para_style_props(&format!("Heading{}", li.level + 1)).0.numbering == Some(NumRef { num: li.list, level: li.level })
}
/// A paragraph's list membership as the flow keeps it: without the list's identity and label
/// unless `opts` asks for them, and none for a heading numbered only through its style, which a
/// format with heading numbering of its own (ODT's outline style) numbers instead.
fn flow_list(kind: Kind, list: Option<ListInfo>, p: &Paragraph, opts: FlowOpts) -> Option<ListInfo> {
/// unless `opts` asks for them, and none for a heading numbered only through its style or
/// [`numbered_by_outline`], which a format with heading numbering of its own (ODT's outline style)
/// numbers instead, in its first six levels.
fn flow_list(doc: &Document, kind: Kind, list: Option<ListInfo>, p: &Paragraph, opts: FlowOpts) -> Option<ListInfo> {
let mut li = list?;
if !opts.lists {
(li.list, li.label) = (0, None);
} else if matches!(kind, Kind::Heading(_)) && p.props.numbering.is_none() {
} else if matches!(kind, Kind::Heading(_)) && li.level < 6 && (p.props.numbering.is_none() || numbered_by_outline(doc, p, &li)) {
return None;
}
Some(li)
@@ -458,7 +486,7 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec<Para> {
fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
let tracked = opts.tracked;
let (kind, list) = para_kind(doc, p);
let list = flow_list(kind, list, p, opts);
let list = flow_list(doc, kind, list, p, opts);
let mut done: Vec<Para> = Vec::new();
let borders = if kind == Kind::Rule { None } else { p.props.borders.filter(|b| b.takes_room()) };
let mut out = Para {
@@ -494,7 +522,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
// Formats that keep hidden text get it, marked hidden.
let hidden_text = (props.hidden.unwrap_or(false) && !opts.chars) || (props.del.is_some() && !tracked);
let hidden_obj = (props.hidden.unwrap_or(false) && !opts.chars) || props.del.is_some();
let mut f = run_fmt(doc, props, in_code, opts);
let mut f = run_fmt(doc, p.props.style.as_deref(), props, in_code, opts);
if tracked {
(f.ins, f.del) = rev_marks(doc, props);
f.fmt_change = props.fmt_change.as_deref().map(|c| Box::new(FmtChange { mark: rev_mark(doc, c.rev), old: fmt_of(doc, &c.old, in_code) }));
+14
View File
@@ -50,6 +50,20 @@ impl WithExtra for CharProps {
if f.underline {
self.underline_color = x.underline_color;
}
// Turned off on the text, over what the style of its paragraph or span turns on.
if !f.bold && x.bold == Some(false) {
self.bold = Some(false);
}
if !f.italic && x.italic == Some(false) {
self.italic = Some(false);
}
if !f.strike && x.strike == Some(false) {
// A double strike under it goes too.
(self.strike, self.double_strike) = (Some(false), Some(false));
}
if !f.underline && x.underline == Some(Underline::None) {
self.underline = Some(Underline::None);
}
if x.double_strike == Some(true) {
(self.strike, self.double_strike) = (None, Some(true));
}
+35 -2
View File
@@ -1259,6 +1259,7 @@ impl Body<'_> {
if self.frame.is_none() && self.note.is_none() && self.del.is_none() && !self.in_tc {
self.group = Some(OpenGroup {
anchor: get(a, "text:anchor-type").map(String::from),
page: get(a, "text:anchor-page-number").map(String::from),
style: get(a, "draw:style-name").map(String::from),
members: Vec::new(),
});
@@ -1300,10 +1301,14 @@ impl Body<'_> {
}
"table-column" => {
let w = get(a, "table:style-name").and_then(|n| self.styles.column_width(n)).unwrap_or(0.0);
let share = self.styles.column_share(get(a, "table:style-name")).unwrap_or(0.0);
let cells = get(a, "table:default-cell-style-name").and_then(|s| self.styles.cell_background(s));
let n = get(a, "table:number-columns-repeated").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).max(1);
if let Some(t) = self.tables.last_mut() {
let room = wordcraft_doc::table::MAX_COLS.saturating_sub(t.widths.len());
t.widths.extend(std::iter::repeat_n(w, n.min(room)));
t.shares.extend(std::iter::repeat_n(share, n.min(room)));
t.column_backgrounds.extend(std::iter::repeat_n(cells, n.min(room)));
}
}
"table-row" => self.start_row(a),
@@ -1311,7 +1316,16 @@ impl Body<'_> {
self.end_para();
let span = get(a, "table:number-columns-spanned").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(1).clamp(1, 63);
let rows = get(a, "table:number-rows-spanned").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(1).clamp(1, 1000);
let shading = get(a, "table:style-name").and_then(|s| self.styles.cell_background(s));
// WordCraft's tables have their backgrounds on their cells: a row's or table's on
// those with none of their own, as it shows behind them. A cell with no style has
// its column's default cell style; LibreOffice doesn't apply a row's.
let behind = self.tables.last().and_then(|t| t.row_background.or(t.background));
let in_column = self.tables.last().and_then(|t| {
let at: usize = t.row.iter().map(|c| c.colspan.max(1) as usize).sum();
t.column_backgrounds.get(at).copied().flatten()
});
let own = get(a, "table:style-name").map_or(in_column, |s| self.styles.cell_background(s));
let shading = own.or(behind);
let valign = get(a, "table:style-name").and_then(|s| self.styles.cell_valign(s));
let borders = get(a, "table:style-name").and_then(|s| self.styles.cell_borders(s));
let margins = get(a, "table:style-name").and_then(|s| self.styles.cell_padding(s));
@@ -1391,16 +1405,35 @@ impl Body<'_> {
// LibreOffice's tables keep their columns' widths, whatever their content (and however
// far past the margins): Word's fixed layout.
let space = get(a, "table:style-name").map(|s| self.styles.table_space(s)).unwrap_or_default();
// What the shares divide: an aligned table's stated width, however wide, as LibreOffice
// keeps it; else its part of the text width.
let room = width.filter(|_| align.is_some() && rel.is_none()).or_else(|| width_pct.map(|p| self.sec.text_width() * p / 100.0));
let contained = !self.tables.is_empty() || self.containers.len() > 1 || self.sec.columns.count > 1;
let extra =
model::TableExtra { align, width: None, width_pct, heights: Vec::new(), keep_rows: Vec::new(), fixed: true, space, header_rows: 0 };
self.tables.push(TableB { rows: Vec::new(), row: Vec::new(), hcover: 0, widths: Vec::new(), extra });
let background = get(a, "table:style-name").and_then(|s| self.styles.table_background(s));
self.tables.push(TableB {
rows: Vec::new(),
row: Vec::new(),
hcover: 0,
widths: Vec::new(),
shares: Vec::new(),
room,
contained,
column_backgrounds: Vec::new(),
extra,
background,
row_background: None,
});
}
/// A table row (`table:table-row`) begins: its height, kept as rows are (see the row's end).
fn start_row(&mut self, a: &Attrs) {
let height = get(a, "table:style-name").and_then(|s| self.styles.row_height(s));
let background = get(a, "table:style-name").and_then(|s| self.styles.table_background(s));
let keep = self.styles.row_keep(get(a, "table:style-name"));
if let Some(t) = self.tables.last_mut() {
t.row_background = background;
t.row.clear();
t.hcover = 0;
if t.extra.heights.len() < wordcraft_doc::table::MAX_ROWS {
+56 -5
View File
@@ -61,9 +61,12 @@ pub(in crate::odt) fn read_formula(xml: &[u8]) -> Option<(String, bool)> {
/// A frame being read: its width and height, picture bytes, alt text and the equation read from
/// an embedded formula object.
/// A group being read (ODF 1.3 §10.3.15 `draw:g`): its anchor, its style, and its members so far.
/// A group being read (ODF 1.3 §10.3.15 `draw:g`): its anchor and page, its style, and its members
/// so far.
pub(in crate::odt) struct OpenGroup {
pub(in crate::odt) anchor: Option<String>,
/// Its `text:anchor-page-number`.
pub(in crate::odt) page: Option<String>,
pub(in crate::odt) style: Option<String>,
pub(in crate::odt) members: Vec<model::Member>,
}
@@ -108,6 +111,13 @@ impl Body<'_> {
self.frame = None;
return;
}
// In line with no stated size, a picture comes at its own: fitted to the text, as
// other formats' pictures are. A stated size is the file's.
let room = self.sec.text_width();
let says = |v: Option<f32>| v.is_some_and(|v| v.is_finite() && v > 0.0);
// (Still in line when raised, turned or mirrored.)
let in_line = float.as_ref().is_none_or(|f| f.wrap == wordcraft_doc::para::Wrap::Inline);
let fit = (!says(w) && !says(h) && in_line && room.is_finite() && room > 0.0).then_some(room);
let inline = match (boxed, eq) {
(Some(blocks), _) => {
let mut look = std::mem::take(&mut self.frame_look);
@@ -146,12 +156,14 @@ impl Body<'_> {
.and_then(|data| picture(data, w, h, alt.trim()))
.or_else(|| crate::odt::objects::placeholder(&o.mime).and_then(|p| make_img(p, w, h, alt.trim())));
shown.map(|mut img| {
fit_to(&mut img, fit);
img.float = float.map(Box::new);
img.object = Some(std::sync::Arc::new(o));
Inline::Image(img)
})
}
(None, None) => img.and_then(|data| picture(data, w, h, alt.trim())).map(|mut img| {
fit_to(&mut img, fit);
img.float = float.map(Box::new);
if let Some(clip) = self.frame_clip.take() {
img.crop = crop_fractions(clip, &img.data);
@@ -338,7 +350,9 @@ impl Body<'_> {
}
let members = g.members.into_iter().map(|m| model::Member { x: fin(m.x) - x0, y: fin(m.y) - y0, ..m }).collect();
let graphic = self.styles.shape_graphic(g.style.as_deref());
let float = crate::odt::frames::float_of(g.anchor.as_deref(), Some(&crate::odt::writer::pt(x0)), Some(&crate::odt::writer::pt(y0)), &graphic);
let mut float =
crate::odt::frames::float_of(g.anchor.as_deref(), Some(&crate::odt::writer::pt(x0)), Some(&crate::odt::writer::pt(y0)), &graphic);
float.page = crate::odt::frames::page_of(g.anchor.as_deref(), g.page.as_deref(), &graphic);
let float = (float.wrap != wordcraft_doc::para::Wrap::Inline).then(|| Box::new(float));
let fit = |v: f32| if v.is_finite() { v.clamp(1.0, MAX_LEN) } else { 1.0 };
self.push_inline(Inline::Group { w: fit(x1 - x0), h: fit(y1 - y0), float, members });
@@ -358,6 +372,7 @@ impl Body<'_> {
let at = |v: Option<f32>| v.map(crate::odt::writer::pt);
let mut f = crate::odt::frames::float_of(get(a, "text:anchor-type"), at(x).as_deref(), at(y).as_deref(), &g);
f.z = crate::odt::frames::z_of(a);
f.page = crate::odt::frames::page_of(get(a, "text:anchor-type"), get(a, "text:anchor-page-number"), &g);
f
} else {
crate::odt::frames::place(a, w, h, &g, self.text_size())
@@ -393,14 +408,50 @@ pub(in crate::odt) fn readable_picture(data: &[u8]) -> bool {
/// A picture from a package's bytes: a raster image, or a Windows metafile (WMF, EMF: LibreOffice
/// keeps clip art and logos so), sized `w` × `h` (points) when the frame says.
pub(in crate::odt) fn picture(data: Vec<u8>, w: Option<f32>, h: Option<f32>, alt: &str) -> Option<model::Img> {
if wordcraft_metafile::is_metafile(&data) {
// What the frame states, if it could be a size (far past any page, kept from overflowing).
let stated = |v: Option<f32>| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(1e6));
let (w, h) = (stated(w), stated(h));
let mut img = if wordcraft_metafile::is_metafile(&data) {
// An EMF starts with its header record, type 1.
let ext = if data.get(..4) == Some(&[1, 0, 0, 0]) { "emf" } else { "wmf" };
// Its own size, when the frame gives none: a placeable WMF's or an EMF's frame, points.
let natural = if w.is_some() && h.is_some() { None } else { metafile_size(&data) };
return Some(model::sized_img(std::sync::Arc::new(data), ext, natural, w, h, alt));
model::sized_img(std::sync::Arc::new(data), ext, natural, w, h, alt)
} else {
make_img(data, w, h, alt)?
};
// A size the frame states is the file's, however large (a picture across a whole page): only
// a picture with none is shrunk to a column. Stated on one side, the other follows the
// picture's proportions.
let (w, h) = match (w, h) {
(Some(w), Some(h)) => (w, h),
(Some(w), None) => (w, w * height_per_width(&img.data)),
(None, Some(h)) => (h / height_per_width(&img.data), h),
(None, None) => return Some(img),
};
// Past what a document holds, it shrinks in proportion.
let (w, h) = match w.max(h) {
most if most <= 1584.0 => (w, h),
_ if w >= h => (1584.0, h * 1584.0 / w),
_ => (w * 1584.0 / h, 1584.0),
};
(img.w, img.h) = (w.max(1.0), h.max(1.0));
Some(img)
}
/// `img` shrunk in proportion to `room` points wide, when it's given and the picture is wider.
fn fit_to(img: &mut model::Img, room: Option<f32>) {
if let Some(room) = room.filter(|room| img.w > *room) {
img.h = (img.h * room / img.w).max(1.0);
img.w = room.max(1.0);
}
make_img(data, w, h, alt)
}
/// A picture's height for each point of its width, by its own size, before anything caps it (1
/// when it doesn't say).
fn height_per_width(data: &[u8]) -> f32 {
let own = if wordcraft_metafile::is_metafile(data) { metafile_size(data) } else { model::image_px(data).map(|(w, h)| (w as f32, h as f32)) };
own.filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0).map_or(1.0, |(w, h)| h / w)
}
/// A metafile's own size, points, from its header: a placeable WMF's bounding box in its units
+55 -2
View File
@@ -73,14 +73,67 @@ pub(in crate::odt) struct TableB {
pub(in crate::odt) hcover: usize,
/// Widths in points of the `table:table-column`s so far, 0 where the style gives none.
pub(in crate::odt) widths: Vec<f32>,
/// Each column's share of the table (`style:rel-column-width`), 0 where the style gives none.
pub(in crate::odt) shares: Vec<f32>,
/// The table's width, points: what the shares divide.
pub(in crate::odt) room: Option<f32>,
/// It is in a cell, a frame or a column narrower than the page, whose width isn't known here.
pub(in crate::odt) contained: bool,
/// Its alignment and width, and each row's height so far (see [`crate::model::TableExtra`]).
pub(in crate::odt) extra: crate::model::TableExtra,
/// The background of each column's default cell style (`table:default-cell-style-name`).
pub(in crate::odt) column_backgrounds: Vec<Option<wordcraft_doc::props::Rgb>>,
/// The table's background, and the row's being read.
pub(in crate::odt) background: Option<wordcraft_doc::props::Rgb>,
pub(in crate::odt) row_background: Option<wordcraft_doc::props::Rgb>,
}
/// The narrowest column the document model keeps (a narrower one would lose the table its grid).
const MIN_COLUMN: f32 = 6.0;
/// Each column's width out of `room` points, as LibreOffice shares it out: by its share if it has
/// one, else its stated width, which comes off the room first; none narrower than a column can be
/// (6 pt). Empty if a column has neither, or no room is left to share.
fn widths_of_shares(widths: &[f32], shares: &[f32], room: f32) -> Vec<f32> {
let columns = || widths.iter().zip(shares);
let usable = |v: f32| v.is_finite() && v > 0.0;
let left = room - columns().filter(|(_, s)| **s <= 0.0).map(|(w, _)| *w).sum::<f32>();
let total: f32 = shares.iter().filter(|s| **s > 0.0).sum();
if widths.len() != shares.len() || !usable(left) || !usable(total) || columns().any(|(w, s)| *w <= 0.0 && *s <= 0.0) {
return Vec::new();
}
columns().map(|(w, s)| if *s > 0.0 { (s / total * left).max(MIN_COLUMN) } else { *w }).collect()
}
impl TableB {
pub(in crate::odt) fn finish(self) -> FTable {
// A column without a width leaves the whole grid to the equal split.
let widths = if self.widths.iter().all(|w| *w > 0.0) { self.widths } else { Vec::new() };
// Columns stating their shares (LibreOffice's tables filling the margins, §20.334) divide
// the table's width by them; a column with neither leaves the whole grid to the equal split.
// An aligned table's shares go before the widths its columns state too, as LibreOffice
// reads them. (A table filling the margins is fitted to them whatever its widths say, and
// files give it both in proportion; in something narrower than the page, whose width isn't
// known here, stated widths are the best guess.)
let all_widths = self.widths.iter().all(|w| *w > 0.0);
let all_shares = !self.shares.is_empty() && self.shares.iter().all(|s| *s > 0.0);
let aligned = self.extra.align.is_some() && !self.contained;
let by_shares = self
.room
.filter(|_| all_shares && (aligned || !all_widths))
.map(|room| widths_of_shares(&self.widths, &self.shares, room))
.filter(|w| !w.is_empty());
let widths = if let Some(w) = by_shares {
w
} else if all_widths {
self.widths
} else {
// With nothing to divide, or in something narrower than the page with widths stated
// too, the shares can't be worked out here: the equal split, as before.
let mixed = self.shares.iter().any(|s| *s <= 0.0);
match self.room.filter(|_| !(self.contained && mixed)) {
Some(room) => widths_of_shares(&self.widths, &self.shares, room),
None => Vec::new(),
}
};
let mut rows = self.rows;
share_edges(&mut rows);
// A table whose cells all say "no line" draws none.
+18 -1
View File
@@ -417,12 +417,26 @@ pub(super) fn place(a: &Attrs, w: f32, h: f32, g: &Graphic, size: f32) -> Float
f.rot = rot;
}
f.z = z_of(a);
f.page = page_of(get(a, "text:anchor-type"), get(a, "text:anchor-page-number"), g);
if f.wrap == Wrap::Inline {
f.y = inline_raise(g, y.as_deref().and_then(length), h, size);
}
f
}
/// The page a drawing anchored as `anchor` (or as its style says) is on: `number`, its
/// `text:anchor-page-number` (§19.872). 0 when it states none, or isn't anchored to a page, where
/// LibreOffice ignores the number.
pub(super) fn page_of(anchor: Option<&str>, number: Option<&str>, g: &Graphic) -> u32 {
if anchor.or(g.anchor.as_deref()).map(str::trim) != Some("page") {
return 0;
}
number.and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(0).min(MAX_ANCHOR_PAGE)
}
/// Highest anchor page kept.
const MAX_ANCHOR_PAGE: u32 = 1_000_000;
/// A drawing's place in the stacking order (§19.231 `draw:z-index`, from 0 at the bottom) as
/// WordCraft's [`Float::z`] (higher on top, 0 for unknown): one more.
pub(super) fn z_of(a: &Attrs) -> u32 {
@@ -564,7 +578,10 @@ pub(super) fn turned(f: &Float) -> bool {
/// `style:graphic-properties` attributes, and the frame's `svg:x`/`svg:y` attributes, or its
/// `draw:transform` when it is turned (`w` × `h` its size).
pub(super) fn frame_of(f: &Float, w: f32, h: f32) -> (&'static str, String, String) {
let anchor = if f.h_rel == Anchor::Page && f.v_rel == Anchor::Page {
// A page number holds until it is placed by its paragraph: moved in WordCraft.
let by_paragraph =
matches!(f.h_rel, Anchor::Column | Anchor::Character) || matches!(f.v_rel, Anchor::Paragraph | Anchor::Line | Anchor::Character);
let anchor = if (f.page > 0 && !by_paragraph) || (f.h_rel == Anchor::Page && f.v_rel == Anchor::Page) {
"page"
} else if f.v_rel == Anchor::Line {
"char"
+157 -3
View File
@@ -296,6 +296,8 @@ fn add_custom_styles(doc: &mut Document, named: &[super::styles::NamedStyle], od
}),
contextual_spacing: look.contextual,
bidi: look.rtl.filter(|r| *r),
// The document's nine levels; ODF's tenth is the last of them.
outline_level: def.outline.map(|o| o.clamp(1, 9) - 1),
..Default::default()
};
let name = super::writer::generating_style(name).map_or_else(|| def.name.clone(), |(_, n)| n);
@@ -327,7 +329,7 @@ fn turned_off(mut chr: wordcraft_doc::props::CharProps, off: [bool; 4]) -> wordc
chr.underline = Some(wordcraft_doc::props::Underline::None);
}
if strike {
chr.strike = Some(false);
(chr.strike, chr.double_strike) = (Some(false), Some(false));
}
chr
}
@@ -362,7 +364,7 @@ fn apply_look(doc: &mut Document, kind: model::Kind, look: &super::styles::ParaL
fn number_headings(doc: &mut Document, levels: &[super::styles::ListLevel]) {
use wordcraft_doc::numbering::{AbstractNum, ListKind, Num, levels_for};
use wordcraft_doc::props::NumRef;
let numbered: Vec<bool> = (0..6).map(|i| levels.get(i).is_some_and(|l| l.ordered)).collect();
let numbered: Vec<bool> = (0..9).map(|i| levels.get(i).is_some_and(|l| l.ordered)).collect();
if !numbered.contains(&true) {
return;
}
@@ -395,11 +397,155 @@ fn number_headings(doc: &mut Document, levels: &[super::styles::ListLevel]) {
numbering.abstracts.push(AbstractNum { id: aid, name: None, levels: out, ..Default::default() });
let nid = numbering.nums.iter().map(|n| n.id + 1).max().unwrap_or(1).max(1);
numbering.nums.push(Num { id: nid, abstract_id: aid, ..Default::default() });
let styles = Arc::make_mut(&mut doc.styles);
for (i, on) in numbered.iter().enumerate() {
if *on && let Some(st) = Arc::make_mut(&mut doc.styles).get_mut(&format!("Heading{}", i + 1)) {
if *on && let Some(st) = styles.get_mut(&format!("Heading{}", i + 1)) {
st.para.numbering = Some(NumRef { num: nid, level: i as u8 });
}
}
// LibreOffice numbers a custom style at an outline level along with that level's headings.
for st in &mut styles.styles {
if let Some(l) = st.para.outline_level.filter(|l| numbered.get(usize::from(*l)) == Some(&true)) {
st.para.numbering = Some(NumRef { num: nid, level: l });
}
}
// A style at a level the outline doesn't number doesn't take its parent's number from it.
let of_outline = |st: &wordcraft_doc::styles::Style| {
let at = wordcraft_doc::props::ParaProps { style: Some(st.id.clone()), ..Default::default() };
st.para.numbering.is_none()
&& st.para.outline_level.is_some_and(|l| numbered.get(usize::from(l)) != Some(&true))
&& styles.resolve_para(&at).numbering.is_some_and(|n| n.num == nid)
};
let unnumbered: Vec<String> = styles.styles.iter().filter(|st| of_outline(st)).map(|st| st.id.clone()).collect();
for id in unnumbered {
if let Some(st) = styles.get_mut(&id) {
// (`num: 0` switches the number off.)
st.para.numbering = Some(NumRef { num: 0, level: 0 });
}
}
// A heading's own outline level goes before its style's: LibreOffice numbers it by that level.
// A heading in a list is that list's.
let styles = doc.styles.clone();
each_body_para(&mut doc.body, 0, &mut |p| {
let Some(l) = p.props.outline_level.filter(|_| p.props.numbering.is_none()) else { return };
let style = styles.resolve_para(&wordcraft_doc::props::ParaProps { style: p.props.style.clone(), ..Default::default() });
if style.outline_level.is_none() {
return;
}
let own = (numbered.get(usize::from(l)) == Some(&true)).then_some(NumRef { num: nid, level: l });
if own != style.numbering {
// (`num: 0` switches the style's number off.)
p.props.numbering = Some(own.unwrap_or(NumRef { num: 0, level: 0 }));
}
});
}
/// Each paragraph of `blocks`, those in tables too (to 16 levels deep).
fn each_body_para(blocks: &mut [Arc<wordcraft_doc::Block>], depth: usize, f: &mut dyn FnMut(&mut wordcraft_doc::Paragraph)) {
for b in blocks {
match Arc::make_mut(b) {
wordcraft_doc::Block::Para(p) => f(p),
wordcraft_doc::Block::Table(t) if depth < 16 => {
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
each_body_para(&mut cell.blocks, depth + 1, f);
}
}
wordcraft_doc::Block::Table(_) => {}
}
}
}
/// What a run's complex size comes from: its paragraph's style, its character style, and the size
/// and complex size it states (as bits).
type SizeSource<'a> = (Option<&'a str>, Option<&'a str>, Option<u32>, Option<u32>);
/// A complex-script size the same as the text's (what WordCraft writes for every script) is no size
/// of its own: a later change of size takes it along. The default's and the styles' go together,
/// unless a run's complex size would change: a size stated alone, or another complex size between
/// them, shows what's above it, so they stay as the file says. A run's goes where its stays the same.
fn one_size_for_all_scripts(doc: &mut Document) {
let mut sources = std::collections::BTreeSet::new();
for blocks in std::iter::once(&doc.body).chain(doc.parts.values().map(|part| &part.blocks)) {
size_sources(blocks, 0, &mut sources);
}
let sizes = |styles: &wordcraft_doc::styles::StyleSheet| -> Vec<f32> {
let of = |(para, chr, size, size_cs): &SizeSource| {
let run = wordcraft_doc::props::CharProps {
style: chr.map(str::to_string),
size: size.map(f32::from_bits),
size_cs: size_cs.map(f32::from_bits),
..Default::default()
};
styles.resolve_char(*para, &run).size_cs
};
sources.iter().map(of).collect()
};
let as_read = Arc::clone(&doc.styles);
let was = sizes(&as_read);
let styles = Arc::make_mut(&mut doc.styles);
let one_size = |c: &mut wordcraft_doc::props::CharProps| {
if c.size.is_some() && c.size_cs == c.size {
c.size_cs = None;
}
};
one_size(&mut styles.default_chr);
styles.styles.iter_mut().for_each(|st| one_size(&mut st.chr));
if sizes(&doc.styles) != was {
doc.styles = as_read;
}
let styles = Arc::clone(&doc.styles);
let stories = std::iter::once(&mut doc.body).chain(doc.parts.values_mut().map(|part| &mut part.blocks));
for blocks in stories {
runs_one_size(blocks, 0, &styles);
}
}
/// What the runs in `blocks` (and their tables, to 16 levels deep) take their complex sizes from.
fn size_sources<'a>(blocks: &'a [Arc<wordcraft_doc::Block>], depth: usize, out: &mut std::collections::BTreeSet<SizeSource<'a>>) {
for b in blocks {
match &**b {
wordcraft_doc::Block::Para(p) => {
for r in &p.runs {
out.insert((
p.props.style.as_deref(),
r.props.style.as_deref(),
r.props.size.map(f32::to_bits),
r.props.size_cs.map(f32::to_bits),
));
}
}
wordcraft_doc::Block::Table(t) if depth < 16 => {
for cell in t.rows.iter().flat_map(|r| r.cells.iter()) {
size_sources(&cell.blocks, depth + 1, out);
}
}
wordcraft_doc::Block::Table(_) => {}
}
}
}
/// [`one_size_for_all_scripts`] for the runs in `blocks` (and their tables, to 16 levels deep).
fn runs_one_size(blocks: &mut [Arc<wordcraft_doc::Block>], depth: usize, styles: &wordcraft_doc::styles::StyleSheet) {
for b in blocks {
match Arc::make_mut(b) {
wordcraft_doc::Block::Para(p) => {
let style = p.props.style.clone();
for r in &mut p.runs {
let Some(said) = r.props.size_cs.filter(|s| Some(*s) == r.props.size) else { continue };
r.props.size_cs = None;
if styles.resolve_char(style.as_deref(), &r.props).size_cs != said {
r.props.size_cs = Some(said);
}
}
}
wordcraft_doc::Block::Table(t) if depth < 16 => {
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
runs_one_size(&mut cell.blocks, depth + 1, styles);
}
}
wordcraft_doc::Block::Table(_) => {}
}
}
}
/// The boolean configuration item `name` of `settings.xml` (ODF 1.3 §3.10), when it has one.
@@ -655,6 +801,8 @@ struct Parsed {
widows: Option<bool>,
/// The document's default Asian and complex-script fonts and languages.
scripts: wordcraft_doc::props::CharProps,
/// The document's default language, if it states one.
default_lang: Option<String>,
/// The default tab interval, points.
default_tab: Option<f32>,
/// Tab stops are measured from the indent (LibreOffice's `TabsRelativeToIndent`).
@@ -850,6 +998,7 @@ fn parse_tracked(bytes: &[u8], paper: (f32, f32)) -> Result<Parsed, String> {
kern: styles.default_kern,
widows: styles.stated_widows(),
scripts: styles.default_scripts.clone(),
default_lang: styles.document_lang(),
default_tab: styles.default_tab,
tabs_from_indent: styles.tabs_from_indent,
page_doc: pages.doc.clone(),
@@ -895,6 +1044,7 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
kern,
widows,
scripts,
default_lang,
default_tab,
tabs_from_indent,
page_doc,
@@ -932,6 +1082,9 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
}
Arc::make_mut(&mut doc.styles).default_chr.kern = kern.then_some(1.0);
Arc::make_mut(&mut doc.styles).default_chr.overlay(&scripts);
// The file's own, or none when it states none (each application then uses its own default):
// not the English of WordCraft's new documents.
Arc::make_mut(&mut doc.styles).default_chr.lang = default_lang;
if let Some(t) = default_tab {
doc.settings.default_tab = t;
}
@@ -970,6 +1123,7 @@ pub fn import_on(bytes: &[u8], page: &SectionProps) -> Result<Document, String>
}
doc.last_section = last;
page_parts::add(&mut doc, parts);
one_size_for_all_scripts(&mut doc);
if let Some(t) = track {
doc.settings.track_changes = t;
}
+63 -15
View File
@@ -29,7 +29,7 @@ fn columns_xml(c: &Columns) -> String {
/// Preformatted Text), each with the look of the document's own style: font, size, bold,
/// italic, colour and the rest of its text formatting, spacing, indents, alignment and keep with
/// next, written out in full so other programs show them as WordCraft does.
fn kind_styles(doc: &Document) -> String {
fn kind_styles(doc: &Document, on_outline: &std::collections::BTreeSet<String>) -> String {
let mut s = String::new();
let mut kinds: Vec<(String, String, String, Option<u8>, &str)> =
(1..=6).map(|n| (format!("Heading_20_{n}"), format!("Heading {n}"), format!("Heading{n}"), Some(n), "text")).collect();
@@ -63,14 +63,7 @@ fn kind_styles(doc: &Document) -> String {
if let Some(c) = pp.contextual_spacing {
para.push_str(&format!(" style:contextual-spacing=\"{c}\""));
}
// A heading level the outline doesn't number names no list style: LibreOffice would
// otherwise give it the outline's (unnumbered) level, and its indent.
let numbered = pp
.numbering
.and_then(|r| doc.numbering.level(r.num, r.level))
.is_some_and(|l| !matches!(l.format, wordcraft_doc::section::NumFormat::None | wordcraft_doc::section::NumFormat::Bullet));
let detach = if outline.is_some() && !numbered { " style:list-style-name=\"\"" } else { "" };
let outline = outline.map(|n| format!(" style:default-outline-level=\"{n}\"{detach}")).unwrap_or_default();
let outline = outline.map(|n| outline_attrs(doc, n, &pp, on_outline.contains(&id))).unwrap_or_default();
s.push_str(&format!(
"<style:style style:name=\"{name}\" style:display-name=\"{display}\" style:family=\"paragraph\" style:parent-style-name=\"Standard\" style:next-style-name=\"Standard\"{outline} style:class=\"{class}\"><style:paragraph-properties{para}/><style:text-properties{}/></style:style>",
props_text(&cp)
@@ -79,6 +72,42 @@ fn kind_styles(doc: &Document) -> String {
s
}
/// The styles of the headings in `blocks` (and their tables, to 16 levels deep) with a number by
/// their own outline level.
fn styles_numbered_by_own_level(blocks: &[std::sync::Arc<wordcraft_doc::Block>], depth: usize, out: &mut std::collections::BTreeSet<String>) {
for b in blocks {
match &**b {
wordcraft_doc::Block::Para(p) => {
if p.props.outline_level.is_some()
&& p.props.numbering.is_some_and(|n| n.num != 0)
&& let Some(s) = &p.props.style
{
out.insert(s.clone());
}
}
wordcraft_doc::Block::Table(t) if depth < 16 => {
for cell in t.rows.iter().flat_map(|r| r.cells.iter()) {
styles_numbered_by_own_level(&cell.blocks, depth + 1, out);
}
}
wordcraft_doc::Block::Table(_) => {}
}
}
}
/// A paragraph style's outline level `n` (`style:default-outline-level`), its properties being `pp`.
fn outline_attrs(doc: &Document, n: u8, pp: &wordcraft_doc::props::ParaProps, on_outline: bool) -> String {
// A level the outline doesn't number names no list style: LibreOffice would otherwise give
// it the outline's (unnumbered) level, and its indent. Unless a heading in the style has a
// number by its own level (`on_outline`), which that would take from it.
let numbered = pp
.numbering
.and_then(|r| doc.numbering.level(r.num, r.level))
.is_some_and(|l| !matches!(l.format, wordcraft_doc::section::NumFormat::None | wordcraft_doc::section::NumFormat::Bullet));
let detach = if numbered || on_outline { "" } else { " style:list-style-name=\"\"" };
format!(" style:default-outline-level=\"{n}\"{detach}")
}
/// The ODF parent of document style `id`: Standard for Normal, the kinds' styles for theirs, a
/// custom style by its id.
fn odf_parent(doc: &Document, id: Option<&str>) -> String {
@@ -98,7 +127,7 @@ fn odf_parent(doc: &Document, id: Option<&str>) -> String {
/// The custom paragraph styles in `named` and the custom styles they are based on, as common
/// styles (ODF 1.3 §16.2): named by their document id, shown by their name, each with its own
/// paragraph and text formatting over its parent's.
fn custom_styles(doc: &Document, named: &std::collections::BTreeSet<String>) -> String {
fn custom_styles(doc: &Document, named: &std::collections::BTreeSet<String>, on_outline: &std::collections::BTreeSet<String>) -> String {
let mut todo: Vec<String> = named.iter().cloned().collect();
let mut done = std::collections::BTreeSet::new();
let mut s = String::new();
@@ -146,16 +175,29 @@ fn custom_styles(doc: &Document, named: &std::collections::BTreeSet<String>) ->
})
.unwrap_or_default();
let parent = odf_parent(doc, st.based_on.as_deref());
let outline = pp.outline_level.filter(|l| *l < 9).map(|l| outline_attrs(doc, l + 1, pp, on_outline.contains(&id))).unwrap_or_default();
s.push_str(&format!(
"<style:style style:name=\"{}\" style:display-name=\"{}\" style:family=\"paragraph\" style:parent-style-name=\"{parent}\"><style:paragraph-properties{attrs}>{tabs}</style:paragraph-properties><style:text-properties{}/></style:style>",
"<style:style style:name=\"{}\" style:display-name=\"{}\" style:family=\"paragraph\" style:parent-style-name=\"{parent}\"{outline}><style:paragraph-properties{attrs}>{tabs}</style:paragraph-properties><style:text-properties{}/></style:style>",
super::writer::odf_name(&id),
x(&super::writer::generated_style(&id).map_or_else(|| st.name.clone(), |(_, d)| d)),
props_text(&st.chr)
props_text(&with_complex_size(doc, &id, &st.chr))
));
}
s
}
/// Paragraph style `id`'s own text formatting `chr`, with the complex-script size it takes when it
/// states only a size: a parent's complex size where one sets it (the model takes it from there,
/// and the writer would otherwise give it this style's size), else its own size.
fn with_complex_size(doc: &Document, id: &str, chr: &wordcraft_doc::props::CharProps) -> wordcraft_doc::props::CharProps {
let mut chr = chr.clone();
if chr.size.is_some() && chr.size_cs.is_none() {
let cs = doc.styles.resolve_char(Some(id), &wordcraft_doc::props::CharProps::default()).size_cs;
chr.size_cs = (chr.size != Some(cs)).then_some(cs);
}
chr
}
/// The custom character styles in `named` and those they are based on, as common text styles.
fn char_styles(doc: &Document, named: &std::collections::BTreeSet<String>) -> String {
let mut todo: Vec<String> = named.iter().cloned().collect();
@@ -346,25 +388,31 @@ fn styles_xml(
let tab = doc.settings.default_tab.clamp(1.0, 1584.0);
let mut s = format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-styles {NS}><office:styles>");
s.push_str(&format!(
"<style:default-style style:family=\"paragraph\"><style:paragraph-properties{}{widows} style:tab-stop-distance=\"{tab}\"/><style:text-properties fo:font-family=\"'{}'\" fo:font-size=\"{size}\" style:letter-kerning=\"{kern}\"{color}{scripts}/></style:default-style>",
"<style:default-style style:family=\"paragraph\"><style:paragraph-properties{}{widows} style:tab-stop-distance=\"{tab}\"/><style:text-properties fo:font-family=\"'{}'\" fo:font-size=\"{size}\" style:font-size-asian=\"{size}\"{size_cs} style:letter-kerning=\"{kern}\"{color}{lang}{scripts}/></style:default-style>",
spacing_props(defaults),
x(&body_font),
// Widow and orphan control as Normal has it, said either way (the standard leaves it open
// when unsaid, and readers differ: docs/odf-interpretation.md).
widows = if normal.widow_control { " fo:widows=\"2\" fo:orphans=\"2\"" } else { " fo:widows=\"0\" fo:orphans=\"0\"" },
size = pt(size),
size_cs = if doc.styles.default_chr.size_cs.is_none() { format!(" style:font-size-complex=\"{}\"", pt(size)) } else { String::new() },
// The document's text colour (a colour, not automatic).
color = match doc.styles.default_chr.color {
Some(wordcraft_doc::props::TextColor::Rgb(c)) => format!(" fo:color=\"#{}\"", c.hex().to_ascii_lowercase()),
_ => String::new(),
},
tab = pt(tab),
// The document's language, without which each reader uses its own default: Normal's, as
// WordCraft has it (a Word file's Normal may state another than its defaults).
lang = doc.styles.resolve_char(None, &Default::default()).lang.as_deref().map(|l| super::writer::lang_attrs(l, "")).unwrap_or_default(),
scripts = super::writer::script_attrs(&doc.styles.default_chr)
));
s.push_str("<style:style style:name=\"Standard\" style:family=\"paragraph\" style:class=\"text\"/>");
s.push_str(&kind_styles(doc));
let mut on_outline = std::collections::BTreeSet::new();
styles_numbered_by_own_level(&doc.body, 0, &mut on_outline);
s.push_str(&kind_styles(doc, &on_outline));
s.push_str(&outline_style(doc));
s.push_str(&custom_styles(doc, named));
s.push_str(&custom_styles(doc, named, &on_outline));
// The note text styles, as WordCraft's Footnote Text and Endnote Text: 10 pt, no spacing.
for (name, class) in [("Footnote", "extra"), ("Endnote", "extra")] {
s.push_str(&format!(
+169 -35
View File
@@ -39,6 +39,26 @@ impl FmtDelta {
($($k:ident),*) => { $( if let Some(v) = self.$k.clone() { f.$k = v; } )* };
}
set!(bold, italic, underline, strike, sup, sub, code);
// What this turns off stays said on the text, or the style of its paragraph or span would
// show through; turned back on, it no longer is.
if self.bold.is_some() || self.italic.is_some() || self.underline.is_some() || self.strike.is_some() {
use wordcraft_doc::props::Underline;
let mut x = f.extra.take().map(|b| *b).unwrap_or_default();
if self.strike == Some(false) {
x.double_strike = None;
}
for (state, said) in [(self.bold, &mut x.bold), (self.italic, &mut x.italic), (self.strike, &mut x.strike)] {
if let Some(on) = state {
*said = (!on).then_some(false);
}
}
match self.underline {
Some(false) => x.underline = Some(Underline::None),
Some(true) if x.underline == Some(Underline::None) => x.underline = None,
_ => {}
}
f.extra = (x != wordcraft_doc::props::CharProps::default()).then(|| Box::new(x));
}
if self.color.is_some() {
f.color = self.color;
}
@@ -61,10 +81,12 @@ impl FmtDelta {
let size = f.size.unwrap_or(12.0);
x.position = Some((r / 100.0 * size).clamp(-1584.0, 1584.0)).filter(|p| *p != 0.0);
}
// An explicit "off" only cancels what a parent style set.
for b in
[&mut x.double_strike, &mut x.caps, &mut x.small_caps, &mut x.hidden, &mut x.outline, &mut x.shadow, &mut x.emboss, &mut x.engrave]
{
// An explicit "off" only cancels what a parent style set (capitals are kept: a
// paragraph's or span's style may set them too).
if self.strike == Some(false) {
x.double_strike = None;
}
for b in [&mut x.double_strike, &mut x.hidden, &mut x.outline, &mut x.shadow, &mut x.emboss, &mut x.engrave] {
if *b == Some(false) {
*b = None;
}
@@ -123,14 +145,10 @@ fn script_properties(a: &Attrs, x: &mut wordcraft_doc::props::CharProps, faces:
if let Some(s) = get(a, "style:font-style-complex") {
x.italic_cs = Some(matches!(s.trim(), "italic" | "oblique"));
}
fn tag(v: Option<&str>) -> Option<&str> {
v.map(str::trim).filter(|v| (1..=8).contains(&v.len()) && v.chars().all(|c| c.is_ascii_alphanumeric()) && *v != "none" && *v != "zxx")
}
let lang = |l: &str, c: &str| tag(get(a, l)).map(|l| tag(get(a, c)).map_or_else(|| l.to_string(), |c| format!("{l}-{c}")));
if let Some(l) = lang("style:language-asian", "style:country-asian") {
if let Some(l) = language_tag(a, "style:language-asian", "style:script-asian", "style:country-asian") {
x.lang_ea = Some(l);
}
if let Some(l) = lang("style:language-complex", "style:country-complex") {
if let Some(l) = language_tag(a, "style:language-complex", "style:script-complex", "style:country-complex") {
x.lang_bidi = Some(l);
}
}
@@ -203,15 +221,26 @@ fn extra_text_properties(a: &Attrs, d: &mut FmtDelta) {
if let Some(v) = get(a, "style:font-relief") {
(x.emboss, x.engrave) = (Some(v.trim() == "embossed"), Some(v.trim() == "engraved"));
}
if let Some(l) = language(a) {
x.lang = Some(l);
}
}
/// The language `fo:language`, `fo:script` and `fo:country` state: see [`language_tag`].
fn language(a: &Attrs) -> Option<String> {
language_tag(a, "fo:language", "fo:script", "fo:country")
}
/// The language the attributes `language`, `script` and `country` state, as a tag (`de-DE`,
/// `sr-Latn-RS`); `None` when they state none, or no language at all (`none`, `zxx`). A script of
/// any other shape than a script's is left out: written back, it would read as a country.
fn language_tag(a: &Attrs, language: &str, script: &str, country: &str) -> Option<String> {
fn tag(v: Option<&str>) -> Option<&str> {
v.map(str::trim).filter(|v| (1..=8).contains(&v.len()) && v.chars().all(|c| c.is_ascii_alphanumeric()))
}
if let Some(l) = tag(get(a, "fo:language")).filter(|l| *l != "none" && *l != "zxx") {
x.lang = Some(match tag(get(a, "fo:country")).filter(|c| *c != "none") {
Some(c) => format!("{l}-{c}"),
None => l.to_string(),
});
v.map(str::trim).filter(|v| (1..=8).contains(&v.len()) && v.chars().all(|c| c.is_ascii_alphanumeric()) && *v != "none")
}
let language = tag(get(a, language)).filter(|l| *l != "zxx")?;
let script = tag(get(a, script)).filter(|s| super::writer::is_script(s));
Some([Some(language), script, tag(get(a, country))].into_iter().flatten().collect::<Vec<_>>().join("-"))
}
#[derive(Clone, Debug, Default)]
@@ -229,7 +258,7 @@ struct StyleInfo {
outline: Option<u8>,
border_bottom: bool,
/// `fo:background-color` of a table-cell style's `style:table-cell-properties`.
cell_background: Option<Rgb>,
cell_background: Option<Option<Rgb>>,
/// A table-cell style's background picture (§17.3 `style:background-image`): its package
/// path, `style:repeat` and `style:position`.
cell_picture: Option<(String, String, String)>,
@@ -242,6 +271,8 @@ struct StyleInfo {
cell_padding: [Option<f32>; 4],
/// `style:column-width` of a table-column style's `style:table-column-properties`, in points.
column_width: Option<f32>,
/// `style:rel-column-width` of the same: its share of the table (`1*`).
column_share: Option<f32>,
/// A table style's `style:table-properties`: `table:align`, and its width (`style:width`,
/// points, or `style:rel-width`, percent of the text width).
table_align: Option<Align>,
@@ -252,6 +283,9 @@ struct StyleInfo {
/// A table-row style's `style:table-row-properties`: its height (`style:row-height`, exact)
/// or least height (`style:min-row-height`), points.
row_height: Option<(f32, bool)>,
/// `fo:background-color` of a table or table-row style (§20.175): what shows behind cells
/// with none of their own. `Some(None)`: `transparent`, which clears a parent style's.
table_background: Option<Option<Rgb>>,
/// `fo:keep-together` of a table-row style: the row doesn't break across pages.
row_keep: Option<bool>,
/// `style:master-page-name` of a paragraph or table style: its paragraphs (tables) start a
@@ -455,6 +489,8 @@ pub(super) struct NamedStyle {
/// What it turns off, which a style it is based on may turn on: bold, italic, underline,
/// strike-through (`fo:font-weight="normal"`…).
pub(super) off: [bool; 4],
/// Its outline level, 1–10 (`style:default-outline-level`).
pub(super) outline: Option<u8>,
}
/// Reads date and time styles (`number:date-style`, `number:time-style`) as Word pictures.
@@ -571,8 +607,28 @@ pub(super) fn parse_border(v: &str) -> Option<Border> {
any.then_some(b)
}
/// The properties element a table column's or row's `style:default-style` (of `family`) holds.
fn table_default_props(family: Option<&str>) -> Option<&'static str> {
match family {
Some("table-column") => Some("table-column-properties"),
Some("table-row") => Some("table-row-properties"),
_ => None,
}
}
/// A table column's share of its table (`style:rel-column-width`, `1*`).
fn column_share_of(a: &Attrs) -> Option<f32> {
get(a, "style:rel-column-width").and_then(|v| v.trim().strip_suffix('*')?.trim().parse::<f32>().ok()).filter(|r| r.is_finite() && *r > 0.0)
}
/// A property element `name` (`q` in full) of the style being read, into `s`: true when it is
/// one (paragraph, table, column, cell, section and graphic properties, tab stops).
/// What `fo:background-color` says of a table or row: a colour, or none (`transparent`).
fn table_background_of(a: &Attrs) -> Option<Option<Rgb>> {
let bg = get(a, "fo:background-color")?;
if bg.trim() == "transparent" { Some(None) } else { model::parse_color(bg).map(Some) }
}
fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
match name {
"paragraph-properties" => paragraph_properties(a, s),
@@ -599,6 +655,7 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
s.page_number = Some(v.trim().parse::<u32>().ok().map(|n| n.min(32_767)));
}
// §20.300 `table:align`: `margins` fills the text width, as no alignment does.
s.table_background = table_background_of(a).or(s.table_background);
s.table_align = match get(a, "table:align").map(str::trim) {
Some("left") => Some(Align::Left),
Some("center") => Some(Align::Center),
@@ -620,6 +677,7 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
}
}
"table-row-properties" => {
s.table_background = table_background_of(a).or(s.table_background);
if let Some(h) = get(a, "style:row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) {
s.row_height = Some((h.min(MAX_LEN), true));
} else if let Some(h) = get(a, "style:min-row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) {
@@ -633,6 +691,9 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
if let Some(w) = get(a, "style:column-width").and_then(length) {
s.column_width = Some(w);
}
if let Some(r) = column_share_of(a) {
s.column_share = Some(r);
}
}
"table-cell-properties" => table_cell_properties(a, s),
"section-properties" => {
@@ -677,7 +738,7 @@ pub(super) fn writing_direction(m: &str) -> wordcraft_doc::props::TextDirection
/// A table-cell style's `style:table-cell-properties` (ODF 1.3 §17.18): background, borders,
/// padding and vertical alignment.
fn table_cell_properties(a: &Attrs, s: &mut StyleInfo) {
if let Some(c) = get(a, "fo:background-color").and_then(model::parse_color) {
if let Some(c) = table_background_of(a) {
s.cell_background = Some(c);
}
cell_borders_and_padding(a, s);
@@ -792,13 +853,18 @@ fn text_properties(a: &Attrs, style: &mut StyleInfo, faces: &HashMap<String, Str
let weight = weight.trim();
weight.eq_ignore_ascii_case("bold") || weight.parse::<u32>().is_ok_and(|n| n >= 600)
};
// Latin attributes take precedence; Asian and complex-script attributes are fallbacks.
if let Some(weight) = get(a, "fo:font-weight").or_else(|| get(a, "style:font-weight-asian")).or_else(|| get(a, "style:font-weight-complex")) {
// Latin attributes take precedence; Asian and complex-script attributes are fallbacks, to turn
// it on: one of those saying normal says nothing of Latin text.
if let Some(weight) = get(a, "fo:font-weight") {
fmt.bold = Some(is_bold(weight));
} else if get(a, "style:font-weight-asian").or_else(|| get(a, "style:font-weight-complex")).is_some_and(is_bold) {
fmt.bold = Some(true);
}
let is_italic = |value: &str| value.trim().eq_ignore_ascii_case("italic") || value.trim().eq_ignore_ascii_case("oblique");
if let Some(value) = get(a, "fo:font-style").or_else(|| get(a, "style:font-style-asian")).or_else(|| get(a, "style:font-style-complex")) {
if let Some(value) = get(a, "fo:font-style") {
fmt.italic = Some(is_italic(value));
} else if get(a, "style:font-style-asian").or_else(|| get(a, "style:font-style-complex")).is_some_and(is_italic) {
fmt.italic = Some(true);
}
let is_not_none = |value: &str| !value.trim().eq_ignore_ascii_case("none");
if let Some(value) = get(a, "style:text-underline-style").or_else(|| get(a, "style:text-underline-type")) {
@@ -1039,6 +1105,8 @@ pub(super) struct Styles {
pub(super) default_tab: Option<f32>,
/// The Asian and complex-script fonts and languages of `style:default-style` for paragraphs.
pub(super) default_scripts: wordcraft_doc::props::CharProps,
/// The language the default paragraph style states.
pub(super) default_lang: Option<String>,
/// Pair kerning of `style:default-style` for paragraphs (`style:letter-kerning`, §20.317;
/// off unless it says so).
pub(super) default_kern: bool,
@@ -1048,6 +1116,8 @@ pub(super) struct Styles {
default_color: Option<wordcraft_doc::props::Rgb>,
/// `style:default-style` for graphics: what every drawing shape's style falls back on.
default_graphic: super::frames::Graphic,
/// `style:default-style` for table columns: the share of a column whose style gives none.
default_column_share: Option<f32>,
/// `style:default-style` for table rows: `fo:keep-together`, for a row whose style doesn't say.
default_row_keep: Option<bool>,
/// The named dashes and gradients graphic styles use.
@@ -1057,12 +1127,23 @@ pub(super) struct Styles {
}
impl Styles {
/// A table column's or row's `style:default-style` properties (`props` names which): what a
/// column or row whose own style doesn't say falls back on.
fn read_table_default(&mut self, props: &str, a: &Attrs) {
if props == "table-row-properties" {
self.default_row_keep = get(a, "fo:keep-together").map(|k| k.trim() == "always");
} else {
self.default_column_share = column_share_of(a);
}
}
pub(super) fn read(&mut self, xml: &[u8], auto_section_is_auto: bool) {
let mut in_auto = false;
let mut cur: Option<(String, StyleInfo)> = None;
let mut in_default = false;
let mut in_graphic_default = false;
let mut in_row_default = false;
// In a table column's or row's `style:default-style`: the properties element it reads.
let mut in_table_default: Option<&str> = None;
let mut dates = DateStyles::default();
let mut list: Option<(String, Vec<ListLevel>)> = None;
// The list or outline level being read.
@@ -1091,20 +1172,20 @@ impl Styles {
"default-style" if q.starts_with("style:") => {
in_default = get(&a, "style:family") == Some("paragraph");
in_graphic_default = get(&a, "style:family") == Some("graphic");
in_row_default = get(&a, "style:family") == Some("table-row");
in_table_default = table_default_props(get(&a, "style:family"));
}
"graphic-properties" if in_graphic_default && cur.is_none() => self.default_graphic = super::frames::Graphic::read(&a),
"table-row-properties" if in_row_default && cur.is_none() => {
self.default_row_keep = get(&a, "fo:keep-together").map(|k| k.trim() == "always")
}
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));
}
self.default_kern = get(&a, "style:letter-kerning").is_some_and(|k| k.trim() == "true");
self.default_color = get(&a, "fo:color").and_then(model::parse_color);
script_properties(&a, &mut self.default_scripts, &self.faces);
self.default_lang = language(&a);
}
"paragraph-properties" if in_default && cur.is_none() => {
paragraph_spacing(&a, &mut self.default_spacing);
@@ -1163,7 +1244,7 @@ impl Styles {
},
Ev::End(q) => match local(&q) {
"automatic-styles" => in_auto = false,
"default-style" if q.starts_with("style:") => (in_default, in_graphic_default, in_row_default) = (false, false, false),
"default-style" if q.starts_with("style:") => (in_default, in_graphic_default, in_table_default) = (false, false, None),
"style" if q.starts_with("style:") => {
if let Some((n, s)) = cur.take()
&& self.map.len() < 100_000
@@ -1202,6 +1283,12 @@ impl Styles {
.map(|v| v.min(1638.0))
}
/// The document's language: its Standard style's, else its default paragraph style's. (Written
/// from the model, Standard is empty and the default style says it for both.)
pub(super) fn document_lang(&self) -> Option<String> {
self.map.get("Standard").filter(|s| !s.auto).and_then(|s| s.fmt.extra.lang.clone()).or_else(|| self.default_lang.clone())
}
/// The document's default font: the Standard paragraph style's, else the default style's.
pub(super) fn doc_font(&self) -> Option<String> {
self.map.get("Standard").filter(|s| !s.auto).and_then(|s| s.fmt.font.as_deref().map(str::to_string)).or_else(|| self.default_font.clone())
@@ -1297,6 +1384,32 @@ impl Styles {
let chain = self.chain(name);
let Some(at) = chain.iter().position(|(n, _)| self.is_named_text(n)) else { return self.text_fmt(name, base) };
let mut f = base.clone();
// What the span's character style says is its own: its paragraph turning that off doesn't
// reach past it (ODF: the span's style wins).
if let Some(x) = f.extra.as_deref_mut() {
use wordcraft_doc::props::Underline;
for (_, s) in chain.iter().skip(at) {
let (d, e) = (&s.fmt, &s.fmt.extra);
if d.bold.is_some() && x.bold == Some(false) {
x.bold = None;
}
if d.italic.is_some() && x.italic == Some(false) {
x.italic = None;
}
if d.underline.is_some() && x.underline == Some(Underline::None) {
x.underline = None;
}
if (d.strike.is_some() || e.double_strike.is_some()) && x.strike == Some(false) {
(x.strike, x.double_strike) = (None, None);
}
if e.caps.is_some() && x.caps == Some(false) {
x.caps = None;
}
if e.small_caps.is_some() && x.small_caps == Some(false) {
x.small_caps = None;
}
}
}
for (_, s) in chain.iter().take(at).rev() {
s.fmt.apply(&mut f);
}
@@ -1340,6 +1453,12 @@ impl Styles {
self.chain(name).iter().find_map(|(_, s)| s.column_width)
}
/// A table column's share of its table (`style:rel-column-width`): its style's, whole chain,
/// else the default style's.
pub(super) fn column_share(&self, name: Option<&str>) -> Option<f32> {
name.and_then(|n| self.chain(n).iter().find_map(|(_, s)| s.column_share)).or(self.default_column_share)
}
/// A table style's alignment, width (points) and relative width (percent), whole chain.
/// Whether a table style starts a page (`fo:break-before="page"`, whole chain).
pub(super) fn table_breaks(&self, name: &str) -> bool {
@@ -1379,8 +1498,13 @@ impl Styles {
self.chain(name).iter().find_map(|(_, s)| s.cell_picture.clone())
}
pub(super) fn table_background(&self, name: &str) -> Option<Rgb> {
self.chain(name).iter().find_map(|(_, s)| s.table_background).flatten()
}
/// Background of a table-cell style (whole chain).
pub(super) fn cell_background(&self, name: &str) -> Option<Rgb> {
self.chain(name).iter().find_map(|(_, s)| s.cell_background)
self.chain(name).iter().find_map(|(_, s)| s.cell_background).flatten()
}
/// Borders of a table-cell style (whole chain).
@@ -1507,6 +1631,13 @@ impl Styles {
if s.parent.is_none() && fmt.size.is_none() && self.doc_size() != self.default_size {
fmt.size = self.default_size;
}
// And its language, likewise.
if s.parent.is_none()
&& let Some(lang) = self.default_lang.as_ref().filter(|l| self.document_lang().as_ref() != Some(*l))
&& fmt.extra.as_ref().is_none_or(|x| x.lang.is_none())
{
fmt.extra.get_or_insert_with(Default::default).lang = Some(lang.clone());
}
let look = ParaLook {
align: s.align,
keep_next: s.keep_next == Some(true),
@@ -1521,13 +1652,16 @@ impl Styles {
};
let name = s.display.clone().unwrap_or_else(|| odt.replace("_20_", " "));
let off = [s.fmt.bold, s.fmt.italic, s.fmt.underline, s.fmt.strike].map(|v| v == Some(false));
Some(NamedStyle { name: name.chars().take(super::super::model::MAX_FONT_NAME).collect(), parent, look, off })
let outline = s.outline.filter(|o| (1..=10).contains(o));
Some(NamedStyle { name: name.chars().take(super::super::model::MAX_FONT_NAME).collect(), parent, look, off, outline })
}
pub(super) fn para(&self, name: &str) -> ParaLook {
let chain = self.chain(name);
let mut kind = Kind::Normal;
let mut kind_at = chain.len();
// The nearest outline level; a style with one is no kind's style: its look is its own.
let mut outline = None;
for (i, (n, s)) in chain.iter().enumerate() {
kind_at = i;
let dn = s.display.as_deref().unwrap_or(n).to_ascii_lowercase().replace("_20_", " ");
@@ -1546,12 +1680,12 @@ impl Styles {
if kind != Kind::Normal {
break;
}
if let Some(o) = s.outline.filter(|o| (1..=10).contains(o)) {
kind = Kind::Heading(o.min(6));
break;
}
outline = outline.or(s.outline.filter(|o| (1..=10).contains(o)));
kind_at = chain.len();
}
if let Some(o) = outline {
kind = Kind::Heading(o.min(6));
}
// The nearest custom style: its formatting stays in it; the automatic styles over it give
// the paragraph's own.
let named_at = chain.iter().take(kind_at).position(|(n, _)| self.is_named(n));
+106 -1
View File
@@ -205,6 +205,81 @@ fn object_without_mathml_falls_back_to_its_replacement_image() {
assert!(imgs[0].data.as_slice() == png.as_slice());
}
/// A picture of `w` by `h` pixels, as a PNG.
fn png_of(w: u32, h: u32) -> Vec<u8> {
let mut png = Cursor::new(Vec::new());
image::RgbImage::new(w, h).write_to(&mut png, image::ImageFormat::Png).unwrap();
png.into_inner()
}
#[test]
fn a_picture_stated_on_one_side_keeps_its_own_proportions() {
// A hundred times as tall as wide: only the cap on a page's size may shrink it, in proportion.
let frame = r#"<draw:frame text:anchor-type="as-char" svg:width="500pt"><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#;
let content = format!(
r#"<office:document-content {NS_T}><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
);
let pkg = zip_of(&[("content.xml", content.as_bytes()), ("Pictures/a.png", &png_of(10, 1000))]);
let flow = parse(&pkg).unwrap();
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
assert!((img.w - 15.84).abs() < 0.01 && (img.h - 1584.0).abs() < 0.01, "{} {}", img.w, img.h);
}
#[test]
fn an_objects_picture_with_no_stated_size_is_fitted_to_the_text() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
);
let frame =
r#"<draw:frame text:anchor-type="as-char"><draw:object xlink:href="./Object 1"/><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#;
let content = format!(
r#"<office:document-content {NS_T}><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
);
let pkg = zip_of(&[
("content.xml", content.as_bytes()),
("styles.xml", styles.as_bytes()),
("Object 1/content.xml", b"<office:spreadsheet/>"),
("Pictures/a.png", &png_of(1000, 500)),
]);
let flow = parse(&pkg).unwrap();
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
assert!(img.object.is_some(), "still the object");
assert!((img.w - 306.14).abs() < 0.5 && (img.h - 153.07).abs() < 0.5, "{} {}", img.w, img.h);
}
#[test]
fn a_frame_stating_a_size_of_nothing_is_fitted_to_the_text() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
);
let frame = r#"<draw:frame text:anchor-type="as-char" svg:width="0pt"><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#;
let content = format!(
r#"<office:document-content {NS_T}><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
);
let pkg = zip_of(&[("content.xml", content.as_bytes()), ("styles.xml", styles.as_bytes()), ("Pictures/a.png", &png_of(1000, 500))]);
let flow = parse(&pkg).unwrap();
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
assert!((img.w - 306.14).abs() < 0.5 && (img.h - 153.07).abs() < 0.5, "{} {}", img.w, img.h);
}
#[test]
fn a_picture_in_line_raised_or_turned_with_no_size_is_fitted_to_the_text() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:automatic-styles><style:page-layout style:name="pm"><style:page-layout-properties fo:page-width="14.8cm" fo:page-height="21cm" fo:margin-left="2cm" fo:margin-right="2cm" fo:margin-top="2cm" fo:margin-bottom="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm"/></office:master-styles></office:document-styles>"#
);
let auto = r#"<office:automatic-styles><style:style style:name="fr1" style:family="graphic"><style:graphic-properties style:vertical-pos="middle" style:vertical-rel="char"/></style:style></office:automatic-styles>"#;
for attrs in [r#" draw:style-name="fr1""#, r#" draw:transform="rotate (0.5) translate (0cm 0cm)""#] {
let frame = format!(r#"<draw:frame text:anchor-type="as-char"{attrs}><draw:image xlink:href="Pictures/a.png"/></draw:frame>"#);
let content = format!(
r#"<office:document-content {NS_T}>{auto}<office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
);
let pkg = zip_of(&[("content.xml", content.as_bytes()), ("styles.xml", styles.as_bytes()), ("Pictures/a.png", &png_of(1000, 500))]);
let flow = parse(&pkg).unwrap();
let img = paras(&flow).into_iter().flat_map(|p| &p.inlines).find_map(|i| if let Inline::Image(img) = i { Some(img) } else { None }).unwrap();
assert!(img.w <= 306.5, "{attrs}: {} {}", img.w, img.h);
}
}
pub(super) const NS_T: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0""#;
/// A package with `content.xml` and, when given, `styles.xml`.
@@ -1214,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);
@@ -1269,7 +1374,7 @@ fn the_default_styles_font_size_is_the_documents() {
let s = entry(&crate::odt::tests::export_model(&d).unwrap(), "styles.xml");
assert!(
s.contains(r#"<style:text-properties fo:font-family="'Liberation Serif'" fo:font-size="11pt""#)
|| s.contains(r#"fo:font-size="11pt" style:letter-kerning"#),
|| s.contains(r#"fo:font-size="11pt" style:font-size-asian="11pt""#),
"{s}"
);
let back = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap();
+425
View File
@@ -285,6 +285,431 @@ fn objects_in_hidden_text_are_kept() {
assert!(p.objects.iter().any(|o| matches!(o, wordcraft_doc::para::InlineObject::BookmarkStart { name } if name == "Secret")), "{:?}", p.objects);
}
/// A paragraph in style `style` holding `span`, with `auto` among its automatic styles.
fn off_doc(styles: &str, style: &str, auto: &str, span: &str) -> Document {
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles>{auto}</office:automatic-styles><office:body><office:text><text:p text:style-name="{style}">{span}</text:p></office:text></office:body></office:document-content>"#
);
let styles = format!("<office:document-styles {NS_T}><office:styles>{styles}</office:styles></office:document-styles>");
import(&crate::odt::tests::package(&content, Some(&styles))).unwrap()
}
#[test]
fn text_turning_off_what_its_style_turns_on_stays_off() {
// A word not bold in a bold heading: it showed bold, on screen and after a save.
let on = r#"fo:font-weight="bold" fo:font-style="italic" style:text-underline-style="solid" style:text-line-through-style="solid" fo:font-variant="small-caps" fo:text-transform="uppercase""#;
let off = r#"fo:font-weight="normal" fo:font-style="normal" style:text-underline-style="none" style:text-line-through-style="none" fo:font-variant="normal" fo:text-transform="none""#;
let styles = format!(r#"<style:style style:name="Laut" style:family="paragraph"><style:text-properties {on}/></style:style>"#);
let auto = format!(r#"<style:style style:name="T1" style:family="text"><style:text-properties {off}/></style:style>"#);
let check = |d: &Document, what: &str| {
let p = d.body[0].as_para().unwrap();
let shown = |run: usize| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[run].props);
assert_eq!(p.text, "onoff", "{what}");
let (a, b) = (shown(0), shown(1));
assert_eq!((a.bold, a.italic, a.strike, a.small_caps, a.caps), (true, true, true, true, true), "{what}: the style's");
assert!(a.underline != Underline::None, "{what}");
assert_eq!((b.bold, b.italic, b.strike, b.small_caps, b.caps), (false, false, false, false, false), "{what}: turned off");
assert_eq!(b.underline, Underline::None, "{what}");
};
let d = off_doc(&styles, "Laut", &auto, r#"on<text:span text:style-name="T1">off</text:span>"#);
check(&d, "read");
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "written from the model");
check(&import(&export(&d).unwrap()).unwrap(), "written as read");
// Turned back on inside it: on.
let auto = format!(
r#"{auto}<style:style style:name="T2" style:family="text"><style:text-properties fo:font-weight="bold" style:text-underline-style="solid"/></style:style>"#
);
let d = off_doc(&styles, "Laut", &auto, r#"<text:span text:style-name="T1"><text:span text:style-name="T2">x</text:span></text:span>"#);
let p = d.body[0].as_para().unwrap();
let x = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
assert!(x.bold);
assert!(x.underline != Underline::None, "{x:?}");
assert!(!x.italic, "still off");
// A character style's: off in the automatic style based on it.
let styles = r#"<style:style style:name="Zeichen" style:family="text"><style:text-properties fo:font-weight="bold" style:text-underline-style="solid"/></style:style>"#;
let auto = r#"<style:style style:name="T3" style:family="text" style:parent-style-name="Zeichen"><style:text-properties fo:font-weight="normal" style:text-underline-style="none"/></style:style>"#;
let d = off_doc(styles, "Standard", auto, r#"<text:span text:style-name="T3">x</text:span>"#);
let p = d.body[0].as_para().unwrap();
let x = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
assert_eq!((x.bold, x.underline), (false, Underline::None), "{x:?}");
// Text nothing turned on says nothing.
let d = off_doc("", "Standard", "", "plain");
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
assert!(!c.contains(r#"fo:font-weight="normal""#) && !c.contains(r#"style:text-underline-style="none""#), "{c}");
}
/// A weight or slant only for Asian or complex-script text says nothing of Latin text when it is
/// normal: the heading's bold stays, on screen and saved. Latin text's own "normal" still counts.
#[test]
fn an_asian_or_complex_normal_doesnt_turn_latin_text_off() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"><style:text-properties fo:font-weight="bold" fo:font-style="italic"/></style:style></office:styles></office:document-styles>"#
);
let open = |attr: &str| {
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties {attr}/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="Heading_20_1" text:outline-level="1">Bold <text:span text:style-name="T1">word</text:span></text:h></office:text></office:body></office:document-content>"#
);
import(&package(&content, Some(&styles))).unwrap()
};
let run = |d: &Document| d.body[0].as_para().unwrap().runs.last().unwrap().props.clone();
for attr in [
r#"style:font-weight-complex="normal""#,
r#"style:font-weight-asian="normal""#,
r#"style:font-style-complex="normal""#,
r#"style:font-style-asian="normal""#,
] {
let d = open(attr);
assert_eq!((run(&d).bold, run(&d).italic), (None, None), "{attr}");
let saved = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
assert!(!saved.contains(r#"fo:font-weight="normal""#) && !saved.contains(r#"fo:font-style="normal""#), "{attr}: {saved}");
}
assert_eq!(run(&open(r#"fo:font-weight="normal""#)).bold, Some(false));
assert_eq!(run(&open(r#"fo:font-style="normal""#)).italic, Some(false));
}
/// Strike-through turned off over a double one is off, not the double one again.
#[test]
fn strike_turned_off_over_a_double_strike_stays_off() {
let double = r#"<style:text-properties style:text-line-through-style="solid" style:text-line-through-type="double"/>"#;
let none = r#"<style:text-properties style:text-line-through-style="none"/>"#;
let check = |d: &Document, what: &str| {
let p = d.body[0].as_para().unwrap();
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
assert_eq!((c.strike, c.double_strike), (false, false), "{what}");
};
// A style over its parent.
let styles = format!(
r#"<style:style style:name="Parent" style:family="paragraph">{double}</style:style><style:style style:name="Kid" style:family="paragraph" style:parent-style-name="Parent">{none}</style:style>"#
);
let d = off_doc(&styles, "Kid", "", "One");
check(&d, "a style, read");
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "a style, written from the model");
// A span over its paragraph's style.
let styles = format!(r#"<style:style style:name="Parent" style:family="paragraph">{double}</style:style>"#);
let auto = format!(r#"<style:style style:name="T1" style:family="text">{none}</style:style>"#);
let d = off_doc(&styles, "Parent", &auto, r#"<text:span text:style-name="T1">One</text:span>"#);
check(&d, "a span, read");
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "a span, written from the model");
}
/// A span's character style is its own: a paragraph turning bold or italic off doesn't reach past it.
#[test]
fn a_spans_character_style_wins_over_its_paragraph_turning_off() {
let styles = r#"<style:style style:name="Standard" style:family="paragraph"/><style:style style:name="Strong_20_Emphasis" style:display-name="Strong Emphasis" style:family="text"><style:text-properties fo:font-weight="bold" fo:font-style="italic"/></style:style>"#;
let auto = r#"<style:style style:name="P1" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-weight="normal" fo:font-style="normal"/></style:style>"#;
let d = off_doc(styles, "P1", auto, r#"<text:span text:style-name="Strong_20_Emphasis">One</text:span>"#);
let check = |d: &Document, what: &str| {
let p = d.body[0].as_para().unwrap();
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
assert_eq!((c.bold, c.italic), (true, true), "{what}: {:?}", p.runs.last().unwrap().props);
};
check(&d, "read");
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "written from the model");
}
/// `default` as a document's default paragraph style's text properties, with `standard` as its
/// Standard style's.
fn with_default_text(default: &str, standard: &str) -> Vec<u8> {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties {default}/></style:default-style><style:style style:name="Standard" style:family="paragraph"><style:text-properties {standard}/></style:style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:body><office:text><text:p>Hallo</text:p></office:text></office:body></office:document-content>"#
);
crate::odt::tests::package(&content, Some(&styles))
}
/// `styles`' default paragraph style.
fn default_style(styles: &str) -> &str {
let at = styles.find(r#"<style:default-style style:family="paragraph">"#).expect("a default paragraph style");
styles[at..].split("</style:default-style>").next().unwrap_or_default()
}
/// A document's language is the one its file states, and a model-written save says it.
#[test]
fn the_default_language_is_the_files_and_written_back_from_the_model() {
let d = import(&with_default_text(r#"fo:language="de" fo:country="DE""#, "")).unwrap();
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("de-DE"));
assert_eq!(d.styles.resolve_char(None, &Default::default()).lang.as_deref(), Some("de-DE"));
// Written from the model (a style or list definition changed), it is still the file's: without
// it LibreOffice shows the document in its own default language.
let saved = crate::odt::tests::export_model(&d).unwrap();
let styles = crate::odt::tests::entry(&saved, "styles.xml");
assert!(default_style(&styles).contains(r#"fo:language="de" fo:country="DE""#), "{styles}");
assert!(!styles.contains(r#"fo:language="en""#), "no style says English: {styles}");
assert_eq!(import(&saved).unwrap().styles.default_chr.lang.as_deref(), Some("de-DE"));
// Saved as read, the styles WordCraft adds beside the file's don't say English either.
let styles = crate::odt::tests::entry(&export(&d).unwrap(), "styles.xml");
assert!(!styles.contains(r#"fo:language="en""#), "{styles}");
}
/// The document's language as WordCraft has it is Normal's (a Word file's Normal may state
/// another than its defaults): that is what the default style says when written from the model.
#[test]
fn the_documents_language_written_is_normals() {
use std::sync::Arc;
let mut d = Document::new();
Arc::make_mut(&mut d.styles).default_chr.lang = Some("en-US".into());
Arc::make_mut(&mut d.styles).get_mut("Normal").unwrap().chr.lang = Some("fr-FR".into());
let saved = crate::odt::tests::export_model(&d).unwrap();
let s = crate::odt::tests::styles_of(&saved);
let def = s.split(r#"<style:default-style style:family="paragraph""#).nth(1).and_then(|t| t.split("</style:default-style>").next()).unwrap();
assert!(def.contains(r#"fo:language="fr""#) && def.contains(r#"fo:country="FR""#), "{def}");
let back = import(&saved).unwrap();
assert_eq!(back.styles.resolve_char(None, &Default::default()).lang.as_deref(), Some("fr-FR"));
}
/// A file stating no language, or `none` or `zxx`, has none: not the English of new documents.
#[test]
fn a_file_stating_no_language_has_none_and_writes_none() {
let d = import(&with_default_text("", "")).unwrap();
assert_eq!(d.styles.default_chr.lang, None, "not the English of WordCraft's new documents");
let saved = crate::odt::tests::export_model(&d).unwrap();
assert!(!crate::odt::tests::entry(&saved, "styles.xml").contains("fo:language"));
assert_eq!(import(&saved).unwrap().styles.default_chr.lang, None);
// `none` and `zxx` are no language.
for none in ["none", "zxx"] {
let d = import(&with_default_text(&format!(r#"fo:language="{none}" fo:country="none""#), "")).unwrap();
assert_eq!(d.styles.default_chr.lang, None, "{none}");
}
}
/// A language only Standard states is the document's, and a model-written save says it in the
/// default style.
#[test]
fn a_language_only_the_standard_style_states_survives_a_rewrite() {
let d = import(&with_default_text("", r#"fo:language="nb" fo:country="NO""#)).unwrap();
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("nb-NO"));
let saved = crate::odt::tests::export_model(&d).unwrap();
let styles = crate::odt::tests::entry(&saved, "styles.xml");
assert!(default_style(&styles).contains(r#"fo:language="nb" fo:country="NO""#), "{styles}");
let back = import(&saved).unwrap();
assert_eq!(back.styles.default_chr.lang.as_deref(), Some("nb-NO"));
let p = back.body[0].as_para().unwrap();
let lang =
p.runs.first().and_then(|r| r.props.lang.clone()).or_else(|| back.styles.resolve_char(p.props.style.as_deref(), &Default::default()).lang);
assert_eq!(lang.as_deref(), Some("nb-NO"), "{}", crate::odt::tests::entry(&saved, "styles.xml"));
}
/// A language with a script keeps it, read and written: the document's, a style's for each kind
/// of text, and a run's.
#[test]
fn a_language_with_a_script_keeps_it() {
let d = import(&with_default_text(r#"fo:language="sr" fo:script="Latn" fo:country="RS""#, "")).unwrap();
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("sr-Latn-RS"));
let saved = crate::odt::tests::export_model(&d).unwrap();
let styles = crate::odt::tests::entry(&saved, "styles.xml");
assert!(default_style(&styles).contains(r#"fo:language="sr" fo:script="Latn" fo:country="RS""#), "{styles}");
assert!(!styles.contains(r#"fo:country="Latn""#), "{styles}");
assert_eq!(import(&saved).unwrap().styles.default_chr.lang.as_deref(), Some("sr-Latn-RS"));
let (latin, asian, complex) = (
r#"fo:language="sr" fo:script="Cyrl" fo:country="BA""#,
r#"style:language-asian="zh" style:script-asian="Hant" style:country-asian="TW""#,
r#"style:language-complex="az" style:script-complex="Arab" style:country-complex="IR""#,
);
let run = r#"fo:language="bs" fo:script="Latn" fo:country="BA""#;
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Standard" style:family="paragraph" style:class="text"/><style:style style:name="Eigen" style:display-name="Eigen" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties {latin} {asian} {complex}/></style:style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties {run}/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="Eigen">Own <text:span text:style-name="T1">span</text:span></text:p></office:text></office:body></office:document-content>"#
);
let check = |d: &wordcraft_doc::Document| {
let chr = &d.styles.get("Eigen").unwrap().chr;
assert_eq!(
(chr.lang.as_deref(), chr.lang_ea.as_deref(), chr.lang_bidi.as_deref()),
(Some("sr-Cyrl-BA"), Some("zh-Hant-TW"), Some("az-Arab-IR"))
);
assert_eq!(d.body[0].as_para().unwrap().runs.iter().find_map(|r| r.props.lang.as_deref()), Some("bs-Latn-BA"));
};
let d = import(&crate::odt::tests::package(&content, Some(&styles))).unwrap();
check(&d);
let saved = crate::odt::tests::export_model(&d).unwrap();
let written = crate::odt::tests::entry(&saved, "styles.xml");
assert!(written.contains(latin) && written.contains(asian) && written.contains(complex), "{written}");
assert!(crate::odt::tests::entry(&saved, "content.xml").contains(run));
check(&import(&saved).unwrap());
// No country, a region of digits, and what isn't a script.
let attrs = crate::odt::writer::lang_attrs;
assert_eq!(attrs("zh-Hant", ""), r#" fo:language="zh" fo:script="Hant""#);
assert_eq!(attrs("es-419", ""), r#" fo:language="es" fo:country="419""#);
assert_eq!(attrs("de-DE", "-asian"), r#" style:language-asian="de" style:country-asian="DE""#);
let d = import(&with_default_text(r#"fo:language="sr" fo:script="Latin1" fo:country="RS""#, "")).unwrap();
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("sr-RS"));
}
/// A style with no parent inherits the default style's language, not Standard's (the document's
/// here), and one based on Standard takes Standard's.
#[test]
fn a_style_with_no_parent_has_the_default_styles_language() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:language="en" fo:country="US"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"><style:text-properties fo:language="de" fo:country="DE"/></style:style><style:style style:name="Eigen" style:display-name="Eigen" style:family="paragraph"/><style:style style:name="Gesagt" style:display-name="Gesagt" style:family="paragraph"><style:text-properties fo:language="fr" fo:country="FR"/></style:style><style:style style:name="Kind" style:display-name="Kind" style:family="paragraph" style:parent-style-name="Standard"/></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:body><office:text><text:p text:style-name="Eigen">a</text:p><text:p text:style-name="Gesagt">b</text:p><text:p text:style-name="Kind">c</text:p></office:text></office:body></office:document-content>"#
);
let check = |d: &wordcraft_doc::Document| {
assert_eq!(d.styles.default_chr.lang.as_deref(), Some("de-DE"));
let lang = |id: &str| d.styles.resolve_char(Some(id), &Default::default()).lang;
assert_eq!(lang("Eigen").as_deref(), Some("en-US"), "no parent: the default style's");
assert_eq!(lang("Gesagt").as_deref(), Some("fr-FR"), "its own");
assert_eq!(lang("Kind").as_deref(), Some("de-DE"), "based on Standard");
};
let d = import(&crate::odt::tests::package(&content, Some(&styles))).unwrap();
check(&d);
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap());
}
/// WordCraft's own documents say their language, so a reader doesn't use its own default.
#[test]
fn a_new_documents_language_is_stated() {
let styles = crate::odt::tests::entry(&export(&wordcraft_doc::Document::new()).unwrap(), "styles.xml");
assert!(default_style(&styles).contains(r#"fo:language="en" fo:country="US""#), "{styles}");
}
#[test]
fn a_size_is_every_scripts_size() {
// Chinese text set to 24 pt stayed at the default size in LibreOffice: only the Latin size was
// written.
let mut d = Document::new();
let mut p = Paragraph::with_text("漢字", CharProps { size: Some(24.0), ..Default::default() });
p.props = Default::default();
d.body = vec![para_block(p)];
let saved = crate::odt::tests::export_model(&d).unwrap();
let c = crate::odt::tests::content_of(&saved);
assert!(c.contains(r#"fo:font-size="24pt" style:font-size-asian="24pt" style:font-size-complex="24pt""#), "{c}");
let styles = crate::odt::tests::entry(&saved, "styles.xml");
assert!(styles.contains(r#"fo:font-size="12pt" style:font-size-asian="12pt" style:font-size-complex="12pt""#), "{styles}");
// Read back every script has it.
let back = import(&saved).unwrap();
let run = &back.body[0].as_para().unwrap().runs[0].props;
assert_eq!(back.styles.resolve_char(None, run).size_cs, 24.0);
// A complex-script size of its own stays its own, written once.
let mut d = Document::new();
d.body = vec![para_block(Paragraph::with_text("x", CharProps { size: Some(24.0), size_cs: Some(30.0), ..Default::default() }))];
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
assert_eq!(c.matches("style:font-size-complex").count(), 1, "{c}");
assert!(c.contains(r#"style:font-size-complex="30pt""#), "{c}");
}
/// Text and its complex-script size stated alike, over a default style with a smaller one (as
/// LibreOffice's files have): that size stays, on screen and saved, for a run and for a heading.
#[test]
fn a_complex_size_like_the_texts_stays_over_the_default() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt" style:font-size-complex="14pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"/><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"><style:text-properties fo:font-size="18pt" style:font-size-complex="18pt"/></style:style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="24pt" style:font-size-complex="24pt"/></style:style></office:automatic-styles><office:body><office:text><text:p>a <text:span text:style-name="T1">big</text:span></text:p><text:h text:style-name="Heading_20_1" text:outline-level="1">h</text:h></office:text></office:body></office:document-content>"#
);
let d = import(&package(&content, Some(&styles))).unwrap();
let check = |d: &Document| {
let p = d.body[0].as_para().unwrap();
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
assert_eq!((c.size, c.size_cs), (24.0, 24.0));
let h = d.body[1].as_para().unwrap();
let c = d.styles.resolve_char(h.props.style.as_deref(), &h.runs[0].props);
assert_eq!((c.size, c.size_cs), (18.0, 18.0));
};
check(&d);
let saved = crate::odt::tests::export_model(&d).unwrap();
let written = crate::odt::tests::entry(&saved, "styles.xml");
let heading = written.split(r#"style:name="Heading_20_1""#).nth(1).and_then(|t| t.split("</style:style>").next()).unwrap();
assert!(heading.contains(r#"style:font-size-complex="18pt""#) && !heading.contains(r#"style:font-size-complex="14pt""#), "{heading}");
check(&import(&saved).unwrap());
// A default stating one size for all goes with that size: Normal changed takes it along.
let same = styles.replace(r#"style:font-size-complex="14pt""#, r#"style:font-size-complex="12pt""#);
let d = import(&package(&content, Some(&same))).unwrap();
assert_eq!(d.styles.default_chr.size_cs, None);
}
/// A complex size equal to the text's goes only where no run's complex size changes without it:
/// not under a character style over another size, nor under a size stated alone.
#[test]
fn a_complex_size_something_shows_stays() {
let open = |s: &str, auto: &str, body: &str| {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt" style:font-size-complex="12pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"/>{s}</office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles>{auto}</office:automatic-styles><office:body><office:text>{body}</office:text></office:body></office:document-content>"#
);
import(&package(&content, Some(&styles))).unwrap()
};
let sizes = |d: &Document| {
let p = d.body[0].as_para().unwrap();
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
(c.size, c.size_cs)
};
// A character style's, over a paragraph style with another complex size.
let d = open(
r#"<style:style style:name="P" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-size="12pt" style:font-size-complex="18pt"/></style:style><style:style style:name="C" style:family="text"><style:text-properties fo:font-size="24pt" style:font-size-complex="24pt"/></style:style>"#,
"",
r#"<text:p text:style-name="P"><text:span text:style-name="C">One</text:span></text:p>"#,
);
assert_eq!(sizes(&d), (24.0, 24.0));
let parent = r#"<style:style style:name="Parent" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-size="16pt" style:font-size-complex="16pt"/></style:style>"#;
// A parent's, under a child stating only a size.
let kid = format!(
r#"{parent}<style:style style:name="Kid" style:family="paragraph" style:parent-style-name="Parent"><style:text-properties fo:font-size="20pt"/></style:style>"#
);
assert_eq!(sizes(&open(&kid, "", r#"<text:p text:style-name="Kid">One</text:p>"#)), (20.0, 16.0));
// A style's, under a run stating only a size.
let d = open(
parent,
r#"<style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="30pt"/></style:style>"#,
r#"<text:p text:style-name="Parent"><text:span text:style-name="T1">One</text:span></text:p>"#,
);
assert_eq!(sizes(&d), (30.0, 16.0));
// Nothing shows it: one size for all, so a change of size takes it along.
let d = open(parent, "", r#"<text:p text:style-name="Parent">One</text:p>"#);
assert_eq!(sizes(&d), (16.0, 16.0));
assert_eq!(d.styles.get("Parent").unwrap().chr.size_cs, None);
}
/// A size stated alone, by a run or a style, under a different complex size above it: saved from the
/// model, and saved again, the complex size is still the one above, not the stated size.
#[test]
fn a_size_stated_alone_keeps_the_complex_size_above_it_when_saved() {
let sizes = |d: &Document, i: usize| {
let p = d.body[i].as_para().unwrap();
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs.last().unwrap().props);
(c.size, c.size_cs)
};
let twice = |d: Document| {
let once = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap();
let again = import(&crate::odt::tests::export_model(&once).unwrap()).unwrap();
(once, again)
};
// From a file: a run stating only a size, and a style stating only a size, under a parent
// style with its own complex size.
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt" style:font-size-complex="12pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"/><style:style style:name="Parent" style:family="paragraph" style:parent-style-name="Standard"><style:text-properties fo:font-size="16pt" style:font-size-complex="16pt"/></style:style><style:style style:name="Kid" style:family="paragraph" style:parent-style-name="Parent"><style:text-properties fo:font-size="20pt"/></style:style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="30pt"/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="Parent"><text:span text:style-name="T1">One</text:span></text:p><text:p text:style-name="Kid">Two</text:p></office:text></office:body></office:document-content>"#
);
let d = import(&package(&content, Some(&styles))).unwrap();
assert_eq!((sizes(&d, 0), sizes(&d, 1)), ((30.0, 16.0), (20.0, 16.0)));
let (once, again) = twice(d);
assert_eq!((sizes(&once, 0), sizes(&once, 1)), ((30.0, 16.0), (20.0, 16.0)), "saved once");
assert_eq!((sizes(&again, 0), sizes(&again, 1)), ((30.0, 16.0), (20.0, 16.0)), "saved twice");
// Built in the model (a DOCX run with `w:sz` and no `w:szCs`): the default's complex size.
let mut d = Document::new();
let styles = std::sync::Arc::make_mut(&mut d.styles);
styles.default_chr.size = Some(12.0);
styles.default_chr.size_cs = Some(16.0);
let mut p = Paragraph::with_text("x", CharProps { size: Some(30.0), ..Default::default() });
p.props = Default::default();
d.body = vec![para_block(p)];
assert_eq!(sizes(&d, 0), (30.0, 16.0));
let (once, again) = twice(d);
assert_eq!((sizes(&once, 0), sizes(&again, 0)), ((30.0, 16.0), (30.0, 16.0)));
}
#[test]
fn a_headings_text_of_another_size_keeps_it() {
// Only the size its style gives goes with the style: a smaller word in a heading came back
@@ -368,6 +368,65 @@ fn a_frame_keeps_its_place_in_the_stacking_order() {
assert!(crate::odt::tests::entry(&crate::odt::tests::export_model(&d).unwrap(), "content.xml").contains(r#"draw:z-index="0""#));
}
#[test]
fn a_frame_anchored_to_a_page_stays_on_that_page() {
// LibreOffice's page-anchored frames name their page. Written without it, a frame on page 3
// came back on page 1 in LibreOffice.
let frame = |attrs: &str| {
let content = format!(
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><draw:frame {attrs} svg:x="5cm" svg:y="5cm" svg:width="4cm" svg:height="2cm"><draw:text-box><text:p>On it</text:p></draw:text-box></draw:frame><text:p>One</text:p></office:text></office:body></office:document-content>"#
);
import(&crate::odt::tests::package(&content, None)).unwrap()
};
let d = frame(r#"text:anchor-type="page" text:anchor-page-number="3""#);
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
assert!(c.contains(r#"text:anchor-type="page" text:anchor-page-number="3""#), "{c}");
let back =
crate::odt::tests::content_of(&crate::odt::tests::export_model(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()).unwrap());
assert!(back.contains(r#"text:anchor-page-number="3""#), "again: {back}");
// A frame anchored to its paragraph names no page, and junk is none.
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&frame(r#"text:anchor-type="paragraph""#)).unwrap());
assert!(!c.contains("anchor-page-number"), "{c}");
for junk in ["-1", "abc", "", "99999999999999"] {
let d = frame(&format!(r#"text:anchor-type="page" text:anchor-page-number="{junk}""#));
let c = crate::odt::tests::content_of(&crate::odt::tests::export_model(&d).unwrap());
assert!(!c.contains("anchor-page-number"), "{junk}: {c}");
}
}
/// A page number only says something on a page anchor. On another LibreOffice ignores it, so it
/// doesn't make a drawing anchored to a page when saved.
#[test]
fn a_page_number_on_another_anchor_is_no_page_anchor() {
let saved = |styles: &str, body: &str| {
let content = format!(
r#"<office:document-content {NS_T} {NS_D}><office:automatic-styles>{styles}</office:automatic-styles><office:body><office:text>{body}</office:text></office:body></office:document-content>"#
);
crate::odt::tests::content_of(&crate::odt::tests::export_model(&import(&crate::odt::tests::package(&content, None)).unwrap()).unwrap())
};
let frame = |attrs: &str| {
format!(
r#"<text:p>One<draw:frame {attrs} svg:x="5cm" svg:y="5cm" svg:width="4cm" svg:height="2cm"><draw:text-box><text:p>On it</text:p></draw:text-box></draw:frame></text:p>"#
)
};
for anchor in ["paragraph", "char"] {
let c = saved("", &frame(&format!(r#"text:anchor-type="{anchor}" text:anchor-page-number="3""#)));
assert!(!c.contains(r#"text:anchor-type="page""#) && !c.contains("anchor-page-number"), "{anchor}: {c}");
}
let c = saved(
"",
r#"<text:p>One<draw:line text:anchor-type="paragraph" text:anchor-page-number="3" svg:x1="1cm" svg:y1="1cm" svg:x2="3cm" svg:y2="3cm"/></text:p>"#,
);
assert!(!c.contains(r#"text:anchor-type="page""#) && !c.contains("anchor-page-number"), "a line: {c}");
// An anchor its style gives counts as one the frame states.
let by_style = r#"<style:style style:name="fr" style:family="graphic"><style:graphic-properties text:anchor-type="page"/></style:style>"#;
let c = saved(by_style, &frame(r#"draw:style-name="fr" text:anchor-page-number="3""#));
assert!(c.contains(r#"text:anchor-type="page" text:anchor-page-number="3""#), "{c}");
let by_style = r#"<style:style style:name="fr" style:family="graphic"><style:graphic-properties text:anchor-type="paragraph"/></style:style>"#;
let c = saved(by_style, &frame(r#"draw:style-name="fr" text:anchor-page-number="3""#));
assert!(!c.contains("anchor-page-number"), "{c}");
}
#[test]
fn a_frame_offering_several_formats_shows_the_first_it_can_read() {
// LibreOffice's letter templates: its own metafile, then a PNG. Taking the first left nothing
@@ -160,6 +160,243 @@ fn a_table_starting_a_page_starts_it() {
assert_eq!(c.matches(r#"fo:break-before="page""#).count(), 2, "{c}");
}
#[test]
fn columns_stating_only_their_shares_keep_their_proportions() {
// LibreOffice's CV template: a table filling the margins whose columns give only
// `style:rel-column-width`. They were read as equal.
let column = |name: &str, share: &str| {
format!(
r#"<style:style style:name="{name}" style:family="table-column"><style:table-column-properties style:rel-column-width="{share}"/></style:style>"#
)
};
let table = r#"<table:table><table:table-column table:style-name="A"/><table:table-column table:style-name="B"/><table:table-row><table:table-cell><text:p>a</text:p></table:table-cell><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row></table:table>"#;
let grid = |d: &wordcraft_doc::Document| d.body.iter().find_map(|b| b.as_table()).map(|t| t.grid.clone()).unwrap_or_default();
let d = doc(&format!("{}{}", column("A", "1000*"), column("B", "3000*")), table);
let g = grid(&d);
assert_eq!(g.len(), 2, "{g:?}");
assert!((g[1] / g[0] - 3.0).abs() < 0.01, "{g:?}");
// Rewritten from the model, the proportions stay.
let back = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap();
let g = grid(&back);
assert!((g[1] / g[0] - 3.0).abs() < 0.01, "{g:?}");
// A column without a share leaves the equal split, and junk shares are none.
for junk in ["0*", "-1*", "NaN*", "1e39*", "abc"] {
let d = doc(&format!("{}{}", column("A", "1000*"), column("B", junk)), table);
let g = grid(&d);
assert_eq!(g.len(), 2, "{junk}: {g:?}");
assert!((g[0] - g[1]).abs() < 0.01, "{junk}: {g:?}");
}
// A table filling the margins with a width stated beside each share keeps the widths: files
// give them in proportion, and the table is fitted to the margins.
let stated = r#"<style:style style:name="A" style:family="table-column"><style:table-column-properties style:column-width="2in" style:rel-column-width="2*"/></style:style><style:style style:name="B" style:family="table-column"><style:table-column-properties style:column-width="1in" style:rel-column-width="1*"/></style:style>"#;
let g = grid(&doc(stated, table));
assert!((g[0] / g[1] - 2.0).abs() < 0.01, "{g:?}");
}
/// A share-only table wider than the text keeps its stated width, as in LibreOffice; a column with
/// only a width takes it first, and the shares divide the rest; a column with no share of its own
/// takes the default style's.
#[test]
fn columns_by_shares_divide_the_right_width() {
let column = |name: &str, props: &str| {
format!(r#"<style:style style:name="{name}" style:family="table-column"><style:table-column-properties {props}/></style:style>"#)
};
let table_style = |props: &str| format!(r#"<style:style style:name="T" style:family="table"><style:table-properties {props}/></style:style>"#);
let table = |names: &[&str]| {
let columns: String = names
.iter()
.map(|n| if n.is_empty() { "<table:table-column/>".to_string() } else { format!(r#"<table:table-column table:style-name="{n}"/>"#) })
.collect();
let cells: String = names.iter().map(|_| "<table:table-cell><text:p>x</text:p></table:table-cell>").collect();
format!(r#"<table:table table:style-name="T">{columns}<table:table-row>{cells}</table:table-row></table:table>"#)
};
let grid = |d: &wordcraft_doc::Document| d.body.iter().find_map(|b| b.as_table()).map(|t| t.grid.clone()).unwrap_or_default();
let near = |got: &[f32], want: &[f32]| {
assert!(got.len() == want.len() && got.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.5), "{got:?}, not {want:?}")
};
// 576 pt, aligned, in a narrower text area.
let d = doc(
&format!(
"{}{}{}",
table_style(r#"style:width="8in" table:align="left""#),
column("A", r#"style:rel-column-width="1000*""#),
column("B", r#"style:rel-column-width="3000*""#)
),
&table(&["A", "B"]),
);
near(&grid(&d), &[144.0, 432.0]);
near(&grid(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), &[144.0, 432.0]);
// 72 pt stated, the other 300 by shares.
let d = doc(
&format!(
"{}{}{}{}",
table_style(r#"style:width="372pt" table:align="left""#),
column("A", r#"style:column-width="72pt""#),
column("B", r#"style:rel-column-width="1000*""#),
column("C", r#"style:rel-column-width="3000*""#)
),
&table(&["A", "B", "C"]),
);
near(&grid(&d), &[72.0, 75.0, 225.0]);
// A share on every column goes before a stated width, as in LibreOffice.
let d = doc(
&format!(
"{}{}{}",
table_style(r#"style:width="400pt" table:align="left""#),
column("A", r#"style:column-width="72pt" style:rel-column-width="1000*""#),
column("B", r#"style:rel-column-width="3000*""#)
),
&table(&["A", "B"]),
);
near(&grid(&d), &[100.0, 300.0]);
// An aligned table whose every column states both: the shares (measured in LibreOffice 26.8).
let d = doc(
&format!(
"{}{}{}",
table_style(r#"style:width="400pt" table:align="left""#),
column("A", r#"style:column-width="300pt" style:rel-column-width="1*""#),
column("B", r#"style:column-width="100pt" style:rel-column-width="1*""#)
),
&table(&["A", "B"]),
);
near(&grid(&d), &[200.0, 200.0]);
// A share too small for a column is the narrowest one, not the grid lost to the equal split.
let d = doc(
&format!(
"{}{}{}",
table_style(r#"style:width="400pt" table:align="left""#),
column("A", r#"style:rel-column-width="1*""#),
column("B", r#"style:rel-column-width="65535*""#)
),
&table(&["A", "B"]),
);
near(&grid(&d), &[6.0, 400.0]);
// The default style's share, for a column with none and one whose style says none.
let styles = r#"<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"><office:styles><style:default-style style:family="table-column"><style:table-column-properties style:rel-column-width="1000*"/></style:default-style></office:styles></office:document-styles>"#;
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles>{}{}{}</office:automatic-styles><office:body><office:text>{}</office:text></office:body></office:document-content>"#,
table_style(r#"style:width="400pt" table:align="left""#),
column("B", r#"style:rel-column-width="3000*""#),
column("E", r#"style:use-optimal-column-width="false""#),
table(&["", "B"])
);
near(&grid(&import(&package(&content, Some(styles))).unwrap()), &[100.0, 300.0]);
}
/// A table inside a cell doesn't know its width from the page: with only shares and no width of
/// its own to divide, or with some columns stating widths, its columns split it equally, as before.
#[test]
fn a_table_in_a_cell_shares_out_no_page_width() {
let styles = r#"<style:style style:name="W" style:family="table-column"><style:table-column-properties style:column-width="100pt"/></style:style><style:style style:name="S" style:family="table-column"><style:table-column-properties style:rel-column-width="1*"/></style:style><style:style style:name="L" style:family="table"><style:table-properties table:align="left"/></style:style>"#;
for (style, columns) in [
(r#" table:style-name="L""#, r#"<table:table-column table:style-name="S"/><table:table-column table:style-name="S"/>"#),
("", r#"<table:table-column table:style-name="W"/><table:table-column table:style-name="S"/>"#),
] {
let inner = format!(
r#"<table:table{style}>{columns}<table:table-row><table:table-cell><text:p>a</text:p></table:table-cell><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row></table:table>"#
);
let body = format!(
r#"<table:table><table:table-column table:style-name="W"/><table:table-column table:style-name="W"/><table:table-row><table:table-cell>{inner}</table:table-cell><table:table-cell><text:p>c</text:p></table:table-cell></table:table-row></table:table>"#
);
let d = doc(styles, &body);
let outer = d.body.iter().find_map(|b| b.as_table()).unwrap();
let inner = outer.rows[0].cells[0].blocks.iter().find_map(|b| b.as_table()).unwrap();
// The equal split main gives, which layout fits to the cell (not two page-wide columns).
let equal = inner.grid.windows(2).all(|w| (w[0] - w[1]).abs() < 0.01);
assert!(equal && inner.grid.first().is_some_and(|w| *w < 300.0), "{style}: {:?}", inner.grid);
}
}
#[test]
fn a_tables_and_rows_background_show_behind_their_cells() {
// Table Properties › Background: it was dropped, on screen and in LibreOffice after a save.
let styles = r##"<style:style style:name="T" style:family="table"><style:table-properties fo:background-color="#ddffdd"/></style:style><style:style style:name="R" style:family="table-row"><style:table-row-properties fo:background-color="#ffdddd"/></style:style><style:style style:name="C" style:family="table-cell"><style:table-cell-properties fo:background-color="#ddddff"/></style:style>"##;
let table = r#"<table:table table:style-name="T"><table:table-row><table:table-cell><text:p>table</text:p></table:table-cell><table:table-cell table:style-name="C"><text:p>cell</text:p></table:table-cell></table:table-row><table:table-row table:style-name="R"><table:table-cell><text:p>row</text:p></table:table-cell><table:table-cell table:style-name="C"><text:p>cell</text:p></table:table-cell></table:table-row></table:table>"#;
let colours = |d: &wordcraft_doc::Document| {
d.body
.iter()
.find_map(|b| b.as_table())
.map(|t| t.rows.iter().flat_map(|r| r.cells.iter().map(|c| c.props.shading.map(|s| s.hex().to_ascii_lowercase()))).collect::<Vec<_>>())
.unwrap_or_default()
};
let want = |t: &str, r: &str, c: &str| vec![Some(t.to_string()), Some(c.to_string()), Some(r.to_string()), Some(c.to_string())];
let d = doc(styles, table);
assert_eq!(colours(&d), want("ddffdd", "ffdddd", "ddddff"));
assert_eq!(colours(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), want("ddffdd", "ffdddd", "ddddff"));
}
/// As LibreOffice shows them: a cell's own style, else its column's default cell style (a row's
/// isn't applied), else the row's background, else the table's. A row or table clearing its
/// background (`transparent`) doesn't take its parent's; the table's shows behind that row.
#[test]
fn a_cells_background_comes_from_its_column_then_row_then_table() {
let cell = |n: &str, c: &str| {
format!(
r##"<style:style style:name="{n}" style:family="table-cell"><style:table-cell-properties fo:background-color="#{c}"/></style:style>"##
)
};
let styles = format!(
r##"{}{}<style:style style:name="T" style:family="table"><style:table-properties fo:background-color="#ffff00"/></style:style><style:style style:name="R" style:family="table-row"><style:table-row-properties fo:background-color="#0000ff"/></style:style><style:style style:name="Clear" style:family="table-row" style:parent-style-name="R"><style:table-row-properties fo:background-color="transparent"/></style:style>"##,
cell("InRow", "ff0000"),
cell("InColumn", "00ff00"),
);
let c = "<table:table-cell><text:p>x</text:p></table:table-cell>";
let table = format!(
r#"<table:table table:style-name="T"><table:table-column table:default-cell-style-name="InColumn"/><table:table-column/><table:table-row table:style-name="R" table:default-cell-style-name="InRow">{c}{c}</table:table-row><table:table-row>{c}{c}</table:table-row><table:table-row table:style-name="Clear">{c}{c}</table:table-row></table:table>"#
);
let colours = |d: &wordcraft_doc::Document| {
d.body
.iter()
.find_map(|b| b.as_table())
.map(|t| t.rows.iter().flat_map(|r| r.cells.iter().map(|c| c.props.shading.map(|s| s.hex().to_ascii_lowercase()))).collect::<Vec<_>>())
.unwrap_or_default()
};
let s = |v: &str| Some(v.to_string());
let want = vec![s("00ff00"), s("0000ff"), s("00ff00"), s("ffff00"), s("00ff00"), s("ffff00")];
let d = doc(&styles, &table);
assert_eq!(colours(&d), want);
assert_eq!(colours(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), want);
}
/// A column default after a spanning cell belongs to the column the cell is in.
#[test]
fn a_column_default_counts_spanned_columns() {
let styles =
r##"<style:style style:name="C" style:family="table-cell"><style:table-cell-properties fo:background-color="#00ff00"/></style:style>"##;
let table = r#"<table:table><table:table-column/><table:table-column/><table:table-column table:default-cell-style-name="C"/><table:table-row><table:table-cell table:number-columns-spanned="2"><text:p>a</text:p></table:table-cell><table:covered-table-cell/><table:table-cell><text:p>b</text:p></table:table-cell></table:table-row></table:table>"#;
let d = doc(styles, table);
let t = d.body.iter().find_map(|b| b.as_table()).unwrap();
let last = t.rows[0].cells.iter().rev().find(|c| !c.blocks.is_empty()).unwrap();
assert_eq!(last.props.shading.map(|s| s.hex().to_ascii_lowercase()).as_deref(), Some("00ff00"));
}
/// A column's default cell style that names nothing there is no background, however long its name.
#[test]
fn a_column_default_naming_no_style_is_no_background() {
let long = "x".repeat(100_000);
let table = format!(
r#"<table:table><table:table-column table:number-columns-repeated="63" table:default-cell-style-name="{long}"/><table:table-row><table:table-cell><text:p>a</text:p></table:table-cell></table:table-row></table:table>"#
);
let d = doc("", &table);
let t = d.body.iter().find_map(|b| b.as_table()).unwrap();
assert_eq!(t.rows[0].cells[0].props.shading, None);
}
/// A cell style saying `transparent` over a coloured parent has no background of its own: what's
/// behind it shows, in LibreOffice too, from a column's default cell style or the cell's own.
#[test]
fn a_transparent_cell_style_stops_its_parents_colour() {
let styles = r##"<style:style style:name="PC" style:family="table-cell"><style:table-cell-properties fo:background-color="#ff0000"/></style:style><style:style style:name="KC" style:family="table-cell" style:parent-style-name="PC"><style:table-cell-properties fo:background-color="transparent"/></style:style><style:style style:name="R" style:family="table-row"><style:table-row-properties fo:background-color="#0000ff"/></style:style>"##;
for (column, cell) in [(r#" table:default-cell-style-name="KC""#, ""), ("", r#" table:style-name="KC""#)] {
let table = format!(
r#"<table:table><table:table-column{column}/><table:table-row table:style-name="R"><table:table-cell{cell}><text:p>a</text:p></table:table-cell></table:table-row></table:table>"#
);
let d = doc(styles, &table);
let t = d.body.iter().find_map(|b| b.as_table()).unwrap();
assert_eq!(t.rows[0].cells[0].props.shading, crate::model::parse_color("#0000ff"), "{column}{cell}");
}
}
#[test]
fn a_row_that_keeps_together_says_so() {
// Table › Text Flow: a row not to break across pages. It came back from a save free to break.

Some files were not shown because too many files have changed in this diff Show More