Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor cb49ac055d Sonar: fix clippy lints that fail the gate; drop unread ScaledGroupSpec field
Collapse nested ifs into match guards, simplify negated boolean
expressions to their equivalent forms, and remove the ScaledGroupSpec
group field, which was written but never read.

Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-26 09:06:48 -04:00
Dylan M. Taylor f2c32842bc Sonar: reduce assert_scaled_group_reroute to 3 params
Wrap the 6 shape/geometry arguments (w, h, kind, rot, flip_h,
flip_v) of the reroute test helper into a ScaledGroupSpec config
struct so the function drops under Sonar's rust:S107 7-parameter
threshold (it was 8). The call site builds the spec inline.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-26 08:46:06 -04:00
Dylan M. Taylor 7b0e079705 Sonar: extract helpers from highlight check, revision span and outline remap
Behaviour-preserving extractions for S3776:
- engine cmd/format.rs target_has_highlight: span_matches checks a paragraph's mark and runs.
- engine cmd/review.rs resolve_sel: revision_span finds the tracked change at a caret.
- engine cmd/outline.rs after_command: remap_collapsed_headings keeps collapsed headings.
2026-10-26 08:46:03 -04:00
Dylan M. Taylor 6a2b520fa4 Merge pull request #4401 from benandez/fix/main-tests-linear-imports
#3866: fix main's layout test build (tests_linear imports Page and Rgb)
2026-10-26 07:27:16 -04:00
Martin BallandClaude Opus 5.5 49c2f61373 #3866: tests_linear imports Page and Rgb (main's layout tests didn't compile after #4392)
#4392's tests used Page and Rgb, which other changes merged around it no longer
brought into scope, so main's CI gate fails with E0425/E0422 in tests_linear.rs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2
2026-10-26 08:08:46 +00:00
Dylan M. Taylor 2c99a42780 Merge pull request #4392 from benandez/fix/3866-followup
#3866 follow-up: PDF export draws items with authors and cell clips built once per page; endnote selection linear
2026-10-26 01:31:58 -04:00
Dylan M. Taylor ab0eccd254 Merge pull request #4377 from storytold/sonar/newcode-s3776-a
Sonar: reduce cognitive complexity in four engine commands
2026-10-26 01:14:55 -04:00
Dylan M. Taylor 0377edabd8 Merge pull request #4376 from storytold/sonar/newcode-lints-1
Sonar: clear S1612, S2208 and S7688 new-code issues
2026-10-26 01:14:51 -04:00
aiongg 438775cea1 Merge pull request #4391 from aiongg/w6
View › New Window opens another window on the same document
2026-10-26 00:12:33 -04:00
Martin BallandClaude Opus 5.5 b94821ea16 #3866: review — count a step per page walked in the first-page index, so the hinted-page test sees an eager build
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 04:05:47 +00:00
Martin BallandClaude Opus 5.5 e71c9ce31d #3866: review — the first-page index is built only when the hinted page doesn't show the paragraph
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 04:01:47 +00:00
aiongg bbf07875d8 Merge branch 'w3' into w6
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-26 03:58:36 +00:00
aiongg 9752ca5897 Merge branch 'w2' into w3 2026-10-26 03:58:00 +00:00
aiongg e8282d957a Merge branch 'w1' into w2 2026-10-26 03:57:59 +00:00
aiongg f93c0cbe52 W1: the merge-records test expects the result beside the unsaved source, with no save prompt 2026-10-26 03:57:59 +00:00
Dylan M. Taylor 73af86b1bf Merge pull request #4355 from storytold/codex/egui-037-word-followup-20261025
Cover formatting copy and paste shortcuts after the egui 0.37 migration
2026-10-25 23:49:35 -04:00
aiongg 503a533e65 Merge branch 'w3' into w6
# Conflicts:
#	crates/engine/src/lib.rs
2026-10-26 03:40:48 +00:00
aiongg 27068500dd Merge branch 'w2' into w3
# Conflicts:
#	crates/ui-egui/Cargo.toml
#	crates/ui-egui/src/panes.rs
2026-10-26 03:40:18 +00:00
aiongg 16f6145cd3 Merge branch 'w1' into w2 2026-10-26 03:38:38 +00:00
aiongg 7d512a6905 Merge remote-tracking branch 'upstream/main' into w1
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	NOTICE
#	apps/wordcraft/Cargo.toml
#	crates/engine/src/lib.rs
#	crates/ui-egui/Cargo.toml
#	docs/control-protocol.md
#	docs/shared-pdf.md
2026-10-26 03:38:36 +00:00
aiongg 54ec19ddf8 Merge branch 'w3' into w6 2026-10-26 03:34:45 +00:00
aiongg d805dd94f1 W3b: ui.screenshot captures the active window, not the first one 2026-10-26 03:34:39 +00:00
aiongg 6dca584bbd Task panes: drop a pane outside or inside a docked pane to order a side (#4386)
* Task panes: a pane dropped over a docked pane's outer or inner third docks outside or inside it, and the order is kept (craft-libs#357)

* Pin craft-libs at b74f1b6 (pane order on an edge, craft-libs#357)

* Pin craft-libs at b926b5c (craft-libs main)
2026-10-25 23:29:51 -04:00
aiongg b9c074a543 W6: a window's input method joins and equation session stay with that window 2026-10-26 03:26:01 +00:00
aiongg e900513104 Merge branch 'w3' into w6
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/lib.rs
#	crates/engine/src/session.rs
#	crates/ui-egui/Cargo.toml
#	crates/ui-egui/src/lib.rs
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/ui-parity.md
2026-10-26 03:22:33 +00:00
Martin BallandClaude Opus 5.5 ecdc90cdc4 #3866: review — port the clipped-cell lookup test (nesting, first in page order, other story/row) to the layout crate
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 03:15:35 +00:00
aiongg 15090b9d81 Merge pull request #3109 from aiongg/panes-trial
Task panes: draw them with craft_ui::panes; Styles docks left or right or floats
2026-10-25 23:13:49 -04:00
aiongg 24986cdb23 W3: new app fields classified as app-wide; window ids and the test host on egui 0.37 2026-10-26 03:11:00 +00:00
aiongg 5632755be4 Merge branch 'w2' into w3
# Conflicts:
#	Cargo.lock
#	ROADMAP.md
#	apps/wordcraft/src/main.rs
#	crates/ui-egui/Cargo.toml
#	crates/ui-egui/src/backstage.rs
#	crates/ui-egui/src/control.rs
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/keys.rs
#	crates/ui-egui/src/lib.rs
#	crates/ui-egui/src/panes.rs
#	crates/ui-egui/src/previews.rs
#	docs/ui-parity.md
2026-10-26 03:09:46 +00:00
Dylan M. Taylor f93fe6053f Merge pull request #4383 from benandez/fix/3876-followup
#3876 follow-up: Zotero owner check streams the socket table and refuses when it can't read it
2026-10-25 23:05:38 -04:00
Dylan M. Taylor 6a1e0d0002 Merge pull request #4384 from benandez/fix/3869-followup
#3869 follow-up: WOFF2 pre-check counts contours as wuff does (crash on open); tripped hinting isn't cached
2026-10-25 23:05:16 -04:00
aiongg 645cbe52d4 Merge branch 'w1' into w2
# Conflicts:
#	ROADMAP.md
2026-10-26 03:04:40 +00:00
aiongg 4576b8e624 W1: a command that ends in another document counts as a state change for the input method 2026-10-26 03:04:24 +00:00
aiongg 5e7790ea85 W1: requests a command makes in another document keep their document id when the queue is full 2026-10-26 03:03:36 +00:00
aiongg 8634995c20 W1: control-channel doc tests in their own module beside the control tests 2026-10-26 03:03:36 +00:00
aiongg a44e3ec8f9 W1: pin craft-libs e8b4dde5 2026-10-26 03:02:07 +00:00
aiongg 4029a4e6fc Merge remote-tracking branch 'upstream/main' into w1
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/lib.rs
#	crates/engine/src/session.rs
#	crates/mcp/src/tools.rs
#	crates/ui-egui/src/control.rs
#	crates/ui-egui/src/i18n/bg.tsv
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/da.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/et.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/hr.tsv
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ro.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/sl.tsv
#	crates/ui-egui/src/i18n/sr-latn.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/sv.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
#	crates/ui-egui/src/lib.rs
#	docs/gaps.md
#	docs/mcp.md
#	docs/parity-checklist.md
#	docs/ui-parity.md
2026-10-26 03:01:45 +00:00
Martin BallandClaude Opus 5.5 e9dd252096 #3866: review — PDF export builds the revision authors and clipped cells once per page; part_pieces finds a paragraph's first page from one index; grid_cols once; balloon colour test
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 02:57:08 +00:00
Martin BallandClaude Opus 5.5 8bc825825f #3876: review — scan_tables allowed unused off Linux; sandbox and empty-table comments match the refusal rule
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2
2026-10-26 02:50:03 +00:00
Dylan M. Taylor 282ebedc85 Merge branch 'main' into sonar/newcode-s3776-a 2026-10-25 22:47:08 -04:00
Dylan M. Taylor a95a570edc Merge branch 'main' into sonar/newcode-lints-1 2026-10-25 22:47:05 -04:00
aiongg 45c4197bc1 Restrict Editing: the protection list and password fields carry their labels' names 2026-10-26 02:44:26 +00:00
aiongg 3c25061bab Merge remote-tracking branch 'upstream/main' into panes-trial 2026-10-26 02:44:26 +00:00
Martin BallandClaude Opus 5.5 328a58dd03 #3869: review — hint_strike is native-only like HINT_STRIKE (wasm build); composite and 0x7FFF contour-count tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2
2026-10-26 02:43:10 +00:00
Dylan M. Taylor 7583871d45 Merge pull request #3875 from benandez/sec/j
Security: engine and MCP authority checks (#3699, #3735–#3737, #3698)
2026-10-25 22:40:27 -04:00
Martin BallandClaude Sonnet 5.5 ed1296a1d4 #3871 follow-up: .doc picture decode charges zlib input and DIB copies; budget scales with the file (#4382)
* #3871: review — metafile input charged to the decode budget; inline BLIPs deduped; budget scales with input

- A compressed metafile is charged max(inflated size, zlib input), so a record claiming to inflate to nothing still costs its input.
- Inline pictures share one decode per BLIP record, cached by (stream, foDelay, record's own length) instead of the FBSE size field.
- The decode budget is max(512 MB, 64 x input bytes), capped at 2 GiB, so documents of many large legitimate DIBs decode; high-ratio bombs stay capped.
- Decode-budget refusals get their own log line.

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

* #3871: review — a DIB's copy into a BMP is charged to the decode budget before it is made

The charge was w*h*4 after the copy, so a 1x1 header followed by megabytes of tail cost 4 bytes per picture. It is now max(pixels, DIB length), charged first; a bitmap whose copy doesn't fit is dropped rather than copied. The doc comment on the budget scale now says RLE8/RLE4 bitmaps (about 510x) rely on the 512 MB floor.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 22:37:10 -04:00
Martin BallandClaude Opus 5.5 e216e7afc1 #3876: review — docs say an unreadable table refuses; EINTR is retried; the huge-table test drives the real read path
- docs/zotero.md and peer.rs module docs: a read error, or an open error other than NotFound,
  refuses (sandboxes denying /proc/net/tcp: AppArmor, Snap, Flatpak, are refused).
- read_line_capped retries ErrorKind::Interrupted like std's read_line.
- New scan_tables (what owner_check calls) takes the table opener; the 70 MiB test drives it,
  so reinstating take(64 MiB) there fails the test. Added tests for EINTR and a denied open.
- Row parsing uses split_whitespace().nth() instead of collecting a Vec.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 02:36:16 +00:00
Martin BallandClaude Opus 5.5 b654049545 #3874: review — text box paste charges only what it copies; stories sharing Arc blocks (PDF imports) keep their text (#4381)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 22:30:32 -04:00
Martin BallandClaude Opus 5.5 ddda3c4670 #3869: review — the draw that turns hinting off is redrawn unhinted, so the outline cache never keeps a hinted glyph from a tripped breaker
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2
2026-10-26 02:09:31 +00:00
Martin BallandClaude Opus 5.5 b094b29958 #3869: review — WOFF2 point pre-check reads glyf nContours as u16 like the decoder (only 0xFFFF is composite)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2
2026-10-26 02:09:24 +00:00
Martin BallandClaude Opus 5.5 4eda77a2de #3876: review — Zotero owner check streams the socket table, and an unreadable table refuses
The Linux same-user check read /proc/net/tcp{,6} through a 64 MiB take(). Hitting the cap
was silent, so a local user binding ~450k loopback listeners pushed the connection rows
past it; both uids came back unknown and the "trust loopback" arm let their fake Zotero
listener on 23116 through. A truncated last line could also glue to the next header.

Now each table is streamed line by line (BufRead, nothing kept, no total cap, lines capped
at 4 KiB) and stops once both rows are found. Any read error refuses. "Unknown, trust
loopback" remains only for a listener absent from a table read in full (WSL2, proxies).
Tests: rows after 70 MiB of filler, and a read error mid-table.

rich.rs: document that the MAX_RUNS cap still allows one first run per paragraph (bounded
by MAX_PARAS).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 02:08:04 +00:00
Dylan M. Taylor adf33b6ba7 Merge branch 'main' into sec/j 2026-10-25 22:01:43 -04:00
Dylan M. Taylor 66601038df tests(layout): build the nbsp test document without struct-update syntax (#4380) 2026-10-25 21:52:01 -04:00
Dylan M. Taylor 4fd9075e99 Sonar: split flood fill, CSV separator, noise collapse and numbered backspace
Extract helpers with unchanged behaviour to bring these functions under the
cognitive-complexity limit (S3776):
- engine cmd/objects.rs clear_background: border_pixels, run_span, push_open_runs.
- engine cmd/tools.rs collapse_noise_matches: flatten_region (Kind is now module-level).
- engine cmd/mailings.rs parse_csv: csv_separator, push_csv_row.
- engine cmd/text.rs backspace_inner: remove_number.
2026-10-25 21:30:49 -04:00
Dylan M. Taylor 4bb3679e41 Sonar: use PoisonError::into_inner, explicit test imports, [[ in import-cert
- proof: pass PoisonError::into_inner instead of an identity closure (S1612).
- layout tests: replace glob super::* imports with explicit names (S2208).
- packaging/macos/import-cert.sh: use [[ for the status test (S7688).
2026-10-25 21:28:59 -04:00
Dylan M. Taylor ee7e98eafb hotfix: same_content must mention media_index (E0027 after #3872) 2026-10-25 21:22:09 -04:00
Dylan M. Taylor ba27ef13fb Merge pull request #3867 from benandez/sec/b
Security: bound layout memory (linked text boxes, repeated headers, crops, gridAfter)
2026-10-25 21:17:01 -04:00
Dylan M. Taylor c0627f2b0b Merge branch 'main' into sec/b 2026-10-25 21:16:51 -04:00
Dylan M. Taylor ebf27211cf Merge pull request #3872 from benandez/sec/g
Security: DOCX package/writer hardening — glossary privacy scrub, case-insensitive part names, chart and font limits, Flat OPC inlining (#3692, #3704, #3742, #3648)
2026-10-25 21:14:19 -04:00
Dylan M. Taylor ced8576f4c Merge branch 'main' into sec/g 2026-10-25 21:14:06 -04:00
Dylan M. Taylor 7740ea650e Merge pull request #3869 from benandez/sec/d
Security: font ingestion checks sizes and counts before allocating (#3703, #3716)
2026-10-25 21:10:28 -04:00
Dylan M. Taylor 8450531b0e Merge branch 'main' into sec/d 2026-10-25 21:10:17 -04:00
Dylan M. Taylor 25a087db43 Merge pull request #3874 from benandez/sec/i
Security: document model work bounds for media, math and paste (part of #3614)
2026-10-25 21:09:43 -04:00
Dylan M. Taylor 0ecfbb57f9 Merge pull request #3873 from benandez/sec/h
Security: import/export format fixes — LaTeX verbatim escape, Markdown link escaping, importer limits (#3713, #3714, #3705)
2026-10-25 21:09:20 -04:00
Dylan M. Taylor 6689d186f6 Merge pull request #3870 from benandez/sec/e
Security: render, PDF and metafile budgets
2026-10-25 21:08:40 -04:00
Martin BallandClaude Opus 5.5 def2b9bd31 #3692: review — doc timing tests get a margin a busy CI machine can't break
Wall-clock bounds of 2 s failed on loaded runners. Each bound is now 10 s, at least 10x the
fixed code's time and well under the old code's on the same input (measured by running the
pre-fix code under the same load):

- many distinct media: 0.5 s now; the old scan of every image took 131 s.
- object walks: under 0.3 s now; the old walks took 360 s (#3614).
- delete across lone control markers: 3,000 markers (was 1,500); 1 ms now, the old delete
  took 18 s at 1,500 and 114 s at 3,000.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:03:06 -04:00
Martin BallandClaude Opus 5.5 bec112a4b8 #3692: review — pasted text boxes skip only the box over the budget, and say so; media_index private; kept markers' order pinned
- A text box whose story doesn't fit what is left of the paste's byte
  budget gets no story, but later boxes with smaller stories are still
  copied while the budget lasts (it used to zero the budget). Each
  source story is costed once per paste, so skipping a big story many
  times doesn't walk it again. The paste logs one warning with the
  number of boxes left empty. wordcraft-doc now depends on log.
- Document::media_index is a private field and MediaIndex a private
  type: a lookup cache, not API. The one struct-update literal outside
  the crate (a formats test) builds the document field by field.
- kept_control_markers documents that delete_range relies on its
  sorted, deduplicated output; delete_range sorts it if it ever isn't,
  and control_markers_kept_are_sorted pins it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:03:06 -04:00
Martin BallandClaude Opus 5.5 26187afac2 #3692: review — pasted text box copies are charged for every payload their objects copy (group members, equations, kept XML), not only the object slots
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:03:06 -04:00
Martin BallandClaude Opus 5.5 72cbbe096e #3692: review — the long-letter-run math test asserts results only, no wall-clock limit
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:03:06 -04:00
Martin BallandClaude Opus 5.5 722a6ab1c0 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> 2026-10-25 21:03:06 -04:00
Martin BallandClaude Opus 5.5 f235208630 #3692: pasted text boxes share what they need not copy, within a byte budget
Finding 80 (CWE-400): adopting a fragment deep-copied a box's source story
once per text box showing it, including boxes inside the story being copied,
so 4096 boxes (the story budget) pointing at one large story copied it 4096
times. Now: a box showing a story it is inside gets no story (a cycle);
blocks without text boxes are shared with the source (copy-on-write Arcs)
instead of copied; and a paste adopts at most MAX_PASTED_BOX_BYTES (32 MiB,
estimated text + runs + objects) of box content, charged before copying,
after which later boxes keep no story, as when the story budget runs out.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:59 -04:00
Martin BallandClaude Opus 5.5 3a1c91f2d4 #3614: crates/doc walks a paragraph's objects in one pass
Finding 71 (CWE-407): loops of object_offsets + object_at recounted the
objects before every offset, so walking a paragraph's objects was quadratic
(40k markers: 360 s for the regression test before, 0.2 s after).
Paragraph::objects_with_offsets yields (offset, object) in one pass, and
every such walk in crates/doc uses it: the field scan (field_ranges, run on
every docx load), control events, bookmarks, perm ranges, text_box_anchor,
past_field_ends, typing_pos and the markers a range delete keeps. Also:
perm_ranges keeps open ranges per id instead of searching a list for each
end; kept_control_markers pairs each container with its table wraps once
(was once per marker) and probes a set instead of every range.

Part of #3614: the engine, docx reader and UI callers it lists live outside
crates/doc and still use object_at in loops; they can move to
objects_with_offsets separately.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:59 -04:00
Martin BallandClaude Opus 5.5 acfc3d5041 #3692: function-name lookahead in linear math stops at the longest name
Finding 48 (CWE-407): P::word scanned and copied the rest of an alphabetic run
for every letter that didn't start a function name, so a 20,000-letter
equation did ~200 million steps outside the parser's step budget (5 parses:
8.3 s before, well under 0.1 s after). The scan now stops one past the
longest name in FUNCTION_NAMES (computed at compile time); a longer run is
never a name, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:59 -04:00
Martin BallandClaude Opus 5.5 01a75b99b1 #3692: add_media looks up duplicates by content instead of scanning every image
Finding 45 (CWE-407): Document::add_media compared each new image with every
stored one, so an import with many distinct small images did quadratic work
(40k images: 213 s before, 0.26 s after). A MediaIndex cache (not saved,
ignored by ==) maps bytes to their first key, is rebuilt when media changed
size behind its back, and every hit is checked against media. Generated
image{n} names also skip taken names once instead of on every add.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:59 -04:00
Martin BallandClaude Opus 5.5 364688a8c3 #3704: review — case-variant zip names keep the spelling a relationship names; chart, font and glossary limits log once per save
Folding zip entries whose names differ only in case now keeps the
spelling a relationship part targets, and the first in byte order only
when no relationship names any of them. The fold logs one warning per
package, not one per entry.

Limits that leave data out now say so, once per document or save:
- a chart past the chart limits on save (series or points dropped);
- embedded fonts past the byte budget (one warning with the count);
- a kept glossary that can't be cleaned of personal information and is
  written from the model instead.

Tests capture the warnings (crate::test_log) and check each is logged
once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:02:52 -04:00
Martin BallandClaude Opus 5.5 5819b1ca62 #3692: review — comment-id test checks the ids, not a wall-clock deadline
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:02:52 -04:00
Martin BallandClaude Opus 5.5 c14e18cfa8 #3704: review — zip entries whose names differ only in case keep one stored spelling
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:02:52 -04:00
Martin BallandClaude Opus 5.5 eb2f0dab46 #3742: review — a kept XML part with a malformed comment goes out in base64
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:02:52 -04:00
Martin BallandClaude Opus 5.5 6dc062d132 #3692: a private save keeps the glossary's parts, without names and dates
Ultrareview finding 1. With "remove personal information" or "remove date
and time" set, the glossary was always written from the model (01c78899),
which holds only the entries the reader took: one without a name, past the
entry cap or with a bad content type was lost, and with none left the whole
glossary part went. The kept parts are now written with the same names and
dates removed that the writer leaves out of the main document (annotation
authors become "Author", comment initials "A", annotation and UTC dates
go; a people part's authors and presence too), rewritten with quick-xml and
matched by namespace. A part that can't be rewritten (malformed, UTF-16)
falls back to the model, so the privacy guarantee holds either way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:52 -04:00
Martin BallandClaude Opus 5.5 a1f8be2a49 Progress log row and the file-format parity Hostile input line for findings 42, 43, 51, 55, 72, 73 and 76.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:52 -04:00
Martin BallandClaude Opus 5.5 60a64acec3 #3704: a part named in several casings is kept, cached and written once
Finding 73: Package lookup matched part names case-insensitively, but relationship targets kept the spelling they were written in, so every casing of one part became its own media entry, graphic parse and passthrough key, and a save wrote a full copy per spelling (64 spellings of a 64 KB picture: 64 media parts). Package now keeps a lowercase index, Package::canonical returns the stored spelling, and rels() resolves internal targets to it, so every downstream cache is keyed by the one part; get_shared uses the index instead of a linear scan.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:46 -04:00
Martin BallandClaude Opus 5.5 722afa4201 #3742: kept XML is inlined in Flat OPC only when it is one well-formed element
Finding 55: flat.rs xml_fragment inlined kept part bytes into pkg:xmlData after a prefix check only, so a part whose text closed the wrapper (</pkg:xmlData></pkg:part><pkg:part ...>) wrote parts of its own into the saved .xml - e.g. a second core properties part. Now the bytes are inlined only when quick-xml reads them as exactly one balanced element with nothing but whitespace, comments and PIs around it; anything else is written as pkg:binaryData and still round-trips.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:46 -04:00
Martin BallandClaude Opus 5.5 1955793aa6 #3648: a chart part is written within the chart limits whatever the model
Finding 76. chart_xml trusted its ChartSpec to be sanitized, but a .json
document is deserialised without ChartSpec::sanitize, and every series
repeats every category, so a chart past the limits multiplied into a
gigabyte-sized part on save. chart_xml now writes a bounded, sanitized copy:
only the series and points the limits keep are copied before sanitizing.
(Sanitizing on .json open, and SmartArt, are in the engine and not covered
here.)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:46 -04:00
Martin BallandClaude Opus 5.5 639964b0ed #3692: a glossary is written from the model when the privacy settings ask for changes
Finding 72. An unchanged glossary was carried byte for byte, judged by a
fingerprint of its entries that leaves out the document's privacy settings,
so a save with "remove personal information" or "remove date and time" kept
the authors and dates of the glossary's tracked changes. With either setting
on, the glossary is now written from the model, which applies them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:46 -04:00
Martin BallandClaude Opus 5.5 4c5930dac1 #3692: embedded fonts are copied once per file and within a byte budget
Finding 51. embedded_fonts copied a part's bytes for every font-table
reference, so the 64 references allowed to one 256 MB part could make 16 GB of
copies on open. A reference repeating one already taken (same part, key and
name) is skipped, and copies stop at MAX_FONT_BYTES (one part's limit),
checked before each copy is made.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:46 -04:00
Martin BallandClaude Opus 5.5 12afc75411 Finding 42. read_comments checked each candidate id for a comment whose
w:id isn't a number against every id mapped so far, so a comments part of
many non-numeric ids took time quadratic in their count (49,000 comments:
2.4 s, now 0.35 s in a debug build). The ids mapped before the part are
collected into a set once; ids given in the part are already in `entries`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:45 -04:00
Martin BallandClaude Opus 5.5 2337ff75fa #3723: zip entries that fail their CRC check are charged to the package total
Finding 43. Package::open_limited dropped an entry whose read failed before
adding the bytes it had already inflated to the running total, so many
entries with a wrong CRC (checked only after the whole stream inflates) could
each inflate up to the part limit for free. The bytes are now charged first
and the size check runs before the read result is looked at.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:28 -04:00
Martin BallandClaude Opus 5.5 2d9bcb6b1b #3692: review — fonts timing test gets a margin a busy CI machine can't break
named_instances_and_collection_members_are_capped: 2 s -> 10 s. The face counts in the test
(one face for a million identical members, MAX_FILE_FACES from add_font) are what catch the old
code, which built a face per member; the time (under 0.1 s on a loaded machine, so 10 s is over
100x) only backs up "read once".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:02:21 -04:00
Martin BallandClaude Opus 5.5 b059da2d4e #3692: review — docs: why the hinting breaker is timed, and what brotli-decompressor brings
The hinting breaker stays wall-clock: skrifa's interpreter budget is fixed,
counts instructions rather than the points each one moves, and reports no
work count, so there is nothing deterministic to bound. The comment now says
the result can depend on the machine by design, and that only faces needing
the interpreter for their contours are hinted at all. Cargo.toml notes that
brotli-decompressor is the crate and version wuff already uses: pure Rust,
forbid(unsafe_code) by default, wasm-ready, BSD-3-Clause/MIT.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:02:21 -04:00
Martin BallandClaude Opus 5.5 57e9e0f244 #3703: review — font store adds run one at a time, so racing adds never share a temporary or a file
CodeRabbit on #3869: two concurrent install_font calls could pick the same
destination and temporary, and the leftover-unlink retry could remove the
other call's open temporary. write_unique now holds a process-wide lock.
In-process only (noted in a ponytail comment).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:02:21 -04:00
Martin BallandClaude Opus 5.5 87d3ea5363 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:02:21 -04:00
Martin BallandClaude Opus 5.5 68d45318bb #3692: review — a colour glyph's clip outline is drawn within the payload left, refused before it is built whole
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:02:11 -04:00
Martin BallandClaude Opus 5.5 d69c386f8f #3692: review — a font's temporary file is created afresh, so a link planted at its name is not written through
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:02:11 -04:00
Martin BallandClaude Opus 5.5 e541a6f62f #3692: hinting turns off only when slow draws are a real share of a face's draws
Ultrareview finding 5: the slow-draw count only grew, so three draws slowed
by a busy machine at any point in a session switched hinting off until
restart, breaking hint-reliant faces (MingLiU, DFKai-SB). Hinting now stops
only when at least three draws were slow and they are at least one in 16 of
the face's hinted draws: a face slow throughout still stops at its third
glyph, and slow glyphs spread among fast ones are still bounded. Counting
interpreter work instead of wall-clock stays with #3827.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:11 -04:00
Martin BallandClaude Opus 5.5 bbe1dad1e7 #3692: hinting turns off after three slow glyphs, not one
CodeRabbit review of finding 62's fix: one hinted draw slowed by a busy
machine (scheduler pause, page fault) switched hinting off for the face for
the rest of the process, leaving hint-reliant faces with timing-dependent
missing strokes. A strike counter now drops hinting only at the third slow
glyph.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:11 -04:00
Martin BallandClaude Opus 5.5 0f1a127e63 Findings 39, 70, 59, 46, 61, 44, 62: record the new font limits where
the matching parity rows and the MCP write-root list live.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:11 -04:00
Martin BallandClaude Opus 5.5 c7148d6ba9 #3692: hint-reliant fonts with huge zones are drawn unhinted, and a slow hinted glyph turns hinting off
Finding 62 (partial): skrifa 0.47 caps a hinting run at 1M instructions
but not the points each moves (SHZ, IUP... walk a whole zone), and a font
opts into the interpreter by its family name. Faces asking for a
twilight zone over 1,024 points or a stack over 4,096 values get no
interpreter; a glyph whose hinting buffer passes 256 KiB is drawn
unhinted; and (native) one hinted glyph over 50 ms turns hinting off for
its face. Residual: a face within the limits can still spend about
1M x zone size point moves on one glyph (and its prep program) before
the strike lands; the full fix is a point-work budget in skrifa or an
isolated worker with a deadline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:01 -04:00
Martin BallandClaude Opus 5.5 bcce02ab36 #3692: confined automation adds or removes fonts only when the font store is inside its write root
Finding 44: font.add stored the file in the user font folder and
font.remove deleted stored files directly, bypassing the automation
write root (#769). Both now check the user font folder against the
write policy in force before touching the database or the disk; with no
folder (headless) a font still lasts for the session only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:01 -04:00
Martin BallandClaude Opus 5.5 b246ea166a #3692: COLR gradient stops and clip paths count against a per-glyph budget
Finding 61: Recorder copied every gradient's stop list (and every clip
glyph's outline) per op, capped only by op count, so a compact COLR graph
reusing one long stop list could expand to billions of stops. Stops and
path elements now share MAX_PAYLOAD (65,536) per glyph, charged before
copying; past it the glyph falls back to its outline and no further clip
outlines are built. The render cache charges each glyph's real ops,
stops and path bytes (ops_bytes) instead of a flat 64 bytes per op.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:01 -04:00
Martin BallandClaude Opus 5.5 ce7e4ea28e #3716: a font file's faces are capped before they are built
Finding 59: enumerate_faces took a collection's member count straight from
its header (a TTC can list one table directory millions of times) and
font_faces read every fvar named instance (up to 65535, with a linear
style dedupe and a name-table lookup each). Every entry became a FontFace
kept for the session, and add_fonts then checked each new face against
every face in the database.

Now a file yields at most 256 faces (MAX_FILE_FACES; user fonts ask for
one more so they can still be refused as "more than 256 faces"), named
instances are read to the same cap, a face with the family and style of
an earlier one in the file is skipped (repeated collection offsets), and
add_fonts dedupes against an indexed set.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:01 -04:00
Martin BallandClaude Opus 5.5 6c6326486f #3692: font names are debug-quoted in log warnings
Finding 46: install_font logged the caller-supplied file name raw when the
font couldn't be stored, and the desktop log writes line breaks as they
come, so a name could forge extra log records. The name is now logged
with {:?} (escaped and quoted), and so are the font-derived family names in
the two glyph-outline warnings, which come from a document's own fonts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:01 -04:00
Martin BallandClaude Opus 5.5 5155d2c18c #3703: WOFF and WOFF2 sizes are checked before the decoder allocates from them
Findings 39 and 70.

39: wuff inflates each WOFF 1.0 table into a buffer sized from the
directory's origLength, before anything checks it against our 128 MB
cap, so a tiny file could ask for gigabytes. The directory's table sizes
are now summed first and a file over MAX_DECODED_BYTES is TooLarge.

70: wuff's WOFF2 glyf reconstruction allocates a glyph's points from its
nPoints counts and checks the 65536-point limit only afterwards. The
brotli stream is now decompressed by us (brotli-decompressor, already in
the tree through wuff) and every transformed glyf table's per-glyph point
totals are checked before wuff unpacks them; a glyph over the limit (which
wuff would refuse anyway) is refused up front.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:02:01 -04:00
Martin BallandClaude Sonnet 5.5 3ee1978910 #3692: rebase onto main — rustfmt after the merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 21:01:51 -04:00
Martin BallandClaude Opus 5.5 3cffe22c00 #3692: review — picture outline cache keys stay unique: the per-pass one documented, the lasting one holds its bytes' allocation
Alphas keys decoded pictures by their bytes' address and length, which is
sound only while the document is borrowed for the layout pass; that is now
said where the key is. LayoutCache's outlines outlive the documents they were
made for and use the same key, so each entry now keeps a Weak reference to
the bytes: their allocation can't be freed and the address reused by another
picture of the same length while the entry is kept. MAX_FLOW_WIDTHS spells
out which layout a box past the 16-width cap reuses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:01:51 -04:00
Martin BallandClaude Opus 5.5 1ae38ec2be #3692: review — stacked linked boxes share one layout; past the cap a box takes one in its own direction
Stacked text is laid out at STACK_WIDTH whatever the frame, so its flow key now is too.
The nearest-layout fallback only picks layouts of the box's TextVert; the first box in a
direction is laid out (at most a few more layouts).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:01:51 -04:00
Martin BallandClaude Opus 5.5 0f9b926497 #3692: review — a picture's transparency is kept for the whole layout, not just the last one decoded
Crops alternating between pictures decoded each again and again, spending the budget on
pictures that fit in it. Every picture charged to the budget keeps its alpha (bounded by
the same 2^28-pixel budget).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:01:51 -04:00
Martin BallandClaude Opus 5.5 32e4900bc5 #3692: a linked chain past 16 widths reuses the nearest; opaque pictures and budget refusals don't cost a later picture its outline
Ultrareview finding 6: boxes past a chain's 16th distinct width (compared
by exact float equality) showed no text, which resized boxes hit easily.
Widths are now told apart to a twip, and past the cap a box takes the
nearest layout already made (same direction, widest that fits, else the
narrowest). Work stays bounded at 16 story layouts.

Ultrareview finding 7 (also raised by CodeRabbit): opaque non-JPEG
pictures were decoded and charged to the per-layout pixel budget though
they give no outline, and a budget refusal was cached in LayoutCache as a
permanent "no outline". The header (read via into_decoder, PNG tRNS
included) now says whether a picture can be transparent before anything is
charged; Alphas::outline reports OverBudget distinctly from Ok(None), and
Ctx::wrap_outline (via picture_outline) doesn't keep a refusal in
LayoutCache::outlines. Within one layout a refused picture is remembered
so its header isn't re-read.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:01:51 -04:00
Martin BallandClaude Opus 5.5 ed78539488 Progress log and layout-parity notes for audit findings 7, 11, 35, 38.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:01:51 -04:00
Martin BallandClaude Opus 5.5 b578be0459 Audit finding 38: the outline cache keys on media and crop, but working
out any crop's outline decoded the whole shared picture again, so many
small picture references with distinct crops multiplied full-image
decodes without repeating the encoded image. Layout now keeps the last
picture's transparency (one alpha byte per pixel) and reuses it for
every crop of it, and charges each decode's pixels against
MAX_DECODED_PIXELS (2^28) per layout; past it pictures wrap as their
rectangles. Tests: tests_budget::a_picture_cropped_many_ways_is_decoded_once,
tests_budget::decoding_pictures_stops_at_the_layouts_budget.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:01:44 -04:00
Martin BallandClaude Opus 5.5 96579e6b4e #3692: repeated table header rows are capped by items, not rows
Audit finding 11: place_table cloned every item of the header rows onto
each page a table spills onto, but its cap (200 000) counted rows. An
exact-height header row keeps all its overflowing paragraphs without
growing, so one header row of thousands of items over hundreds of
pages placed millions of copies. The cap now counts placed items
(MAX_HEADER_ITEMS = 500 000, a row costing at least one) and the
header set repeats whole or not at all; past it later pages carry no
header rows and pagination still finishes. Test:
tests_budget::repeated_header_items_are_capped_across_the_table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:01:25 -04:00
Martin BallandClaude Opus 5.5 8efba59b35 #3692: a linked text-box chain lays its story out at most 16 widths
Audit finding 7: flow() laid the head box's whole story out again at
every distinct box line length in the chain and kept each layout while
splitting, so a chain of many one-line boxes of different widths
multiplied the story's layout work and memory by the chain length.
Past MAX_FLOW_WIDTHS (16) distinct lengths the text stops: the box that
would need another layout and the rest of the chain show nothing, as
overflow past the last box is hidden. Work is now at most 16x the
story. Test: tests_budget::a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:01:25 -04:00
Martin BallandClaude Opus 5.5 4963e30eed #3692: a row's gridAfter is bounded like the DOCX reader's
Audit finding 35: Table::cols() added a deserialized row's grid_after
(u32) unchecked, and layout_table sized its column vectors from it, so a
small native JSON document could ask for a 16 GB grid (and overflow the
sum on 32-bit wasm). Row::grid_after() now clamps to MAX_COLS as
first_grid() does and the importer does; cols() sums with saturating
adds; layout reads the bounded value. Test:
tests_budget::a_huge_grid_after_is_bounded_like_the_docx_readers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:01:25 -04:00
Martin BallandClaude Opus 5.5 22df7caf4d #3692: review — formats timing tests get a margin a busy CI machine can't break
many_formatted_leading_breaks_trim_quickly failed at its 2 s bound on a machine at load ~50
(it takes about a second there). Bounds now sit at least 10x over the fixed code's time on a
loaded machine and far under the old code's on the same input (measured by running the
pre-fix code, capped at 400 s):

- HTML leading breaks: 2 s -> 20 s; ~1 s now, old trim ran past 6 minutes.
- LaTeX nested multicolumn: 2 s -> 10 s; under 1 s now, old unwrapping took 213 s.
- Markdown delimiters: 2 s -> 10 s; under 1 s now, old backwards search past 6 minutes.
- Para::trim blank spans: 1 s -> 10 s; 0.16 s now, old Vec::remove(0) past 6 minutes.
- repeated sheet headers: 2 s -> 10 s; 20 ms now, old numbering past 6 minutes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:00:54 -04:00
Martin BallandClaude Opus 5.5 124243b1be Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:00:54 -04:00
Martin BallandClaude Opus 5.5 2c4f4669c6 #3713: review — importer limits that drop fonts, links, style names or sheet rows log a warning once per document
Every limit this branch added that leaves data out now says so:
- font names over 256 bytes and links over 8 KiB (HTML, ODT, RTF,
  LaTeX, Markdown), and RTF style names cut to 256 bytes;
- runs' links and fonts past the 64 MiB run-string budget;
- spreadsheet rows refused for being over the text budget (ODS and
  Grid::push), once per sheet.

model::dropped logs each kind once per imported document; each
importer's entry point starts a new document with begin_import. Tests
capture the warnings (crate::test_log) and check each is logged once,
again for the next document, and not at all when nothing is dropped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:00:20 -04:00
Martin BallandClaude Opus 5.5 41de47398a #3713: review — ODS repeated cells are budgeted only for the copies the grid can still keep
With one cell slot left, a long value repeated a thousand times was refused for all
its copies' text although Grid::push keeps only the first, which fits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 21:00:20 -04:00
Martin BallandClaude Opus 5.5 582f411876 #3692: Markdown export wraps link destinations with any Unicode whitespace in <…>
CodeRabbit review: only an ASCII space triggered the angle brackets, but the reader ends a bare
destination at any whitespace (U+00A0, U+3000), so such a target read back as plain text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:00:20 -04:00
Martin BallandClaude Opus 5.5 d8501a5aeb #3713: rustfmt
Finding 58 follow-up: formatting only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:00:20 -04:00
Martin BallandClaude Opus 5.5 9bee82220e Findings 40, 47, 52, 53, 57, 58, 65, 67, 74, 77 (#3692, #3705, #3713,
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 21:00:19 -04:00
Martin BallandClaude Opus 5.5 79e3610cbb #3713: importers drop overlong font names and links; spans share them; runs' copies budgeted
Findings 58 and 65. Every span deep-copied its inherited link and font
strings, so one long name or Markdown definition times many spans or
references expanded a small file into gigabytes. Fmt now holds both as
Arc<str> (spans and Markdown references share one copy), names over
256 bytes and links over 8 KiB are dropped at the importers (RTF, HTML,
Markdown, ODT, LaTeX), and the document builder stops giving runs link
and font copies past 64 MiB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 20:59:48 -04:00
Martin BallandClaude Opus 5.5 faa8221bca #3692: LaTeX — verbatim delimiter check after control characters go; multicolumn/multirow unwrapping bounded
Finding 57: Writer::code replaced `\end{verbatim}` in a code line and then removed control
characters, so `\end{verb<U+000E>atim}` became a real closing delimiter and the rest of
the line was compiled as TeX. Control characters are now removed first and the
replacement runs on the final text.

Finding 52: the table reader unwrapped every nested \multicolumn / \multirow in a cell,
rescanning the remaining braced content each time (quadratic; the nesting limit didn't
apply). At most MAX_NEST wrappers are unwrapped now; real tables use one or two, which
read as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 20:59:47 -04:00
Martin BallandClaude Opus 5.5 e9fe3d29b6 #3692: Markdown export escapes link destinations instead of deleting characters
Finding 53: link_target removed `<`, `>` and newlines from a destination after
is_safe_href had accepted it, so an accepted relative target such as
`java<script:alert(1)` was written as `<javascript:alert(1)>` (or `/</host` as
`//host`). The exporter now backslash-escapes `<`, `>`, a backslash that a reader would
treat as an escape, and `&` that starts an entity reference, so a CommonMark reader gets
back exactly the checked target; control characters, which the check ignores, are still
left out. The importer now ends a `<...>` destination at the first unescaped `>`, so these
round-trip. Ordinary destinations are written as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 20:59:47 -04:00
Martin BallandClaude Opus 5.5 f6398e29a9 #3714: Markdown import tracks the text before each delimiter instead of searching back for it
Finding 67: when emphasis was flattened, every delimiter run searched backwards for the
nearest text node to borrow its formatting, reaching the start of the paragraph when the
paragraph was all delimiters (or links): quadratic. The last text node's formatting is now
carried forward through the loop, and nothing is looked up for a delimiter with no literal
left. The result is the same.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 20:59:47 -04:00
Martin BallandClaude Opus 5.5 42c254c87a #3705: Para::trim_with drops the leading blank spans with one drain
Finding 74: trimming removed each leading whitespace-only inline with Vec::remove(0),
shifting the rest every time. A paragraph opening with many separately formatted line
breaks (HTML <b><br></b><i><br></i>…, LaTeX \\) took quadratic time on open or paste.
The removable prefix is now counted and drained once; the first remaining span is
trimmed as before. 200,000 pairs ran past 60 s before, now milliseconds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 20:59:47 -04:00
Martin BallandClaude Opus 5.5 89a64d1ef5 #3692: sheet import — linear duplicate-header naming; ODS string attributes capped and over-budget rows refused before copying
Finding 40: field_names restarted every repeated header at "Name 2" and compared each
candidate with every name so far (cubic). It now keeps the names in an ASCII-lowercased
set and remembers each base name's next number; existing headers like "Name 2" stay
collision-free and the output is unchanged.

Finding 47: an ODS office:string-value bypassed MAX_CELL_CHARS and was copied for each
repeated column before the text budget was checked. The attribute is now capped like
<text:p> text, a row stops being built (and is refused) once its text can't fit the
remaining budget, and Grid::push measures what fits before cloning.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 20:59:47 -04:00
Dylan M. Taylor d81b43b336 Merge pull request #3876 from benandez/sec/k
Security: Zotero bridge budgets; loopback-only client, same-user check on Linux only
2026-10-25 20:50:29 -04:00
Dylan M. Taylor fd53f15332 Merge pull request #3871 from benandez/sec/f
Security: legacy .doc reader budgets (#3642, #3708–#3712)
2026-10-25 20:50:23 -04:00
Dylan M. Taylor d41b8b48ca Merge pull request #3868 from benandez/sec/c
Security: bound proofing work in affixes, hyphenation and Turkish (#3661, #3657, #3625)
2026-10-25 20:50:12 -04:00
Dylan M. Taylor 00ba3d4198 Merge pull request #3866 from benandez/sec/a
Security: layout runs in linear time on hostile documents (#3706)
2026-10-25 20:50:07 -04:00
Martin BallandClaude Sonnet 5.5 91c030bc9f #3692: rebase onto main — the dash test's Draw::Path takes the new cap field
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:44:52 +00:00
Martin BallandClaude Sonnet 5.5 b7403f41ad #3692: rebase onto main — rustfmt after the merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:44:52 +00:00
Martin BallandClaude Opus 5.5 77402c4e7e #3692: review — pdf and render timing tests get a margin a busy CI machine can't break
Wall-clock bounds of 1-2 s failed on loaded runners. Each bound is now 10 s, at least 10x the
fixed code's time on a loaded machine and well under the old code's on the same input:

- cluster ends: a million marks (was 200,000); 30 ms now. The rescan (the test's brute) took
  2.9 s at 50,000 and is quadratic: about 20 minutes at a million.
- clipped-cell lookups: 100,000 cells and lines (was 20,000); 0.25 s now, a pass over the cells
  per line would be 10^10 steps.
- metafile text with a huge font: ~20 ms now; with the size check off it ran past 4 minutes.
- hostile dash patterns: milliseconds now; drawn as dashes they ran past 4 minutes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-26 00:44:52 +00:00
Martin BallandClaude Opus 5.5 1225f56187 #3692: review — left-out metafile glyphs and over-budget pictures are logged
A metafile text run's glyphs larger than 65,536 device pixels (or of no
finite size) were left out silently; the first time any are this run a
warning says how many (once a run: a page is drawn on every repaint). A
picture refused by decode_peak_fits is logged too, once per picture since
the refusal is cached with it. The 12 bytes a pixel charged to 16-bit
images is the decoder's true peak: image's decoder_to_vec allocates
w×h×8 for Rgba16 and into_rgba8 copies to RGBA8 while it is alive.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-26 00:44:52 +00:00
Martin BallandClaude Opus 5.5 f4c9c89c64 #3692: PDF export finds cluster ends and clipped cells without rescans
Finding 14: each glyph's text end was found by scanning the following
glyphs for a larger cluster, quadratic in a run of glyphs sharing one
cluster (a letter with many combining marks); cluster_ends does it in one
backward pass. Finding 31: every placed line was drawn with a clone of
every clipped table cell on its page (lines x cells); the page's clipped
cells are now indexed once by story and path and each line takes only its
own cell, the first in page order as layout picks it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:44:51 +00:00
Martin BallandClaude Opus 5.5 0aae9d05db #3692: PDF export looks bookmarks up case-blind through an index
Finding 22. A link whose bookmark name differed in case from the bookmark
fell back to scanning the whole bookmark map with eq_ignore_ascii_case, per
link (bookmarks x links), and the HashMap's order made which of several
same-but-for-case bookmarks won arbitrary. The exporter now keeps an exact
and an ASCII-folded index, each filled first-in-document-order.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:44:51 +00:00
Martin BallandClaude Opus 5.5 c98efffc06 #3692: empty polygon groups in metafiles reserve nothing
Finding 33. geom::subpaths reserved pts + counts ops up front, so a
poly-polygon record with a long list of zero counts reserved room for ops
it never emits, and the canvas charged only ops.len() against MAX_BYTES.
subpaths now sizes the vector exactly, and Canvas::push charges what each
item holds (capacity) for paths, bitmaps and text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:44:51 +00:00
Martin BallandClaude Opus 5.5 2a647661fb #3692: dash patterns too fine or broken to draw are drawn solid
Finding 36. kurbo's dash iterator stops advancing when a dash is so small
that subtracting it from the segment length changes nothing (and loops on
negative entries), so a document dash pattern like [1e-30, 1e-30] hung the
renderer. Before calling kurbo, safe_dashes drops a pattern that is
negative, non-finite, zero-length, or would cut the stroke into more than
100 000 pieces for its path's length bound; the stroke is drawn solid.
kurbo is unchanged; normal patterns dash as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:44:51 +00:00
Martin BallandClaude Opus 5.5 05ee2e0126 #3692: metafile text far larger than the page is left out
Finding 27. An EMF/WMF text record's font height is file data; a glyph
billions of device pixels tall reached vello's flattening, which subdivides
its curves for ever. draw_vector_text now skips a glyph whose device bounds
are non-finite, larger than 65536 px a side, or off the raster. Text at
normal sizes draws as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:44:51 +00:00
Martin BallandClaude Opus 5.5 0d5a149093 #3692: PNG/JPEG export refuses pixels that don't match the page size
Finding 26. Rendered::to_png resized its pixel buffer to width*height*4
when the lengths differed, so a Rendered claiming a huge size (its fields
are public) allocated that much with no budget; to_jpeg asserted inside
the encoder on the same mismatch. Both now encode only pixels that are
exactly width*height RGBA within the raster cap, and return empty (logged)
otherwise.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:44:51 +00:00
Martin BallandClaude Opus 5.5 f82319deb7 #3692: picture decode budgets charge the decoder's own pixel size
Finding 37. decode_fits charged every picture 8 bytes a pixel (RGBA8 decode
plus its copy), but a 16-bit or float image decodes to 8-16 bytes a pixel
before conversion, so the real peak was up to 2.5x the budget.
geom::decode_peak_fits takes the decoder's native bytes per pixel; render's
decode_pixmap_within and pdf's load_image_within read it from the decoder
(header only, same limits) before decoding.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:44:51 +00:00
Martin BallandClaude Opus 5.5 af9d5c9541 #3692: bitmap glyph PNG header probe keeps the glyph's budget
Finding 0. decode_png probed the PNG's size with the image crate's default
limits, and the PNG decoder inflates an iCCP profile while reading the
header: a tiny colour-font glyph could carry a few-KB chunk that inflates
to hundreds of MB before the size check. The probe and the decode now share
one reader with the glyph's own size and byte budget; past it the profile
is dropped and the glyph still draws.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:44:51 +00:00
Dylan M. Taylor d7530233cc Merge pull request #2560 from benandez/feat/39-per-user-msi
#39: per-user Windows installer that needs no administrator rights
2026-10-25 20:37:35 -04:00
Martin BallandClaude Sonnet 5.5 1fa7383264 #3692: rebase onto main — drop the Ctx sections list the linked header/footer index replaces
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:19:17 +00:00
Martin BallandClaude Sonnet 5.5 17e648bde7 #3692: rebase onto main — sort the merged test module declarations (rustfmt)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:19:17 +00:00
Martin BallandClaude Opus 5.5 ddb67eaa45 #3692: review — linear-time tests count lookup steps instead of timing them
The layout's linear-time tests (#3866) asserted CPU-time ratios, which a busy
CI machine can skew. Each walk that once ran again for every item it served
(a line's clusters, a page's items, the revision authors, the sections, a
table's rows, a run's glyphs, the clipped cells) now counts the elements it
looks at in a test-only counter (crate::work, nothing outside tests), and the
tests assert 8x the input takes at most 12x the steps (quadratic: ~64x).
proof_text, whose walk is the document model's, stays timed, and a ratio
over the limit is measured twice more before it fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-26 00:19:17 +00:00
Martin BallandClaude Opus 5.5 74dbec4107 #3692: findings 3 and 13 tests fail clearly on the old code
Finding 3: the table's rows carry no cells, only a column with a width skipped before and after,
so each row adds two spanning entries for next to no layout and counting the grid per entry
dominates (old code 75x for 8x the rows, fixed code 4-8x).

Finding 13: sections that link to the previous section's headers and footers are laid out
against the same sections naming them (title page on, so first-page parts are looked up too);
old code 6.1x the time, fixed code 1.0x, limit 2x.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:19:17 +00:00
Martin BallandClaude Opus 5.5 0674573b45 #3692: progress log and layout parity note for linear-time lookups
Findings 1, 3, 5, 13, 23, 28, 29 and 30 (#3706): one Progress log row, one Performance row in layout-parity.md, revision-history rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:19:16 +00:00
Martin BallandClaude Opus 5.5 ee96c4c99d #3706: author colours ranked once per drawing, not per run or comment
Finding 30: author_index rebuilt the list of revision authors for every tracked run and paragraph mark drawn, and balloons rebuilt the comment-author list for every comment (quadratic in revisions or comments). display::author_order ranks authors by first appearance once; page_display, page_layers and object_display build the revision order lazily once per call and pass it down, and balloons builds the comment order once per layout. Same colours.

Tests: drawing_tracked_changes_by_many_authors_takes_linear_time, tracked_changes_colour_by_author_in_order_of_appearance, balloons_for_many_authors_lay_out_in_linear_time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:19:16 +00:00
Martin BallandClaude Opus 5.5 342855315e #3692: clipped cells looked up by path, not scanned per line
Finding 1: cell_clip searched every clipped cell on the page for each line drawn (quadratic in a page of many exact-height cells). clipped_cells now indexes them by story and content path, keeping each one's order; a line looks up only its path's prefixes and takes the earliest, the same cell the scan found first.

Test: drawing_many_clipped_cells_takes_linear_time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:19:16 +00:00
Martin BallandClaude Opus 5.5 5a07c46534 #3692: selections across a header index each page's pieces once
Finding 28: column_segments and selection_rects called pieces() per paragraph, and for a header, footer or note each call scanned the whole page (pieces_on). PartPages indexes a page's lines of the story by paragraph the first time the page is looked at; the page order (hint first, continued notes) is unchanged.

Test: selecting_a_long_header_takes_linear_time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:19:16 +00:00
Martin BallandClaude Opus 5.5 897add99a8 #3692: headers and footers linked through sections once per layout
Finding 13: linked() and defining_width() walked back through the sections before each section, per section and again per page (layout_pass, headers_footers, footer_table_wraps, header_table_areas). HfLinks resolves every section's linked header and footer sets incrementally once (each inherits the previous one's for the parts it leaves out) and keeps, per part id, the sections naming it, so the defining section is a binary search. Same sets and widths as before.

Test: headers_inherited_through_many_sections_lay_out_in_linear_time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:19:16 +00:00
Martin BallandClaude Opus 5.5 629711582f #3692: autofit counts the grid's columns once
Finding 3: column_content called Table::cols (a walk of every row) for each spanning cell, and there can be one in every row. Hoisted out of the loop; the end is also added with saturating_add.

Test: autofit_tables_with_a_spanning_cell_in_every_row_lay_out_in_linear_time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:19:16 +00:00
Martin BallandClaude Opus 5.5 4d027de402 #3692: math text runs find character boundaries in one walk
Finding 23: Ctx::glyphs found each character's first glyph by scanning the shaped glyphs from the start (quadratic in a long run). The first glyph at or past a boundary never moves back as the boundary grows, so one walk gives the same cuts.

Test: math::tests::a_long_text_run_lays_out_in_linear_time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:19:16 +00:00
Martin BallandClaude Opus 5.5 7d31c01f6f #3692: bidi clicks and caret steps walk a line's carets once
Finding 5: off_at_x and visual_step on a bidirectional line called x_of for every caret offset, each searching the clusters from the line's start (quadratic in a line of many zero-width offsets, e.g. hidden text). ParaLayout::line_carets walks them with a cursor; the first matching cluster never moves back for non-decreasing offsets, so the x's are the same.

Tests: bidi_clicks_and_caret_steps_take_linear_time, line_carets_match_x_of.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:19:16 +00:00
Martin BallandClaude Opus 5.5 6c20b3e609 #3692: proof_text walks the runs once
Finding 29: proof_text looked up each character's run with props_of_char, walking the runs from the first for every character (quadratic in a paragraph of many runs). A run cursor advancing with the text gives the same props in one pass.

Adds layout::tests_linear (sharing tests.rs's cost_ratio helpers) with proof_text_walks_the_runs_once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:19:16 +00:00
Martin BallandClaude Opus 5.5 c1eb02f23c #39: README: installing both Windows installers gives two entries
Review follow-up (FredApps): the all-users and just-me MSIs install side
by side, so pick one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WEhZXSxzS8orX7f9J3qpr
2026-10-26 00:18:09 +00:00
Martin BallandClaude Opus 5.5 7f203bc1c8 #39: per-user Windows installer that needs no administrator rights
Build wordcraft.wxs a second time as a dual-purpose package
(Scope=perUserOrMachine: ALLUSERS=2, MSIINSTALLPERUSER=1), shipped as
wordcraft-<ver>-windows-user-<arch>.msi. Registry keys move from HKLM to
HKMU so the same components land in HKLM per-machine and HKCU per-user.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:18:09 +00:00
Martin BallandClaude Opus 5.5 6bcd997be8 #3692: review — proof timing tests get a margin a busy CI machine can't break
Wall-clock bounds of 1-2 s failed on loaded runners. Each bound is now 10 s, at least 10x the
fixed code's time and well under the old code's on the same input (measured by running the
pre-fix code):

- self-continuing rules: 40,000 rules (was 16,000); ~1 ms now, old checker 14 s at 16,000.
- many strips per suffix: 200,000 rules (was 30,000); 0.4 s now, old index 3 s at 30,000 and
  11 min at 300,000.
- forms of self-continuing rules: the MAX_FORMS count catches the old code (9 million forms);
  the time bound is a backstop.
- overlong Turkish words: ~20 ms now; without the caps it ran past 5 minutes.
- capped Hunspell reads: no clock; the "not a regular file" error is the proof (the old reader
  never returned from /dev/zero).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-26 00:17:25 +00:00
Martin BallandClaude Opus 5.5 5a5aa591cd #3661: review — ISO 8859-9 Turkish dictionaries load through the capped reader
Asked to confirm a non-UTF-8 Turkish dictionary still loads: before this PR
tr_TR was read with read_to_string, so a non-UTF-8 file never loaded; now it
goes through read_capped and decode, which didn't know ISO 8859-9 (Latin-5,
the encoding of older tr_TR dictionaries) and refused it as unsupported.
decode now reads `SET ISO8859-9`, and a test writes an ISO 8859-9 .aff/.dic
to disk and finds words with ğ ı ş İ ç ö ü, as stems and with suffixes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-26 00:17:25 +00:00
Martin BallandClaude Opus 5.5 6a66004a06 #3625: review — "bir kaç" still gets "birkaç": the replacement table is checked before multi-word input is refused
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-26 00:17:25 +00:00
Martin BallandClaude Opus 5.5 5cd68ee632 #3692: progress log and gap 3 note for the proofing robustness fixes
Audit findings 2, 4, 6, 10, 15, 20 (#3613 part, #3661, #3657, #3625).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:17:25 +00:00
Martin BallandClaude Opus 5.5 c187515ba1 #3625: Turkish suggestions and lookups have a word-length cap
Audit finding 20.

- tr::suggest_with refuses words over 64 letters (and words with spaces), like the other
  languages' suggesters (40 there; Turkish agglutinated words run past 40). Each candidate edit
  copies the whole word, so a long word cost O(n^2) per suggestion request on the UI thread.
- TrDict::contains returns false past MAX_WORD_BYTES (120, the affix engine's bound) instead of
  collecting the char indices of the whole word.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:17:25 +00:00
Martin BallandClaude Opus 5.5 2cff091c1a #3657: Russian hyphenation and hyphenated output linear in word length
Audit findings 10 (#3657) and 15 (#3692).

- ru::hyphen::points: the cuts come in increasing order, so a repeat can only be the last one;
  compare with `out.last()` instead of `out.contains` per syllable (O(V^2) on a long Cyrillic
  word, during layout).
- hyphenate_word_in: walk the sorted break points alongside the chars instead of searching the
  point list for every char. Same output.

Test: russian_hyphenation_and_hyphenated_output_are_linear (8x the letters costs ~8x; the old
code measured 31x and 30x).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:17:25 +00:00
Martin BallandClaude Opus 5.5 e4d2dafa9d #3661: bounded dictionary reads — hyphenation inflate limit, capped Hunspell and Turkish reads
Audit finding 4, and the rest of #3661.

- dict.rs: `Dictionary::from_bytes` inflates with `decompress_to_vec_with_limit` (64 MiB; the
  bundled en-US one is ~1 MB) and caps the words it keeps (bytes and count): front coding lets a
  2-byte line spell out a 255-byte word, so the inflated size alone doesn't bound storage.
- hunspell.rs: one `read_capped` reader — regular files only, size checked on the open handle and
  the read itself bounded by `take(MAX_FILE + 1)`, so no TOCTOU between metadata and read and no
  endless read from a FIFO or device.
- tr: the Turkish loader reads through `read_capped` and decodes with `hunspell::decode` (it
  read both files whole, with no cap at all).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:17:25 +00:00
Martin BallandClaude Opus 5.5 45556583a5 #3613: affix rules — checker follows merged flag masks, forms bounded, suffix index hashed
Part of #3613 (audit findings 2 and 6).

- Checker: `suffixed` and `form_has_flag` OR the flags of a group's matching rules into one
  mask and recurse once, not once per rule. Same answer (the walk is a disjunction over flags);
  16k self-continuing empty-append rules: 6 s -> <1 ms per check (debug).
- Finding 2: `forms` expands each (form, continuation) pair once, walks continuation rules through
  a per-flag rule list, and stops at MAX_FORMS (4096) forms / MAX_FORMS_WORK rule visits, so a
  dictionary of self-continuing rules can't allocate R^2 strings per stem.
- Finding 6: the suffix index finds a rule's (append, strip) group by hash, not by scanning the
  append's groups: building with many strips per append is linear.

Not done here (rest of #3613): a work budget for distinct-strip chains in the checker, rule caps
in parse_aff, proofing off the UI thread.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 00:17:17 +00:00
Martin BallandClaude Opus 5.5 d785c05c53 #3692: review — zotero timing tests get a margin a busy CI machine can't break
Wall-clock bounds of 2 s failed on loaded runners (checked against the pre-fix code, put back
in the worktree):

- note referenced many times: no clock; listing the note once per reference gave a million
  fields, which the count of 200 catches.
- rich text with many runs: 2 s -> 10 s; 0.35 s now, merging after every run ran past 9 minutes.
- Document_convert with one id repeated: 2 s -> 10 s, 1,000 fields (was 300); 22 ms now, the
  per-id check took 81 s with 300 fields and grows with the field count.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 23:49:15 +00:00
Martin BallandClaude Opus 5.5 bf2a379ec6 #3692: review — say plainly the same-user Zotero check is Linux only; Windows and macOS rely on loopback alone
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 23:49:15 +00:00
Martin BallandClaude Opus 5.5 e251c960d9 #3692: review — doc comments on the functions the Zotero hardening added or changed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 23:49:15 +00:00
Martin BallandClaude Opus 5.5 65f3c3418f #3692: Zotero owner check falls back to this process's uid when our socket row is missing
Ultrareview finding 3 follow-up (owner's call): with our own socket's
row missing from /proc/net/tcp(6), a listed listener of another user
was let through on the loopback rule. Our uid now falls back to the
owner of /proc/self, so a listed listener with a different uid is
refused either way. An unlisted listener still falls back to the
loopback rule. Test: a_listed_listener_is_checked_against_our_process_uid.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:49:08 +00:00
Martin BallandClaude Opus 5.5 7ee101a5b9 #3692: Zotero owner check refuses only another user's listener; unlisted falls back to loopback
Ultrareview finding 3: on Linux, when /proc/net/tcp(6) doesn't list the
listener on Zotero's port (WSL2 mirrored networking with Zotero on
Windows, a local proxy, a sandbox), every connection was refused, so
Zotero couldn't be used at all there. Per the owner's decision, refuse
only when both sockets are found and the listener's uid differs from
ours; otherwise log at debug and apply the plain loopback rule, as on
macOS/Windows/BSD. Test: an_unlisted_listener_falls_back_to_the_loopback_rule.
docs/zotero.md and the progress-log row say so. Non-Linux owner check
stays with #3831.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:49:08 +00:00
Martin BallandClaude Opus 5.5 a9cc06dc0f #3692: Zotero client talks only to this computer and, on Linux, only to our own user
Audit finding 21 (CWE-306): run_command handed document callback authority to
whatever accepted the TCP connection. Zotero's protocol is fixed and has no
authentication, so WordCraft now establishes what it can about the peer itself:
- the address must be loopback, refused before connecting (ZoteroError::Refused);
- on Linux/Android, before the command is sent, the kernel socket table
  (/proc/net/tcp, tcp6) must show the listener's end of this connection owned
  by the same UID as ours; another user's listener, or one whose owner can't be
  established, is refused. No unsafe, no new dependencies.
macOS, Windows and the BSDs have no such table without OS calls; there the
loopback rule is the guard. The residual risk is documented in docs/zotero.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:49:08 +00:00
Martin BallandClaude Opus 5.5 d8d75e4473 #3692: wordcraft-cli zotero escapes peer text on stderr
Audit finding 79 (CWE-117): the CLI printed Zotero's alert text (and, with
--trace, the method name and call/reply JSON) to stderr as received, so a peer
on Zotero's port could end the "Zotero:" line, forge following output or send
terminal escape sequences. escape_peer shows backslashes, control characters
(C0, DEL, C1), line separators and bidi overrides as visible escapes (\\, \n,
\u{1b}), keeping each record on its own attributable line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:49:08 +00:00
Martin BallandClaude Opus 5.5 ab26f22c4a #3692: Document_convert checks each distinct field id once against one indexed list
Audit finding 32 (CWE-407): Document_convert rebuilt the whole field list for
every id it was given, duplicates included, before deciding whether anything
had to move, so one valid id repeated 100,000 times took 18 s on the UI thread
(now milliseconds). Ids are deduplicated (a repeated id takes its last note
type), all of them are checked against one indexed snapshot before anything
moves, already-placed citations are skipped from the snapshot, and the index
is rebuilt only after a citation actually moves. The id list is capped at
fields::MAX_FIELDS, the most fields Zotero is ever handed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:49:08 +00:00
Martin BallandClaude Opus 5.5 020194ebdb #3692: Zotero rich text is built in one pass, not re-normalized per run
Audit finding 16 (CWE-407): rich::from_doc added each formatting run through
Paragraph::insert_text, which splits and normalizes every earlier run, so a
compact paragraph of alternating formatting was quadratic on the UI thread
(100,000 runs: over 9 minutes; now 0.1 s). Runs are appended, merged only with
the run before, and the paragraph is normalized once. A cumulative MAX_RUNS
budget keeps the text but stops adding runs past it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:49:08 +00:00
Martin BallandClaude Opus 5.5 90efee7636 #3692: Zotero lists each note's fields once, within a field and code-byte budget
Audit finding 12 (CWE-400): fields::list expanded a note's fields again for
every body reference to it, so a crafted file with thousands of references to
one citation-heavy endnote multiplied into millions of copied fields on the UI
thread. Each note part is now enumerated once, at its first reference, and the
list stops at MAX_FIELDS fields / MAX_CODE_BYTES of codes. Note references are
walked with the paragraph's objects directly instead of object_at per marker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:49:08 +00:00
Martin BallandClaude Opus 5.5 58898d5168 #3692: review — docbin timing tests get a margin a busy CI machine can't break
Wall-clock bounds of 1-2 s failed on loaded runners (checked against the pre-fix behaviour,
re-enabled in the worktree):

- DIFAT precheck: no clock; the precheck's own error is the proof it ran before cfb.
- long hyperlink: no clock (the old per-character copy took ~2.5 s, too close to any safe
  bound); finish_field on the same field charges the work budget exactly one target copy.
- large Prc: 1 s -> 10 s; ~60 ms now, interpreting it per character took 88 s.
- group named by many anchors: 1 s -> 10 s backstop; ~10 ms now. The old code (6-8 s here)
  is caught by the group count, [1000; 3] instead of [1000].

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 23:47:39 +00:00
Martin BallandClaude Opus 5.5 17d19b42c5 #3642: review — the DIFAT sample is test-only, valid .doc/.xls files pass the guard, and budget drops are logged
- repeating_difat_sample is built only for tests: this crate's, and the
  readers' through a `test-support` feature they turn on as a
  dev-dependency; it is no longer in the production API.
- Tests write valid compound files with the cfb crate in Word's and Excel's
  shapes (WordDocument + table stream with Data, summary and ObjectPool
  streams; Workbook with summary streams), in 512- and 4096-byte sectors,
  plus an 8 MB one whose FAT sectors spill into a DIFAT chain; each passes
  cfb_guard::check and opens (no Word- or Excel-made files are added).
- The .doc work budget notes what it leaves out (floating objects, link
  targets, alt text, Prm formatting) and logs one warning per kind once the
  document is read (#3692).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 23:47:39 +00:00
Martin BallandClaude Opus 5.5 bd3d260837 #3708: review — finish_field's doc comment back on finish_field
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 23:47:30 +00:00
Martin BallandClaude Opus 5.5 3aadd0a4af #3642: review — compound-file check refuses a FAT sector listed twice or more FAT sectors than the file needs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-25 23:47:30 +00:00
Martin BallandClaude Opus 5.5 dd6585f166 #3710: .doc floating-object copies charged their alt text and group members' too
CodeRabbit review: the anchor copy cost counted only the struct size, so an aliased picture's
alt text (up to ~12 KB) was copied per walk uncharged. Recursion is bounded by MAX_GROUP_DEPTH.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:47:30 +00:00
Martin BallandClaude Opus 5.5 bbeb7e2253 #3642: compound-file precheck shared in wordcraft-doc; .docx encryption sniff and .xls reader call it too
Finding 63 follow-up. #3642 names three cfb call sites: the .doc reader, the encrypted-package sniff and decrypt in docx/crypt.rs (reached for any .docx name), and the .xls reader. The DIFAT precheck moves from docbin to wordcraft_doc::cfb_guard::check (bytes only, no cfb dependency) and runs before every CompoundFile::open in the three crates. The crypt.rs change is only the two precheck calls (open PR #2125 also edits that file).

Regression tests use the shared hostile sample (cfb_guard::repeating_difat_sample): docbin a_difat_repeating_one_fat_sector_is_refused_before_cfb_builds_the_fat, docx tests/malformed.rs and formats sheet::xls a_repeating_difat_is_refused_before_cfb_opens_it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:47:30 +00:00
Martin BallandClaude Opus 5.5 a58a7618be #3692: docs — .doc reader hostile-file bounds in the progress log and file-format parity
Findings 49, 60, 63, 64, 68, 75: progress-log row and a note on the .doc parity row.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:47:30 +00:00
Martin BallandClaude Opus 5.5 b2f1197f84 #3711: .doc picture decoding has a document-wide work budget; aliased delay-stream BLIPs decode once
Finding 60 (also #3712). Metafile inflation and DIB decoding had per-picture caps only: thousands of BLIP store entries aliasing one delay-stream record each inflated and copied it again, failed inflations cost nothing, and an RLE DIB tiny on disk decoded to its full declared w x h x 4 pixels.

Now every inflate (its declared size, success or not) and every DIB decode (its declared pixels) is charged to a 512 MB per-document decode budget before it runs; past it metafiles are skipped and DIBs stay BMP placeholders. Store entries naming the same (foDelay, end) record share one decode and one media key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:47:26 +00:00
Martin BallandClaude Opus 5.5 0ecdf2ff2d #3710: .doc floating shapes built once per spid; anchor copies charged to the work budget
Finding 49. drawing::anchors built a fresh object for every PlcSpa entry,
so one group of 1000 members named by 5000 entries built 5M children
(1.4 s, ~1 GB) with the per-group child cap resetting each time. Word
anchors each shape once, so a repeated spid is now skipped: every node of
the shape tree is built at most once, bounded by the shared OfficeArt
record budget. walk also cloned each floating object every time a story
holding its anchor was walked; each copy is now charged by size (a group
with all its members) to the document-wide MAX_WORK budget.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:47:26 +00:00
Martin BallandClaude Opus 5.5 878bac84ba #3709: .doc inline-picture alt text copies charged to a work budget
Finding 68. Pictures::get cloned the cached Inline (alt text up to 4096
UTF-16 units) for every '\x01' anchor, and the char budget allows ~64M
anchors, so one picture referenced repeatedly could copy hundreds of GB.
get now returns a reference; walk copies the alt text per anchor only
while the document-wide work budget lasts, then leaves it empty (the
picture itself is still placed).

The Prm work budget from #3692 becomes one shared MAX_WORK (64 MiB) for
repeated work beyond the text: Prm grpprls interpreted, hyperlink targets
(#3708, moved off the char budget so long links in small files are never
dropped) and alt text copied.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:47:26 +00:00
Martin BallandClaude Opus 5.5 36e43fa672 #3692: .doc piece Prm resolved once per formatting change, under a work budget
Finding 64. push_formatted re-ran char_props and apply_prm for every
character, so a single piece whose Prm names a 63 KB Prc over 100k
characters took 15 s. walk now keeps the last resolved CharProps and
reuses it while the CHPX slice, the piece's raw Prm and the paragraph
style are unchanged. Prm1 grpprl bytes interpreted are charged to a
document-wide MAX_PRM_WORK (64 MiB) budget, so files alternating pieces
per character can't multiply the work; past it the Prm is skipped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:47:26 +00:00
Martin BallandClaude Opus 5.5 dc3a999081 #3708: .doc hyperlink target copied once per run, charged to the char budget
Finding 75. finish_field cloned the HYPERLINK target into every result
character's CharProps and ParaBuild::push then compared it per character,
so a long target over a long result was quadratic (250k x 250k: 2.6 s).
Equal-formatted result characters now extend the current run; each run
start copies the target once and charges its length to the document's
character budget, past which the rest of the result is kept unlinked.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:47:26 +00:00
Martin BallandClaude Opus 5.5 e73ce9c274 #3642: .doc reader checks the compound file's DIFAT before cfb builds the FAT
Finding 63. cfb 0.15 appends a whole sector of FAT entries for every FAT
sector the DIFAT lists and validates the FAT only afterwards, so a small
file whose DIFAT chain names one sector thousands of times made it
allocate ~128x the file size (2048 DIFAT sectors: 133 MB, 370 ms) before
failing. docbin now walks the header DIFAT and chain the way cfb does
(same trimming, one step per sector) and refuses a file listing more FAT
sectors than it has sectors, so the FAT is never larger than the file.
Both is_encrypted and read_with_password go through it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 23:47:26 +00:00
Dylan M. Taylor 6d3370635d Merge pull request #4356 from storytold/chore/deps-safe-refresh
chore(deps): safe dependency refresh
2026-10-25 19:27:38 -04:00
skylar b729cf0cb4 fix(proof): keep inflate error output out of messages 2026-10-25 16:57:21 -06:00
skylar 68407a34cd chore(deps): bump miniz_oxide to 0.9 2026-10-25 16:57:11 -06:00
skylar a1aa17fc14 fix(deps): keep miniz_oxide at 0.8 2026-10-25 16:53:12 -06:00
skylar 5ee71b4862 chore(deps): refresh safe dependency bumps
serde_json 1.0.154, proptest 1.12, base64 0.23, arboard 3.7, open 5.4.5,
winresource 0.1.34, pollster 1.0, miniz_oxide 0.9, sha2 0.11.1, libc 0.2.191.
2026-10-25 16:36:45 -06:00
Dylan M. Taylor a029d8aed1 build: pin craft-update to craft-libs main (495e597) (#4352)
Picks up the startup prompt without the glyphs a font may lack (storytold/craft-libs#343, #344): the close mark is painted and the version pill is ASCII, so apps that panic on a missing glyph no longer take the prompt down.

Part of tracking issue storytold/craft-libs#319
2026-10-25 17:52:32 -04:00
Dylan M. TaylorandCodebuff ee03feb81d build: pin craft-update to craft-libs main (495e597) (#4345)
Picks up the update prompt fix that draws its close mark and version pill
without glyphs a font may lack, so the prompt no longer takes the app down
when a bundled face has no ✕ or →.

Part of tracking issue storytold/craft-libs#319

🤖 Generated with Codebuff

Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-25 17:37:24 -04:00
Dylan M. Taylor e5cffe7641 Merge pull request #4233 from VoiceLessQ/fix/small-cleanups
Small fixes: MERGEFIELD in any case, 18 SonarCloud closures, one unused constructor
2026-10-25 16:45:39 -04:00
skylartaylor c1484abe80 egui037: preserve startup updater in shortcut regression follow-up 2026-10-25 21:29:39 +01:00
skylartaylor 49600e0344 egui037: cover formatting copy and paste shortcuts 2026-10-25 21:07:35 +01:00
Dylan M. Taylor 7ef2633a7e Merge pull request #4298 from dylanmtaylor/feat/startup-update-prompt
feat: startup update prompt modal and direct self-updating
2026-10-25 15:53:32 -04:00
Dylan M. Taylor 57ba4b9ed8 fix(updates): localize the update status messages and format the sources 2026-10-25 15:34:12 -04:00
Dylan M. Taylor 62736d93df fix(updates): address review feedback
Wake the UI when a manual check is queued from a menu callback; the update prompt owns the keyboard while it is open (LightCraft); Download opens through the desktop URL hook (FilmCraft); the Help menu dispatches through menus::invoke (FigCraft); print jobs and comment drafts hold an install back (WordCraft); Check for Updates is a registered command (GridCraft); an install holds back while documents are unsaved.
2026-10-25 15:17:06 -04:00
Dylan M. Taylor f01a107a40 build: pin craft-update to craft-libs main (db19c37) 2026-10-25 15:03:15 -04:00
Dylan M. Taylor dd2e2c8605 fix(updates): give the update command a ui. id so the ribbon button is enabled 2026-10-25 14:37:40 -04:00
Dylan M. Taylor e3d93581b7 refactor(updates): drive the flow with craft-update's Updater
Results are read independent of the prompt, a dropped worker is an error, failures return to the prompt, and no update starts over unsaved work. A manual check during the startup check is honored.
2026-10-25 14:33:47 -04:00
Dylan M. Taylor e6954843bc build: pin craft-update to craft-libs main (b9ba47f) 2026-10-25 13:39:32 -04:00
Dylan M. Taylor 4148fb1171 refactor(updates): drive the flow with craft-update's Updater
Startup check is opt-in for the desktop app, the Windows update helper is dispatched before argument parsing, and a successful update closes through the app's own close path.
2026-10-25 13:31:27 -04:00
Dylan M. Taylor 67ffe00b32 build: pin craft-update to the dirs-free, zip 9 build
Satisfies cargo-deny: no MPL-2.0 option-ext, no zip 8 advisories, and a versioned git dependency instead of a wildcard.
2026-10-25 13:31:19 -04:00
Dylan M. Taylor a91e1b5ea4 build: merge the craft-update dependency into the existing non-wasm table 2026-10-25 13:31:16 -04:00
Dylan M. Taylor b39c1ba1a3 fix(updates): report self-update results to the prompt and finish through the app's close path
Keeps the worker's failure visible instead of leaving the prompt installing, repaints while a check runs, and no longer calls process::exit past the unsaved-work prompt.
2026-10-25 13:31:15 -04:00
Dylan M. Taylor 04121fb020 feat: startup update prompt modal and direct self-updating
Integrate `craft-update` pinned to storytold/craft-libs fe61243 to check for
updates on startup, display the standard suite update modal with changelog notes,
and support one-click in-app updating and restart.

Ref: storytold/craft-libs#319
2026-10-25 13:31:15 -04:00
Dylan M. Taylor 5dd5e8b31b Merge pull request #4241 from storytold/codex/egui-037-wordcraft-20261023
Port WordCraft to egui 0.37 with current-main editing changes
2026-10-25 13:30:14 -04:00
Martin BallandClaude Opus 5.5 4095cc3413 Security: bounded file reads, Read Aloud and macOS signing temp file (#3680, #3666) (#3877)
* #3680: Read Aloud tidies in one linear pass and cuts space-free sentences

Finding 56. tidy() repeated whole-string replacements until nothing
changed; each pass removed one level of nested empty brackets, so 40,000
pairs took ~50 s in a debug build. It is now one pass that drops a closing
bracket's empty pair as it goes, checked against the old algorithm on
20,000 generated strings. Sentences with no space to split at are cut at
MAX_UTTERANCE (1,200) characters, and each sentence is sliced from a char
vector instead of skipping from the paragraph's start.

Fixes #3680

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

* #3692: Paste Special reads only a regular picture file, bounded

Finding 24. A copied path naming a device such as /dev/zero reports a
length of 0, so the size check passed and std::fs::read read forever.
The path and the opened handle must both be regular files, and at most
the picture limit + 1 bytes are read. A named pipe swapped in between the
check and the open can still block the open (no portable non-blocking
open without platform flags); noted in the code.

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

* #3692: macOS signing import decodes the certificate into a private temp folder

Finding 25. The .p12 bundle was written to a predictable name in the
shared temp folder, where a planted link could redirect it. It now goes
into a mktemp -d folder under the same temp root, removed on exit. The
keychain is unchanged.

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

* #3666: engine file reads open once and check the handle; a FIFO is refused without blocking

read_file_capped checked the path's metadata and then read the path
again, so a file swapped or grown after the check was read whole;
read_file_head opened the path with no type check, so saving to a FIFO
.xml path (the Word 2003 sniff) blocked until a writer appeared. Both
now go through open_regular_file (path checked before opening, handle
checked after, opened once) and a bounded read; read_regular_file uses
the same pieces. Tests: both helpers refuse a FIFO without blocking
and a folder; read_file_capped refuses content over the cap.

Fixes #3666

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

* #3692: Paste Special reads picture files through the engine's bounded read

Ultrareview finding 9: paste_picture kept a third copy of the regular-file
bounded read; it now calls wordcraft_engine::io::read_regular_file (public,
with a wasm stub).

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

* #3666: open a checked file non-blocking so a swap to a named pipe can't hang

CodeRabbit: a path replaced by a FIFO between the regular-file check and
File::open blocked in open, before the handle check could refuse it. On Unix the
open now uses O_NONBLOCK (no effect on regular files; libc is already in the
lockfile) and the handle check refuses the pipe. The FIFO tests' cleanup writers
open non-blocking too, so a regression fails the test instead of hanging it.

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

* #3666: review — Paste Special refuses a picture file whose handle reports more than the cap before reading it

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

* #3692: review — macOS signing keychain lives in the private mktemp folder too; removed if the import fails

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

* #3666: review — libc comes from the workspace dependencies

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

* #3680: review — Read Aloud logs a warning when its sentence cap leaves text unread

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

* #3692: review — engine speech timing test gets a margin a busy CI machine can't break

tidy_is_linear_on_nested_brackets: 2 s -> 10 s. The one-pass tidy takes ~13 ms on a loaded
machine; tidy_by_replacing (the old tidy, kept in the tests) took 293 s on the same input.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 12:32:19 -04:00
Dylan M. Taylor 6b97197f69 Merge pull request #3945 from storytold/dependabot/github_actions/actions-6dcc66fbd4
ci(deps): bump Swatinem/rust-cache from 2.9.2 to 2.9.3 in the actions group across 1 directory
2026-10-25 12:30:17 -04:00
skylar taylor-barrick ea541fe66d Merge current WordCraft main while preserving egui 0.37 migration 2026-10-25 17:30:11 +01:00
skylar taylor-barrick fe9f6dbcc9 M0: reconcile current-main dialogs and canvas changes with egui 0.37 2026-10-25 17:03:15 +01:00
ea5780d2ab Gap 9: open AES-encrypted Word files (Standard encryption) (#2628)
* Gap 9: open AES-encrypted Word files (Standard encryption)

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

* Gap 9: restore progress-doc rows lost in the main merge

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

* Gap 9: Standard package layout test built from the spec

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

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:48:13 -04:00
skylar taylor-barrick 42477bd806 M0: reconcile current main and classify double-space rewrites for egui 0.37 2026-10-25 16:47:30 +01:00
Max MurphyandClaude Opus 5.5 49731f3faa #2982: File ▸ Info Custom Document Properties dialog (#2997)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:44:31 -04:00
SlavenDj 1b2f7a8c09 #1845: filter-as-you-type command search in Customize Ribbon, QAT and Keyboard pickers (#1848)
# Please enter the commit message for your changes. Lines starting
# with '#' will be kept; you may remove them yourself if you want to.
# An empty message aborts the commit.
#
# interactive rebase in progress; onto d588e0b8
# Last command done (1 command done):
#    pick 6a1268ea # #1845: filter-as-you-type command search in Customize Ribbon, QAT and Keyboard pickers
# No commands remaining.
# You are currently rebasing branch 'feat/1845-command-filter' on 'd588e0b8'.
#
# Changes to be committed:
#	modified:   crates/ui-egui/src/dialogs_keyboard.rs
#	modified:   crates/ui-egui/src/dialogs_ribbon.rs
#	modified:   docs/ui-parity.md
#

# Conflicts:
#	docs/ui-parity.md
2026-10-25 11:39:07 -04:00
MexandClaude Opus 5.5 40a7fe6be2 Make three-column header/footer presets editable tables (#2197) (#2266)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:34:23 -04:00
William de O. SilvaandClaude Opus 5.5 1489161611 fix/canvas-picture-crop-activation (#2440)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:21:53 -04:00
bf70f48bbd Bound MCP stdio request and bridge reply line reads (#3950)
* Bound MCP stdio request and bridge reply line reads

Fixes #3685

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

* #3685: an unreadable reply from the app is final, not retried

A reply that is too long (or not UTF-8) means the app already ran the
request; retrying on a new connection could run a state-changing request
twice.

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

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-25 11:17:58 -04:00
NetterSilverandClaude Opus 5.5 d5f23907dc Bound total LCS work per review.compare (#4006)
Fixes #3632

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:15:42 -04:00
skylar taylor-barrick f7c49cb0f8 egui-037: reconcile main and protect IME undo and wrapped layout 2026-10-25 16:15:05 +01:00
0cb2af4f4b AutoFormat As You Type: optional "Add a period with double-space", and an AutoCorrect Options button in Options › Proofing (#2848) (#3318)
* AutoFormat As You Type: optional period with double-space, off by default (#2848)

* Test the double-space option through the AutoCorrect dialog (#2848)

* File > Options > Proofing: an AutoCorrect Options button opens the dialog (#476)

* #2848: report doubleSpacePeriod in tools.autocorrect's params doc and result

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:11:20 -04:00
Dmitrii KhlapovandDmitrii Khlapov 50a1c34647 #1475: Scale all style font sizes at once (#2620)
Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
2026-10-25 11:06:42 -04:00
NetterSilverandClaude Opus 5.5 b638caa2d2 Keep a document's own styles and defaults when applying a style set (#4013)
Fixes #3650

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 11:01:31 -04:00
Martin BallandClaude Opus 5.5 85db9cfef5 #4177: regex Replace expands capture groups (#4201)
* With Regular expressions on, Replace with reads $1…$n, ${n}, ${name}, $name,
$0/${0}, $$, \1…\9, \n (paragraph mark, splits the paragraph through the ^p
path), \t and \\, alongside the ^ codes. Named groups resolve through
meta::Regex::group_info(); missing, unknown and out-of-range groups expand to
nothing. Plain and wildcard Replace are unchanged.

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

* #4177: review — special:false keeps regex group references

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

* #4177: review — \$ and \^ in regex Replace keep the character as typed

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 10:53:13 -04:00
Marco Arnulfo d1db090a7c Mini toolbar: the alignment buttons mark the alignment seen on the page, as the ribbon does (#3886) 2026-10-25 10:48:42 -04:00
NetterSilverandClaude Haiku 5.5 4bf785c4cf Keep Arabic-Indic digits as typed in Arabic numeral mode (#3329)
Fixes #3259

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 10:44:31 -04:00
Fin WarmanandClaude Opus 5.5 a5526724aa Cover repeated DOCX style replacement across saves (#2469)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 10:43:03 -04:00
divyanshu2074 0696c55698 fix(docx): support sym hex prefixes and normalize break types and note references (#2413) 2026-10-25 10:41:23 -04:00
divyanshu2074 b37a732455 fix(formats): support negative timezone offsets and author fallback in ODT metadata (#2189) 2026-10-25 10:39:59 -04:00
SimoneandLucenx9 b0b7e6b5ea Fix Markdown code span line endings (#3804)
Co-authored-by: Lucenx9 <185146821+Lucenx9@users.noreply.github.com>
2026-10-25 10:32:31 -04:00
divyanshu2074 1b0faddfa8 fix(formats): ignore block and inline HTML comments in markdown import (#2225) 2026-10-25 10:31:01 -04:00
divyanshu2074 0d2d1776dd fix(formats): support backslash space, soft breaks and zero-width spaces in RTF (#2250) 2026-10-25 10:29:09 -04:00
MarekSpodBiedry 14f0eec255 Stretch justified lines that end in a manual line break (#3924) (#4023) 2026-10-25 10:27:35 -04:00
skylar taylor-barrick 5b46f9ba4d M-egui037: fix fontless tests and document migration behavior 2026-10-25 15:24:23 +01:00
Marco Arnulfo 7563ebb314 Layout: a no-break space is as wide as a space, not as the font's own U+00A0 glyph (#4210)
* Layout: a no-break space is as wide as a space, not as the font's own U+00A0 glyph (fixes #4192)

* Layout parity: a no-break space is as wide as a space (#4192)
2026-10-25 10:23:25 -04:00
Marco Arnulfo 8c366eb176 Layout: a bookmark is no line-break opportunity (#4211)
* Layout: a bookmark is no line-break opportunity (fixes #4193)

* Layout parity: a bookmark is no line-break opportunity (#4193)
2026-10-25 10:19:38 -04:00
NetterSilverandClaude Haiku 5.5 77200f9c13 Indent a whole selected paragraph on Tab instead of replacing it (#4291)
Fixes #4285

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 10:04:21 -04:00
Michael HigginsandClaude Opus 5.5 9003e2b0af Limit DOCX XML attributes (#3857)
Caps attributes per XML element at 1024 with a Limit error.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:59:48 -04:00
NetterSilverandClaude Haiku 5.5 84c2d37a49 Cap list label length when expanding level template text (#4003)
Fixes #3638

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 09:57:06 -04:00
7mz410 66e5c06b25 Lists: draw Arabic letter numbering (arabicAlpha, arabicAbjad) (#4218)
* Lists: draw Arabic letter numbering (arabicAlpha, arabicAbjad) instead of decimal

Both orders of the 28 letters; past the last letter they double, as Latin
and Russian letters do.

* Docs: layout parity lists Arabic list labels as drawn; status lines and revision history
2026-10-25 09:55:53 -04:00
Marco Arnulfo f490f9da9a Linux desktop entry: stop declaring text/plain (#4206)
* Linux desktop entry: stop declaring text/plain (fixes #4137)

* Docs: plain text is no longer in the Linux desktop entry (#4137)
2026-10-25 09:54:32 -04:00
NetterSilverandClaude Haiku 5.5 52d81d02d3 Parse \genfrac in LaTeX import so binomials survive export (#4242)
Found while testing crates/doc/src/math_latex.rs:861-865 (to_latex, FracKind::NoBar)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-25 09:47:48 -04:00
aiongg f199efde84 Pin craft-libs at 4dc543c (task pane drop previews show where the pane lands, craft-libs#312) 2026-10-25 06:07:10 +00:00
skylartaylor 5d428e408f M-egui037: reconcile upstream and document migration review fixes 2026-10-25 06:42:20 +01:00
VoiceLessQ f3961d74d9 Mail merge: an IF is a merge rule only when it tests a MERGEFIELD
Preview Results and Finish & Merge took any IF whose code held the word
MERGEFIELD anywhere, and with the type now matched in any case, a quoted
value counted too: IF { PAGE } = "mergefield" "yes" "no" was evaluated
as a rule on an empty field and showed "no". Both guards now ask what
condition() already reads: the first nested field, through
mergefield_name. An IF on other fields keeps its result.
2026-10-25 02:37:43 -02:00
aiongg 823ce76e39 Pin craft-libs at 8a503cf (Adobe-style drop targets, craft-libs#310) 2026-10-25 03:55:42 +00:00
VoiceLessQ 944c67ba89 Mail merge: MERGEFIELD in any case is a merge field, MERGEFIELDX is not
`mergefield_name` matched with `strip_prefix("MERGEFIELD")`. A field typed as `mergefield City` was therefore no merge field at all: Check for Errors skipped it, and Preview Results and Finish & Merge left it alone. A code such as `MERGEFIELDX a` went the other way and was read as a merge field named `X`.

Field-type names ignore case (ECMA-376 Part 1, 17.16.1: "The field-type names DATE, Date, dAtE, and date are equivalent"). The other keywords in this file already go through the upper-cased `keyword()`. Now the type ends at the first space or quote and is compared ignoring case, which is the rule the shared HYPERLINK parser in #4222 follows. The two IF checks for a nested MERGEFIELD ignore case as well, so `IF { mergefield X } = ...` merges like its upper-case form.

Old and new compared on 5,940 generated codes: 2,760 give the same name, 960 are case variants that are now found, and 2,220 start with a longer word such as `MERGEFIELDX` that no longer counts as MERGEFIELD.

Test: merge_field_types_ignore_case.
2026-10-25 01:21:48 -02:00
VoiceLessQ 500f9cdfea proof: remove the unused Patterns::from_parts
It came in with the first proofing commit (d1a0ed39c) and has never had a caller; `#[allow(dead_code)]` kept the warning quiet. Because it is `pub(crate)`, nothing outside wordcraft-proof can reach it.
2026-10-25 01:21:48 -02:00
VoiceLessQ 65a0dc8dc6 Method paths for 18 closures that only call a method (SonarCloud S1612)
SonarCloud's analysis of main at b4da59f0c lists 23 closures such as `|v| v.as_slice()` that hand their argument straight to one method. This passes the method itself: `Vec::as_slice`, `str::to_ascii_lowercase`, `f64::total_cmp` and the like, all forms the repo already uses elsewhere. Behaviour is unchanged.

Five stay. The four in crates/proof/src/es/mod.rs sit in code that #4191 replaces, and its replacement already uses `PoisonError::into_inner`. In crates/engine/src/io/safe_save.rs the path would be `<dyn std::error::Error + Send + Sync>::is::<Guided>`, which reads worse than `|inner| inner.is::<Guided>()`.
2026-10-25 01:19:28 -02:00
skylartaylor 0b007fd47b egui037: reconcile WordCraft main and preserve input migration 2026-10-25 03:17:45 +01:00
aiongg 0bdd2a3e04 Pin craft-libs at 869928d (craft-print verifies Windows print capabilities, craft-libs#303) 2026-10-25 00:37:48 +00:00
skylartaylor a605fa5ca7 M-EGUI037: reconcile WordCraft main and preserve IME editing 2026-10-25 02:19:38 +02:00
aiongg 9129d713ca Escape during a task pane drag cancels the drag and stays in full screen 2026-10-25 00:14:31 +00:00
aiongg f501f7cb5f Task panes: Dock Left, Dock Right and Float run ui.paneDock for every pane but Styles 2026-10-24 23:43:01 +00:00
aiongg 4f071a5b88 Shortcuts: a focused dialog button keeps Ctrl+Z and other shortcuts from the document 2026-10-24 23:23:51 +00:00
aiongg de8a85dc75 Merge upstream/main into panes-trial 2026-10-24 23:09:05 +00:00
aiongg 7f236c3ec5 Print pane: show each printer's spooler description as its label 2026-10-24 22:41:36 +00:00
aiongg 01e32ff863 Pin craft-libs at 6e7f285 for craft-ui docking, craft-i18n, craft-print, craft-pdf and krilla 2026-10-24 22:41:02 +00:00
aiongg 9fca3bae3a Merge upstream/main into panes-trial 2026-10-24 22:30:04 +00:00
skylartaylor c4974d9e8d egui037: reconcile current main, qualify dependency pins and preview accessibility 2026-10-24 23:22:06 +02:00
aiongg 401d811fa0 Task panes: every pane keeps its dock or float place between runs (#1756) 2026-10-24 20:20:20 +00:00
aiongg f06a1d3ba5 Task panes: rows and fields fit a 200 pt pane (#1756) 2026-10-24 20:17:53 +00:00
aiongg 6fac5994f7 Task panes: bump only craft-ui; craft-print, craft-pdf and craft-i18n stay at main's revs (#1756) 2026-10-24 20:13:42 +00:00
aiongg 8e0353ae41 Task panes: shortcuts run while a pane title bar has focus (#1756) 2026-10-24 20:12:55 +00:00
aiongg 4c246f082a Task panes: F6 stays in dialogs and runs user bindings (#1756) 2026-10-24 20:11:34 +00:00
aiongg 86b44280ac Merge upstream/main into panes-trial
- Format Shape is a task pane with the others: its title bar comes from
  craft_ui::panes and its drag undo mark is forgotten while it is closed.
- The in-repo catalogs are gone upstream; the pane strings this branch
  added to them (Styles Pane Position, Dock Left, Dock Right, Float, Pane
  Options, Move Pane: {title}, Resize Pane) go to craft-i18n.
- Every craft-libs crate (craft-ui, craft-i18n, craft-print, craft-pdf,
  krilla) is pinned at storytold/craft-libs 16fb3d4. craft_ui's Printer
  has a label: the spooler's description of the printer.
2026-10-24 19:04:55 +00:00
aiongg 3db25e4aa9 Tests from main: mailing results open beside the source document 2026-10-24 06:51:04 +00:00
skylartaylor fd071202e7 E37: migrate WordCraft to egui 0.37 with guarded IME and capture lifecycle 2026-10-24 08:30:31 +02:00
aiongg 753c624a7d W1: the log says why an opened file didn't replace the active document 2026-10-24 06:14:27 +00:00
aiongg 6f7da7345f W1: every way of opening a file goes through WordApp::open_file 2026-10-24 04:12:56 +00:00
aiongg 00856d7178 Merge remote-tracking branch 'upstream/main' into w6
# Conflicts:
#	ROADMAP.md
#	crates/engine/src/session.rs
#	crates/ui-egui/Cargo.toml
#	crates/ui-egui/src/control.rs
#	crates/ui-egui/src/i18n/bg.tsv
#	crates/ui-egui/src/i18n/cs.tsv
#	crates/ui-egui/src/i18n/da.tsv
#	crates/ui-egui/src/i18n/de.tsv
#	crates/ui-egui/src/i18n/es.tsv
#	crates/ui-egui/src/i18n/et.tsv
#	crates/ui-egui/src/i18n/fr.tsv
#	crates/ui-egui/src/i18n/hr.tsv
#	crates/ui-egui/src/i18n/hu.tsv
#	crates/ui-egui/src/i18n/it.tsv
#	crates/ui-egui/src/i18n/ja.tsv
#	crates/ui-egui/src/i18n/ko.tsv
#	crates/ui-egui/src/i18n/nl.tsv
#	crates/ui-egui/src/i18n/pl.tsv
#	crates/ui-egui/src/i18n/pt-br.tsv
#	crates/ui-egui/src/i18n/ro.tsv
#	crates/ui-egui/src/i18n/ru.tsv
#	crates/ui-egui/src/i18n/sl.tsv
#	crates/ui-egui/src/i18n/sr-latn.tsv
#	crates/ui-egui/src/i18n/sr.tsv
#	crates/ui-egui/src/i18n/sv.tsv
#	crates/ui-egui/src/i18n/tr.tsv
#	crates/ui-egui/src/i18n/uk.tsv
#	crates/ui-egui/src/i18n/vi.tsv
#	crates/ui-egui/src/i18n/zh-hans.tsv
#	crates/ui-egui/src/i18n/zh-hant.tsv
#	crates/ui-egui/src/lib.rs
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/ui-parity.md
2026-10-24 03:53:36 +00:00
Martin BallandClaude Opus 5.5 3d944e735a #3692: review — the typed-paragraph line cap is text.insert's own, so control-channel and CLI calls are held to it too
The MCP tool no longer counts lines itself; it quotes the engine's MAX_TYPED_PARAGRAPHS.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-23 20:19:49 +01:00
Martin BallandClaude Opus 5.5 6766118d05 #3692: review — doc comments on the functions this branch touched that had none
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-23 20:19:49 +01:00
Martin BallandClaude Opus 5.5 6b23f3b368 #3698: template bytes need no local path; verbatim and device paths with a drive are local
Ultrareview finding 4. (a) load_other resolved the template name against
the document's folder even when its bytes came in `data`, so a document
opened from a share or a `\\?\` path could not attach, update or organise
styles from a relative name. With bytes nothing is read: the name only
gives the format. Without bytes local_path still refuses a relative
reference from a document on a share (no NTLM leak). (b) is_network_path
called `\\?\C:\…` and `\\.\C:\…` network; a drive letter after the
verbatim, device or NT prefix is local. `\\?\UNC\…`, `\\.\UNC\…`, other
devices and `\\host\…` stay refused.

Tests: cmd::templates::tests::template_bytes_need_no_local_path (new),
template_references_become_local_paths (extended).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:19:49 +01:00
Martin BallandClaude Opus 5.5 3a8ff6b6bc #3692: typed paragraphs break like Enter, still one command and one undo step
Ultrareview finding 2. MCP `type_text` with `paragraphs: true` sent one raw
text.insert with `\r` breaks, which skipped Enter: a break could land in a
one-line, check-box or drop-down content control (text.newParagraph refuses
there), and Enter's AutoFormat (--- borders, +---+ tables) no longer ran.

text.insert takes `paragraphs: true`: each line is typed raw and each break
runs text.newParagraph's own implementation (`enter`), inside the one
command, so there is one checkpoint. Where Enter would refuse, the command
fails and Session::run puts the document and history back (all or nothing).
Enter's AutoFormat normally records the plain Enter as its own undo step
after the fact (push_undo, a whole-document clone); inside typed paragraphs
that step is skipped, so AutoFormatted lines add no snapshot each. MCP sends
`paragraphs: true` and keeps MAX_TYPED_PARAGRAPHS.

Tests: mcp type_text_paragraphs_are_refused_in_a_one_line_control,
type_text_paragraphs_autoformat_like_enter_in_one_undo_step.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:19:49 +01:00
Martin BallandClaude Opus 5.5 8a18026a13 #3692: rustfmt type_text
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:19:49 +01:00
Martin BallandClaude Opus 5.5 4f5e596c71 #3735: docs — automation roots, type_text limit, Copy Styles under protection
Progress log row for group J (findings 9, 18, 34, 41, 50, 54, 69, 78);
docs/mcp.md says what the roots now cover; the Restrict Editing row notes
Copy Styles.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:19:49 +01:00
Martin BallandClaude Opus 5.5 c7dfd92ff1 #3698: template paths refuse the NT namespace and are checked again after joining the document's folder
Finding 78 and WC-02. is_network_path looked only at the first two bytes,
so `\??\UNC\host\share\T.dotx` (any separator mix) passed as local, and
local_path checked the reference before joining it to the document's
folder, so a relative reference from a document named `\\host\share\x`
(file.open with data) became a remote path after the check. The
classifier now also refuses `\??\…` unless a drive letter follows, and
local_path checks the completed path. Pure string logic, the same on
every platform; the same helper guards the folder settings.

Test: cmd::templates::tests::template_references_become_local_paths (extended).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:19:49 +01:00
Martin BallandClaude Opus 5.5 66e92e3698 #3692: formatting restriction refuses Copy Styles
Finding 50. check_formatting blocked Update Styles and Attach Template
with update, but not styles.copyFrom, which replaces style definitions
(locked flag included) from another file. Under "limit formatting to a
selection of styles" an editor could import changed formatting and
unlock a locked style without the password. styles.copyFrom is now
refused like Update Styles. copy_styles' callers are all blocked under
the restriction, so its lock handling is left as is.

Test: cmd::protect::tests::formatting_restriction_refuses_attach_template_update (extended).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:19:49 +01:00
Martin BallandClaude Opus 5.5 6142567b6a #3737: dictionary file and folder settings held to the automation roots
Finding 34 and #3737 (WC-35), same root cause: proofing paths bypassed
file_access. persist_user_dictionary now checks the dictionary file for
read and write (entry included, so its sibling temporary file too) and
keeps the words in memory when refused. folder_setting checks
dictionariesFolder and userTemplatesFolder with check_read before probing
the folder, so a confined session can't install or save one outside the
read root. Finding 34 is defensive today: only the desktop app sets
user_dict_path, and it runs unconfined.

Test: tests::confined_sessions_neither_save_the_dictionary_nor_choose_folders_outside_their_roots.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:19:49 +01:00
Martin BallandClaude Opus 5.5 02fffea7c9 #3736: HTML pictures load only from inside the automation read root
Finding 54. Opening an HTML file checks the file through its links, but
its pictures load relative to the folder the given path names. Through a
link outside the read root to a page inside it, pictures beside the link
(outside the root) were embedded. Each picture's canonical path is now
checked with check_read before it is read; unconfined opens are unchanged.

Test: tests::html_pictures_load_only_from_inside_the_read_root.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:19:49 +01:00
Martin BallandClaude Opus 5.5 8ce3eb58c7 #3735: automation writes check the directory entry they replace, not only the link's target
Findings 41 and 69. check_write resolved a path through links and compared
the target with the write root, but safe_save puts its temporary file beside
the path given and renames it over that entry; rename_file removes it. A link
outside the root pointing in passed the check while the write landed outside.
Write access now also requires the entry itself (resolved parent + literal
name) inside the root; the existing target check still refuses an inside
link that points out.

Tests: file_access::tests::links_are_followed_before_comparing (extended),
mcp automation_roots::a_link_outside_the_write_root_into_it_is_not_written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:19:49 +01:00
Martin BallandClaude Opus 5.5 3d51cd3db2 #3692: MCP type_text types its paragraphs as one edit and one undo step
Audit finding 18 (CWE-407). `type_text` with `paragraphs: true` ran a
text.insert and a text.newParagraph per line, each a mutating command with
its own whole-document undo snapshot, so a long text did quadratic work and
filled the undo history. It now sends one raw text.insert with `\r`
paragraph breaks (Enter's split): one edit, one undo step, all or nothing.
More than MAX_TYPED_PARAGRAPHS (10 000) lines is refused before anything
is typed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:19:49 +01:00
Martin BallandClaude Opus 5.5 08da5c5433 #3699: MCP bridge sends the control key only to 127.0.0.1, where the app listens
Audit finding 9 (CWE-522). The bridge sent the reusable control key to any
loopback peer (127.0.0.0/8, ::1) and tried every address `localhost`
resolved to, while the app binds only 127.0.0.1. Another local user could
listen on ::1 or 127.0.0.2 and receive the key. Now `localhost` connects to
127.0.0.1 only, and the key goes only to a 127.0.0.1 peer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:19:49 +01:00
dependabot[bot] 358614e1ff ci(deps): bump Swatinem/rust-cache
Bumps the actions group with 1 update in the / directory: [Swatinem/rust-cache](https://github.com/swatinem/rust-cache).


Updates `Swatinem/rust-cache` from 2.9.2 to 2.9.3
- [Release notes](https://github.com/swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/swatinem/rust-cache/compare/6323deb102c322ba6fcbdcafc7e3dddab59af2b6...d1527c8f67ef9af357290475d4af4b0fab8f12e0)

---
updated-dependencies:
- dependency-name: Swatinem/rust-cache
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-23 18:53:02 +00:00
aiongg f2c21cd62c W6: View: New Window opens another window on the document
Each window on a document keeps its own view (selection, zoom, view
mode, panes) as an engine WindowView, moved in and out of the session as
the windows draw and clamped to edits made through another window. The
document, undo history and save state are shared. Two windows on one
document are titled Name:1 and Name:2; closing one of them asks nothing,
the last asks, and quitting asks once per document. Switch Windows lists
windows. The layout memo keeps two layouts and keys on every view option,
so windows with different views don't lay out again each frame.
2026-10-22 17:30:33 +00:00
aiongg a6d5820e16 W3b review: quit asks from the empty window; background requests keep the active document
- Quitting from the Start screen's empty window asks about other windows' documents.
- A request for another window's document is handled when that window draws; the
  active window and document stay.
- Keys after a window closes or goes to the back in the same frame are dropped.
- A window whose document closed isn't entered.
- A window's title is kept only when it is still the drawn one after drawing.
- Control input queued for a closed viewport is dropped instead of stalling.
- Comment reply state is keyed by document and comment.
2026-10-22 17:14:18 +00:00
aiongg 338b5d6360 W3b: test: control typing reaches a window in its own viewport 2026-10-22 16:57:38 +00:00
aiongg 6c595dfd16 W3b: a window moved into the root viewport keeps its place on the page; the active viewport is the root's when windows are embedded 2026-10-22 16:56:50 +00:00
aiongg 3a6f3fbb17 W3b: docs: one window per document (control protocol, UI parity, progress log) 2026-10-22 16:54:37 +00:00
aiongg 9feaa3d480 W3b: UI: one native window per open document
New and Open open the document in a window of its own (an egui viewport,
craft-window's window list and driver), focused and cascaded; Open replaces
an untouched blank document in its window and brings an already-open file's
window forward. Each window keeps its own interface state and draws its own
document. Close and a window's close button close that window, asking first;
the root viewport adopts another window when its own closes; the last window
stays. Quitting asks about each document in its window. Requests for another
window's document go to that window. Control input, screenshots and the saved
geometry follow the active window. The web app keeps one window.
2026-10-22 16:54:37 +00:00
aiongg 4ca965d990 W3a: UI: per-window state that a window swaps in
WindowUi holds what one window shows and is doing (canvas, dialogs,
menus, status, ribbon tab, Backstage, keytips, file dialogs...) for
windows not being drawn; WordApp::swap_window exchanges it with the drawn
window's fields by moves. The swap destructures WordApp and UiState
exhaustively, so every new field must be classified as per window,
per document or app-wide. WordApp::new takes its window defaults from
WindowUi::new.
2026-10-22 16:09:38 +00:00
aiongg 3963cf3012 W2: Requests dropped for a document that isn't shown are logged as warnings 2026-10-22 15:28:37 +00:00
aiongg 3d0fcaccbf W2: The first blank document from the Start screen is Document1
The Start screen's stand-in document, and the one left after closing the
last document, give back their untitled name when it is the newest.
2026-10-22 15:28:19 +00:00
aiongg 4480fd50d9 W2: Recovery: every open document keeps its own copy
One recovery worker and store serve every open document. Each document
keeps its slot (record id, revision, timer) and is ticked in place, parked
or active. Closing a document deletes its copy, Don't Save when quitting
does too, and a clean exit covers every open document. File > Recover
lists only copies no open document keeps; Restore opens a copy beside the
open documents or in place of an untouched blank one.
2026-10-22 15:26:38 +00:00
aiongg a8e39fec53 Engine: find_open resolves paths through io, inside the readable roots 2026-10-22 15:06:45 +00:00
aiongg faeb272559 Merge branch 'w1-mcp' into w1 2026-10-22 15:02:43 +00:00
aiongg 3693759149 UI: several open documents in one window; Switch Windows
The desktop window follows the session's active document. What the window
keeps per document (AutoSave's file, comment drafts) is parked while
another document shows and comes back with it; everything about the page
that was shown (dialogs, menus, drags, composition, status, caches keyed
by revision) starts afresh. The page's scroll area is per document, so
switching back finds the page where it was left; zoom is in the session.

- View › Window › Switch Windows lists the open documents (active marked,
  unsaved dotted) and shows the one chosen (view.switchWindows).
- New, Open, the mailings and personal templates open beside the open
  documents and no longer ask about unsaved changes.
- File › Close shows the next open document; after the last one the
  window shows no page, as before.
- Quitting asks Save / Don't Save / Cancel about each document with
  unsaved work, showing it; Cancel keeps the app open.
- UI requests tagged for a document that isn't shown never act on the
  shown one: theme changes apply, Close closes that document when it has
  nothing unsaved, the rest are dropped.
- Read Aloud highlights and follows only the document being read.
- A late Save As answer shows and saves the document it was for.
- Untitled documents are numbered in the interface language.
2026-10-22 15:01:56 +00:00
aiongg a2ca57ca3f W1: MCP, control channel and CLI address an open document by id
- MCP: execute, batch, type_text, select_text, get_text, inspect_document,
  render_page and save_document take an optional doc (an open document's
  id); the headless server runs them through Session::with_document and
  the bridge passes doc to the app. New list_documents tool; new_document
  replies with the new document's id. Tool descriptions and the server
  instructions say that file.new and file.open open another document.
- Control channel: engine.execute, document.inspect and ui.render take
  doc and work on that document while the window keeps the active one;
  file.close on an addressed document refuses unsaved changes unless
  forced; ui.* commands need the document on screen. ui.inspect reports
  document and documents. Unknown or malformed ids are errors.
- CLI run starts with one open document; help and docs describe how
  file.new, file.open and view.switchWindows work in a run.
- docs/mcp.md and docs/control-protocol.md describe document ids and doc.
2026-10-22 14:53:21 +00:00
aiongg 08028c44c0 Engine: several open documents; file.new opens another
One session holds several open documents. The active document stays in
the Session's own fields, so commands and the UI keep working on it; the
others are parked as DocState values and swapped in field by field
(std::mem::swap, no clones or relayout). Session and DocState are
destructured exhaustively, so a new field must be classified as per
document or shared before it builds.

- Session::documents, activate, add_document, close_document,
  is_untouched_blank, with_document, find_open, is_open; DocInfo.
- Document ids come from a process-wide counter.
- file.new opens beside the open documents; file.open replaces an
  untouched blank document, opens beside the others otherwise, and
  activates a file that is already open ("alreadyOpen": true).
- file.closeDocument {id?, force?} closes a document without a window
  (refuses unsaved changes unless forced); file.close is unchanged.
- view.switchWindows {id?} lists the open documents or activates one.
- Envelopes, Labels, Finish & Merge and New from Template open their
  result as a new document; the main document stays open, with no undo
  step for the command.
- Each document keeps its own recovery worker and copy; Read Aloud
  readings name their document; with_document tags UI requests with the
  document they came from.
2026-10-22 14:41:20 +00:00
aiongg 023cda9554 Task panes: shortcuts run right after a pane resize (#1756)
A pointer drag on a pane's resize edge or a floating pane's grip leaves
no keyboard focus behind, so global Ctrl and F-key shortcuts, which
defer to any focused widget, run right after it. The test also covers
an edge focused from the keyboard: arrow keys move it and Escape leaves
it.
2026-10-20 18:10:59 +00:00
aiongg 080b05a0fd Task panes: follow craft-ui panes at 352a830 (#1756)
craft-ui 352a830 is the head of storytold/craft-libs#187. The panes API
WordCraft uses is unchanged. A floating pane also resizes from its top
edge and top corners, through a 4 pt band above the title bar.
2026-10-20 18:10:59 +00:00
aiongg 0d06d94867 Merge upstream/main into panes-trial 2026-10-20 02:31:52 +00:00
aiongg e4804235c5 Task panes: F6 past the last pane gives the document the keyboard (#1756) 2026-10-19 21:01:32 +00:00
aiongg 0802788da9 Task panes: follow craft-ui panes at 787b706 (#1756) 2026-10-19 20:46:51 +00:00
aiongg af9b00dd4c Task panes: follow craft-ui panes at f4f9e5b (#1756)
craft-ui's pane host names each title bar after its pane, so the
accessible name is "Move Pane: {title}" ("Move Pane: Styles") in all 22
catalogs. The pane tests find the title bars by that name.
2026-10-19 12:44:47 +00:00
aiongg 0dd5b56dfe Task panes: draw them with craft_ui::panes; Styles docks left or right or floats (#1756)
The nine task panes (SmartArt Text Pane, Navigation, Clipboard, Styles,
Style Inspector, Accessibility, Alt Text, Restrict Editing, Comments) are
drawn by craft_ui::panes with the Columns policy: a title bar with a menu
and close button, a resize edge that also takes arrow keys, and F6 to move
between panes and the document. Panes open from the same view flags, in
the same order and at the same default widths; close runs each pane's
existing command.

The Styles pane can dock left or right or float through the pane menu or
the new command view.stylesPaneDock. ui.json keeps its position and float
rect; unknown values read as the defaults.
2026-10-19 12:17:59 +00:00
261 changed files with 28650 additions and 3582 deletions
+2
View File
@@ -21,6 +21,8 @@ jobs:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
+2
View File
@@ -27,3 +27,5 @@ jobs:
freebsd:
# 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/freebsd.yml@main
with:
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
+1
View File
@@ -24,6 +24,7 @@ jobs:
with:
app-name: wordcraft
app-title: WordCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
+2
View File
@@ -8,6 +8,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `egui` dependency: `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full copyright and permission text in NOTICE) | Small egui special-icon subset; embedded by default_fonts, retained in FontDefinitions::default() |
| `egui` dependency: `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full copyright and permission text in NOTICE) | Parent monochrome icon/emoji face embedded by monochrome_emoji_fonts; separate from the egui-icons subset |
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) |
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation |
| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~240 lowercase English base forms supplementing spelling (#520), including British spellings (#40) |
Generated
+481 -313
View File
File diff suppressed because it is too large Load Diff
+34 -9
View File
@@ -52,13 +52,18 @@ harfrust = "0.13"
# WOFF / WOFF2 web fonts added by the user (#582): pure Rust (MIT); its brotli and zlib decoders
# (brotli-decompressor, flate2) are already in the tree.
wuff = "0.2.9"
# WOFF2's brotli stream, decoded ahead of wuff so its glyph point counts are checked first (#3703).
# The same crate and version wuff already decodes it with (no new code in the build): pure Rust,
# `forbid(unsafe_code)` in its default build, no_std-capable (builds for wasm), BSD-3-Clause/MIT.
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 = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", default-features = false, features = ["write"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", 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.
miniz_oxide = "0.8"
miniz_oxide = "0.9"
cfb = "0.15"
# Legacy text encodings (GBK/GB18030, Big5, Shift_JIS, EUC-KR, single-byte code pages) for plain
# text and CSV recipient lists (#778): encoding_rs ((Apache-2.0 OR MIT) AND BSD-3-Clause) and its
@@ -70,17 +75,22 @@ aes = "0.9"
sha1 = "0.11"
sha2 = "0.11"
hmac = "0.13"
base64 = "0.22"
base64 = "0.23"
getrandom = "0.3"
zeroize = "1"
# Unix open flags (O_NONBLOCK for the engine's checked file reads, #3666); already in the lock as a
# dependency of other crates.
libc = "0.2"
# ui
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
# A monotonic clock that works on native and in the browser (`performance.now()`).
web-time = "1.1"
rfd = "0.17"
arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] }
arboard = { version = "3.7", default-features = false, features = ["wayland-data-control"] }
[workspace.lints.rust]
unsafe_code = "forbid"
@@ -110,4 +120,19 @@ 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 = "17744430be327d0257496a6a31c8d38fe20a6756" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
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" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+20 -2
View File
@@ -95,14 +95,32 @@ list (author, source, license) is in ATTRIBUTION.md.
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
- Application-drawn UI icons (crates/ui-egui/src/icons.rs) are original work, MIT OR Apache-2.0;
the bundled egui font icons have the separate notice below.
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources.
The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision
c3487320b2d1de657b860b3c8abb44e1f2b32bd8. The backend retains its MIT OR Apache-2.0 licensing
b926b5c03e256c86ca10934f58d03f628810b5b7. The backend retains its MIT OR Apache-2.0 licensing
and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md.
The backend patch prevents finalization during an unwind and rejects a writer after failed
cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled.
No Microsoft, Adobe, Avid or Autodesk icons, images, templates or artwork are included.
Bundled egui font icons
The canonical egui0.37 dependency includes egui-icons.ttf (a small subset of emoji-icon-font)
through default_fonts, and emoji-icon-font.ttf through monochrome_emoji_fonts. Both are embedded
by the UI font definitions. Their upstream project is https://github.com/jslegers/emoji-icon-font;
the exact copyright and permission notice from the pinned egui dependency follows.
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+9 -6
View File
@@ -221,13 +221,16 @@ closer pagination fidelity, a stability sweep and native printing. Details and e
### Windows
| Build | Installer | Portable |
|---|---|---|
| x64 (64-bit Intel/AMD) | `wordcraft-<ver>-windows-x64.msi` | `wordcraft-<ver>-windows-x64-portable.zip` |
| arm64 (Snapdragon and other ARM PCs) | `wordcraft-<ver>-windows-arm64.msi` | `wordcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `wordcraft-<ver>-windows-x86.msi` | `wordcraft-<ver>-windows-x86-portable.zip` |
| Build | Installer (all users) | Installer (just me) | Portable |
|---|---|---|---|
| x64 (64-bit Intel/AMD) | `wordcraft-<ver>-windows-x64.msi` | `wordcraft-<ver>-windows-user-x64.msi` | `wordcraft-<ver>-windows-x64-portable.zip` |
| arm64 (Snapdragon and other ARM PCs) | `wordcraft-<ver>-windows-arm64.msi` | `wordcraft-<ver>-windows-user-arm64.msi` | `wordcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `wordcraft-<ver>-windows-x86.msi` | `wordcraft-<ver>-windows-user-x86.msi` | `wordcraft-<ver>-windows-x86-portable.zip` |
Installers and executables are code-signed.
The all-users installer puts WordCraft in Program Files and needs administrator rights. The
just-me installer needs none: it installs into `%LOCALAPPDATA%\Programs\WordCraft` for your
account only. Choose one: installing both gives two WordCraft entries in Settings › Apps and two
in the Start menu. Installers and executables are code-signed.
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
+29 -1
View File
File diff suppressed because one or more lines are too long
+44 -4
View File
@@ -36,7 +36,11 @@ USAGE:
{\"file\"?, \"template\"?, \"commands\": [{\"command\": id,
\"params\"?: {…}} | id, …], \"save\"?: [OUT, …]}; save
formats follow the extension (docx and pdf in one run);
relative paths resolve against the script's folder
relative paths resolve against the script's folder.
file.new and file.open open another document and
make it active; later commands and the saves work on
the active one; view.switchWindows lists the open
documents and with {\"id\": N} makes one active
wordcraft-cli commands [--json] list every command
wordcraft-cli parity [--markdown] feature-catalog parity
wordcraft-cli mcp [--connect HOST:PORT | --author NAME] [--automation-read-root DIR] [--automation-write-root DIR]
@@ -387,6 +391,8 @@ fn valid_connect_addr(addr: &str) -> bool {
!host.is_empty() && port.parse::<u16>().is_ok()
}
/// Run the subcommand named by `args` (the command line after the program name). Files named
/// there are opened by their real names, which may not be valid Unicode.
fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let script_path = script_operand(args);
let (args, file) = text_args(args)?;
@@ -482,10 +488,14 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let mut s = match (from_file, from_template) {
(Some(f), _) => open(&f, password.as_deref())?,
// Both go through `file.new`, which puts a blank document on the region's paper (#3152).
// `file.new` opens beside the session's first document, which is then closed: the
// run starts with one open document.
(None, t) => {
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
}
};
@@ -657,14 +667,14 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let out = wordcraft_zotero::client::run_command(&opts, command, &mut |call| {
let r = bridge.handle(&mut s, &mut host, call);
if trace {
eprintln!("← {} {}", call.method, clip(&Value::Array(call.args.clone()).to_string()));
eprintln!("→ {}", clip(&String::from_utf8_lossy(&wordcraft_zotero::wire::encode_reply(&r))));
eprintln!("← {} {}", escape_peer(&call.method), escape_peer(&clip(&Value::Array(call.args.clone()).to_string())));
eprintln!("→ {}", escape_peer(&clip(&String::from_utf8_lossy(&wordcraft_zotero::wire::encode_reply(&r)))));
}
r
})
.map_err(|e| e.to_string())?;
for a in &host.alerts {
eprintln!("Zotero: {a}");
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") {
@@ -687,6 +697,27 @@ fn clip(s: &str) -> String {
}
}
/// Text from another program (Zotero, or whatever answered on its port) for one diagnostic
/// line: backslashes, control characters (newline, escape, C1…), line separators and bidi
/// 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 {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => 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 => 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",
@@ -733,6 +764,15 @@ mod tests {
values.iter().map(|value| (*value).into()).collect()
}
#[test]
fn peer_text_stays_on_one_visible_line() {
let alert = "Done.\nwrote /etc/passwd\r\u{1b}[2J\u{9b}31m\u{202e}txt.exe\\n\tok é";
let shown = escape_peer(alert);
assert_eq!(shown, "Done.\\nwrote /etc/passwd\\r\\u{1b}[2J\\u{9b}31m\\u{202e}txt.exe\\\\n\\tok é");
assert!(!shown.chars().any(|c| c.is_control()));
assert_eq!(escape_peer("Citation (Doe, 2020) — “ok”"), "Citation (Doe, 2020) — “ok”");
}
#[test]
fn validators_accept_only_the_documented_options() {
assert!(validate_options("info", &args(&["file.docx"]), PASSWORD_OPTIONS).is_ok());
+39
View File
@@ -0,0 +1,39 @@
//! `wordcraft-cli run` with documents opened along the way: `file.new` and `file.open` open
//! another document and make it active, and `--save` saves the active one.
use std::path::Path;
use std::process::{Command, Output};
fn run(args: &[&str], out: &Path) -> Output {
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
cli.arg("run").args(args).arg("--save").arg(out);
cli.output().unwrap()
}
#[test]
fn run_saves_the_document_opened_last() {
let dir = std::env::temp_dir().join(format!("wordcraft-cli-documents-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (src, out) = (dir.join("opened.txt"), dir.join("out.txt"));
std::fs::write(&src, "Opened\n").unwrap();
let open = format!(r#"file.open={{"path":{}}}"#, serde_json::json!(src.to_string_lossy()));
// A script that starts a new document and types into it saves that document.
let made = run(&["--template", "sample", "--cmd", "file.new", "--cmd", r#"text.insert={"text":"Fresh"}"#], &out);
let fresh = std::fs::read_to_string(&out).unwrap_or_default();
// A file opened after typing is what is saved; the typed document stays out of it.
let opened = run(&["--template", "blank", "--cmd", r#"text.insert={"text":"Typed"}"#, "--cmd", &open, "--cmd", "caret.docEnd"], &out);
let reopened = std::fs::read_to_string(&out).unwrap_or_default();
// A run starts with one open document.
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
let listed = cli.args(["run", "--template", "sample", "--print", "--cmd", "view.switchWindows"]).output().unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(made.status.success(), "stderr: {}", String::from_utf8_lossy(&made.stderr));
assert_eq!(fresh.trim(), "Fresh");
assert!(opened.status.success(), "stderr: {}", String::from_utf8_lossy(&opened.stderr));
assert_eq!(reopened.trim(), "Opened");
assert!(listed.status.success(), "stderr: {}", String::from_utf8_lossy(&listed.stderr));
let stdout = String::from_utf8_lossy(&listed.stdout);
assert_eq!(stdout.matches("\"id\"").count(), 1, "{stdout}");
}
+28 -17
View File
@@ -146,22 +146,10 @@ fn stored_prefs() -> Option<String> {
}
impl eframe::App for WebShell {
/// Runs one browser frame: forwards queued host messages to the status bar, advances the app,
/// then writes changed preferences to browser storage immediately, because a reload or closed
/// tab never runs the exit path.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
let msgs = std::mem::take(&mut *self.messages.borrow_mut());
for msg in msgs {
self.app.status(msg);
@@ -180,11 +168,34 @@ impl eframe::App for WebShell {
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
/// Forwards this frame's raw browser input to the app before widgets run.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(ctx, raw);
}
/// Draws the app. On the first pass of a visible frame it takes the browser's dropped files and
/// spawns asynchronous byte reads into the inbox; later passes only draw.
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
// Browser drops are input: take them only in the first real UI pass, never in a hidden
// `run_logic` tick that still sees the previous shown frame's RawInput.
if ui.ctx().current_pass_index() == 0 {
let ctx = ui.ctx().clone();
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
}
self.app.ui(ui);
}
}
+6 -3
View File
@@ -19,7 +19,7 @@ wordcraft-fonts = { workspace = true }
wordcraft-ui-egui = { workspace = true }
# The control key file's location (shared with the MCP bridge).
wordcraft-control-key = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11", "links"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@@ -73,13 +73,16 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
clipboard-win = { version = "5.4", features = ["std"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
rfd = { workspace = true }
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
# rfd itself already depends on it.
pollster = "0.4"
pollster = "1"
# Pictures and copied files on the system clipboard (#45); egui's paste only carries text. Already
# in the build through egui-winit, with the same features.
arboard = { workspace = true, features = ["image-data"] }
# The control server's random key.
getrandom = "0.3"
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
+10 -4
View File
@@ -35,14 +35,15 @@ impl AppleEvents {
}
/// Open the documents and act on the quit requests that arrived since the last frame. A document
/// opens like File › Open (asking about unsaved changes first); quitting closes the window, which
/// asks the same.
/// opens like File › Open: beside the open ones, in place of an untouched blank one, or, when it
/// is already open, by showing it. Quitting closes the window, which asks about every document
/// with unsaved changes.
pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) {
for e in inbox.drain() {
match e {
Event::Open(paths) => {
for p in paths.iter().filter_map(|p| p.to_str()) {
match app.run("file.open", serde_json::json!({"path": p})) {
match app.open_file(p, None) {
// Leave Backstage, as opening from the Open dialog does.
Ok(_) => app.ui.backstage = false,
Err(e) => {
@@ -54,7 +55,12 @@ pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) {
}
}
}
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
// Quitting asks about every document with unsaved changes, each in its window; the
// app closes once that's done (`quit_requested`).
Event::Quit => {
app.quit();
ctx.request_repaint();
}
}
}
}
+12 -1
View File
@@ -36,7 +36,8 @@ const DESKTOP_ID: &str = "ai.storyteller.wordcraft.desktop";
/// The types the button makes WordCraft the default for on Linux and BSD: Word documents and
/// templates (also macro-enabled and legacy), RTF and ODT. The same set the Windows installer
/// lists under WordCraft's Capabilities; Markdown and plain text are only offered under Open With.
/// lists under WordCraft's Capabilities; Markdown is only offered under Open With, and plain
/// text only on Windows and macOS (the `.desktop` file leaves it out, #4137).
/// Every one is in the `.desktop` file's `MimeType=` (a test checks).
#[cfg(any(all(unix, not(target_os = "macos")), test))]
const WORD_MIME_TYPES: &[&str] = &[
@@ -164,6 +165,16 @@ mod tests {
assert!(entry.lines().any(|l| l == "Exec=wordcraft %F"), "files are passed on the command line");
}
/// Declaring `text/plain` makes WordCraft a handler for every config, script and log on systems with no default set.
#[test]
fn the_desktop_entry_does_not_declare_plain_text() {
let entry = include_str!("../../../packaging/linux/ai.storyteller.wordcraft.desktop");
let declared: Vec<&str> =
entry.lines().find_map(|l| l.strip_prefix("MimeType=")).map(|v| v.split(';').filter(|s| !s.is_empty()).collect()).unwrap_or_default();
assert!(!declared.is_empty(), "MimeType= line not found");
assert!(!declared.contains(&"text/plain"), "text/plain must not be declared");
}
#[cfg(unix)]
#[test]
fn application_dirs_follow_the_xdg_spec() {
+1 -1
View File
@@ -208,7 +208,7 @@ fn save_exts(name: &str) -> Vec<String> {
let suggested = std::path::Path::new(name)
.extension()
.and_then(std::ffi::OsStr::to_str)
.map(|e| e.to_ascii_lowercase())
.map(str::to_ascii_lowercase)
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()))
.unwrap_or_else(|| "docx".to_string());
let mut exts: Vec<String> = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len());
+48 -11
View File
@@ -27,47 +27,66 @@ mod traffic_lights;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{
Services, UiState, WordApp,
window_geometry::{WindowGeometry, take_rescue},
window_geometry::{RawWindowState, WindowGeometry, take_rescue},
};
/// The app, the restored window geometry (or, with none, the default: no position) until the
/// first frame that knows the monitor has checked it, the control server's key file (removed on
/// exit), the file dialogs the app asked for and, on macOS, the documents opened from Finder.
/// exit), the file dialogs the app asked for, this frame's raw host window state and, on macOS,
/// the documents opened from Finder.
struct App(
WordApp,
Option<WindowGeometry>,
Option<control_server::KeyFile>,
file_dialogs::Launcher,
// The host's answer for this frame. `InputState` still describes the last painted frame on
// a hidden logic tick.
RawWindowState,
#[cfg(target_os = "macos")] fmv_macos_events::Inbox,
);
impl eframe::App for App {
/// Advances one desktop frame: polls Finder events on macOS, runs the app and its file dialogs,
/// fits or rescues the window and maximizes it after opening, then gates a close or quit request
/// on unsaved changes and active printing.
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
apple_events::poll(&self.4, &mut self.0, ctx);
apple_events::poll(&self.5, &mut self.0, ctx);
self.0.logic(ctx);
self.3.show(frame);
let window = self.4;
// A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit
// there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first
// knows the monitor: the off-screen check below takes the pending geometry then.
if let Some((pos, size)) = ctx.input(|i| self.1.and_then(|g| g.fit_on_monitor(i.viewport()))) {
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
}
// The geometry saved for the next start is the active window's.
let prev = self.0.ui.window;
self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size()));
// Another document window's state is the one it last painted with: the host reports only
// the root window's for this frame.
let active = self.0.active_viewport();
let tracked = if active == egui::ViewportId::ROOT {
window
} else {
ctx.input_for(active, |i| RawWindowState::from_viewport(Some(i.viewport()), Some(i.viewport_rect())))
};
self.0.ui.window = WindowGeometry::track(prev, &tracked);
// The first frame, once the monitor is known (as `take_rescue` waits for): maximize a
// window that opened at its normal size so it never flashed on screen half-opened.
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| ctx.input(|i| i.viewport().monitor_size.is_some()));
if let Some(pos) = ctx.input(|i| take_rescue(&mut self.1, i.viewport())) {
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| window.monitor_size.is_some());
if let Some(pos) = take_rescue(&mut self.1, &window) {
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
}
if let Some(command) = maximize {
ctx.send_viewport_cmd(command);
}
// The window's close button: with unsaved changes, ask first (`WordApp::close_requested`).
// The window's close button: with other windows open only this window closes (the app
// handles that while drawing it); the last one quits, asking first about unsaved changes
// (`WordApp::close_requested`).
let closing = ctx.input(|i| i.viewport().close_requested());
if closing && !self.0.close_requested() {
if closing && (self.0.closes_a_window() || !self.0.close_requested()) {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
} else if (closing || self.0.quit_requested) && !self.0.printing.busy() {
// On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in
@@ -85,8 +104,13 @@ impl eframe::App for App {
}
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
/// Records this frame's native window state before forwarding the hook, so logic never reads a
/// stale inner rectangle from a hidden tick.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// Keep this frame's native window state. `logic` must not read the last painted
// `InputState` when a hidden Wayland tick reports no fresh inner rect.
self.4 = RawWindowState::from_raw(raw);
self.0.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.0.ui(ui);
@@ -320,7 +344,15 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
}
}
/// Parses the command line, installs logging and the panic hook, restores preferences and window
/// geometry, opens requested documents or the Start screen, starts the control server when
/// asked, and runs the eframe event loop.
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::WORDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
logging::install_panic_hook();
@@ -397,6 +429,8 @@ fn main() -> eframe::Result {
#[cfg(target_os = "macos")]
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
load_user_fonts();
// Windows opened for further documents start like the first one, before its saved place.
let window_builder = options.viewport.clone();
let restored = saved_window();
if let Some(window) = restored {
options.viewport = window.apply(options.viewport);
@@ -429,6 +463,7 @@ fn main() -> eframe::Result {
let mut session = Session::new(doc);
session.system_region = region;
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
app.set_startup_update_check(true);
load_prefs(&mut app);
load_user_dictionary(&mut app);
// A region chosen in File › Options replaces the system's for the first blank
@@ -447,6 +482,7 @@ fn main() -> eframe::Result {
}
}
app.ui.window = restored;
app.set_window_builder(window_builder);
app.integrated_titlebar = cfg!(target_os = "macos");
#[cfg(target_os = "macos")]
if let Some(bar) = traffic_lights::title_bar() {
@@ -480,6 +516,7 @@ fn main() -> eframe::Result {
Some(restored.unwrap_or_default()),
key_file,
dialogs,
RawWindowState::default(),
#[cfg(target_os = "macos")]
apple_events.connect(&cc.egui_ctx),
)))
+6 -1
View File
@@ -1,11 +1,16 @@
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
/// Returns a discovery callback that enumerates spooler printers on a named worker thread and
/// sends the list through a channel, keeping spooler calls off the UI thread.
pub fn discover() -> Discover {
Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
let printers = craft_print::printers()
.into_iter()
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
.collect();
let _ = sender.send(printers);
});
receiver
+1 -1
View File
@@ -1 +1 @@
04600cbb1508f1b15927ec574a5e32e5f376fd8b
9205108cfdced5177706264038848b68b2422d2b
+6
View File
@@ -8,9 +8,15 @@ description = "WordCraft document model: stories, paragraphs, runs, tables, sect
[lints]
workspace = true
[features]
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`); turned on only as a
# dev-dependency, so never built into the app.
test-support = []
[dependencies]
wordcraft-geom = { workspace = true }
base64 = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+133
View File
@@ -0,0 +1,133 @@
//! A bytes-only check of an OLE compound file ([MS-CFB]) before the `cfb` crate opens it
//! (#3642). Every reader of `.doc`, `.xls` and encrypted Office packages calls [`check`] first.
/// Check a compound file's FAT layout before `cfb` reads it ([MS-CFB] §2.2 header, §2.5
/// DIFAT). `cfb` appends a whole sector of FAT entries for every FAT sector the DIFAT lists and
/// only validates the FAT once it is built, so a small header naming one sector over and over
/// makes it allocate gigabytes. Here the DIFAT is walked the way `cfb` walks it (header entries,
/// then the DIFAT chain, at most one step per sector; trailing zero and free entries trimmed
/// the same way) and the file is refused when it lists one FAT sector twice, or more FAT sectors
/// than its sectors need (one FAT sector covers `sector_len / 4` of them, plus one spare), so the
/// FAT `cfb` builds is about the size the file needs. Anything else that is wrong is left for
/// `cfb` to report.
pub fn check(bytes: &[u8]) -> Result<(), String> {
const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
const FREE: u32 = 0xFFFF_FFFF;
const END_OF_CHAIN: u32 = 0xFFFF_FFFE;
const HEADER_ENTRIES: usize = 109;
let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]]));
let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
if bytes.len() < 512 || bytes.get(..8) != Some(MAGIC.as_slice()) {
return Ok(());
}
let sector_len: usize = match u16_at(0x1A) {
Some(3) => 512,
Some(4) => 4096,
_ => return Ok(()),
};
if bytes.len() < sector_len {
return Ok(());
}
// `cfb` counts the sectors after the header sector, the last one possibly partial.
let num_sectors = bytes.len().div_ceil(sector_len) - 1;
let num_fat_sectors = u32_at(0x2C).unwrap_or(0) as usize;
let mut difat: Vec<u32> = (0..HEADER_ENTRIES).filter_map(|i| u32_at(0x4C + i * 4)).collect();
let mut next = u32_at(0x44).unwrap_or(END_OF_CHAIN);
let mut steps = 0usize;
while next != END_OF_CHAIN && next != FREE {
steps += 1;
// `cfb` refuses a chain sector past the file or seen before; a chain longer than the
// file has sectors must have done one of the two.
if steps > num_sectors || next as usize >= num_sectors {
return Err(format!("the DIFAT chain is longer than the file or leaves it (sector {next})"));
}
let at = (next as usize + 1) * sector_len;
let entries = sector_len / 4 - 1;
difat.extend((0..entries).map(|i| u32_at(at + i * 4).unwrap_or(FREE)));
next = u32_at(at + entries * 4).unwrap_or(END_OF_CHAIN);
}
while difat.len() > HEADER_ENTRIES && difat.len() > num_fat_sectors && difat.last() == Some(&0) {
difat.pop();
}
while difat.last() == Some(&FREE) {
difat.pop();
}
// Each listed FAT sector adds a whole sector of entries. A FAT sector holds the entries of
// `sector_len / 4` sectors, so a file needs no more FAT sectors than that covers (one spare),
// and none of them twice. A free entry in the middle is left for `cfb` to refuse.
let listed: Vec<u32> = difat.into_iter().filter(|&s| s != FREE).collect();
let needed = num_sectors.div_ceil(sector_len / 4).saturating_add(1);
if listed.len() > needed {
return Err(format!("the DIFAT lists {} FAT sectors in a file of {num_sectors} sectors", listed.len()));
}
let mut seen = std::collections::HashSet::with_capacity(listed.len());
if let Some(dup) = listed.iter().find(|&&s| !seen.insert(s)) {
return Err(format!("the DIFAT lists FAT sector {dup} twice"));
}
Ok(())
}
/// A compound file of about 1 MB whose DIFAT chain lists one FAT sector 2048 × 127 times:
/// without [`check`], `cfb` builds a FAT of about 130 MB for it. Regression input for the
/// readers' tests: built only for tests, this crate's and (through the `test-support` feature,
/// which they turn on as a dev-dependency) the readers', never into the app.
#[cfg(any(test, feature = "test-support"))]
pub fn repeating_difat_sample() -> Vec<u8> {
const S: usize = 512;
const CHAIN: u32 = 2048; // DIFAT sectors 1..=CHAIN, sector 0 is the "FAT"
let mut f = vec![0u8; S * (CHAIN as usize + 2)];
let mut put = |at: usize, v: &[u8]| {
if let Some(dst) = f.get_mut(at..at + v.len()) {
dst.copy_from_slice(v);
}
};
put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
put(0x18, &0x3Eu16.to_le_bytes());
put(0x1A, &3u16.to_le_bytes());
put(0x1C, &0xFFFEu16.to_le_bytes());
put(0x1E, &9u16.to_le_bytes());
put(0x20, &6u16.to_le_bytes());
put(0x2C, &u32::MAX.to_le_bytes()); // no zero trimming
put(0x38, &4096u32.to_le_bytes());
put(0x44, &1u32.to_le_bytes());
put(0x48, &CHAIN.to_le_bytes());
// The 109 header entries are all 0 already; each chain sector's entries too.
for d in 1..=CHAIN {
let next = if d == CHAIN { 0xFFFF_FFFE } else { d + 1 };
put((d as usize + 2) * S - 4, &next.to_le_bytes());
}
f
}
#[cfg(test)]
mod tests {
#[test]
fn a_repeating_difat_is_refused_and_a_small_file_passes() {
assert!(super::check(&super::repeating_difat_sample()).is_err_and(|e| e.contains("FAT sectors in a file of")));
assert!(super::check(b"not a compound file").is_ok());
}
/// A 512-byte-sector file of `sectors` sectors after the header, whose header DIFAT lists
/// `fat` (the rest free).
fn header_difat(sectors: usize, fat: &[u32]) -> Vec<u8> {
let mut f = vec![0u8; 512 * (sectors + 1)];
f[..8].copy_from_slice(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
f[0x1A..0x1C].copy_from_slice(&3u16.to_le_bytes());
f[0x2C..0x30].copy_from_slice(&(fat.len() as u32).to_le_bytes());
f[0x44..0x48].copy_from_slice(&0xFFFF_FFFEu32.to_le_bytes());
for i in 0..109 {
let v = fat.get(i).copied().unwrap_or(u32::MAX);
f[0x4C + i * 4..0x50 + i * 4].copy_from_slice(&v.to_le_bytes());
}
f
}
#[test]
fn a_fat_sector_listed_twice_or_more_than_the_file_needs_is_refused() {
// Each list is shorter than the file has sectors, which the guard used to accept.
assert!(super::check(&header_difat(10, &[0, 0])).is_err_and(|e| e.contains("FAT sector 0 twice")));
assert!(super::check(&header_difat(10, &[0, 1, 2])).is_err_and(|e| e.contains("3 FAT sectors")));
assert!(super::check(&header_difat(10, &[0])).is_ok());
assert!(super::check(&header_difat(200, &[0, 1])).is_ok());
}
}
+1 -1
View File
@@ -238,7 +238,7 @@ pub(crate) fn ideograph_zodiac_traditional(n: u32) -> String {
/// The `n`th (1-based) item of a sequence, or decimal past its end.
fn nth(set: &[char], n: u32) -> String {
n.checked_sub(1).and_then(|i| set.get(i as usize)).map(|c| c.to_string()).unwrap_or_else(|| n.to_string())
n.checked_sub(1).and_then(|i| set.get(i as usize)).map(ToString::to_string).unwrap_or_else(|| n.to_string())
}
/// Code points `first..` enclosing 1 to `count`, decimal beyond.
+45 -21
View File
@@ -14,6 +14,8 @@
//! outside what's deleted (so deleting across a control's edge empties it rather than breaking
//! it); writers call [`Document::balance_controls`] so a saved file always has whole controls.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::para::OBJ;
@@ -312,10 +314,10 @@ pub fn control_events(bl: &Blocks) -> Vec<ControlEvent<'_>> {
if !p.objects.iter().any(|o| matches!(o, InlineObject::ControlStart { .. } | InlineObject::ControlEnd)) {
continue;
}
for off in p.object_offsets() {
match p.object_at(off) {
Some(InlineObject::ControlStart { control }) => out.push(ControlEvent::Open { block: i, off: Some(off), control }),
Some(InlineObject::ControlEnd) => out.push(ControlEvent::Close { block: i, off: Some(off) }),
for (off, o) in p.objects_with_offsets() {
match o {
InlineObject::ControlStart { control } => out.push(ControlEvent::Open { block: i, off: Some(off), control }),
InlineObject::ControlEnd => out.push(ControlEvent::Close { block: i, off: Some(off) }),
_ => {}
}
}
@@ -639,34 +641,52 @@ impl Document {
out.push((r.end.path.clone(), r.end.off));
}
}
// Markers paired with a table wrap: their partner is never deleted with them.
// Markers paired with a table wrap: their partner is never deleted with them. Each lookup
// is a set probe, and each container is paired once, not once per marker (#3614).
let in_ranges: BTreeSet<(&Path, usize)> = ranges.iter().flat_map(|r| [(&r.start.path, r.start.off), (&r.end.path, r.end.off)]).collect();
let mut table_paired: BTreeMap<&[u32], BTreeSet<(usize, usize)>> = BTreeMap::new();
for path in &paths {
let Some(p) = self.para(a.story, path) else { continue };
for off in p.object_offsets() {
if !inside(path, off) || !matches!(p.object_at(off), Some(InlineObject::ControlStart { .. } | InlineObject::ControlEnd)) {
for (off, o) in p.objects_with_offsets() {
if !inside(path, off)
|| !matches!(o, InlineObject::ControlStart { .. } | InlineObject::ControlEnd)
|| in_ranges.contains(&(path, off))
{
continue;
}
let paired = ranges.iter().any(|r| (r.start.path == *path && r.start.off == off) || (r.end.path == *path && r.end.off == off));
if !paired && self.paired_with_table(a.story, path, off) {
let paired = table_paired.entry(path.parent()).or_insert_with(|| self.table_paired_markers(a.story, path));
if paired.contains(&(path.last(), off)) {
out.push((path.clone(), off));
}
}
}
// Sorted and without repeats: `delete_range` groups this by paragraph and binary-searches
// each group (`control_markers_kept_are_sorted` pins it).
out.sort();
out.dedup();
out
}
/// Whether the marker at `path`/`off` is paired with a table wrap's open or close.
fn paired_with_table(&self, s: StoryRef, path: &Path, off: usize) -> bool {
let Some(bl) = self.container(s, path) else { return false };
/// (block, offset) of the paragraph markers in `path`'s container that are paired with a
/// table wrap's open or close.
fn table_paired_markers(&self, s: StoryRef, path: &Path) -> BTreeSet<(usize, usize)> {
let Some(bl) = self.container(s, path) else { return BTreeSet::new() };
let events = control_events(bl);
let (pairs, _) = pair_events(&events);
let me = events.iter().position(|e| e.block() == path.last() && e.off() == Some(off));
let Some(me) = me else { return false };
pairs.iter().any(|(o, c, _)| {
(*o == me && events.get(*c).is_some_and(|e| e.off().is_none())) || (*c == me && events.get(*o).is_some_and(|e| e.off().is_none()))
})
let mut out = BTreeSet::new();
for (o, c, _) in pairs {
let (Some(open), Some(close)) = (events.get(o), events.get(c)) else { continue };
match (open.off(), close.off()) {
(Some(off), None) => {
out.insert((open.block(), off));
}
(None, Some(off)) => {
out.insert((close.block(), off));
}
_ => {}
}
}
out
}
/// Where typing at `p` should go: a caret at the start of a paragraph, before the start of
@@ -674,8 +694,11 @@ impl Document {
/// end, goes inside it too (the control is around the whole paragraph).
pub fn typing_pos(&self, p: &Pos) -> Pos {
let Some(para) = self.para_at(p) else { return p.clone() };
let is_start = |off: usize| matches!(para.object_at(off), Some(InlineObject::ControlStart { control }) if control.block);
let is_end = |off: usize| matches!(para.object_at(off), Some(InlineObject::ControlEnd));
// The objects once, looked up by offset (object_at per step rescans the text).
let objs: Vec<(usize, &InlineObject)> = para.objects_with_offsets().collect();
let obj = |off: usize| objs.binary_search_by_key(&off, |(o, _)| *o).ok().and_then(|k| objs.get(k)).map(|(_, o)| *o);
let is_start = |off: usize| matches!(obj(off), Some(InlineObject::ControlStart { control }) if control.block);
let is_end = |off: usize| matches!(obj(off), Some(InlineObject::ControlEnd));
let w = OBJ.len_utf8();
let mut off = p.off;
// Only block starts before the caret (or none) and block starts right after it.
@@ -688,8 +711,9 @@ impl Document {
}
if p.off == para.len() && off >= w && is_end(off - w) {
// Step back over the trailing ends that close block-level controls.
let ranges = self.control_ranges(p.story);
while off >= w && is_end(off - w) && ranges.iter().any(|r| r.end.path == p.path && r.end.off == off - w && r.control.block) {
let block_ends: BTreeSet<usize> =
self.control_ranges(p.story).into_iter().filter(|r| r.end.path == p.path && r.control.block).map(|r| r.end.off).collect();
while off >= w && is_end(off - w) && block_ends.contains(&(off - w)) {
off -= w;
}
return Pos { off, ..p.clone() };
+312 -19
View File
@@ -7,7 +7,7 @@ use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::para::InlineObject;
use crate::para::{InlineObject, Run};
use crate::props::{CharProps, ParaProps};
use crate::{Block, Blocks, DocError, Document, Paragraph, Part, PartKind, Path, Pos, Result, para_block};
use crate::{Table, table::MAX_TABLE_DEPTH};
@@ -188,6 +188,111 @@ fn fit_tables(b: Block, room: usize, out: &mut Vec<Block>) {
const MAX_BOX_DEPTH: usize = 8;
/// Most text box stories one paste may create (hostile documents can fan out).
const MAX_PASTED_BOXES: usize = 4096;
/// Most text box content (estimated bytes, see [`block_cost`]) one paste may copy: thousands of
/// boxes can show the same story, and each needs a story of its own. Blocks without text boxes
/// are shared between those stories, not copied, so they are not charged per box.
const MAX_PASTED_BOX_BYTES: usize = 32 << 20;
/// What a paste may still spend on text box stories.
struct BoxBudget {
stories: usize,
bytes: usize,
/// The source stories being copied, innermost last: a box inside one of them showing it
/// again is a cycle.
active: Vec<u32>,
/// Each source story's cost ([`block_cost`]), worked out once per paste: a story too big for
/// what is left is not walked again for every box that shows it.
costs: BTreeMap<u32, usize>,
/// Boxes left without a story because the paste's budget couldn't hold theirs.
skipped: usize,
}
impl BoxBudget {
fn new() -> BoxBudget {
BoxBudget { stories: MAX_PASTED_BOXES, bytes: MAX_PASTED_BOX_BYTES, active: Vec::new(), costs: BTreeMap::new(), skipped: 0 }
}
}
/// Rough size of a block's content, and whether it holds text boxes (and so must be copied).
fn block_cost(b: &Block) -> (usize, bool) {
let (mut cost, mut boxes) = (std::mem::size_of::<Block>(), false);
each_para(b, 0, &mut |p| {
let items = p
.runs
.len()
.saturating_mul(std::mem::size_of::<Run>())
.saturating_add(p.objects.len().saturating_mul(std::mem::size_of::<InlineObject>()));
let objects = p.objects.iter().fold(0usize, |n, o| n.saturating_add(object_cost(o)));
cost = cost.saturating_add(p.text.len()).saturating_add(items).saturating_add(objects);
boxes |= box_ids(p).next().is_some();
});
(cost, boxes)
}
/// Rough heap bytes a clone of `o` copies beyond its own storage: its strings and lists, and its
/// group members however deep. What it shares through `Arc` (pictures' embedded objects, charts,
/// freeforms, wrap polygons, gradients) is not copied, so not counted.
fn object_cost(o: &InlineObject) -> usize {
let float = |f: &crate::para::Float| f.alt.text.len().saturating_add(f.alt.title.len());
let mut cost = 0usize;
let mut todo = vec![o];
while let Some(o) = todo.pop() {
let add = match o {
InlineObject::Image { media, alt, float: f, .. } => media.len().saturating_add(alt.len()).saturating_add(float(f)),
InlineObject::Graphic { alt, float: f, .. } => alt.len().saturating_add(float(f)),
InlineObject::Shape { float: f, extra, .. } => {
let warp = extra.warp.as_ref().map_or(0, |w| {
let adj = w.adj.iter().map(|(name, _)| std::mem::size_of::<(String, i64)>().saturating_add(name.len()));
adj.fold(w.preset.len(), usize::saturating_add)
});
float(f).saturating_add(warp)
}
InlineObject::Group { float: f, children, .. } => {
todo.extend(children.iter().map(|c| &c.obj));
children.len().saturating_mul(std::mem::size_of::<crate::para::GroupChild>()).saturating_add(float(f))
}
InlineObject::Field { instr, result, form, .. } => instr.len().saturating_add(result.len()).saturating_add(form.len()),
InlineObject::FieldStart { instr, .. } => instr.len(),
InlineObject::ControlStart { control } => std::mem::size_of::<crate::control::ContentControl>().saturating_add(serialized_len(control)),
InlineObject::NoteRef { custom, .. } => custom.len(),
InlineObject::BookmarkStart { name } | InlineObject::BookmarkEnd { name } => name.len(),
InlineObject::PermStart { group, editor, .. } => group.len().saturating_add(editor.len()),
InlineObject::Equation { linear, math, .. } => linear.len().saturating_add(math.heap_bytes()),
InlineObject::Ruby { base, ruby, lang, props, .. } => {
[base.len(), ruby.len(), lang.len(), std::mem::size_of::<CharProps>(), serialized_len(props)]
.into_iter()
.fold(0, usize::saturating_add)
}
InlineObject::Opaque { format, xml, text } => format.len().saturating_add(xml.len()).saturating_add(text.len()),
InlineObject::FieldEnd
| InlineObject::ControlEnd
| InlineObject::CommentStart { .. }
| InlineObject::CommentEnd { .. }
| InlineObject::PermEnd { .. }
| InlineObject::PositionalTab { .. } => 0,
};
cost = cost.saturating_add(add);
}
cost
}
/// Length of `v` written as JSON: at least the bytes of every string and list it owns. For the
/// nested formatting and control types, which share nothing through `Arc`.
fn serialized_len<T: Serialize>(v: &T) -> usize {
struct Count(usize);
impl std::io::Write for Count {
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
self.0 = self.0.saturating_add(b.len());
Ok(b.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut n = Count(0);
let _ = serde_json::to_writer(&mut n, v);
n.0
}
/// Call `f` on every paragraph of a block, in document order, however deep its tables nest. The
/// walk keeps its own stack instead of recursing, so it is complete: a text box it doesn't reach
@@ -474,7 +579,13 @@ impl Document {
}
// Content control markers whose partner is outside the range stay (the control is
// emptied, not broken).
let kept = self.kept_control_markers(&a, &b);
let mut kept = self.kept_control_markers(&a, &b);
// The walk below groups `kept` by paragraph (`chunk_by`) and looks offsets up
// (`binary_search`), so it must be sorted; `kept_control_markers` returns it so, and this
// keeps a change there from quietly losing markers.
if !kept.is_sorted() {
kept.sort();
}
let kept_in = |path: &Path| -> Vec<usize> { kept.iter().filter(|(p, _)| p == path).map(|(_, o)| *o).collect() };
if a.path == b.path {
self.para_mut(a.story, &a.path)?.delete_except(a.off, b.off, &kept_in(&a.path))?;
@@ -485,13 +596,21 @@ impl Document {
let (ia, ib) = (a.path.last(), b.path.last());
// Kept markers of the paragraphs in between move to the join.
let mut moved: Vec<(InlineObject, CharProps)> = Vec::new();
for (path, off) in &kept {
if *path != a.path
&& *path != b.path
&& let Some(p) = self.para(a.story, path)
&& let Some(o) = p.object_at(*off)
{
moved.push((o.clone(), p.props_of_char(*off).clone()));
// `kept` is sorted: one walk of each paragraph's objects and runs (#3614).
for group in kept.chunk_by(|x, y| x.0 == y.0) {
let Some((path, _)) = group.first() else { continue };
if *path == a.path || *path == b.path {
continue;
}
let Some(p) = self.para(a.story, path) else { continue };
let mut runs = p.run_ranges().peekable();
for (off, o) in p.objects_with_offsets() {
if group.binary_search_by_key(&off, |(_, k)| *k).is_err() {
continue;
}
while runs.next_if(|(r, _)| r.end <= off).is_some() {}
let props = runs.peek().filter(|(r, _)| r.contains(&off)).map_or(&p.mark, |(_, c)| *c);
moved.push((o.clone(), props.clone()));
}
}
{
@@ -744,7 +863,7 @@ impl Document {
/// Give every text box in `blocks` a new story of its own, copied from `parts` (or, for a
/// fragment without them, from this document's text box of that id). A box whose story can't
/// be found keeps no story rather than showing an unrelated one.
fn adopt_text_boxes(&mut self, blocks: &mut [Block], parts: &BTreeMap<u32, Part>, depth: usize, budget: &mut usize) {
fn adopt_text_boxes(&mut self, blocks: &mut [Block], parts: &BTreeMap<u32, Part>, depth: usize, budget: &mut BoxBudget) {
let mut any = false;
for b in blocks.iter() {
each_para(b, 0, &mut |p| any |= box_ids(p).next().is_some());
@@ -770,18 +889,53 @@ impl Document {
}
}
/// A new text box story copied from `old`; its own boxes get new stories too.
fn adopt_text_box(&mut self, old: u32, parts: &BTreeMap<u32, Part>, depth: usize, budget: &mut usize) -> Option<u32> {
if depth >= MAX_BOX_DEPTH || *budget == 0 {
/// A new text box story copied from `old`; its own boxes get new stories too. Blocks without
/// text boxes are shared with the source (copy-on-write), the others copied. A box showing a
/// story it is inside, or whose story the paste's budget can't hold, gets none (CWE-400);
/// only that box: a later box with a smaller story is still copied while the budget lasts.
fn adopt_text_box(&mut self, old: u32, parts: &BTreeMap<u32, Part>, depth: usize, budget: &mut BoxBudget) -> Option<u32> {
if depth >= MAX_BOX_DEPTH || budget.active.contains(&old) {
return None;
}
if budget.stories == 0 {
budget.skipped = budget.skipped.saturating_add(1);
return None;
}
let src = parts.get(&old).or_else(|| self.parts.get(&old).filter(|p| p.kind == PartKind::TextBox))?;
let mut blocks: Vec<Block> = src.blocks.iter().map(|b| (**b).clone()).collect();
// Charge what the copy really allocates before making any of it: the block slots, and the
// blocks holding text boxes (deep-copied below). The rest are shared `Arc`s, however many
// boxes show them, so they cost nothing more. Worked out once per paste.
let cost = *budget.costs.entry(old).or_insert_with(|| {
let slots = src.blocks.len().saturating_mul(std::mem::size_of::<Arc<Block>>());
src.blocks.iter().fold(slots, |a, b| match block_cost(b) {
(c, true) => a.saturating_add(c),
(_, false) => a,
})
});
let Some(left) = budget.bytes.checked_sub(cost) else {
budget.skipped = budget.skipped.saturating_add(1);
return None;
};
budget.bytes = left;
budget.stories -= 1;
let src_blocks = src.blocks.clone();
// The copy keeps its text direction and alignment, not its link (a copy stands alone).
let body = crate::TextBody { next: None, ..src.body };
*budget -= 1;
self.adopt_text_boxes(&mut blocks, parts, depth + 1, budget);
let id = self.try_add_part(PartKind::TextBox, blocks.into_iter().map(Arc::new).collect()).ok()?;
budget.active.push(old);
let mut blocks: Blocks = Vec::with_capacity(src_blocks.len());
for b in src_blocks {
let mut boxes = false;
each_para(&b, 0, &mut |p| boxes |= box_ids(p).next().is_some());
if boxes {
let mut copy = (*b).clone();
self.adopt_text_boxes(std::slice::from_mut(&mut copy), parts, depth + 1, budget);
blocks.push(Arc::new(copy));
} else {
blocks.push(b);
}
}
budget.active.pop();
let id = self.try_add_part(PartKind::TextBox, blocks).ok()?;
if let Some(p) = self.parts.get_mut(&id) {
p.body = body;
}
@@ -803,8 +957,15 @@ impl Document {
return Err(DocError::BadPath(pos.path.to_string()));
}
// Pasted text boxes get stories of their own (never shared with the copy's source).
let mut budget = MAX_PASTED_BOXES;
let mut budget = BoxBudget::new();
self.adopt_text_boxes(&mut blocks, &frag.parts, 0, &mut budget);
if budget.skipped > 0 {
// Once per paste, not once per box (review of #3874).
log::warn!(
"paste: {} text boxes are left empty; their stories are past what one paste copies ({MAX_PASTED_BOXES} stories, {MAX_PASTED_BOX_BYTES} bytes)",
budget.skipped
);
}
// Single paragraph: inline insert keeping runs.
if blocks.len() == 1
&& let Some(Block::Para(src)) = blocks.first()
@@ -1196,7 +1357,139 @@ mod tests {
let before = d.parts.len();
d.insert_fragment(&Pos::body(0, 0), &f).unwrap();
let added = d.parts.len() - before;
assert!((1..=MAX_BOX_DEPTH).contains(&added), "{added}");
// The copy's inner box would show the story being copied: a cycle, so it gets none.
assert_eq!(added, 1);
}
/// A fragment whose body is one paragraph of `refs` text boxes all showing story 1.
fn fan_out(story: Vec<Arc<Block>>, refs: usize) -> Fragment {
let mut p = Paragraph::new();
for _ in 0..refs {
p.insert_object(0, box_shape(Some(1)), &CharProps::default()).unwrap();
}
let mut parts = BTreeMap::new();
parts.insert(1, crate::Part { kind: PartKind::TextBox, blocks: story, body: Default::default() });
Fragment { blocks: vec![Block::Para(p)], parts }
}
#[test]
fn pasted_boxes_share_the_blocks_they_need_not_copy() {
let big = para_block(Paragraph::with_text(&"x".repeat(100_000), CharProps::default()));
let f = fan_out(vec![big.clone()], 3);
let mut d = doc(&["x"]);
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
let got = boxes_in(&d, &Path::top(0));
assert_eq!(got.len(), 3);
let ids: Vec<u32> = got.into_iter().flatten().collect();
assert_eq!(ids.len(), 3, "each box has a story");
assert!(ids.windows(2).all(|w| w[0] != w[1]), "stories of their own: {ids:?}");
for id in ids {
let b = d.parts[&id].blocks.first().unwrap();
assert!(Arc::ptr_eq(b, &big), "no text box inside: shared, not copied");
}
}
#[test]
fn a_box_over_the_budget_leaves_later_smaller_boxes_their_stories() {
// Review of #3874: once one story didn't fit, every later box in the paste was left
// empty, however small its story. Only the box that doesn't fit is.
// (Story 1 holds a text box, so each box showing it needs a real copy.)
let mut bp = Paragraph::with_text(&"x".repeat(100_000), CharProps::default());
bp.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
let big = para_block(bp);
let small = para_block(Paragraph::with_text("small", CharProps::default()));
let mut p = Paragraph::new();
for story in [2, 1, 2, 1] {
p.insert_object(0, box_shape(Some(story)), &CharProps::default()).unwrap();
}
let mut parts = BTreeMap::new();
parts.insert(1, crate::Part { kind: PartKind::TextBox, blocks: vec![big], body: Default::default() });
parts.insert(2, crate::Part { kind: PartKind::TextBox, blocks: vec![small], body: Default::default() });
let mut blocks = vec![Block::Para(p)];
let mut d = doc(&["x"]);
let mut budget = BoxBudget { bytes: 50_000, ..BoxBudget::new() };
d.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget);
let Some(Block::Para(p)) = blocks.first() else { panic!() };
let got: Vec<Option<u32>> = p.objects.iter().map(|o| if let InlineObject::Shape { story, .. } = o { *story } else { None }).collect();
assert_eq!(got.iter().map(Option::is_some).collect::<Vec<_>>(), [false, true, false, true], "{got:?}");
assert_eq!(budget.skipped, 2);
assert_eq!(budget.costs.len(), 2, "each story costed once");
}
#[test]
fn many_boxes_sharing_one_story_paste_with_their_text() {
// Review of #3874: a document converted from PDF has thousands of boxes showing story
// data they share. 64 boxes x a 1 MiB block is 64 MiB counted per box (over the 32 MiB
// cap) but the block is shared, not copied, so every box keeps its text.
let big = para_block(Paragraph::with_text(&"x".repeat(1 << 20), CharProps::default()));
let f = fan_out(vec![big.clone()], 64);
let mut d = doc(&["x"]);
let (_, warned) = crate::test_log::warnings(|| d.insert_fragment(&Pos::body(0, 1), &f).unwrap());
assert!(!warned.iter().any(|w| w.contains("text boxes are left empty")), "{warned:?}");
let ids: Vec<u32> = boxes_in(&d, &Path::top(0)).into_iter().flatten().collect();
assert_eq!(ids.len(), 64, "every box keeps a story");
for id in ids {
let b = d.parts[&id].blocks.first().unwrap();
assert!(Arc::ptr_eq(b, &big), "shared, so not charged per box");
assert_eq!(d.parts[&id].blocks.len(), 1);
}
}
#[test]
fn pasted_box_copies_are_capped_in_bytes() {
// Story 1 is a 1 MiB paragraph that holds a text box itself, so each box showing it needs
// its own copy; 64 of them used to copy 64 MiB.
let mut p = Paragraph::with_text(&"x".repeat(1 << 20), CharProps::default());
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
let f = fan_out(vec![para_block(p)], 64);
let mut d = doc(&["x"]);
let before = d.parts.len();
let (_, warned) = crate::test_log::warnings(|| d.insert_fragment(&Pos::body(0, 1), &f).unwrap());
let added = d.parts.len() - before;
// The boxes left empty are said to be, once for the paste (review of #3874).
let empty: Vec<_> = warned.iter().filter(|w| w.contains("text boxes are left empty")).collect();
assert_eq!(empty.len(), 1, "{warned:?}");
assert!(empty[0].starts_with(&format!("paste: {} text boxes", 64 - added)), "{empty:?}");
let copied: usize = d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { p.text.len() } else { 0 }).sum();
assert!((1..64).contains(&added), "{added}");
assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}");
// The boxes past the cap keep no story rather than a shared one.
let got = boxes_in(&d, &Path::top(0));
assert_eq!(got.iter().flatten().count(), added);
}
#[test]
fn pasted_box_copies_count_the_objects_they_copy() {
// #3614 review: a story holding a text box and a 1 MiB kept-for-round-trip object costs
// the object's XML too, not only its slot: 64 copies used to copy 64 MiB of it.
let mut p = Paragraph::with_text("x", CharProps::default());
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
let opaque = InlineObject::Opaque { format: "docx".into(), xml: "x".repeat(1 << 20), text: String::new() };
p.insert_object(0, opaque, &CharProps::default()).unwrap();
let f = fan_out(vec![para_block(p)], 64);
let mut d = doc(&["x"]);
let before = d.parts.len();
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
let added = d.parts.len() - before;
let xml = |o: &InlineObject| if let InlineObject::Opaque { xml, .. } = o { xml.len() } else { 0 };
let copied: usize =
d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { p.objects.iter().map(xml).sum() } else { 0 }).sum();
assert!((1..64).contains(&added), "{added}");
assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}");
// Nested group members and equations count too.
let member = |obj| crate::para::GroupChild { x: 0.0, y: 0.0, obj };
let deep = InlineObject::Opaque { format: String::new(), xml: "y".repeat(5000), text: String::new() };
let inner =
InlineObject::Group { w: 1.0, h: 1.0, float: Default::default(), ch_w: 1.0, ch_h: 1.0, children: vec![member(deep)], canvas: None };
let outer =
InlineObject::Group { w: 1.0, h: 1.0, float: Default::default(), ch_w: 1.0, ch_h: 1.0, children: vec![member(inner)], canvas: None };
assert!(object_cost(&outer) >= 5000);
let eq = InlineObject::Equation {
linear: String::new(),
display: false,
math: crate::math::Math { nodes: crate::math::parse_linear(&"z".repeat(5000)), ..Default::default() },
};
assert!(object_cost(&eq) >= 5000);
}
#[test]
+8 -5
View File
@@ -43,12 +43,12 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out:
match &**b {
Block::Para(p) => {
let path = Path(prefix.clone());
for off in p.object_offsets() {
match p.object_at(off) {
Some(InlineObject::FieldStart { instr, locked, .. }) => {
for (off, o) in p.objects_with_offsets() {
match o {
InlineObject::FieldStart { instr, locked, .. } => {
open.push((instr.clone(), *locked, Pos::new(story, path.clone(), off)));
}
Some(InlineObject::FieldEnd) => match open.pop() {
InlineObject::FieldEnd => match open.pop() {
Some((instr, locked, start)) => {
let depth = open.len();
out.ranges.push(FieldRange { instr, locked, start, end: Pos::new(story, path.clone(), off), depth });
@@ -99,7 +99,10 @@ impl Document {
pub fn past_field_ends(&self, p: &Pos) -> Pos {
let Some(para) = self.para_at(p) else { return p.clone() };
let mut off = p.off;
while matches!(para.object_at(off), Some(InlineObject::FieldEnd)) {
for (at, o) in para.objects_with_offsets().skip_while(|(at, _)| *at < p.off) {
if at != off || !matches!(o, InlineObject::FieldEnd) {
break;
}
off += OBJ.len_utf8();
}
Pos { off, ..p.clone() }
+712
View File
@@ -0,0 +1,712 @@
//! Visible logical text for IME surrounding-text queries.
//!
//! The platform input method works on what the reader sees, not on the stored run text: tracked
//! deletions are hidden, and an inline object stands for its plain-text result. The helpers here
//! build a bounded window around a selection without cloning the paragraph or the document, and
//! map both ends in both directions so a query and a delete use exactly the same text.
use std::ops::Range;
use crate::para::{OBJ, Paragraph};
use crate::{Document, Pos};
/// A bounded visible-logical window around a selection.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeWindow {
/// At most [`MAX_IME_TEXT_BYTES`] bytes of visible committed text.
pub text: String,
/// UTF-8 byte offset of the primary end in [`Self::text`].
pub cursor: usize,
/// UTF-8 byte offset of the other end in [`Self::text`].
pub anchor: usize,
}
/// The largest visible window an IME query copies.
pub const MAX_IME_TEXT_BYTES: usize = 4000;
/// Which unit an input method's delete-surrounding counts use.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DeleteUnit {
/// Unicode scalar values of the visible text.
Chars,
/// UTF-8 bytes of the visible text.
Bytes,
}
/// A deletion request in the paragraph's stored byte offsets.
///
/// The selection itself is never part of `before` or `after`; both ranges are outside it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeDelete {
/// Stored byte range to erase before the selection.
pub before: Option<Range<usize>>,
/// Stored byte range to erase after the selection.
pub after: Option<Range<usize>>,
/// Stored byte offset of the selection's anchor **before** the deletions.
///
/// The ranges are returned in the paragraph's stored text as it is now; the caller applies
/// them (and shifts these offsets past `before`) itself.
pub anchor: usize,
/// Stored byte offset of the selection's focus **before** the deletions.
pub focus: usize,
}
/// One paragraph's visible logical text.
///
/// Deleted runs are skipped and inline objects contribute their plain-text result. Iteration is
/// lazy: no full visible copy is made.
pub struct VisibleParagraph<'a> {
para: &'a Paragraph,
deleted: Vec<Range<usize>>,
}
impl<'a> VisibleParagraph<'a> {
/// A view of `para` with tracked deletions hidden.
pub fn new(para: &'a Paragraph) -> Self {
let mut deleted = para.deleted_ranges();
deleted.sort_unstable_by_key(|r| r.start);
Self { para, deleted }
}
/// Whether `off` falls inside a tracked deletion, advancing `next` past the runs that end
/// before it.
///
/// The ranges are sorted, so one monotone cursor makes a whole scan O(text + ranges) instead
/// of O(text × ranges): alternating tracked revisions used to make every character scan every
/// range.
fn is_deleted_at(&self, next: &mut usize, off: usize) -> bool {
while let Some(r) = self.deleted.get(*next) {
if r.end > off {
break;
}
*next = next.saturating_add(1);
}
self.deleted.get(*next).is_some_and(|r| r.start <= off && off < r.end)
}
/// Call `f` for every visible piece: `(stored byte start, piece, stored byte len)`.
fn for_each_piece(&self, mut f: impl FnMut(usize, &str, usize)) {
self.for_each_piece_while(|start, piece, stored| {
f(start, piece, stored);
true
});
}
/// Call `f` for visible pieces until it returns `false`.
fn for_each_piece_while(&self, mut f: impl FnMut(usize, &str, usize) -> bool) {
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let keep_going = if is_object {
let piece = self.para.objects.get(object - 1).map_or("", |o| o.plain_text());
f(i, piece, OBJ.len_utf8())
} else {
let mut buf = [0u8; 4];
f(i, c.encode_utf8(&mut buf), c.len_utf8())
};
if !keep_going {
break;
}
}
}
/// Total visible UTF-8 bytes.
pub fn visible_len(&self) -> usize {
let mut len = 0usize;
self.for_each_piece(|_, piece, _| len = len.saturating_add(piece.len()));
len
}
/// Visible offset of a stored paragraph byte offset.
///
/// `None` when the stored offset is not a character boundary, points inside a tracked
/// deletion, or would split an inline object's plain-text result.
pub fn visible_offset(&self, stored: usize) -> Option<usize> {
if stored > self.para.text.len() || !self.para.text.is_char_boundary(stored) {
return None;
}
let mut visible = 0usize;
let mut found = false;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
if i == stored {
found = true;
break;
}
if i > stored {
break;
}
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
// A position at the start of a deleted run is still a visible position; one
// inside it is not.
continue;
}
if is_object {
let piece = self.para.objects.get(object - 1).map_or("", |o| o.plain_text());
visible = visible.saturating_add(piece.len());
} else {
visible = visible.saturating_add(c.len_utf8());
}
}
if !found {
// The end of the paragraph, or a deleted run at its end.
let mut next = 0usize;
return (!self.is_deleted_at(&mut next, stored)).then_some(visible);
}
if stored < self.para.text.len() && self.is_deleted_at(&mut deleted, stored) {
// The start of a deleted run is still a visible caret position; only a position
// strictly inside one is unrepresentable.
let at_start = self.deleted.iter().any(|r| r.start == stored);
return at_start.then_some(visible);
}
Some(visible)
}
/// Stored paragraph byte offset for a visible offset.
///
/// `None` when the visible offset falls inside an inline object's plain-text result or is
/// not a boundary of the visible representation.
pub fn stored_offset(&self, visible: usize) -> Option<usize> {
let mut acc = 0usize;
let mut answer = None;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
if visible == acc {
answer = Some(i);
break;
}
if visible < acc.saturating_add(piece_len) {
// Inside a character (or an object's expansion): no stored boundary there.
return None;
}
acc = acc.saturating_add(piece_len);
}
answer.or_else(|| (visible == acc).then_some(self.para.text.len()))
}
/// Stored offset for the **start** of a visible range, skipping zero-length objects at
/// the same boundary.
///
/// A content control's markers are inline objects with no plain text, so they share a
/// visible offset with the content just inside (or after) them. A range start must map
/// after a control-start marker and after a control-end marker, never onto the marker
/// itself: an IME deletion must not break the control. Non-empty object expansions still
/// map to the object's own stored offset, so a range that reaches one deletes it whole.
pub fn stored_offset_after(&self, visible: usize) -> Option<usize> {
let mut acc = 0usize;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
if visible == acc && piece_len > 0 {
return Some(i);
}
if visible < acc.saturating_add(piece_len) {
return None;
}
acc = acc.saturating_add(piece_len);
}
(visible == acc).then_some(self.para.text.len())
}
/// Stored offset for a visible offset, preferring the position **before** a tracked
/// deletion that starts there. Use this for the end of a range so a deletion run just
/// after the caret is never drawn into it.
pub fn stored_offset_before(&self, visible: usize) -> Option<usize> {
let mut acc = 0usize;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
if visible == acc {
return Some(i);
}
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
if visible < acc.saturating_add(piece_len) {
return None;
}
acc = acc.saturating_add(piece_len);
}
(visible == acc).then_some(self.para.text.len())
}
/// Round a visible byte offset forward to the next visible boundary (inward for a start).
pub fn ceil_boundary(&self, visible: usize) -> usize {
let mut acc = 0usize;
let mut out = None;
self.for_each_piece(|_, piece, _| {
if out.is_some() {
return;
}
if acc >= visible {
out = Some(acc);
return;
}
acc = acc.saturating_add(piece.len());
if acc >= visible {
out = Some(acc);
}
});
out.unwrap_or(acc)
}
/// Round a visible byte offset backward to the previous visible boundary (inward for an end).
pub fn floor_boundary(&self, visible: usize) -> usize {
let mut acc = 0usize;
let mut out = 0usize;
self.for_each_piece(|_, piece, _| {
let next = acc.saturating_add(piece.len());
if next > visible {
return;
}
acc = next;
out = acc;
});
out
}
/// A bounded window with `cursor` and `anchor` as visible byte offsets.
///
/// The selection is never split and the window never exceeds [`MAX_IME_TEXT_BYTES`]. A
/// selection wider than that, an invalid offset, or an offset that cannot be represented as
/// visible text returns `None` (unavailable, not fabricated).
pub fn window(&self, cursor: usize, anchor: usize, max_bytes: usize) -> Option<ImeWindow> {
// The public API caps its own budget: a caller cannot ask for an unbounded copy.
let max_bytes = max_bytes.min(MAX_IME_TEXT_BYTES);
let (sel_start, sel_end) = (cursor.min(anchor), cursor.max(anchor));
let selection_len = sel_end - sel_start;
if selection_len > max_bytes {
return None;
}
// Spend the budget around the selection and stop walking at its end: the paragraph is
// never copied and the work is bounded by the caret's distance from the paragraph start.
let before = (max_bytes - selection_len) / 2;
let window_start = sel_start.saturating_sub(before);
let window_end = window_start.saturating_add(max_bytes);
let mut text = String::with_capacity(window_end.saturating_sub(window_start).min(max_bytes));
let mut first_kept = None;
let mut acc = 0usize;
self.for_each_piece_while(|_, piece, _| {
for (k, c) in piece.char_indices() {
let start = acc.saturating_add(k);
let end = start.saturating_add(c.len_utf8());
if end > window_end {
return false;
}
if start >= window_start {
if first_kept.is_none() {
first_kept = Some(start);
}
text.push(c);
}
}
acc = acc.saturating_add(piece.len());
acc < window_end
});
let Some(first) = first_kept else {
// An empty document is an available empty context, never unavailable.
return (cursor == 0 && anchor == 0).then_some(ImeWindow { text: String::new(), cursor: 0, anchor: 0 });
};
Some(ImeWindow { text, cursor: cursor.saturating_sub(first), anchor: anchor.saturating_sub(first) })
}
}
impl Document {
/// Bounded visible logical text around `cursor`/`anchor` in one paragraph.
///
/// A selection across paragraphs or blocks is unrepresentable and returns `None`.
pub fn ime_surrounding(&self, cursor: &Pos, anchor: &Pos, max_bytes: usize) -> Option<ImeWindow> {
if cursor.story != anchor.story || cursor.path != anchor.path {
return None;
}
let para = self.para_at(cursor)?;
let view = VisibleParagraph::new(para);
let c = view.visible_offset(cursor.off)?;
let a = view.visible_offset(anchor.off)?;
view.window(c, a, max_bytes.min(MAX_IME_TEXT_BYTES))
}
/// Delete-surrounding ranges in the stored paragraph text.
///
/// Counts exclude the selection, tracked deletions and any pre-edit (which never lives in
/// the model here). Byte counts that end inside a scalar leave it whole: a start rounds
/// forward and an end rounds backward; a byte count beyond the visible text is refused.
/// Character counts clamp at the paragraph's ends, as the web text agent's do. A range that
/// cannot be represented in stored text returns `None` instead of guessing; so does a
/// request with nothing to erase (`0` on both sides).
pub fn ime_delete_surrounding(&self, cursor: &Pos, anchor: &Pos, before: usize, after: usize, unit: DeleteUnit) -> Option<ImeDelete> {
if cursor.story != anchor.story || cursor.path != anchor.path {
return None;
}
if before == 0 && after == 0 {
// Nothing to erase: don't build the boundary table (or walk a long paragraph) to
// discover it.
return None;
}
let para = self.para_at(cursor)?;
let view = VisibleParagraph::new(para);
let c = view.visible_offset(cursor.off)?;
let a = view.visible_offset(anchor.off)?;
let (sel_start, sel_end) = (c.min(a), c.max(a));
let (before_start, after_end) = match unit {
DeleteUnit::Bytes => {
let total = view.visible_len();
if before > sel_start || after > total - sel_end {
return None;
}
(view.ceil_boundary(sel_start.saturating_sub(before)), view.floor_boundary(sel_end.saturating_add(after)))
}
DeleteUnit::Chars => char_boundaries(&view, sel_start, sel_end, before, after)?,
};
// Start a range after a deletion run and end it before one, so tracked deletions are
// never part of the erased stored range.
let before_start = view.stored_offset_after(before_start)?;
let before_end = view.stored_offset_before(sel_start)?;
let after_start = view.stored_offset_after(sel_end)?;
let after_end = view.stored_offset_before(after_end)?;
let before_range = (before_start < before_end).then_some(before_start..before_end);
let after_range = (after_start < after_end).then_some(after_start..after_end);
Some(ImeDelete { before: before_range, after: after_range, anchor: anchor.off, focus: cursor.off })
}
}
/// The visible byte boundaries `before` characters back from `sel_start` and `after`
/// characters on from `sel_end`.
///
/// Two walks and constant memory replace the old per-character `Vec` that held every boundary
/// (a hostile count could make it allocate the whole paragraph). Character counts clamp at the
/// paragraph's ends; `None` when either selection end is not a boundary of the visible text.
fn char_boundaries(view: &VisibleParagraph<'_>, sel_start: usize, sel_end: usize, before: usize, after: usize) -> Option<(usize, usize)> {
let mut acc = 0usize;
let mut chars = 0usize;
let mut start_index = None;
let mut end_index = None;
view.for_each_piece(|_, piece, _| {
for (k, _) in piece.char_indices() {
let at = acc.saturating_add(k);
if at == sel_start {
start_index = Some(chars);
}
if at == sel_end {
end_index = Some(chars);
}
chars = chars.saturating_add(1);
}
acc = acc.saturating_add(piece.len());
});
if sel_start == acc {
start_index = Some(chars);
}
if sel_end == acc {
end_index = Some(chars);
}
let start_index = start_index?;
let end_index = end_index?;
let target_before = start_index.saturating_sub(before);
let target_after = end_index.saturating_add(after).min(chars);
let mut acc = 0usize;
let mut index = 0usize;
let mut before_start = None;
let mut after_end = (target_after == 0).then_some(0);
view.for_each_piece(|_, piece, _| {
for (k, _) in piece.char_indices() {
let at = acc.saturating_add(k);
if index == target_before && before_start.is_none() {
before_start = Some(at);
}
if index == target_after && after_end.is_none() {
after_end = Some(at);
}
index = index.saturating_add(1);
}
acc = acc.saturating_add(piece.len());
});
if index == target_before && before_start.is_none() {
before_start = Some(acc);
}
if index == target_after && after_end.is_none() {
after_end = Some(acc);
}
Some((before_start?, after_end?))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::para::Run;
use crate::props::CharProps;
use crate::{InlineObject, StoryRef};
/// Builds a paragraph with one run per `(text, deleted)` part; deleted parts carry revision 0.
fn para_with_runs(parts: &[(&str, bool)]) -> Paragraph {
let mut p = Paragraph::new();
for (text, deleted) in parts {
let mut props = CharProps::default();
if *deleted {
props.del = Some(0);
}
p.text.push_str(text);
p.runs.push(Run { len: text.len(), props });
}
p
}
/// A tracked deletion is hidden from the IME view but still maps stored offsets: its start is a
/// visible caret position, its interior is not, and a window concatenates only visible text.
#[test]
fn tracked_deletions_are_hidden_and_offsets_still_map() {
let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]);
let view = VisibleParagraph::new(&p);
assert_eq!(view.visible_len(), 4);
assert_eq!(view.visible_offset(0), Some(0));
assert_eq!(view.visible_offset(2), Some(2), "the start of a deleted run is a visible caret position");
assert_eq!(view.visible_offset(3), None, "inside a tracked deletion");
assert_eq!(view.visible_offset(5), Some(2));
assert_eq!(view.visible_offset(7), Some(4));
assert_eq!(view.stored_offset(2), Some(5));
assert_eq!(view.stored_offset(4), Some(7));
assert_eq!(view.window(2, 2, 4000).map(|w| w.text), Some("abcd".into()));
}
/// An inline object contributes its plain text to visible length and boundary mapping; offsets
/// strictly inside that expansion have no stored boundary.
#[test]
fn objects_contribute_their_plain_text_and_split_mapping_stops() {
let mut p = Paragraph::new();
p.text = format!("a{OBJ}b");
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
p.objects.push(InlineObject::Equation { linear: "x+y".into(), display: false, math: Default::default() });
let view = VisibleParagraph::new(&p);
assert_eq!(view.visible_len(), 5);
assert_eq!(view.visible_offset(1), Some(1));
assert_eq!(view.visible_offset(4), Some(4));
assert_eq!(view.stored_offset(1), Some(1));
assert_eq!(view.stored_offset(4), Some(4));
assert_eq!(view.stored_offset(2), None, "inside the object's text");
}
/// An empty paragraph yields an available empty IME context at offset 0; an empty document
/// yields an available empty context too, though this test asserts only its text, not its offsets.
#[test]
fn an_empty_document_is_available_and_empty() {
let p = Paragraph::new();
let view = VisibleParagraph::new(&p);
let w = view.window(0, 0, MAX_IME_TEXT_BYTES).expect("an available empty context");
assert_eq!((w.text.as_str(), w.cursor, w.anchor), ("", 0, 0));
let doc = Document::new();
let w = doc.ime_surrounding(&Pos::body(0, 0), &Pos::body(0, 0), MAX_IME_TEXT_BYTES).expect("available");
assert_eq!(w.text, "");
}
/// A window is capped at the IME byte budget while keeping cursor and anchor inside its text; a
/// selection wider than the budget is unavailable.
#[test]
fn bounded_window_keeps_the_selection_and_rejects_too_wide_ones() {
let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default());
let view = VisibleParagraph::new(&p);
let w = view.window(5000, 5000, MAX_IME_TEXT_BYTES).expect("a bounded window");
assert!(w.text.len() <= MAX_IME_TEXT_BYTES);
assert_eq!(w.cursor, w.anchor);
assert!(w.cursor <= w.text.len());
assert!(view.window(0, 4001, MAX_IME_TEXT_BYTES).is_none());
}
/// Byte deletion rounds its start to a visible UTF-8 boundary, leaves partially covered scalar
/// values whole, and refuses counts that run past the paragraph.
#[test]
fn byte_deletion_rounds_inward_and_leaves_partial_scalars_whole() {
let doc = Document::from_text("aé👍b");
let caret = Pos::body(0, 8);
let one = doc.ime_delete_surrounding(&caret, &caret, 1, 0, DeleteUnit::Bytes).expect("b");
assert_eq!(one.before, Some(7..8));
// Two bytes start inside the emoji: it is left whole, only `b` goes.
let two = doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Bytes).expect("b");
assert_eq!(two.before, Some(7..8));
// Six bytes start inside `é`: it stays whole, the emoji and `b` go.
let six = doc.ime_delete_surrounding(&caret, &caret, 6, 0, DeleteUnit::Bytes).expect("emoji and b");
assert_eq!(six.before, Some(3..8));
// A count beyond the paragraph is refused, not clipped to delete a whole side.
assert!(doc.ime_delete_surrounding(&caret, &caret, 0, 1, DeleteUnit::Bytes).is_none());
assert!(doc.ime_delete_surrounding(&caret, &caret, 99, 0, DeleteUnit::Bytes).is_none());
}
/// Surrounding deletion skips the selected range on both sides and returns the selection's
/// original reversed anchor and focus.
#[test]
fn byte_deletion_excludes_a_reversed_selection() {
let doc = Document::from_text("abcd");
let cursor = Pos::body(0, 1);
let anchor = Pos::body(0, 3);
let del = doc.ime_delete_surrounding(&cursor, &anchor, 1, 1, DeleteUnit::Bytes).expect("a deletion");
assert_eq!(del.before, Some(0..1), "only the character before the selection");
assert_eq!(del.after, Some(3..4), "only the character after the selection");
assert_eq!((del.anchor, del.focus), (3, 1), "the reversed selection is preserved");
}
/// Tracked deletions are absent from windows and skipped when deleting around a caret, so an IME
/// edit cannot remove them.
#[test]
fn tracked_deletions_are_not_counted_for_deletion_or_query() {
let mut doc = Document::from_text("abDELcd");
let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]);
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
let view = VisibleParagraph::new(doc.para_at(&Pos::body(0, 0)).expect("paragraph"));
assert_eq!(view.window(2, 2, 100).map(|w| w.text), Some("abcd".into()));
let caret = Pos::body(0, 7);
let del = doc.ime_delete_surrounding(&caret, &caret, 1, 0, DeleteUnit::Bytes).expect("c");
assert_eq!(del.before, Some(6..7), "the stored deletion run is skipped");
}
/// A selection spanning paragraphs has no single surrounding context: both the query and
/// surrounding deletion return `None`.
#[test]
fn cross_paragraph_selection_is_unavailable() {
let doc = Document::from_text("one\ntwo");
let cursor = Pos::body(0, 1);
let anchor = Pos::body(1, 1);
assert!(doc.ime_surrounding(&cursor, &anchor, MAX_IME_TEXT_BYTES).is_none());
assert!(doc.ime_delete_surrounding(&cursor, &anchor, 1, 0, DeleteUnit::Chars).is_none());
}
/// An object's plain-text result stands for the object in the visible text; a deletion
/// that starts inside that expansion leaves the object whole (byte) or is refused (chars).
#[test]
fn object_expansion_is_never_partly_deleted() {
let mut doc = Document::from_text("");
let mut p = Paragraph::new();
p.text = format!("a{OBJ}b");
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
p.objects.push(InlineObject::Equation { linear: "x+y".into(), display: false, math: Default::default() });
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
let caret = Pos::body(0, 5);
// Two bytes before the caret end inside `x+y` (`y` is the last byte): the start rounds
// forward past the object, so only `b` goes.
let two = doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Bytes).expect("b only");
assert_eq!(two.before, Some(4..5), "stored UTF-8 offsets keep the object whole");
let mut remaining = format!("a{OBJ}b");
remaining.replace_range(two.before.clone().unwrap(), "");
assert_eq!(remaining, format!("a{OBJ}"));
// A character that lands inside the expansion has no stored boundary: refused.
assert!(doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Chars).is_none());
// A count reaching before the object erases the object as a whole.
let five = doc.ime_delete_surrounding(&caret, &caret, 5, 0, DeleteUnit::Bytes).expect("object and b");
assert_eq!(five.before, Some(0..5));
}
/// Character counts on the right side clamp at the paragraph's end and never cross into the
/// next paragraph.
#[test]
fn trailing_character_deletion_clamps_at_the_paragraph_end() {
let doc = Document::from_text("ab\ncd");
let caret = Pos::body(0, 1);
let del = doc.ime_delete_surrounding(&caret, &caret, 0, 99, DeleteUnit::Chars).expect("b only");
assert_eq!(del.after, Some(1..2));
let empty = doc.ime_delete_surrounding(&caret, &caret, 0, 0, DeleteUnit::Chars);
assert!(empty.is_none(), "nothing to erase");
}
/// Sorted tracked deletions are scanned with one cursor: alternating revisions stay correct
/// (and linear) instead of each character scanning every range.
#[test]
fn alternating_tracked_revisions_map_correctly() {
// Equal-length visible/deleted pieces, so the expected visible byte count is exact.
let parts: Vec<(String, bool)> =
(0..2000).map(|i| (if i % 2 == 0 { format!("v{i:04};") } else { format!("D{i:04};") }, i % 2 == 1)).collect();
let parts: Vec<(&str, bool)> = parts.iter().map(|(t, d)| (t.as_str(), *d)).collect();
let p = para_with_runs(&parts);
let view = VisibleParagraph::new(&p);
assert_eq!(view.visible_len(), p.text.len() - 6 * 1000);
assert_eq!(view.visible_offset(p.text.len()), Some(view.visible_len()));
let w = view.window(0, 0, 100).expect("a window");
assert!(w.text.len() <= 100);
assert!(!w.text.contains('D'), "tracked deletions never show");
}
/// The public window never copies more than the IME cap, whatever a caller asks for.
#[test]
fn window_budget_is_capped() {
let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default());
let view = VisibleParagraph::new(&p);
let w = view.window(0, 0, usize::MAX).expect("a bounded window");
assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES);
let doc = Document::from_text(&"a".repeat(10_000));
let w = doc.ime_surrounding(&Pos::body(0, 0), &Pos::body(0, 0), usize::MAX).expect("bounded");
assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES);
}
/// A caret in a table-cell paragraph maps against that cell's text, not the body around the table.
#[test]
fn table_cell_paragraph_maps_inside_its_cell() {
let mut doc = Document::from_text("outside");
let mut table = crate::Table::new(1, 1, 100.0);
table.rows[0].cells[0].blocks = vec![crate::para_block(Paragraph::with_text("cell", CharProps::default()))];
doc.body.push(std::sync::Arc::new(crate::Block::Table(table)));
let path = crate::Path(vec![1, 0, 0, 0]);
let cursor = Pos { story: StoryRef::Body, path: path.clone(), off: 4 };
let anchor = Pos { story: StoryRef::Body, path, off: 2 };
let w = doc.ime_surrounding(&cursor, &anchor, MAX_IME_TEXT_BYTES).expect("the cell text");
assert_eq!(w.text, "cell");
assert_eq!(w.cursor, 4);
assert_eq!(w.anchor, 2);
}
/// Content-control markers are zero-length visible objects. A deletion range starts after
/// the marker and ends before the next one, so an ordinary IME edit never removes a marker
/// or leaves a half-paired control.
#[test]
fn control_markers_are_not_included_in_delete_ranges() {
use crate::control::{ContentControl, ControlKind};
let mut p = Paragraph::new();
p.text = format!("pre{OBJ}Ada{OBJ}X");
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
let mut ctl = ContentControl::new(ControlKind::RichText);
ctl.placeholder_text = "Enter name".into();
p.objects.push(InlineObject::ControlStart { control: Box::new(ctl) });
p.objects.push(InlineObject::ControlEnd);
let mut doc = Document::new();
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
// Stored positions are UTF-8 byte offsets; each OBJ marker occupies three bytes.
let cursor = Pos::body(0, 9);
let del = doc.ime_delete_surrounding(&cursor, &cursor, 3, 1, DeleteUnit::Bytes).expect("a deletion");
assert_eq!(del.before, Some(6..9), "the control-start marker and `pre` stay outside");
assert_eq!(del.after, Some(12..13), "the control-end marker stays outside; only `X` goes");
assert_eq!((del.anchor, del.focus), (9, 9));
}
}
+94 -23
View File
@@ -12,6 +12,7 @@
pub mod bidi;
mod byte_store;
pub mod cfb_guard;
pub mod chart;
mod cjk_numbers;
pub mod cluster;
@@ -26,6 +27,7 @@ pub mod fields;
pub mod fill;
pub mod freeform;
pub mod graphic;
pub mod ime_text;
pub mod legacy_form;
pub mod line_dash;
pub mod link;
@@ -639,6 +641,10 @@ pub struct Settings {
/// after plus the second's space before, not the larger of the two.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub sum_paragraph_spacing: bool,
/// `w:doNotExpandShiftReturn`: a justified line that ends in a manual line break keeps its
/// natural spacing; by default Word stretches it to the margin like a wrapped line.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub do_not_expand_shift_return: bool,
/// Keep contextual paragraph spacing inside legacy table cells (`allowSpaceOfSameStyleInTable`).
/// Word ignores the layout override in compatibility mode 15, but the flag still round-trips.
pub allow_space_of_same_style_in_table: bool,
@@ -805,6 +811,7 @@ impl Default for Settings {
compat_mode: COMPAT_MODE_CURRENT,
roman_fonts: Vec::new(),
sum_paragraph_spacing: false,
do_not_expand_shift_return: false,
allow_space_of_same_style_in_table: false,
grid_h: DEFAULT_GRID,
grid_v: DEFAULT_GRID,
@@ -852,6 +859,50 @@ pub struct Document {
/// The document's own building blocks (its glossary document), in file order.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub glossary: Vec<BuildingBlock>,
/// Lookup cache for [`Document::add_media`]; not part of the document (never saved, ignored
/// by `==`).
#[serde(skip)]
media_index: MediaIndex,
}
/// [`Document::media`] by content, so adding media is linear in the bytes added rather than a
/// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back;
/// a hit is always checked against `media`, so a stale entry can at worst miss a duplicate.
#[derive(Clone, Default)]
struct MediaIndex {
by_bytes: std::collections::HashMap<Arc<Vec<u8>>, String>,
/// `media.len()` when the index last matched it.
len: usize,
/// The number after the last generated `image{n}` key, so taken names are probed once.
next: usize,
}
impl MediaIndex {
fn rebuild(&mut self, media: &BTreeMap<String, Arc<Vec<u8>>>) {
self.by_bytes.clear();
// The first key (in key order) of equal bytes wins, as a scan would find it.
for (k, v) in media.iter().rev() {
self.by_bytes.insert(v.clone(), k.clone());
}
self.len = media.len();
self.next = 0;
}
fn find(&self, media: &BTreeMap<String, Arc<Vec<u8>>>, bytes: &Vec<u8>) -> Option<String> {
self.by_bytes.get(bytes).filter(|k| media.get(*k).is_some_and(|v| **v == *bytes)).cloned()
}
}
impl PartialEq for MediaIndex {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl std::fmt::Debug for MediaIndex {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("MediaIndex")
}
}
impl Default for Document {
@@ -880,6 +931,7 @@ impl Document {
media: BTreeMap::new(),
passthrough: BTreeMap::new(),
glossary: Vec::new(),
media_index: MediaIndex::default(),
}
}
@@ -955,6 +1007,7 @@ impl Document {
media,
passthrough,
glossary,
media_index: _,
} = self;
let Document {
body: other_body,
@@ -973,6 +1026,7 @@ impl Document {
media: other_media,
passthrough: other_passthrough,
glossary: other_glossary,
media_index: _,
} = other;
same_blocks(body, other_body)
&& same_parts(parts, other_parts)
@@ -1095,8 +1149,8 @@ impl Document {
for story in stories {
for path in self.para_paths(story) {
let Some(p) = self.para(story, &path) else { continue };
for off in p.object_offsets() {
if p.object_at(off).is_some_and(|o| o.text_boxes().contains(&part)) {
for (off, o) in p.objects_with_offsets() {
if o.text_boxes().contains(&part) {
return Some(Pos { story, path, off: off + para::OBJ.len_utf8() });
}
}
@@ -1556,17 +1610,32 @@ impl Document {
/// Add a media item; returns its key (deduplicated by content).
pub fn add_media(&mut self, bytes: Vec<u8>, ext: &str) -> String {
if let Some((k, _)) = self.media.iter().find(|(_, v)| v.as_slice() == bytes.as_slice()) {
return k.clone();
let ix = &mut self.media_index;
if ix.len != self.media.len() {
ix.rebuild(&self.media);
}
let n = self.media.len() + 1;
let mut key = format!("image{n}.{ext}");
let mut i = n;
if let Some(k) = ix.find(&self.media, &bytes) {
return k;
}
if ix.by_bytes.contains_key(&bytes) {
// Stale (`media` was edited directly without changing its size): look again.
ix.rebuild(&self.media);
if let Some(k) = ix.find(&self.media, &bytes) {
return k;
}
}
let n = self.media.len().saturating_add(1);
let mut i = n.max(ix.next);
let mut key = format!("image{i}.{ext}");
while self.media.contains_key(&key) {
i += 1;
i = i.saturating_add(1);
key = format!("image{i}.{ext}");
}
self.media.insert(key.clone(), Arc::new(bytes));
let bytes = Arc::new(bytes);
self.media.insert(key.clone(), bytes.clone());
ix.by_bytes.entry(bytes).or_insert_with(|| key.clone());
ix.len = self.media.len();
ix.next = i.saturating_add(1);
key
}
@@ -1601,22 +1670,22 @@ impl Document {
let mut out: Vec<PermRange> = Vec::new();
let stories = std::iter::once(StoryRef::Body).chain(self.parts.keys().map(|k| StoryRef::Part(*k)));
for story in stories {
let mut open: Vec<usize> = Vec::new();
// Open ranges by id, latest last (an end closes the latest open range of its id).
let mut open: BTreeMap<u32, Vec<usize>> = BTreeMap::new();
for path in self.para_paths(story) {
let Some(p) = self.para(story, &path) else { continue };
for off in p.object_offsets() {
match p.object_at(off) {
Some(InlineObject::PermStart { id, group, editor }) => {
for (off, o) in p.objects_with_offsets() {
match o {
InlineObject::PermStart { id, group, editor } => {
let start = Pos { story, path: path.clone(), off: off + para::OBJ.len_utf8() };
open.push(out.len());
open.entry(*id).or_default().push(out.len());
out.push(PermRange { id: *id, group: group.clone(), editor: editor.clone(), start: start.clone(), end: start });
}
Some(InlineObject::PermEnd { id }) => {
if let Some(k) = open.iter().rposition(|i| out.get(*i).is_some_and(|r| r.id == *id)) {
let i = open.remove(k);
if let Some(r) = out.get_mut(i) {
r.end = Pos { story, path: path.clone(), off };
}
InlineObject::PermEnd { id } => {
if let Some(i) = open.get_mut(id).and_then(Vec::pop)
&& let Some(r) = out.get_mut(i)
{
r.end = Pos { story, path: path.clone(), off };
}
}
_ => {}
@@ -1625,7 +1694,7 @@ impl Document {
}
// A range left open runs to the end of its story.
let end = self.end_of(story);
for i in open {
for i in open.into_values().flatten() {
if let Some(r) = out.get_mut(i) {
r.end = end.clone();
}
@@ -1639,8 +1708,8 @@ impl Document {
let mut out = Vec::new();
for path in self.para_paths(StoryRef::Body) {
if let Some(p) = self.para(StoryRef::Body, &path) {
for off in p.object_offsets() {
if let Some(InlineObject::BookmarkStart { name }) = p.object_at(off) {
for (off, o) in p.objects_with_offsets() {
if let InlineObject::BookmarkStart { name } = o {
out.push((name.clone(), Pos { story: StoryRef::Body, path: path.clone(), off }));
}
}
@@ -1822,5 +1891,7 @@ fn story_word_count_of(bl: &Blocks, numbering: &Numbering, styles: &styles::Styl
total
}
#[cfg(test)]
mod test_log;
#[cfg(test)]
mod tests;
+34
View File
@@ -41,6 +41,24 @@ impl Math {
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
/// Rough heap bytes a clone copies: every node, its argument lists and its run text.
/// Iterative, so it costs at most the size of the structure.
pub(crate) fn heap_bytes(&self) -> usize {
let mut n = self.omml.len();
let mut todo: Vec<&MNode> = self.nodes.iter().collect();
while let Some(node) = todo.pop() {
n = n.saturating_add(std::mem::size_of::<MNode>());
if let MNode::Run(r) = node {
n = n.saturating_add(r.text.len()).saturating_add(r.font.as_ref().map_or(0, String::len));
}
each_arg(node, &mut |a| {
n = n.saturating_add(std::mem::size_of::<Arg>());
todo.extend(a);
});
}
n
}
}
/// Display equation justification (`m:oMathParaPr/m:jc`).
@@ -1095,4 +1113,20 @@ mod tests {
let deep = "√".repeat(3000);
let _ = parse_linear(&deep);
}
#[test]
fn a_long_run_of_letters_parses_in_linear_time() {
// Each letter that doesn't start a function name used to rescan (and copy) the rest of
// the run, so a 20,000-letter equation did 200 million steps. (No wall-clock assert: a
// loaded runner would fail it; a regression shows as a slow test.)
for _ in 0..5 {
let e = parse_linear(&"x".repeat(30_000));
assert!(matches!(e.as_slice(), [MNode::Run(r)] if r.text.len() == 20_000), "{:?}", e.len());
}
// Function names are still found at the start of a run, and only as whole runs.
assert!(matches!(parse_linear("sin x").as_slice(), [MNode::Func { .. }]));
assert!(matches!(parse_linear("arcsin x").as_slice(), [MNode::Func { .. }]));
assert!(matches!(parse_linear("sinx").as_slice(), [MNode::Run(_)]));
assert!(matches!(parse_linear("arcsinhx").as_slice(), [MNode::Run(_)]));
}
}
+68
View File
@@ -324,6 +324,21 @@ impl Lp {
let den = self.arg();
one(MNode::Frac { kind: FracKind::Bar, num, den })
}
"genfrac" => {
let beg = delim_arg(&self.arg());
let end = delim_arg(&self.arg());
let thickness = self.arg();
let _style = self.arg();
let num = self.arg();
let den = self.arg();
let kind = if is_zero_length(&thickness) { FracKind::NoBar } else { FracKind::Bar };
let frac = MNode::Frac { kind, num, den };
if beg.is_none() && end.is_none() {
one(frac)
} else {
one(MNode::Delim { beg, end, sep: None, grow: true, shp_match: false, elems: vec![vec![frac]] })
}
}
"binom" | "dbinom" | "tbinom" => {
let n = self.arg();
let k = self.arg();
@@ -801,6 +816,31 @@ fn text_chars(text: &str, s: &mut String) {
}
}
/// The delimiter of a `\genfrac` argument: one character, or none for `{}` and anything else.
fn delim_arg(a: &[MNode]) -> Option<char> {
let [MNode::Run(r)] = a else { return None };
let mut cs = r.text.chars();
match (cs.next(), cs.next()) {
(Some(c), None) if c != '.' => Some(c),
_ => None,
}
}
/// Whether a `\genfrac` thickness is a zero length such as `0pt`, `0.0pt` or `0 em`. An empty
/// thickness means the default rule and anything that is not a plain number and unit is not zero.
fn is_zero_length(a: &[MNode]) -> bool {
let mut text = String::new();
for n in a {
let MNode::Run(r) = n else { return false };
text.extend(r.text.chars().filter(|c| !c.is_whitespace() && !matches!(c, '\u{2009}' | '\u{205F}' | '\u{2005}')));
}
let number = text.trim_end_matches(|c: char| c.is_ascii_alphabetic());
let unit = &text[number.len()..];
!number.is_empty()
&& matches!(unit, "pt" | "em" | "ex" | "mm" | "cm" | "in" | "bp" | "pc" | "sp" | "mu")
&& number.parse::<f64>().is_ok_and(|v| v == 0.0)
}
fn delim_tex(c: Option<char>) -> String {
match c {
None => ".".into(),
@@ -1076,6 +1116,34 @@ mod tests {
let _ = (LimLoc::UndOvr, MathJc::Center);
}
#[test]
fn latex_binom_round_trip() {
let a = parse_latex(r"\binom{n}{k}");
let l = to_latex(&a);
assert_eq!(parse_latex(&l), a, "exported as {l}");
let nobar = |n: &str, k: &str| MNode::Frac { kind: FracKind::NoBar, num: parse_latex(n), den: parse_latex(k) };
let b = parse_latex(r"\genfrac{}{}{0pt}{}{n}{k}");
assert_eq!(b, vec![nobar("n", "k")]);
assert_eq!(parse_latex(&to_latex(&b)), b);
// Other spellings of a zero thickness.
for z in ["0 pt", "0.0pt", r"0\,pt", "0em"] {
let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{z}}}{{}}{{n}}{{k}}"));
assert_eq!(c, vec![nobar("n", "k")], "thickness {z}");
}
// A non-zero or default thickness keeps the bar.
for t in ["1pt", "0.4pt", ""] {
let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{t}}}{{}}{{n}}{{k}}"));
let bar = MNode::Frac { kind: FracKind::Bar, num: parse_latex("n"), den: parse_latex("k") };
assert_eq!(c, vec![bar], "thickness {t:?}");
}
// Delimiters are kept.
let d = parse_latex(r"\genfrac(){0pt}{}{n}{k}");
assert_eq!(d, a);
let e = parse_latex(r"\genfrac[]{0pt}{}{n}{k}");
assert!(matches!(&e[..], [MNode::Delim { beg: Some('['), end: Some(']'), .. }]), "{e:#?}");
assert_eq!(parse_latex(&to_latex(&e)), e);
}
#[test]
fn latex_round_trip() {
for lin in ["x=(-b±√(b^2-4ac))/2a", "∑_(i=1)^n▒i^2", "√(3&x)", "a⃗+x̂", "■(a&b@c&d)", "sin⁡x+cos⁡y", "e^(-x^2)"] {
+17 -3
View File
@@ -18,6 +18,18 @@ pub const FUNCTION_NAMES: &[&str] = &[
"tanh",
];
/// Length of the longest of [`FUNCTION_NAMES`].
const MAX_NAME: usize = {
let (mut max, mut i) = (0, 0);
while i < FUNCTION_NAMES.len() {
if FUNCTION_NAMES[i].len() > max {
max = FUNCTION_NAMES[i].len();
}
i += 1;
}
max
};
/// Function names whose subscripts go underneath (`lim_(n→∞)`).
const LIMIT_NAMES: &[&str] = &["lim", "liminf", "limsup", "max", "min", "sup", "inf", "det", "gcd", "Pr"];
@@ -410,10 +422,12 @@ impl P {
fn word(&mut self, stops: &[char]) -> Arg {
let start = self.i;
let mut end = start;
while self.c.get(end).is_some_and(char::is_ascii_alphabetic) {
// Look no further than the longest name (and one past it, to see the run is longer): a
// scan to the end of every run made a long run of letters quadratic (CWE-407).
while end - start <= MAX_NAME && self.c.get(end).is_some_and(char::is_ascii_alphabetic) {
end += 1;
}
let word: String = self.c.get(start..end).unwrap_or(&[]).iter().collect();
let word: String = if end - start > MAX_NAME { String::new() } else { self.c.get(start..end).unwrap_or(&[]).iter().collect() };
if !FUNCTION_NAMES.contains(&word.as_str()) {
self.i = start + 1;
return vec![text(self.c.get(start).copied().unwrap_or(' '))];
@@ -556,7 +570,7 @@ impl P {
}
out.pop()
};
if !matches!(c, '^' | '_') && !(matches!(c, '┬' | '┴') && self.no_limits == 0) && !is_combining(c) {
if !(matches!(c, '^' | '_') || matches!(c, '┬' | '┴') && self.no_limits == 0 || is_combining(c)) {
break;
}
// The base is already deep: the operator is text (see `too_deep`).
+24 -1
View File
@@ -357,7 +357,8 @@ impl Counters {
return Some((def.text.clone(), def));
}
let mut label = String::new();
let mut chars = def.text.chars().peekable();
// Word's level text is short; .docx and the lists command already cap it at 64.
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
@@ -375,6 +376,9 @@ impl Counters {
f => f,
};
label.push_str(&fmt.format(val));
if label.len() > MAX_LABEL_BYTES {
break;
}
} else {
label.push(c);
}
@@ -383,10 +387,29 @@ impl Counters {
}
}
/// Longest level text (in characters) expanded into a list label.
const MAX_LEVEL_TEXT: usize = 64;
/// Labels stop growing once they pass this many bytes.
const MAX_LABEL_BYTES: usize = 256;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_huge_level_text_gives_a_bounded_label() {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
let huge = Level { format: NumFormat::UpperRoman, text: "%1".repeat(30_000), start: u32::MAX, ..Level::default() };
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.level_overrides = vec![(0, huge)];
}
let mut c = Counters::default();
let label = c.next_label(&n, id, 0).unwrap().0;
assert!(label.len() <= 512, "label is {} bytes", label.len());
assert!(label.starts_with("MMMM"));
}
#[test]
fn a_list_s_own_level_definitions_win() {
// A specification's article numbers: level 1 counts parts with no label of its own, level 2
+11
View File
@@ -1089,6 +1089,12 @@ impl Paragraph {
pub fn object_offsets(&self) -> Vec<usize> {
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
}
/// (byte offset, object) for every object, in text order, in one pass. Prefer it to
/// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before
/// each offset again, so a loop over a paragraph's objects is quadratic (#3614).
pub fn objects_with_offsets(&self) -> impl Iterator<Item = (usize, &InlineObject)> + '_ {
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
}
/// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length.
pub fn insert_text(&mut self, off: usize, s: &str, props: &CharProps) -> Result<usize> {
@@ -1559,6 +1565,11 @@ mod tests {
assert!(matches!(p.object_at(0), Some(InlineObject::BookmarkStart { .. })));
let off = p.object_offsets()[1];
assert!(matches!(p.object_at(off), Some(InlineObject::Field { .. })));
// The one-pass walk agrees with offsets + object_at.
let one_pass: Vec<(usize, &InlineObject)> = p.objects_with_offsets().collect();
let each: Vec<(usize, &InlineObject)> = p.object_offsets().into_iter().filter_map(|o| Some((o, p.object_at(o)?))).collect();
assert_eq!(one_pass, each);
assert_eq!(one_pass.len(), 2);
assert_eq!(p.plain_text(), "a1b");
p.delete(0, 3).unwrap();
assert_eq!(p.objects.len(), 1);
+37 -3
View File
@@ -255,7 +255,7 @@ impl NumFormat {
pub fn from_ooxml(s: &str) -> NumFormat {
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
}
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Arabic, Hindi, Thai,
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai,
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
/// (the 21st item of ①…⑳).
pub fn format(self, n: u32) -> String {
@@ -268,6 +268,8 @@ impl NumFormat {
NumFormat::LowerLetter => letters(n),
NumFormat::RussianUpper => russian_letters(n).to_uppercase(),
NumFormat::RussianLower => russian_letters(n),
NumFormat::ArabicAlpha => repeated_letter(&ARABIC_ALPHA, n),
NumFormat::ArabicAbjad => repeated_letter(&ARABIC_ABJAD, n),
NumFormat::Ordinal => format!("{n}{}", ordinal_suffix(n)),
NumFormat::CardinalText => cardinal_text(n),
NumFormat::OrdinalText => ordinal_text(n),
@@ -308,8 +310,6 @@ impl NumFormat {
| NumFormat::VietnameseCounting
| NumFormat::Hebrew1
| NumFormat::Hebrew2
| NumFormat::ArabicAlpha
| NumFormat::ArabicAbjad
| NumFormat::HindiVowels
| NumFormat::HindiConsonants
| NumFormat::HindiNumbers
@@ -399,6 +399,27 @@ pub fn russian_letters(n: u32) -> String {
std::iter::repeat_n(c, k as usize).collect()
}
/// `arabicAlpha`: the 28 letters in alphabetical (hijā'ī) order.
const ARABIC_ALPHA: [char; 28] =
['أ', 'ب', 'ت', 'ث', 'ج', 'ح', 'خ', 'د', 'ذ', 'ر', 'ز', 'س', 'ش', 'ص', 'ض', 'ط', 'ظ', 'ع', 'غ', 'ف', 'ق', 'ك', 'ل', 'م', 'ن', 'ه', 'و', 'ي'];
/// `arabicAbjad`: the 28 letters in abjad order (أبجد هوز حطي كلمن سعفص قرشت ثخذ ضظغ).
const ARABIC_ABJAD: [char; 28] =
['أ', 'ب', 'ج', 'د', 'ه', 'و', 'ز', 'ح', 'ط', 'ي', 'ك', 'ل', 'م', 'ن', 'س', 'ع', 'ف', 'ص', 'ق', 'ر', 'ش', 'ت', 'ث', 'خ', 'ذ', 'ض', 'ظ', 'غ'];
/// One letter of `alphabet`, repeated once per pass through it (as [`letters`]: a..z, aa..zz).
fn repeated_letter(alphabet: &[char], n: u32) -> String {
let Some(&first) = alphabet.first() else { return n.to_string() };
if n == 0 {
return String::new();
}
let len = alphabet.len() as u32;
let n = n.min(len * 30);
let k = (n - 1) / len + 1;
let c = alphabet.get(((n - 1) % len) as usize).copied().unwrap_or(first);
std::iter::repeat_n(c, k as usize).collect()
}
const ONES: [&str; 20] = [
"zero",
"one",
@@ -920,6 +941,19 @@ mod tests {
assert_eq!(NumFormat::RussianUpper.format(36), "ЖЖ");
}
#[test]
fn arabic_letters_count_in_both_orders() {
let alpha: Vec<String> = (1..=5).map(|n| NumFormat::ArabicAlpha.format(n)).collect();
assert_eq!(alpha.join(" "), "أ ب ت ث ج");
let abjad: Vec<String> = (1..=5).map(|n| NumFormat::ArabicAbjad.format(n)).collect();
assert_eq!(abjad.join(" "), "أ ب ج د ه");
assert_eq!(NumFormat::ArabicAlpha.format(28), "ي");
assert_eq!(NumFormat::ArabicAbjad.format(28), "غ");
// Past the 28th letter they double, as Latin and Russian letters do.
assert_eq!(NumFormat::ArabicAlpha.format(29), "أأ");
assert_eq!(NumFormat::ArabicAbjad.format(30), "بب");
}
#[test]
fn landscape_swaps() {
let mut s = SectionProps { margin_left: 50.0, ..Default::default() };
+8 -1
View File
@@ -48,6 +48,12 @@ impl Row {
pub fn first_grid(&self) -> usize {
(self.props.grid_before as usize).min(MAX_COLS)
}
/// Grid columns `w:gridAfter` leaves empty after the row's last cell, bounded as the DOCX
/// reader bounds them (a native document can say anything).
pub fn grid_after(&self) -> usize {
(self.props.grid_after as usize).min(MAX_COLS)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
@@ -97,7 +103,8 @@ impl Table {
}
pub fn cols(&self) -> usize {
let row_cols = |r: &Row| r.first_grid() + r.cells.iter().map(Cell::span).sum::<usize>() + r.props.grid_after as usize;
let row_cols =
|r: &Row| r.first_grid().saturating_add(r.cells.iter().map(Cell::span).fold(0, usize::saturating_add)).saturating_add(r.grid_after());
self.grid.len().max(self.rows.iter().map(row_cols).max().unwrap_or(0))
}
+36
View File
@@ -0,0 +1,36 @@
//! Test helper: the warnings this crate logs, captured per test thread, so a test can check that
//! a limit which leaves something out says so.
use std::cell::RefCell;
struct Capture;
thread_local! {
static LINES: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
}
impl log::Log for Capture {
fn enabled(&self, m: &log::Metadata) -> bool {
m.level() <= log::Level::Warn
}
fn log(&self, r: &log::Record) {
if self.enabled(r.metadata()) {
LINES.with(|l| l.borrow_mut().push(r.args().to_string()));
}
}
fn flush(&self) {}
}
static CAPTURE: Capture = Capture;
/// `f`'s result and the warnings logged on this thread while it ran.
pub(crate) fn warnings<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
// Only this logger is ever installed in a test binary; a second call keeps it.
let _ = log::set_logger(&CAPTURE);
log::set_max_level(log::LevelFilter::Warn);
LINES.with(|l| l.borrow_mut().clear());
let out = f();
(out, LINES.with(|l| std::mem::take(&mut *l.borrow_mut())))
}
+128
View File
@@ -90,6 +90,45 @@ fn parts_and_media() {
assert_ne!(k1, k3);
}
#[test]
fn many_distinct_media_add_in_linear_time() {
// An import with tens of thousands of small distinct images (same length, differing only at
// the end) used to compare each against every earlier one.
let mut d = Document::new();
let t = std::time::Instant::now();
let n = 40_000u32;
for i in 0..n {
let mut bytes = vec![7u8; 256];
bytes.extend(i.to_le_bytes());
d.add_media(bytes, "png");
}
assert_eq!(d.media.len(), n as usize);
// Duplicates still find their first copy, including media inserted directly.
let mut first = vec![7u8; 256];
first.extend(0u32.to_le_bytes());
assert_eq!(d.add_media(first, "png"), "image1.png");
d.media.insert("direct.png".into(), Arc::new(vec![9, 9]));
assert_eq!(d.add_media(vec![9, 9], "png"), "direct.png");
// About half a second on a loaded machine; the old scan took over two minutes.
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
// The lookup cache isn't part of the document.
let mut e = d.clone();
e.media_index = MediaIndex::default();
assert_eq!(d, e);
}
#[test]
fn media_keys_skip_taken_names_once() {
let mut d = Document::new();
// A file whose media already use the names add_media would pick next.
for i in 2..2002u32 {
d.media.insert(format!("image{i}.png"), Arc::new(i.to_le_bytes().to_vec()));
}
assert_eq!(d.add_media(vec![0], "png"), "image2002.png");
assert_eq!(d.add_media(vec![1], "png"), "image2003.png");
assert_eq!(d.add_media(vec![2], "png"), "image2004.png");
}
#[test]
fn free_ids_skip_taken_ones_instead_of_overflowing() {
assert_eq!(free_id(None, std::iter::empty(), 1), Some(1));
@@ -523,3 +562,92 @@ fn custom_props_set_get_remove() {
assert!(!d.remove_custom_prop("other"));
assert_eq!(d.custom_props.len(), 1);
}
/// A paragraph that is nothing but `objs` (built directly: inserting one at a time is slow too).
fn para_of_objects(objs: Vec<InlineObject>) -> Paragraph {
let mut p = Paragraph::new();
p.text = para::OBJ.to_string().repeat(objs.len());
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
p.objects = objs;
p
}
#[test]
fn object_walks_are_linear_in_the_paragraph() {
// #3614: loops of object_offsets + object_at recounted the objects before each one, so a
// paragraph of 40,000 markers took seconds per walk.
let n = 40_000u32;
let w = para::OBJ.len_utf8();
let mut objs: Vec<InlineObject> = Vec::new();
for i in 0..n {
objs.push(InlineObject::BookmarkStart { name: format!("b{i}") });
objs.push(fs("ADDIN x"));
objs.push(InlineObject::PermStart { id: i, group: "everyone".into(), editor: String::new() });
}
// Ranges close in the order they opened (the worst case for a backwards search).
for i in 0..n {
objs.push(InlineObject::FieldEnd);
objs.push(InlineObject::PermEnd { id: i });
}
let mut d = Document::new();
d.body = vec![Arc::new(Block::Para(para_of_objects(objs)))];
let t = std::time::Instant::now();
assert_eq!(d.bookmarks().len(), n as usize);
assert_eq!(d.field_ranges(StoryRef::Body).len(), n as usize);
let perms = d.perm_ranges();
assert_eq!(perms.len(), n as usize);
assert!(perms.iter().all(|r| r.end.off > r.start.off), "every range closed by its own end");
assert_eq!(d.text_box_anchor(7), None);
// A caret before a long row of field ends moves past all of them.
let first_end = 3 * n as usize * w;
let p = d.past_field_ends(&Pos::body(0, first_end));
assert_eq!(p.off, first_end + w);
// Under 0.3 s on a loaded machine; the old walks took minutes (360 s for the first test).
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
let ends: Vec<InlineObject> = (0..n).map(|_| InlineObject::FieldEnd).collect();
d.body = vec![Arc::new(Block::Para(para_of_objects(ends)))];
let t = std::time::Instant::now();
assert_eq!(d.past_field_ends(&Pos::body(0, 0)).off, n as usize * w);
// Typing at the end of a row of block-level control starts.
let block = ContentControl { block: true, ..Default::default() };
let starts: Vec<InlineObject> = (0..n).map(|_| InlineObject::ControlStart { control: Box::new(block.clone()) }).collect();
d.body = vec![Arc::new(Block::Para(para_of_objects(starts)))];
let end = Pos::body(0, n as usize * w);
assert_eq!(d.typing_pos(&end), end);
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
}
#[test]
fn deleting_across_many_lone_control_markers_is_not_quadratic() {
// A millisecond now; the old delete took 18 s at 1,500 markers and 114 s at 3,000.
let n = 3_000;
let starts: Vec<InlineObject> = (0..n).map(|_| InlineObject::ControlStart { control: Box::default() }).collect();
let mut d = Document::from_text("before\n\nafter");
d.body[1] = Arc::new(Block::Para(para_of_objects(starts)));
let t = std::time::Instant::now();
let at = d.delete_range(&Pos::body(0, 3), &Pos::body(2, 2)).unwrap();
assert_eq!(at, Pos::body(0, 3));
assert_eq!(d.plain_text(StoryRef::Body), "befter");
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
}
#[test]
fn control_markers_kept_are_sorted() {
// `delete_range` groups the kept markers by paragraph and binary-searches each group, so
// `kept_control_markers` must hand them over sorted and without repeats (review of #3874).
let start = || InlineObject::ControlStart { control: Box::default() };
let mut d = Document::from_text("before");
// Controls open and close across paragraphs: 1 closes the one 0 opens, 3 the one 2 opens…
for i in 0..12 {
let objs = if i % 2 == 0 { vec![start(), start()] } else { vec![InlineObject::ControlEnd, InlineObject::ControlEnd] };
d.body.push(Arc::new(Block::Para(para_of_objects(objs))));
}
d.body.push(Arc::new(Block::Para(Paragraph::with_text("after", CharProps::default()))));
let w = para::OBJ.len_utf8();
for (a, b) in [(Pos::body(2, w), Pos::body(11, w)), (Pos::body(1, w), Pos::body(12, w)), (Pos::body(3, w), Pos::body(8, 0))] {
let kept = d.kept_control_markers(&a, &b);
assert!(!kept.is_empty(), "{a:?}..{b:?}");
assert!(kept.windows(2).all(|p| p[0] < p[1]), "sorted, no repeats: {kept:?}");
}
}
+1
View File
@@ -20,5 +20,6 @@ log = { workspace = true }
chrono = { workspace = true }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
proptest = { workspace = true }
serde_json = { workspace = true }
+6 -1
View File
@@ -295,8 +295,13 @@ impl Cipher {
if hflags & H_EXTERNAL != 0 {
return Err(unsupported("the document uses an external encryption provider"));
}
// [MS-OFFCRYPTO] defines AES only for the encrypted packages of Word 2007 and later
// (which open through the .docx reader whatever their extension); a Word 97-2003 file
// may only use XOR, RC4 or RC4 CryptoAPI, so there is no public scheme to follow here.
if hflags & H_AES != 0 {
return Err(unsupported("AES-encrypted .doc files are not supported"));
return Err(unsupported(
"this Word 97-2003 file claims AES encryption, which is only defined for the newer encrypted-package format; it can't be opened",
));
}
if hflags & H_CRYPTO_API == 0 {
return Err(unsupported("unknown encryption header"));
+6
View File
@@ -81,12 +81,18 @@ pub(crate) fn anchors(
) -> Vec<(u32, InlineObject)> {
let Some(pair) = pair else { return Vec::new() };
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
for (cp, spa) in plc(table, pair, CB_SPA) {
let i32_at = |o: usize| spa.get(o..o + 4).and_then(|b| b.try_into().ok()).map(i32::from_le_bytes).unwrap_or(0);
let spid = i32_at(0) as u32;
let [xl, yt, xr, yb] = [4, 8, 12, 16].map(i32_at);
let flags = spa.get(20..22).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0);
let Some(node) = shapes.get(&spid) else { continue };
// Word anchors each shape once. A repeated spid would build another copy of the shape,
// and of every member of a group, per anchor (#3710).
if !seen.insert(spid) {
continue;
}
let tw = |v: i64| (v as f32 / 20.0).clamp(-2.0 * MAX_PT, 2.0 * MAX_PT);
let (mut x, mut y) = (tw(xl as i64), tw(yt as i64));
let (mut w, mut h) = (tw(xr as i64 - xl as i64).clamp(0.0, MAX_PT), tw(yb as i64 - yt as i64).clamp(0.0, MAX_PT));
+191 -29
View File
@@ -49,6 +49,91 @@ const MAX_PARTS: usize = 50_000;
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
const MAX_TOTAL_CHARS: u64 = 64 << 20;
/// Bytes of repeated work per document beyond the text itself: piece Prm grpprls (Prcs, up
/// to 64 KB each) interpreted (#3692), hyperlink targets (#3708), picture alt text (#3709)
/// and floating objects (#3710) copied into every run or anchor. Past it Prms are skipped,
/// copied strings left empty and floating objects dropped, each kind logged once a document
/// ([`Work::log`]).
const MAX_WORK: u64 = 64 << 20;
/// What the [`MAX_WORK`] budget pays for, and leaves out once it is spent.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Cost {
/// A piece's Prm grpprl: past the budget the piece keeps its CHPX formatting only.
Formatting,
/// A hyperlink target copied into a run of its result: past it the text is kept, unlinked.
LinkTarget,
/// A picture's alt text copied into an anchor: past it the picture has none.
AltText,
/// A floating object copied into its anchor: past it the object is left out.
FloatingObject,
}
impl Cost {
const ALL: [Cost; 4] = [Cost::Formatting, Cost::LinkTarget, Cost::AltText, Cost::FloatingObject];
/// What is left out, for the log.
fn left_out(self) -> &'static str {
match self {
Cost::Formatting => "some text keeps only part of its formatting",
Cost::LinkTarget => "some hyperlinks' text is kept without its link",
Cost::AltText => "some pictures are left without their alt text",
Cost::FloatingObject => "some floating shapes and pictures are left out",
}
}
}
/// One document's [`MAX_WORK`] budget, and what it has left out (each kind logged once when
/// the document has been read, [`Work::log`]).
struct Work {
left: std::cell::Cell<u64>,
dropped: std::cell::Cell<[bool; 4]>,
}
impl Work {
fn new(budget: u64) -> Self {
Work { left: std::cell::Cell::new(budget), dropped: std::cell::Cell::new([false; 4]) }
}
/// Spends `cost` bytes on `what`: false (and `what` noted as left out) when they aren't left.
fn charge(&self, cost: u64, what: Cost) -> bool {
match self.left.get().checked_sub(cost) {
Some(left) => {
self.left.set(left);
true
}
None => {
let mut dropped = self.dropped.get();
if let Some(d) = Cost::ALL.iter().position(|c| *c == what).and_then(|i| dropped.get_mut(i)) {
*d = true;
}
self.dropped.set(dropped);
false
}
}
}
/// The kinds of work left out so far, in [`Cost::ALL`] order.
fn dropped(&self) -> Vec<Cost> {
Cost::ALL.iter().zip(self.dropped.get()).filter(|(_, d)| *d).map(|(c, _)| *c).collect()
}
/// Logs one warning for each kind left out.
fn log(&self) {
let dropped = self.dropped();
for c in &dropped {
log::warn!(".doc: the document repeats more than WordCraft copies ({MAX_WORK} bytes): {}", c.left_out());
}
#[cfg(test)]
LAST_DROPPED.with(|d| *d.borrow_mut() = dropped);
}
}
#[cfg(test)]
thread_local! {
/// What the last document read on this thread left out for the work budget ([`Work::log`]).
static LAST_DROPPED: std::cell::RefCell<Vec<Cost>> = const { std::cell::RefCell::new(Vec::new()) };
}
/// `sprmCPicLocation`: the fc of a picture in the Data stream.
const C_PIC_LOCATION: u16 = 0x6A03;
/// `sprmCSymbol`: the record font and character of a symbol character ([MS-DOC] §2.6.1); the
@@ -88,10 +173,16 @@ pub enum DocbinError {
/// Is this `.doc` password-protected (XOR obfuscation, RC4 or RC4 CryptoAPI)? False for
/// anything that is not a readable Word 97-2003 file.
pub fn is_encrypted(bytes: &[u8]) -> bool {
let Ok(mut comp) = cfb::CompoundFile::open(std::io::Cursor::new(bytes)) else { return false };
let Ok(mut comp) = open_container(bytes) else { return false };
stream(&mut comp, "WordDocument").is_some_and(|w| fib::peek_encryption(&w).is_some())
}
/// Open the compound file around a `.doc`, after [`wordcraft_doc::cfb_guard::check`].
fn open_container(bytes: &[u8]) -> Result<cfb::CompoundFile<std::io::Cursor<&[u8]>>, DocbinError> {
wordcraft_doc::cfb_guard::check(bytes).map_err(DocbinError::Container)?;
cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))
}
/// Read a `.doc` (Word 97-2003 binary) file into a [`Document`]. A password-protected file
/// gives [`DocbinError::PasswordRequired`]; see [`read_with_password`].
pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
@@ -102,7 +193,7 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
/// verifier ([`DocbinError::WrongPassword`] when it does not match) and then decrypted. The
/// password is ignored for an unprotected file.
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Document, DocbinError> {
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))?;
let mut comp = open_container(bytes)?;
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
if word.len() as u64 > MAX_STREAM {
return Err(DocbinError::Limit(format!("WordDocument stream is {} bytes", word.len())));
@@ -168,13 +259,14 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
let ftn_base = fib.ccp.text;
let hdd_base = fib.ccp.text.saturating_add(fib.ccp.ftn);
let edn_base = hdd_base.saturating_add(fib.ccp.hdd).saturating_add(fib.ccp.atn);
let mut pics = media::Pictures::default();
let mut pics = media::Pictures::for_input(word.len().saturating_add(data.len()));
// Every materialised character comes from at least one byte of the WordDocument stream, so a
// legitimate document's total text (body plus every header, footnote and text box, each read
// once) cannot exceed its stream. A hostile piece or story table that aliases the same bytes
// many times can, so the walk is held to twice the stream's size — generous for real files,
// tight against aliasing — and never past the absolute ceiling.
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
let work = Work::new(MAX_WORK);
let ctx = WalkCtx {
word: &word,
@@ -190,6 +282,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
drawn: &[],
sect_ends: &[],
budget: &budget,
work: &work,
};
// Drawings: text box stories become parts first, then the floating shapes and pictures
@@ -298,6 +391,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
doc.media.extend(pics.media);
doc.body = table::assemble(paras);
doc.ensure_nonempty();
work.log();
Ok(doc)
}
@@ -334,6 +428,8 @@ struct WalkCtx<'a> {
/// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of
/// them could otherwise materialise the same text thousands of times; this bounds the total.
budget: &'a std::cell::Cell<u64>,
/// Repeated-work bytes left ([`MAX_WORK`]), and what they left out.
work: &'a Work,
}
/// State while assembling one paragraph from per-CP formatting.
@@ -365,6 +461,14 @@ impl ParaBuild {
}
self.text.push(c);
}
/// Append `c` to the current run, whose properties the caller knows it matches.
fn extend(&mut self, c: char) {
if let Some(r) = self.runs.last_mut() {
r.len += c.len_utf8();
}
self.text.push(c);
}
}
/// Walk a CP range, splitting paragraphs at 0x0D/0x07 marks, applying the direct character
@@ -376,7 +480,7 @@ impl ParaBuild {
/// The range is clamped to the last piece's CP, and unreadable stretches (gaps, truncated
/// pieces) are skipped a whole piece at a time, so the work is bounded by the text that
/// really exists, not by the CP numbers a file claims.
fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -> Vec<ParaOut> {
fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -> Vec<ParaOut> {
let cp_end = cp_end.min(ctx.pieces.cp_end());
let mut out = Vec::new();
let mut pb = ParaBuild::default();
@@ -385,6 +489,7 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
// Field assembly state: instruction characters are swallowed, the result is kept until
// the end character, then emitted as one object (hyperlinks keep their result text).
let mut field: Option<FieldBuild> = None;
let mut memo: Option<FmtMemo<'a>> = None;
while cp < cp_end {
// Bookmark boundaries at this CP (main-document walks only; subdocument walks get
// an empty table and `cp` never matches the main-document CPs).
@@ -490,7 +595,7 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
f.depth -= 1;
field = Some(f);
} else {
finish_field(&mut pb, f);
finish_field(&mut pb, f, ctx.work);
}
}
cp = cp.saturating_add(1);
@@ -502,12 +607,15 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
let fc_pic = pic_location(ctx.chpx_bins.chpx(ctx.word, fc));
match fc_pic.and_then(|at| pics.get(ctx.data, at)) {
Some(p) => {
// Every anchor of a picture gets its own copy of the alt text; the
// copies are charged to the work budget (#3709).
let alt = if ctx.work.charge(p.alt.len() as u64, Cost::AltText) { p.alt.clone() } else { String::new() };
pb.push('\u{FFFC}', CharProps::default());
pb.objects.push(InlineObject::Image {
media: p.key,
media: p.key.clone(),
w: p.w,
h: p.h,
alt: p.alt,
alt,
float: Default::default(),
crop: p.crop,
ole: None,
@@ -519,7 +627,12 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
'\u{8}' => {
// Drawn object anchor: the floating shape or picture anchored at this CP.
let at = ctx.drawn.partition_point(|(c, _)| *c < cp);
// Each copy (a story can be walked more than once) is charged to the work budget
// by what a clone copies: a picture's alt text, a group with all its members (#3710).
for (_, obj) in ctx.drawn.get(at..).unwrap_or_default().iter().take_while(|(c, _)| *c == cp) {
if !ctx.work.charge(object_cost(obj), Cost::FloatingObject) {
continue;
}
pb.push('\u{FFFC}', CharProps::default());
pb.objects.push(obj.clone());
}
@@ -532,16 +645,16 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() });
}
}
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut()),
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut()),
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut()),
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut()),
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut()),
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut()),
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut memo),
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut memo),
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut memo),
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut memo),
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut memo),
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut memo),
// Object anchors, comment references and other special characters are dropped
// (or handled by later passes).
c if (c as u32) < 0x20 => {}
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut()),
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut memo),
}
cp = cp.saturating_add(units);
}
@@ -564,19 +677,44 @@ struct FieldBuild {
sep: Option<Vec<(char, CharProps)>>,
}
/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all
/// its members (#3710).
fn object_cost(obj: &InlineObject) -> u64 {
let shallow = std::mem::size_of::<InlineObject>() as u64;
match obj {
InlineObject::Image { alt, .. } => shallow.saturating_add(alt.len() as u64),
InlineObject::Group { children, .. } => children.iter().fold(shallow, |n, c| n.saturating_add(object_cost(&c.obj))),
_ => shallow,
}
}
/// Emit a finished field: HYPERLINK keeps its result text as a linked run; every other
/// field becomes one inline object with its cached result.
fn finish_field(pb: &mut ParaBuild, f: FieldBuild) {
///
/// The target is copied once per run of equally formatted result text, not per character,
/// and each copy is charged to `work` ([`MAX_WORK`]); once that is spent the rest of the
/// result is kept as plain text (#3708).
fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
let instr = f.instr.trim().to_string();
let result = f.sep.unwrap_or_default();
let result_text: String = result.iter().map(|(c, _)| *c).collect();
if let Some(target) = hyperlink_target(&instr) {
for (c, mut props) in result {
props.link = Some(target.clone());
pb.push(c, props);
let cost = target.len() as u64;
let mut run: Option<CharProps> = None;
for (c, props) in result {
if run.as_ref() == Some(&props) {
pb.extend(c);
continue;
}
let mut linked = props.clone();
if work.charge(cost, Cost::LinkTarget) {
linked.link = Some(target.clone());
}
pb.push(c, linked);
run = Some(props);
}
return;
}
let result_text: String = result.iter().map(|(c, _)| *c).collect();
pb.push('\u{FFFC}', CharProps::default());
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
}
@@ -702,19 +840,28 @@ fn num_of(ilfo: i32) -> Option<u32> {
/// plus the piece's Prm, over the paragraph style's base character props. Inside a field,
/// the character is recorded into the instruction or the cached result instead of (or as
/// well as) the visible text.
fn push_formatted(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx, field: Option<&mut FieldBuild>) {
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, memo: &mut Option<FmtMemo<'a>>) {
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
let grpprl = ctx.chpx_bins.chpx(ctx.word, fc);
let default;
let base = match pb.props.style.as_deref().and_then(|id| ctx.sheet.get(id)) {
Some(st) => &st.chr,
let prm = ctx.pieces.prm_of(cp);
let style = pb.props.style.as_deref();
let mut props = match memo.as_ref().filter(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style) {
Some(m) => m.props.clone(),
None => {
default = CharProps::default();
&default
let default;
let base = match style.and_then(|id| ctx.sheet.get(id)) {
Some(st) => &st.chr,
None => {
default = CharProps::default();
&default
}
};
let mut props = char_props(grpprl, ctx.fonts, base);
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
*memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props: props.clone() });
props
}
};
let mut props = char_props(grpprl, ctx.fonts, base);
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base);
// A symbol character (Insert › Symbol) is stored as the placeholder U+0028 with
// sprmCSymbol carrying the font and the character code ([MS-DOC] §2.6.1).
let mut c = c;
@@ -739,8 +886,20 @@ fn push_formatted(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx, field: Op
}
}
/// Apply a piece's Prm (Prm0 short sprm or Prm1 grpprl) to character properties.
fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &CharProps) {
/// The last character formatting resolved, reused while the CHPX, the piece's Prm and the
/// paragraph style stay the same, so a Prm grpprl (up to 64 KB) is interpreted once per
/// formatting change, not once per character (#3692).
struct FmtMemo<'a> {
/// The CHPX grpprl, compared by address: it is a slice of the WordDocument stream.
chpx: &'a [u8],
prm: u16,
style: Option<String>,
props: CharProps,
}
/// Apply a piece's Prm (Prm0 short sprm or Prm1 grpprl) to character properties. A Prm1
/// grpprl is charged to `work`; once that is spent the grpprl is skipped.
fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &CharProps, work: &Work) {
match prm {
PrmRef::None => {}
PrmRef::Sprm0 { isprm, val } => {
@@ -753,6 +912,9 @@ fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &Ch
}
}
PrmRef::Grpprl(Some(g)) => {
if !work.charge(g.len() as u64, Cost::Formatting) {
return;
}
for prl in sprm::iter(g) {
if prl.sgc() == 2 {
fmt::apply_char(props, &prl, base, fonts);
+68 -26
View File
@@ -17,6 +17,24 @@ const OFF_PICMID: usize = 28;
const MAX_PICTURES: usize = 5_000;
/// Most picture bytes one document copies out of the Data stream, all pictures together.
pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20;
/// Most bytes one document decodes pictures into (metafiles inflated, DIB pixels), charged
/// per attempt whether it succeeds or not: retained media is capped separately, by
/// `MAX_MEDIA_BYTES` (#3711, #3712).
const MAX_DECODE_WORK: usize = 512 << 20;
/// The decode budget grows with the input: up to this many bytes per byte of the WordDocument
/// and Data streams. An uncompressed DIB declares at most 32 bytes of pixels per file byte (1
/// bit per pixel, 4 bytes charged each), so 64 passes any such picture. RLE8/RLE4 bitmaps
/// reach about 510 times that and rely on the `MAX_DECODE_WORK` floor instead; a decompression
/// bomb (ratios around 1e6) still hits the cap.
const DECODE_WORK_PER_INPUT_BYTE: usize = 64;
/// The most the decode budget grows to, whatever the input.
const MAX_DECODE_WORK_CEILING: usize = 2 << 30;
/// The decode budget for a document whose pictures live in `input` stream bytes, never less
/// than `floor`.
pub(crate) fn decode_work(input: usize, floor: usize) -> usize {
input.saturating_mul(DECODE_WORK_PER_INPUT_BYTE).clamp(floor, floor.max(MAX_DECODE_WORK_CEILING))
}
/// The document's pictures, keyed by their location in the Data stream: anchors that share a
/// `fc_pic` share one media entry (or one failure), and the total bytes copied are capped, so a
@@ -28,11 +46,17 @@ pub(crate) struct Pictures {
by_fc: HashMap<u32, Option<Inline>>,
/// BLIP store index → media key (`None`: unreadable, not retried).
by_bse: HashMap<u32, Option<String>>,
/// (stream, foDelay, record length) of a BLIP record outside the store → media key: store
/// entries and inline pictures that alias one record share one decode (`None`: failed, not
/// retried). The stream is told apart by its address.
by_delay: HashMap<(usize, usize, usize), Option<String>>,
/// The media entries, merged into the document at the end.
pub(crate) media: BTreeMap<String, Arc<Vec<u8>>>,
total: usize,
/// The byte cap (`MAX_MEDIA_BYTES`; smaller in tests).
pub(crate) limit: usize,
/// Decode work left (`MAX_DECODE_WORK`; smaller in tests).
pub(crate) work: officeart::Decode,
/// Anchors whose picture was dropped (only the first few are logged).
dropped: usize,
/// Pictures kept as placeholders (formats we can't draw).
@@ -44,9 +68,11 @@ impl Default for Pictures {
Pictures {
by_fc: HashMap::new(),
by_bse: HashMap::new(),
by_delay: HashMap::new(),
media: BTreeMap::new(),
total: 0,
limit: MAX_MEDIA_BYTES,
work: officeart::Decode::new(MAX_DECODE_WORK),
dropped: 0,
placeholders: 0,
}
@@ -64,19 +90,24 @@ pub(crate) struct Inline {
}
impl Pictures {
/// An empty store whose decode budget scales with the `input` stream bytes (WordDocument
/// plus Data) the pictures can come from.
pub(crate) fn for_input(input: usize) -> Self {
Pictures { work: officeart::Decode::new(decode_work(input, MAX_DECODE_WORK)), ..Pictures::default() }
}
/// The inline picture at `fc_pic` in the Data stream, copying its bytes the first time.
/// `None` when the header is malformed, the picture can't be read, or a picture
/// count/byte cap is reached.
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<Inline> {
if let Some(hit) = self.by_fc.get(&fc_pic) {
return hit.clone();
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<&Inline> {
if !self.by_fc.contains_key(&fc_pic) {
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
return None;
}
let pic = self.read(data, fc_pic);
self.by_fc.insert(fc_pic, pic);
}
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
return None;
}
let pic = self.read(data, fc_pic);
self.by_fc.insert(fc_pic, pic.clone());
pic
self.by_fc.get(&fc_pic)?.as_ref()
}
/// Read the inline picture at `fc_pic`: nothing is decoded or copied beyond what is left of
@@ -86,16 +117,13 @@ impl Pictures {
if room == 0 {
return None;
}
match locate(data, fc_pic, room)? {
match locate(data, fc_pic, self)? {
Found::Range(pic) => {
let bytes = data.get(pic.range.clone()).filter(|b| b.len() <= room)?.to_vec();
let key = self.insert(bytes, pic.ext, false)?;
Some(Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() })
}
Found::Shape { w, h, blip, props } => {
let key = self.insert(blip.bytes, blip.ext, blip.placeholder)?;
Some(Inline { key, w, h, crop: props.crop, alt: props.alt })
}
Found::Shape { w, h, key, props } => Some(Inline { key: key?, w, h, crop: props.crop, alt: props.alt }),
}
}
@@ -109,12 +137,26 @@ impl Pictures {
return None;
}
let fbse = bstore.get((pib as usize).checked_sub(1)?)?;
let room = self.limit.saturating_sub(self.total);
let key = officeart::fbse_blip(fbse, delay, room).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder));
let key = self.decode_bse(fbse, delay);
self.by_bse.insert(pib, key.clone());
key
}
/// Decode a store entry's BLIP, once per delay-stream record however many entries name it.
fn decode_bse(&mut self, fbse: &[u8], delay: &[u8]) -> Option<String> {
let (rec, at) = officeart::fbse_record(fbse, delay)?;
let at = at.map(|(fo, used)| (delay.as_ptr() as usize, fo, used));
if let Some(hit) = at.and_then(|at| self.by_delay.get(&at)) {
return hit.clone();
}
let room = self.limit.saturating_sub(self.total);
let key = officeart::blip(&rec, room, &mut self.work).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder));
if let Some(at) = at {
self.by_delay.insert(at, key.clone());
}
key
}
/// Add picture bytes as a new media entry, within the byte cap.
fn insert(&mut self, bytes: Vec<u8>, ext: &str, placeholder: bool) -> Option<String> {
let total = self.total.checked_add(bytes.len()).filter(|&t| t <= self.limit)?;
@@ -151,7 +193,7 @@ struct Located {
/// `OfficeArtInlineSpContainer` (Word 97 and later) whose BLIP is decoded.
enum Found {
Range(Located),
Shape { w: f32, h: f32, blip: officeart::Blip, props: officeart::Props },
Shape { w: f32, h: f32, key: Option<String>, props: officeart::Props },
}
/// `PICF.mfpf.mm` values of pictures stored as a shape (`MM_SHAPE`) or a shape with a file
@@ -160,8 +202,8 @@ const MM_SHAPE: u16 = 0x0064;
const MM_SHAPEFILE: u16 = 0x0066;
/// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData);
/// a shape's picture is decoded only up to `room` bytes.
fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
/// the BLIP is decoded once per record and charged to the decode budget.
fn locate(data: &[u8], fc_pic: u32, pics: &mut Pictures) -> Option<Found> {
let base = fc_pic as usize;
let pic = data.get(base..)?;
let lcb = u32::from_le_bytes(pic.get(0..4)?.try_into().ok()?) as usize;
@@ -180,12 +222,12 @@ fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
if mm == MM_SHAPEFILE {
at = at.checked_add(1 + *pic.get(at)? as usize)?;
}
let (blip, props) = inline_shape(pic.get(at..end)?, data, room)?;
let (key, props) = inline_shape(pic.get(at..end)?, data, pics)?;
// The goal size is before cropping; the crop fractions take their share off it.
let [cl, ct, cr, cb] = props.crop;
let w = (dxa_goal / 20.0 * (1.0 - cl - cr).max(0.01) * scale(mx)).clamp(1.0, 1584.0);
let h = (dya_goal / 20.0 * (1.0 - ct - cb).max(0.01) * scale(my)).clamp(1.0, 1584.0);
return Some(Found::Shape { w, h, blip, props });
return Some(Found::Shape { w, h, key, props });
}
let w = (dxa_goal / 20.0 * scale(mx)).clamp(1.0, 1584.0);
let h = (dya_goal / 20.0 * scale(my)).clamp(1.0, 1584.0);
@@ -197,16 +239,16 @@ fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
}
/// An `OfficeArtInlineSpContainer`: the picture shape, then the `OfficeArtFBSE` records its
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). `room` caps the bytes
/// the BLIP decodes to.
fn inline_shape(buf: &[u8], data: &[u8], room: usize) -> Option<(officeart::Blip, officeart::Props)> {
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). The BLIP is decoded
/// once per record, however many pictures (and FBSE sizes) name it.
fn inline_shape(buf: &[u8], data: &[u8], pics: &mut Pictures) -> Option<(Option<String>, officeart::Props)> {
let mut budget = 10_000usize;
let recs = officeart::children(buf, &mut budget);
let sp = recs.iter().find(|r| r.typ == officeart::rt::SP_CONTAINER).map(|r| officeart::shape(r.body, &mut budget))?;
let fbses: Vec<&[u8]> = recs.iter().filter(|r| r.typ == officeart::rt::FBSE).map(|r| r.body).collect();
let fbse = sp.props.pib.and_then(|p| fbses.get((p as usize).checked_sub(1)?)).or(fbses.first())?;
let blip = officeart::fbse_blip(fbse, data, room)?;
Some((blip, sp.props))
let key = pics.decode_bse(fbse, data);
Some((key, sp.props))
}
/// Offset of the first embedded raster image inside the payload, skipping the small
+84 -24
View File
@@ -112,23 +112,65 @@ pub(crate) struct Blip {
/// The picture of an `OfficeArtFBSE` body: its embedded BLIP, or the BLIP stored at `foDelay`
/// in `delay` (the WordDocument stream for the document's BLIP store, the Data stream for
/// inline pictures). `limit` caps the bytes produced.
pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize) -> Option<Blip> {
#[cfg(test)]
pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize, work: &mut Decode) -> Option<Blip> {
let (rec, _) = fbse_record(fbse, delay)?;
blip(&rec, limit, work)
}
/// The document's decode budget: bytes of inflating and pixel decoding left, and how many
/// attempts it has refused (only the first few are logged).
pub(crate) struct Decode {
pub(crate) left: usize,
refused: usize,
}
impl Decode {
pub(crate) fn new(left: usize) -> Self {
Decode { left, refused: 0 }
}
/// Take `cost` off the budget; `false` (and a log line) when it doesn't fit.
fn charge(&mut self, cost: usize, what: &str) -> bool {
match self.left.checked_sub(cost) {
Some(left) if cost > 0 => {
self.left = left;
true
}
_ => {
if self.refused < 10 {
log::warn!(
"docbin: a {what} needing {cost} bytes of decode work is over the decode budget ({} left) and was not decoded",
self.left
);
}
self.refused = self.refused.saturating_add(1);
false
}
}
}
}
/// The BLIP record of an `OfficeArtFBSE`: embedded after it, or in the delay stream, where
/// its (`foDelay`, record length) location is returned too (entries can share one record,
/// whatever sizes their FBSEs claim: the record's own header is what counts).
pub(crate) fn fbse_record<'a>(fbse: &'a [u8], delay: &'a [u8]) -> Option<(Rec<'a>, Option<(usize, usize)>)> {
// btWin32, btMacOS, rgbUid[16], tag, size, cRef, foDelay, unused, cbName, unused×2.
let size = u32_at(fbse, 20)? as usize;
let fo_delay = u32_at(fbse, 28)? as usize;
let cb_name = *fbse.get(33)? as usize;
let at = 36usize.checked_add(cb_name)?;
if let Some((rec, _)) = fbse.get(at..).and_then(record) {
return blip(&rec, limit);
return Some((rec, None));
}
// Not embedded: the record sits in the delay stream.
let end = fo_delay.checked_add(size.max(8))?.min(delay.len());
let (rec, _) = record(delay.get(fo_delay..end)?)?;
blip(&rec, limit)
let (rec, used) = record(delay.get(fo_delay..)?)?;
Some((rec, Some((fo_delay, used))))
}
/// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31).
pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
/// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31). `work` is the
/// document's decode budget: inflating a metafile or decoding a DIB is charged to it before
/// it runs, whether it succeeds or not, and nothing is decoded once it is spent.
pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut Decode) -> Option<Blip> {
// Each type has two instance values; the odd one carries a second 16-byte UID.
let uids = if rec.inst & 1 == 1 { 32 } else { 16 };
match rec.typ {
@@ -141,7 +183,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
rt::BLIP_PNG => Some(Blip { bytes: data.to_vec(), ext: "png", placeholder: false }),
rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK => Some(Blip { bytes: data.to_vec(), ext: "jpeg", placeholder: false }),
rt::BLIP_TIFF => Some(Blip { bytes: data.to_vec(), ext: "tiff", placeholder: false }),
_ => dib_to_png(data, limit),
_ => dib_to_png(data, limit, work),
}
}
rt::BLIP_EMF | rt::BLIP_WMF | rt::BLIP_PICT => {
@@ -155,6 +197,11 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
let cap = limit.min(MAX_METAFILE);
let bytes = if compression == 0 {
let want = if cb_size > 0 { cb_size.min(cap) } else { cap };
// The compressed stream is read whole whatever it inflates to: a record that
// claims to inflate to nothing still costs its input (#3871).
if !work.charge(want.max(raw.len()), "metafile") {
return None;
}
miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, want).ok()?
} else {
if raw.len() > cap {
@@ -177,8 +224,9 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
/// A device-independent bitmap (BITMAPINFOHEADER + palette + pixels) as a PNG: the DIB gets
/// the 14-byte BMP file header it lacks, is decoded with size limits and re-encoded. When the
/// bitmap can't be decoded the BMP bytes are kept (and render as a grey placeholder).
fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
/// bitmap can't be decoded, or its declared pixels (4 bytes each) don't fit in `work`, the
/// BMP bytes are kept (and render as a grey placeholder).
fn dib_to_png(dib: &[u8], limit: usize, work: &mut Decode) -> Option<Blip> {
let header = u32_at(dib, 0)? as usize;
if !(12..=124).contains(&header) {
return None;
@@ -197,12 +245,30 @@ fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
let masks = if header == 40 && (compression == 3 || compression == 6) { if compression == 3 { 12 } else { 16 } } else { 0 };
let off_bits = 14usize.checked_add(header)?.checked_add(colors * entry)?.checked_add(masks)?;
let file_size = 14usize.checked_add(dib.len()).filter(|&size| size <= limit)?;
// The declared size is what decoding allocates, however small the (RLE) data is.
let (w, h) = if header == 12 {
(u64::from(u16_at(dib, 4)?), u64::from(u16_at(dib, 6)?))
} else {
(u64::from(u32_at(dib, 4)?), u64::from((u32_at(dib, 8)? as i32).unsigned_abs()))
};
let cost = w.saturating_mul(h).saturating_mul(4).try_into().unwrap_or(usize::MAX);
// Copying the DIB into a BMP is work too, however few pixels it declares: it is charged
// before the copy is made (#3871). A bitmap whose pixels don't fit still keeps its BMP
// bytes as a placeholder, if the copy fits.
let decode = work.charge(cost.max(dib.len()), "bitmap");
if !decode && !work.charge(dib.len(), "bitmap") {
return None;
}
let mut bmp = Vec::with_capacity(file_size);
bmp.extend_from_slice(b"BM");
bmp.extend_from_slice(&u32::try_from(file_size).ok()?.to_le_bytes());
bmp.extend_from_slice(&[0, 0, 0, 0]);
bmp.extend_from_slice(&u32::try_from(off_bits).ok()?.to_le_bytes());
bmp.extend_from_slice(dib);
let placeholder = |bmp| Some(Blip { bytes: bmp, ext: "bmp", placeholder: true });
if !decode {
return placeholder(bmp);
}
let mut reader = image::ImageReader::new(std::io::Cursor::new(&bmp));
reader.set_format(image::ImageFormat::Bmp);
let mut limits = image::Limits::default();
@@ -217,7 +283,7 @@ fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
{
Some(Blip { bytes: png.bytes, ext: "png", placeholder: false })
}
_ => Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }),
_ => placeholder(bmp),
}
}
@@ -311,21 +377,15 @@ impl Props {
}
0x0104 => self.pib = Some(op),
0x0181 => self.fill_color = color(op),
0x01BF => {
// fFilled (bit 4) is valid when fUsefFilled (bit 20) is set.
if op & (1 << 20) != 0 {
self.filled = Some(op & (1 << 4) != 0);
}
}
// fFilled (bit 4) is valid when fUsefFilled (bit 20) is set.
0x01BF if op & (1 << 20) != 0 => self.filled = Some(op & (1 << 4) != 0),
0x01BF => {}
0x01C0 => self.line_color = color(op),
0x01CB => self.line_width = Some(op),
0x01D0 | 0x01D1 => self.arrowhead |= op != 0,
0x01FF => {
// fLine (bit 3) is valid when fUsefLine (bit 19) is set.
if op & (1 << 19) != 0 {
self.line = Some(op & (1 << 3) != 0);
}
}
// fLine (bit 3) is valid when fUsefLine (bit 19) is set.
0x01FF if op & (1 << 19) != 0 => self.line = Some(op & (1 << 3) != 0),
0x01FF => {}
0x0384..=0x0387 => self.wrap_dist[(pid - 0x0384) as usize] = Some(op),
0x038F => self.posh = Some(op),
0x0390 => self.posrelh = Some(op),
+7 -1
View File
@@ -113,10 +113,16 @@ impl PieceTable {
}
PrmRef::Sprm0 { isprm, val: (p.prm >> 8) as u8 }
} else {
PrmRef::Grpprl(self.prc_grpprls.get((p.prm >> 1) as usize).map(|v| v.as_slice()))
PrmRef::Grpprl(self.prc_grpprls.get((p.prm >> 1) as usize).map(Vec::as_slice))
}
}
/// The raw Prm of the piece holding `cp` (0, no properties, when there is none): two CPs
/// with the same value get the same [`PrmRef`].
pub(crate) fn prm_of(&self, cp: u32) -> u16 {
self.piece_of(cp).map_or(0, |p| p.prm)
}
fn piece_of(&self, cp: u32) -> Option<&Pcd> {
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
if i >= self.pieces.len() {
+179 -4
View File
@@ -1732,9 +1732,9 @@ fn picture_bytes_are_capped_per_document() {
let data: Vec<u8> = [a.clone(), b].concat();
let mut pics = crate::media::Pictures::default();
pics.limit = 1000;
let first = pics.get(&data, 0).expect("first fits");
let first = pics.get(&data, 0).cloned().expect("first fits");
// Same location again: no new bytes.
assert_eq!(pics.get(&data, 0).map(|p| p.key), Some(first.key.clone()));
assert_eq!(pics.get(&data, 0).map(|p| p.key.clone()), Some(first.key.clone()));
// A second distinct picture would exceed the cap.
assert!(pics.get(&data, a.len() as u32).is_none());
assert_eq!(pics.media.len(), 1);
@@ -1754,8 +1754,8 @@ fn rejected_inline_pictures_are_not_read_again() {
assert!(pics.get(&small, 0).is_none());
// The rejected picture took nothing off the cap, and anchors sharing a picture share it.
let at = big.len() as u32;
let fits = pics.get(&data, at).expect("fits");
assert_eq!(pics.get(&data, at).map(|p| p.key), Some(fits.key));
let fits = pics.get(&data, at).cloned().expect("fits");
assert_eq!(pics.get(&data, at).map(|p| p.key.clone()), Some(fits.key));
assert_eq!(pics.media.len(), 1);
// Filling the cap exactly works; after that, a place never tried before isn't read.
let rest = picf_with_png(700);
@@ -2392,3 +2392,178 @@ fn paragraph_borders_read_from_both_forms() {
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 2.0));
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
}
/// #3642: a small compound file whose DIFAT names one FAT sector thousands of times made `cfb`
/// build a FAT of hundreds of megabytes before checking it. The precheck refuses it first.
#[test]
fn a_difat_repeating_one_fat_sector_is_refused_before_cfb_builds_the_fat() {
let f = wordcraft_doc::cfb_guard::repeating_difat_sample();
// The precheck's own error is the proof that it ran before cfb (no clock to skew).
assert!(matches!(read(&f), Err(super::DocbinError::Container(e)) if e.contains("FAT sectors in a file of")));
assert!(!super::is_encrypted(&f));
// A file cfb wrote itself passes.
assert!(wordcraft_doc::cfb_guard::check(&cfb_file(&[("WordDocument", &[0u8; 4096])])).is_ok());
}
/// A compound file `cfb` writes in `version`: `streams` at its root, then `extra` streams under
/// an `ObjectPool` storage (where Word keeps embedded objects).
fn cfb_file_in(version: cfb::Version, streams: &[(&str, &[u8])], extra: &[(&str, &[u8])]) -> Vec<u8> {
let mut comp = cfb::CompoundFile::create_with_version(version, std::io::Cursor::new(Vec::new())).expect("cfb create");
for (name, data) in streams {
comp.create_stream(format!("/{name}")).expect("cfb stream").write_all(data).expect("cfb write");
}
comp.create_storage("/ObjectPool").expect("cfb storage");
for (name, data) in extra {
comp.create_stream(format!("/ObjectPool/{name}")).expect("cfb stream").write_all(data).expect("cfb write");
}
comp.into_inner().into_inner()
}
/// #3642 review: valid compound files pass the guard and open, so it refuses only the hostile
/// ones. The repository keeps no files Word made, so these are written by the `cfb` crate in
/// the shape of Word 97–2003 files: the WordDocument and table streams of a .doc these tests
/// build, with the summary, Data and embedded-object streams Word adds, in 512-byte sectors
/// (version 3, Word's own) and 4096-byte ones (version 4); and one with a Data stream of 8 MB,
/// past what the header's 109 FAT sector slots map in 512-byte sectors (109 × 128 sectors,
/// about 7 MB), so its FAT sectors are listed in a DIFAT chain too, which the guard walks.
#[test]
fn valid_doc_shaped_compound_files_pass_the_guard_and_open() {
let doc = struct_doc(&structured_spec("Hello world\r", 12));
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(doc.as_slice())).expect("cfb open");
// The table stream is 1Table or 0Table, as the FIB's fWhichTblStm says: kept under its name.
let table_name = if comp.is_stream("/1Table") { "1Table" } else { "0Table" };
let mut stream = |name: &str| {
let mut v = Vec::new();
std::io::Read::read_to_end(&mut comp.open_stream(name).expect("stream"), &mut v).expect("read");
v
};
let (word, table) = (stream("/WordDocument"), stream(&format!("/{table_name}")));
let summary = vec![0xFEu8; 4096];
let data = |n: usize| (0..n).map(|i| (i % 251) as u8).collect::<Vec<u8>>();
let small = data(200_000);
let big = data(8 << 20);
let ole = data(70_000);
let files = [
("as built", doc.clone()),
(
"version 3",
cfb_file_in(
cfb::Version::V3,
&[("WordDocument", &word), (table_name, &table), ("Data", &small), ("\u{5}SummaryInformation", &summary)],
&[("_1234567890", &ole)],
),
),
("version 4", cfb_file_in(cfb::Version::V4, &[("WordDocument", &word), (table_name, &table), ("Data", &small)], &[("_1", &ole)])),
("with a DIFAT chain", cfb_file_in(cfb::Version::V3, &[("WordDocument", &word), (table_name, &table), ("Data", &big)], &[])),
];
let difat_sectors = |f: &[u8]| f.get(0x48..0x4C).map_or(0, |b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
assert!(difat_sectors(&files[3].1) > 0, "the 8 MB file lists FAT sectors past the header's");
for (what, f) in &files {
assert_eq!(wordcraft_doc::cfb_guard::check(f), Ok(()), "{what}");
let doc = read(f).unwrap_or_else(|e| panic!("{what}: {e}"));
assert_eq!(body_texts(&doc).first().map(String::as_str), Some("Hello world"), "{what}");
}
}
/// #3708: a long HYPERLINK target over a long result was copied (and compared) once per result
/// character. It is now copied once per run.
#[test]
fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
let n = 250_000;
let target = "x".repeat(n);
let text: &'static str = Box::leak(format!("\u{13}HYPERLINK \"http://{target}\"\u{14}{}\u{15}\r", "y".repeat(n)).into_boxed_str());
let spec = structured_spec(text, text.chars().count() as u32);
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let linked: Vec<_> = p0.runs.iter().filter(|r| r.props.link.is_some()).collect();
assert_eq!(linked.len(), 1);
assert_eq!(linked.first().map(|r| r.len), Some(n));
// One copy of the target for the one run, not one per character (~2.5 s here): counted by
// what the copies charge to the work budget, which a busy machine can't skew.
let result = vec![('y', wordcraft_doc::props::CharProps::default()); n];
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) };
let work = super::Work::new(u64::MAX);
super::finish_field(&mut super::ParaBuild::default(), f, &work);
assert_eq!(u64::MAX - work.left.get(), ("http://".len() + n) as u64, "one copy of the target");
}
/// #3708: a result whose formatting changes every character pays for a target copy per run
/// out of the work budget; past it the text stays, unlinked.
#[test]
fn hyperlink_target_copies_are_charged_to_the_work_budget() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let result: Vec<(char, CharProps)> = (0..1000).map(|i| ('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })).collect();
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) };
let mut pb = super::ParaBuild::default();
let budget = super::Work::new(10_000);
super::finish_field(&mut pb, f, &budget);
assert_eq!(pb.text.len(), 1000, "all text kept");
let linked: usize = pb.runs.iter().filter_map(|r| r.props.link.as_ref()).map(|l| l.len()).sum();
assert!(linked <= 10_000, "linked bytes {linked}");
assert_eq!(pb.runs.iter().filter(|r| r.props.link.is_some()).count(), 100);
assert_eq!(budget.left.get(), 0);
assert_eq!(budget.dropped(), [super::Cost::LinkTarget], "the unlinked text is noted, to be logged");
}
/// #3871 review: what the work budget leaves out is noted once per kind, in a fixed order, and
/// logged once per kind when the document has been read; nothing is noted while it lasts.
#[test]
fn the_work_budget_notes_each_kind_it_leaves_out_once() {
use super::Cost::*;
let work = super::Work::new(10);
assert!(work.charge(10, AltText));
assert!(work.dropped().is_empty(), "spent to the last byte, nothing left out");
assert!(work.charge(0, LinkTarget), "a free copy is never refused");
for _ in 0..3 {
assert!(!work.charge(1, FloatingObject));
}
assert!(!work.charge(1, AltText));
assert_eq!(work.dropped(), [AltText, FloatingObject]);
work.log();
assert_eq!(super::LAST_DROPPED.with(|d| d.borrow().clone()), [AltText, FloatingObject]);
// A document within the budget leaves nothing out.
read(&struct_doc(&structured_spec("Hello\r", 6))).expect("opens");
assert!(super::LAST_DROPPED.with(|d| d.borrow().is_empty()));
}
/// #3692: a piece's Prm grpprl (a Prc of up to 64 KB) was re-interpreted for every character.
/// It is now resolved once per formatting change.
#[test]
fn a_large_prc_is_interpreted_once_per_formatting_change() {
let prc = grpprl(&vec![(0x0835u16, &[0x01u8][..]); 21_000]);
let text: &'static str = Box::leak(format!("{}\r", "a".repeat(100_000)).into_boxed_str());
let spec = RichSpec {
text,
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
fonts: vec!["Times New Roman"],
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, Vec::new())],
};
let bytes = rich_doc_with(&spec, &RichExtra { prcs: vec![prc], prm: 0x0001, ..Default::default() });
let t = std::time::Instant::now();
let doc = read(&bytes).expect("opens");
// About 60 ms on a loaded machine; interpreting the Prc per character took 88 s.
assert!(t.elapsed().as_secs() < 10, "opened in {:?}", t.elapsed());
let p = para0(&doc);
assert_eq!(p.runs.len(), 1);
assert_eq!(p.runs.first().and_then(|r| r.props.bold), Some(true));
}
/// #3692: Prm grpprls are charged to a document-wide work budget; once it is spent the Prm
/// is skipped (the text keeps its CHPX formatting).
#[test]
fn prm_grpprls_past_the_work_budget_are_skipped() {
use wordcraft_doc::props::CharProps;
let g = grpprl(&[(0x0835, &[0x01])]);
let work = super::Work::new(g.len() as u64 + 1);
let base = CharProps::default();
let mut first = CharProps::default();
super::apply_prm(&mut first, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work);
assert_eq!(first.bold, Some(true));
let mut second = CharProps::default();
super::apply_prm(&mut second, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work);
assert_eq!(second.bold, None);
assert_eq!(work.left.get(), 1);
assert_eq!(work.dropped(), [super::Cost::Formatting]);
}
+290 -7
View File
@@ -247,7 +247,7 @@ fn dib_becomes_png_and_compressed_metafiles_inflate() {
dib.extend_from_slice(&[0, 0, 255, 0, 255, 0, 0, 0]);
let r = rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat());
let (dib_rec, _) = officeart::record(&r).unwrap();
let blip = officeart::blip(&dib_rec, 1 << 20).expect("DIB decodes");
let blip = officeart::blip(&dib_rec, 1 << 20, &mut officeart::Decode::new(usize::MAX)).expect("DIB decodes");
assert_eq!(blip.ext, "png");
let img = image::load_from_memory(&blip.bytes).unwrap().to_rgb8();
assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (2, 1, [255, 0, 0]));
@@ -262,7 +262,7 @@ fn dib_becomes_png_and_compressed_metafiles_inflate() {
hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter
let r = rec(0, 0x216, 0xF01B, &[[0u8; 16].as_slice(), &hdr, &packed].concat());
let (wmf_rec, _) = officeart::record(&r).unwrap();
let blip = officeart::blip(&wmf_rec, 1 << 20).expect("WMF inflates");
let blip = officeart::blip(&wmf_rec, 1 << 20, &mut officeart::Decode::new(usize::MAX)).expect("WMF inflates");
assert_eq!((blip.ext, blip.bytes), ("wmf", wmf));
}
@@ -281,10 +281,10 @@ fn deeply_nested_groups_and_lying_lengths_are_bounded() {
hdr.extend_from_slice(&[0, 0]);
let r = rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, &[0x78, 0x9C, 1, 2, 3]].concat());
let (emf, _) = officeart::record(&r).unwrap();
assert!(officeart::blip(&emf, 1 << 20).is_none());
assert!(officeart::blip(&emf, 1 << 20, &mut officeart::Decode::new(usize::MAX)).is_none());
let mut fbse = vec![0u8; 36];
fbse[28..32].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20).is_none());
assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20, &mut officeart::Decode::new(usize::MAX)).is_none());
}
proptest! {
@@ -322,13 +322,296 @@ fn dib_conversion_caps_header_and_encoded_bytes() {
let record = rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat());
let (record, _) = officeart::record(&record).unwrap();
let bmp_size = 14 + dib.len();
assert!(officeart::blip(&record, bmp_size - 1).is_none(), "the BMP header also needs room");
let fallback = officeart::blip(&record, bmp_size).expect("the BMP fallback fits exactly");
assert!(officeart::blip(&record, bmp_size - 1, &mut officeart::Decode::new(usize::MAX)).is_none(), "the BMP header also needs room");
let fallback = officeart::blip(&record, bmp_size, &mut officeart::Decode::new(usize::MAX)).expect("the BMP fallback fits exactly");
assert_eq!((fallback.ext, fallback.placeholder, fallback.bytes.len()), ("bmp", true, bmp_size));
let img = image::load_from_memory(&fallback.bytes).unwrap().to_rgb8();
assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (1, 1, [90, 60, 30]));
let normal = officeart::blip(&record, 512).expect("normal conversion fits");
let normal = officeart::blip(&record, 512, &mut officeart::Decode::new(usize::MAX)).expect("normal conversion fits");
assert_eq!((normal.ext, normal.placeholder), ("png", false));
assert!(normal.bytes.len() > bmp_size && normal.bytes.len() <= 512);
assert_eq!(image::load_from_memory(&normal.bytes).unwrap().width(), 1);
}
/// #3709: every anchor of one inline picture got its own copy of the alt text (up to 4096
/// UTF-16 units). Copies are charged to the document's work budget; past it alt is empty.
#[test]
fn alt_text_copies_across_many_anchors_are_bounded() {
let alt: String = "€".repeat(4096); // 12 KB as UTF-8
let shape = sp(&[fsp(75, 1025, 0xA00), fopt(&[(0x0104, 1)], &[(0x0381, wz(&alt))])]);
let data = picf_shape(&[shape, fbse_png(&tiny_png())].concat());
let text: &'static str = Box::leak(format!("{}\r", "\u{1}".repeat(8000)).into_boxed_str());
let ccp = text.chars().count() as u32;
let spec = StructSpec { text, data_stream: data, chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))], ..structured_spec(text, ccp) };
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let alts: Vec<&str> = p0.objects.iter().filter_map(|o| if let InlineObject::Image { alt, .. } = o { Some(alt.as_str()) } else { None }).collect();
assert_eq!(alts.len(), 8000, "every anchor keeps its picture");
assert_eq!(alts.first().copied(), Some(alt.as_str()));
let total: usize = alts.iter().map(|a| a.len()).sum();
assert!(total <= 64 << 20, "alt bytes {total}");
assert_eq!(alts.last().copied(), Some(""));
// Leaving it out is logged, once (#3871 review).
assert_eq!(crate::LAST_DROPPED.with(|d| d.borrow().clone()), [crate::Cost::AltText]);
}
/// #3710: every PlcSpa entry naming a group built another copy of all its members. A shape is
/// anchored once; repeated spids are skipped.
#[test]
fn a_group_named_by_many_anchors_is_built_once() {
let rect = |l: i32| rec(0, 0, 0xF00F, &[l, 0, l + 10, 10].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>());
let head = sp(&[fsp(0, 3000, 0x201), rec(1, 0, 0xF009, &[0i32, 0, 10_000, 10].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>())]);
let members: Vec<Vec<u8>> = (0..1000).map(|i| sp(&[fsp(1, 4000 + i as u32, 0xA02), rect(i * 10)])).collect();
let group = container(0xF003, &[vec![head], members].concat());
let art = art_content(&[], &[group]);
let n = 5000u32;
let cps: Vec<u32> = (1..=n + 1).collect();
let spas: Vec<u8> = (0..n).flat_map(|_| spa(3000, [0, 0, 1440, 1440], spa_flags(0, 0, 3, false))).collect();
let text = "A\u{8}\u{8}\u{8}B\r";
let ccp = text.chars().count() as u32;
let spec = StructSpec { text, extra_pairs: vec![(40, plc(&cps, &spas)), (50, art)], ..structured_spec(text, ccp) };
let t = std::time::Instant::now();
let doc = read(&struct_doc(&spec)).expect("opens");
// About 10 ms on a loaded machine, so 10 s is a backstop; the old code (a copy per entry,
// 6-8 s here) is caught by the group count below, one group per anchor ([1000; 3]).
assert!(t.elapsed().as_secs() < 10, "opened in {:?}", t.elapsed());
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let groups: Vec<usize> =
p0.objects.iter().filter_map(|o| if let InlineObject::Group { children, .. } = o { Some(children.len()) } else { None }).collect();
assert_eq!(groups, [1000]);
}
/// #3710: a floating picture's copy is charged its alt text too, and a group every member's.
#[test]
fn a_floating_copy_is_charged_its_alt_text() {
let image = |alt: String| InlineObject::Image {
media: String::new(),
w: 1.0,
h: 1.0,
alt,
float: Default::default(),
crop: Default::default(),
ole: None,
};
let bare = crate::object_cost(&image(String::new()));
assert_eq!(crate::object_cost(&image("€".repeat(4096))), bare + 3 * 4096);
let member = |obj| wordcraft_doc::para::GroupChild { x: 0.0, y: 0.0, obj };
let group = InlineObject::Group {
w: 1.0,
h: 1.0,
float: Default::default(),
ch_w: 1.0,
ch_h: 1.0,
children: vec![member(image("x".repeat(100))), member(image(String::new()))],
canvas: None,
};
assert_eq!(crate::object_cost(&group), 3 * bare + 100);
}
/// A deflated WMF BLIP record declaring `size` uncompressed bytes.
fn packed_wmf(size: u32, packed: &[u8]) -> Vec<u8> {
let mut hdr = size.to_le_bytes().to_vec();
hdr.extend_from_slice(&[0u8; 24]); // rcBounds, ptSize
hdr.extend_from_slice(&(packed.len() as u32).to_le_bytes());
hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter
rec(0, 0x216, officeart::rt::BLIP_WMF, &[[0u8; 16].as_slice(), &hdr, packed].concat())
}
/// An `OfficeArtFBSE` body whose BLIP (of `size` bytes) sits at `fo_delay` in the delay stream.
fn delayed_fbse(size: usize, fo_delay: usize) -> Vec<u8> {
let mut fbse = vec![0u8; 36];
fbse[20..24].copy_from_slice(&(size as u32).to_le_bytes());
fbse[28..32].copy_from_slice(&(fo_delay as u32).to_le_bytes());
fbse
}
/// #3711: store entries aliasing one delay-stream metafile each inflated (and copied) it again.
/// Now the record is decoded once and every entry shares its media key.
#[test]
fn store_entries_aliasing_one_delayed_metafile_decode_it_once() {
let wmf = vec![0u8; 1 << 20];
let delay = packed_wmf(wmf.len() as u32, &miniz_oxide::deflate::compress_to_vec_zlib(&wmf, 6));
let fbse = delayed_fbse(delay.len(), 0);
let store: Vec<&[u8]> = vec![&fbse; 5_000];
let mut pics = crate::media::Pictures::default();
pics.work = officeart::Decode::new(8 << 20);
let keys: Vec<_> = (1..=store.len() as u32).map(|pib| pics.bse(pib, &store, &delay)).collect();
assert!(keys.iter().all(|k| k.is_some() && *k == keys[0]), "every entry shares one picture");
assert_eq!(pics.work.left, (8 << 20) - wmf.len(), "inflated once");
}
/// #3711: distinct metafiles that fail to inflate are charged their declared size anyway, so
/// a document of them stops decoding once the work budget is spent.
#[test]
fn failing_metafile_inflations_spend_the_decode_budget() {
let records: Vec<Vec<u8>> = (0..10u8).map(|i| packed_wmf(1 << 20, &[0x78, 0x9C, i, 1, 2, 3])).collect();
let delay = records.concat();
let mut at = 0;
let fbses: Vec<Vec<u8>> = records
.iter()
.map(|r| {
at += r.len();
delayed_fbse(r.len(), at - r.len())
})
.collect();
let store: Vec<&[u8]> = fbses.iter().map(Vec::as_slice).collect();
let mut pics = crate::media::Pictures::default();
pics.work = officeart::Decode::new(3 << 20);
for pib in 1..=store.len() as u32 {
assert!(pics.bse(pib, &store, &delay).is_none());
}
assert_eq!(pics.work.left, 0, "three attempts spent the budget; the rest weren't tried");
}
/// #3712: a DIB's declared pixels are charged before decoding (an RLE bitmap is tiny on disk but
/// decodes to w × h × 4 bytes); past the budget it stays a BMP placeholder, undecoded.
#[test]
fn dib_decoding_is_charged_its_declared_pixels() {
let blip_of = |dib: &[u8]| rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], dib].concat());
let one = blip_of(&one_pixel_dib());
let (one, _) = officeart::record(&one).unwrap();
let mut work = officeart::Decode::new(3);
assert!(officeart::blip(&one, 512, &mut work).is_none(), "not even the copy fits: nothing is copied");
assert_eq!(work.left, 3, "nothing decoded, nothing charged");
let dib_len = one.body.len() - 17;
let mut work = officeart::Decode::new(dib_len);
assert_eq!(officeart::blip(&one, 512, &mut work).map(|b| (b.ext, b.placeholder)), Some(("png", false)));
assert_eq!(work.left, 0, "the copy is charged, not just 4 bytes a pixel");
// 8-bit RLE declaring 16384 × 16384 (1 GiB decoded) in a few bytes.
let mut big = [40u32, 16_384, 16_384].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>();
big.extend_from_slice(&1u16.to_le_bytes());
big.extend_from_slice(&8u16.to_le_bytes());
big.extend_from_slice(&1u32.to_le_bytes()); // BI_RLE8
big.extend_from_slice(&[0u8; 12]);
big.extend_from_slice(&1u32.to_le_bytes()); // one palette colour
big.extend_from_slice(&[0u8; 4]); // clrImportant
big.extend_from_slice(&[0u8; 4]); // the palette colour
big.extend_from_slice(&[0, 0, 0, 1]); // end of line, end of bitmap
let big = blip_of(&big);
let (big, _) = officeart::record(&big).unwrap();
let mut work = officeart::Decode::new(512 << 20);
assert_eq!(officeart::blip(&big, 1 << 20, &mut work).map(|b| (b.ext, b.placeholder)), Some(("bmp", true)));
assert_eq!(work.left, (512 << 20) - (big.body.len() - 17), "only the copy of the DIB was charged, not its declared pixels");
}
/// An EMF `OfficeArtBlip` record (compressed) with `raw` as its stream, claiming `cb_size`.
fn emf_blip(cb_size: u32, raw: &[u8]) -> Vec<u8> {
let mut hdr = vec![0u8; 34];
hdr[0..4].copy_from_slice(&cb_size.to_le_bytes());
hdr[28..32].copy_from_slice(&(raw.len() as u32).to_le_bytes());
hdr[32] = 0; // compressed
hdr[33] = 0xFE;
rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, raw].concat())
}
/// #3871 review: the zlib input is charged to the decode budget, not just what it inflates to.
#[test]
fn compressed_metafile_input_is_charged_to_the_decode_budget() {
let raw = vec![0x5Au8; 1 << 20]; // not even zlib: costs its input all the same
let r = emf_blip(1, &raw);
let (emf, _) = officeart::record(&r).unwrap();
let mut work = officeart::Decode::new(10 << 20);
assert!(officeart::blip(&emf, 64 << 20, &mut work).is_none());
assert_eq!(work.left, (10 << 20) - raw.len(), "1 MB of input charged, however little it claims to inflate to");
// Ten more of them exhaust the budget; none is inflated past it.
let mut work = officeart::Decode::new(3 << 20);
for _ in 0..10 {
let _ = officeart::blip(&emf, 64 << 20, &mut work);
}
assert_eq!(work.left, 0, "three attempts fit, the rest are refused");
}
/// An inline `PICFAndOfficeArtData` (shape, FBSE) whose BLIP sits at `fo_delay` in Data; the
/// FBSE claims `size` bytes.
fn inline_pic(fo_delay: u32, size: u32) -> Vec<u8> {
let mut fbse = vec![0u8; 36];
fbse[20..24].copy_from_slice(&size.to_le_bytes());
fbse[28..32].copy_from_slice(&fo_delay.to_le_bytes());
let body = [container(0xF004, &[]), rec(2, 6, 0xF007, &fbse)].concat();
let mut picf = vec![0u8; 0x44];
picf[0..4].copy_from_slice(&((0x44 + body.len()) as u32).to_le_bytes());
picf[4..6].copy_from_slice(&0x44u16.to_le_bytes());
picf[6..8].copy_from_slice(&0x64u16.to_le_bytes()); // MM_SHAPE
picf[28..30].copy_from_slice(&1440i16.to_le_bytes());
picf[30..32].copy_from_slice(&1440i16.to_le_bytes());
[picf, body].concat()
}
/// #3871 review: inline pictures that alias one BLIP record share one decode, whatever sizes
/// their FBSEs claim and wherever their PICFs sit.
#[test]
fn inline_pictures_aliasing_one_blip_record_are_decoded_once() {
let blip = emf_blip(1, &vec![0x5Au8; 64 << 10]);
let a = inline_pic(0, 70_000);
let b = inline_pic(0, 80_000);
let c = inline_pic(0, 90_000);
let blip_at = (a.len() + b.len() + c.len()) as u32;
let (a, b, c) = (inline_pic(blip_at, 70_000), inline_pic(blip_at, 80_000), inline_pic(blip_at, 90_000));
let data = [a.clone(), b.clone(), c, blip].concat();
let mut pics = crate::media::Pictures::default();
let start = pics.work.left;
let _ = pics.get(&data, 0);
let _ = pics.get(&data, a.len() as u32);
let _ = pics.get(&data, (a.len() + b.len()) as u32);
assert_eq!(start - pics.work.left, 64 << 10, "one record, one charge");
}
/// A 1-bit-per-pixel DIB of `side`×`side` pixels with `bytes` of pixel data.
fn dib_1bpp(side: u32, bytes: usize) -> Vec<u8> {
let mut d = vec![0u8; 40];
d[0..4].copy_from_slice(&40u32.to_le_bytes());
d[4..8].copy_from_slice(&side.to_le_bytes());
d[8..12].copy_from_slice(&side.to_le_bytes());
d[12..14].copy_from_slice(&1u16.to_le_bytes());
d[14..16].copy_from_slice(&1u16.to_le_bytes());
d.extend_from_slice(&[0, 0, 0, 0, 255, 255, 255, 0]); // palette
d.extend(std::iter::repeat_n(0u8, bytes));
d
}
fn dib_record(dib: &[u8]) -> Vec<u8> {
rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], dib].concat())
}
/// #3871 review: the decode budget scales with the input, so a document of many legitimate
/// large scans decodes them all; a high-ratio set is still capped.
#[test]
fn the_decode_budget_scales_with_the_input_but_still_caps_bombs() {
let floor = 1 << 20;
// 20 legitimate 1024² 1-bit scans: 4 MB charged each, 128 KB of file each.
let real = dib_record(&dib_1bpp(1024, 1024 * 1024 / 8));
let (rec_real, _) = officeart::record(&real).unwrap();
let input = 20 * real.len();
let mut work = officeart::Decode::new(crate::media::decode_work(input, floor));
for i in 0..20 {
let b = officeart::blip(&rec_real, 64 << 20, &mut work).unwrap();
assert!(!b.placeholder, "scan {i} decodes");
}
// Declared 20000² (1.6 GB each) in a few dozen bytes each: a placeholder, never decoded.
let bomb = dib_record(&dib_1bpp(20_000, 16));
let (rec_bomb, _) = officeart::record(&bomb).unwrap();
let mut work = officeart::Decode::new(crate::media::decode_work(20 * bomb.len(), floor));
for _ in 0..20 {
assert!(officeart::blip(&rec_bomb, 64 << 20, &mut work).unwrap().placeholder);
}
assert!(work.left <= crate::media::decode_work(20 * bomb.len(), floor), "nothing was refunded or invented");
}
/// #3871 review: the copy of a DIB into a BMP is charged before it is made, however few pixels
/// the header declares; with the budget spent nothing is copied at all.
#[test]
fn a_dib_with_a_large_tail_is_charged_for_its_copy() {
let big = dib_record(&dib_1bpp(1, 1 << 20)); // 1 pixel declared, 1 MB of trailing bytes
let (big, _) = officeart::record(&big).unwrap();
let dib_len = big.body.len() - 17;
let mut work = officeart::Decode::new(10 << 20);
assert!(!officeart::blip(&big, 64 << 20, &mut work).unwrap().placeholder);
assert_eq!(10 << 20, work.left + dib_len, "charged for the bytes, not just 4 per pixel");
// Room for the copy: decoded. Not even that: nothing is copied.
let mut work = officeart::Decode::new(dib_len + 1);
assert!(officeart::blip(&big, 64 << 20, &mut work).is_some());
let mut work = officeart::Decode::new(dib_len - 1);
assert!(officeart::blip(&big, 64 << 20, &mut work).is_none());
assert_eq!(work.left, dib_len - 1);
}
+3
View File
@@ -199,6 +199,9 @@ fn unsupported_variants_say_so() {
let (h, k) = capi_header("pw", 128, hflags);
let e = read_with_password(&rc4_file_with(h, &*k), Some("pw")).unwrap_err();
assert!(matches!(e, DocbinError::Encryption(_)), "{e:?}");
if hflags & 0x20 != 0 {
assert!(e.to_string().contains("only defined for the newer encrypted-package format"), "{e}");
}
}
let (mut h, k) = rc4_header("pw");
h[0] = 9;
+1
View File
@@ -31,6 +31,7 @@ zeroize = { workspace = true }
getrandom = { workspace = true, features = ["wasm_js"] }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
proptest = { workspace = true }
image = { workspace = true }
zip = { workspace = true }
+56 -3
View File
@@ -6,7 +6,7 @@
//! [`spec_of`] turns a chart part back into the model only when writing that model again gives
//! the same markup, so a chart with anything the model leaves out stays as it was read.
use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, LegendPos};
use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, LegendPos, MAX_POINTS, MAX_SERIES};
use wordcraft_doc::graphic::GraphicItem;
use wordcraft_doc::props::Rgb;
@@ -23,9 +23,21 @@ pub(crate) const CHART_URI: &str = "http://schemas.openxmlformats.org/drawingml/
const CAT_AX: &str = "500000001";
const VAL_AX: &str = "500000002";
/// The chart part for `spec`: a complete XML document. `spec` should be sanitized
/// ([`ChartSpec::sanitize`]); a value that isn't finite is written as a gap either way.
/// The chart part for `spec`: a complete XML document, written as [`ChartSpec::sanitize`] leaves
/// it (a value that isn't finite is a gap).
pub fn chart_xml(spec: &ChartSpec) -> String {
chart_part(spec).0
}
/// [`chart_xml`], and whether the chart limits left series or points of `spec` out of it.
pub(crate) fn chart_part(spec: &ChartSpec) -> (String, bool) {
let kept = bounded(spec);
let points = spec.series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(spec.categories.len());
let cut = kept.series.len() < spec.series.len() || kept.points() < points;
(write_chart(&kept), cut)
}
fn write_chart(spec: &ChartSpec) -> String {
let mut w = W::new();
w.open("c:chartSpace", &[("xmlns:c", NS_C), ("xmlns:a", NS_A), ("xmlns:r", NS_R)]);
w.empty("c:roundedCorners", &[("val", "0")]);
@@ -79,6 +91,26 @@ pub fn chart_xml(spec: &ChartSpec) -> String {
w.s
}
/// `spec` held to the chart limits. A model deserialised as is (a `.json` document) never went
/// through [`ChartSpec::sanitize`], and every series repeats every category, so its size would
/// multiply (#3648). Only what the limits keep is copied before sanitizing.
fn bounded(spec: &ChartSpec) -> ChartSpec {
ChartSpec {
kind: spec.kind,
categories: spec.categories.iter().take(MAX_POINTS).cloned().collect(),
series: spec
.series
.iter()
.take(MAX_SERIES)
.map(|s| ChartSeries { name: s.name.clone(), values: s.values.iter().take(MAX_POINTS).copied().collect() })
.collect(),
title: spec.title.clone(),
legend: spec.legend,
data_labels: spec.data_labels,
}
.sanitized()
}
/// What `spec` draws as, `w` × `h` points, in the colours of `theme` (series take the accents in
/// turn). Empty when the chart has no series.
pub fn chart_items(spec: &ChartSpec, theme: &[Rgb], w: f32, h: f32) -> Vec<GraphicItem> {
@@ -421,6 +453,27 @@ mod tests {
e.els().map(|c| c.name.as_str()).collect()
}
#[test]
fn a_chart_past_the_limits_is_written_within_them() {
// A model deserialised as is (a `.json` document) never went through `sanitize`, and every
// series repeats every category: 300 series of 4,500 points wrote 2.7 million points (#3648).
use wordcraft_doc::chart::MAX_CELLS;
let spec = ChartSpec {
kind: ChartType::Column,
categories: (0..4500).map(|i| i.to_string()).collect(),
series: (0..300).map(|i| ChartSeries { name: format!("S{i}"), values: vec![Some(1.0); 4500] }).collect(),
title: None,
legend: None,
data_labels: false,
};
let x = chart_xml(&spec);
let series = x.matches("<c:ser>").count();
let points = x.matches("<c:pt ").count();
assert!((1..=MAX_SERIES).contains(&series), "{series} series");
assert!(points <= 2 * MAX_CELLS, "{points} points");
assert!(!x.contains(&format!("<c:pt idx=\"{MAX_POINTS}\"")), "past the point limit");
}
#[test]
fn every_type_reads_back_as_itself() {
for kind in ChartType::ALL {
+369 -2
View File
@@ -9,6 +9,10 @@
//! a verifier that tells a wrong password apart; an HMAC over the encrypted stream checks that it
//! hasn't been damaged or tampered with.
//!
//! Older packages (Word 2007) use the *Standard* method (§2.3.4.5–§2.3.4.9): a binary
//! `EncryptionInfo`, one AES key derived from the password with SHA-1 and 50,000 spins, and the
//! whole package AES-ECB encrypted. [`decrypt`] reads both; [`encrypt`] writes agile only.
//!
//! Files are hostile: every stream read is capped, every length is checked before slicing, and the
//! spin count is bounded by the specification's maximum. Passwords and keys are wiped from memory
//! when dropped ([`Zeroizing`]) and never logged.
@@ -47,6 +51,20 @@ const BK_KEY_VALUE: [u8; 8] = [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6];
const BK_HMAC_KEY: [u8; 8] = [0x5f, 0xb2, 0xad, 0x01, 0x0c, 0xb9, 0xe1, 0xf6];
const BK_HMAC_VALUE: [u8; 8] = [0xa0, 0x67, 0x7f, 0x02, 0xb2, 0x2c, 0x84, 0x33];
/// Standard encryption (§2.3.2, §2.3.4.5–§2.3.4.7): `EncryptionHeader.Flags` bits, the AES and
/// SHA-1 algorithm ids, the provider type, the fixed spin count and the SHA-1 size.
const F_CRYPTO_API: u32 = 0x04;
const F_EXTERNAL: u32 = 0x10;
const F_AES: u32 = 0x20;
const STANDARD_FLAGS: u32 = F_CRYPTO_API | F_AES;
const ALG_AES_128: u32 = 0x660E;
const ALG_AES_192: u32 = 0x660F;
const ALG_AES_256: u32 = 0x6610;
const ALG_SHA1: u32 = 0x8004;
const PROV_RSA_AES: u32 = 0x18;
const STANDARD_SPIN_COUNT: u32 = 50_000;
const SHA1_SIZE: usize = 20;
/// An OLE compound file starts with these bytes ([MS-CFB] §2.2).
const CFB_MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
@@ -56,7 +74,7 @@ const NS_PASSWORD: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor
/// Is this an encrypted Office package (a compound file with `EncryptionInfo` and
/// `EncryptedPackage` streams) rather than a zip?
pub fn is_encrypted(bytes: &[u8]) -> bool {
if bytes.get(..8) != Some(&CFB_MAGIC[..]) {
if bytes.get(..8) != Some(&CFB_MAGIC[..]) || wordcraft_doc::cfb_guard::check(bytes).is_err() {
return false;
}
match cfb::CompoundFile::open(Cursor::new(bytes)) {
@@ -69,9 +87,13 @@ pub fn is_encrypted(bytes: &[u8]) -> bool {
/// [`DocxError::PasswordRequired`] once it knows the file is one it can decrypt; a password that
/// doesn't fit gives [`DocxError::WrongPassword`].
pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxError> {
wordcraft_doc::cfb_guard::check(bytes).map_err(|e| bad(format!("not a readable compound file ({e})")))?;
let mut comp = cfb::CompoundFile::open(Cursor::new(bytes)).map_err(|e| bad(format!("not a readable compound file ({e})")))?;
let info = stream(&mut comp, "/EncryptionInfo", MAX_INFO)?;
let package = stream(&mut comp, "/EncryptedPackage", bytes.len() as u64)?;
if matches!(info.get(..4), Some([2..=4, 0, 2, 0])) {
return decrypt_standard(&info, &package, password);
}
let info = parse_info(&info)?;
let Some(password) = password else { return Err(DocxError::PasswordRequired) };
let pe = &info.password;
@@ -125,6 +147,92 @@ pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxErro
Ok(out)
}
/// [`decrypt`] for Standard encryption (§2.3.4.5–§2.3.4.9): one AES key from the password,
/// checked with the verifier, and the whole package AES-ECB encrypted after its 8-byte size.
fn decrypt_standard(info: &[u8], package: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxError> {
let s = parse_standard(info)?;
let Some(password) = password else { return Err(DocxError::PasswordRequired) };
let key = standard_key(s.salt, password, s.key_bytes);
// The verifier (§2.3.4.9): a random value and its SHA-1, both encrypted with the key.
let verifier = Zeroizing::new(ecb(&key, s.verifier, false)?);
let hash = Zeroizing::new(ecb(&key, s.verifier_hash, false)?);
let hash = hash.get(..SHA1_SIZE).ok_or_else(|| bad("the password verifier hash is too short"))?;
if !same(&Hash::Sha1.hash(&[&verifier]), hash) {
return Err(DocxError::WrongPassword);
}
// The package (§2.3.4.4): StreamSize, then the data; bytes past the last block are ignored.
let size = package
.get(..8)
.and_then(|b| <[u8; 8]>::try_from(b).ok())
.map(u64::from_le_bytes)
.ok_or_else(|| bad("the encrypted package is truncated"))?;
let data = package.get(8..).unwrap_or(&[]);
let size = usize::try_from(size).ok().filter(|s| *s <= data.len()).ok_or_else(|| bad("the encrypted package is truncated"))?;
let needed = size.div_ceil(BLOCK).saturating_mul(BLOCK);
let data = data.get(..needed).ok_or_else(|| bad("the encrypted package is truncated"))?;
let mut out = ecb(&key, data, false)?;
out.truncate(size);
Ok(out)
}
/// What a Standard `EncryptionInfo` stream says (§2.3.4.5): the key size, and the verifier's salt
/// and encrypted values.
struct Standard<'a> {
key_bytes: usize,
salt: &'a [u8],
verifier: &'a [u8],
verifier_hash: &'a [u8],
}
fn parse_standard(info: &[u8]) -> Result<Standard<'_>, DocxError> {
let short = || bad("EncryptionInfo is truncated");
let u32_at = |at: usize| -> Result<u32, DocxError> {
let b = at.checked_add(4).and_then(|end| info.get(at..end)).ok_or_else(short)?;
<[u8; 4]>::try_from(b).map(u32::from_le_bytes).map_err(|_| short())
};
let flags = u32_at(4)?;
if flags & F_EXTERNAL != 0 {
return Err(bad("this file uses extensible encryption, which WordCraft can't open"));
}
if flags & STANDARD_FLAGS != STANDARD_FLAGS {
return Err(bad("this file's encryption isn't AES, which WordCraft can't open"));
}
// EncryptionHeader (§2.3.2): Flags, SizeExtra, AlgID, AlgIDHash, KeySize, ProviderType, two
// reserved words, then the CSP name, which isn't needed.
let header_size = usize::try_from(u32_at(8)?).map_err(|_| short())?;
if header_size < 32 {
return Err(bad("the encryption header is too short"));
}
let verifier_at = header_size.checked_add(12).filter(|&v| v <= info.len()).ok_or_else(short)?;
// AlgID 0 and AlgIDHash 0 mean "set by the flags": AES-128 and SHA-1.
let bits = match u32_at(20)? {
0 | ALG_AES_128 => 128,
ALG_AES_192 => 192,
ALG_AES_256 => 256,
id => return Err(bad(format!("encryption algorithm 0x{id:X} isn't supported"))),
};
let hash_id = u32_at(24)?;
if !matches!(hash_id, 0 | ALG_SHA1) {
return Err(bad(format!("hash algorithm 0x{hash_id:X} isn't supported")));
}
if u32_at(28)? != bits {
return Err(bad("the key size doesn't match the encryption algorithm"));
}
// EncryptionVerifier (§2.3.3): SaltSize (16), Salt, EncryptedVerifier, VerifierHashSize (20
// for SHA-1), EncryptedVerifierHash (32 bytes for AES).
if u32_at(verifier_at)? != SALT as u32 {
return Err(bad("the salt isn't 16 bytes"));
}
let field = |at: usize, len: usize| verifier_at.checked_add(at).and_then(|a| info.get(a..a.checked_add(len)?)).ok_or_else(short);
let salt = field(4, SALT)?;
let verifier = field(4 + SALT, BLOCK)?;
if u32_at(verifier_at.saturating_add(4 + SALT + BLOCK))? != SHA1_SIZE as u32 {
return Err(bad("the password verifier hash isn't SHA-1"));
}
let verifier_hash = field(8 + SALT + BLOCK, 2 * BLOCK)?;
Ok(Standard { key_bytes: bits as usize / 8, salt, verifier, verifier_hash })
}
/// Encrypt a zip package with `password` (agile encryption: AES-256-CBC, SHA-512, Word's spin
/// count), as Word's "Encrypt with Password" does.
pub fn encrypt(package: &[u8], password: &str) -> Result<Vec<u8>, DocxError> {
@@ -213,6 +321,47 @@ pub fn encrypt_with_spin_count(package: &[u8], password: &str, spin_count: u32)
Ok(comp.into_inner().into_inner())
}
/// A package encrypted with the *Standard* method (§2.3.4.5–§2.3.4.9: AES-ECB, SHA-1, 50,000
/// spins), as Word 2007 wrote it, for tests only: WordCraft saves with the agile method
/// ([`encrypt`]). `key_bits` is 128, 192 or 256. The compound file holds just the two streams
/// [`decrypt`] reads.
#[doc(hidden)]
pub fn encrypt_standard_for_tests(package: &[u8], password: &str, key_bits: u32) -> Result<Vec<u8>, DocxError> {
check_password(password)?;
let alg_id = match key_bits {
128 => ALG_AES_128,
192 => ALG_AES_192,
256 => ALG_AES_256,
_ => return Err(bad(format!("unsupported AES key size {key_bits}"))),
};
let salt = random(SALT)?;
let verifier = random(SALT)?;
let key = standard_key(&salt, password, key_bits as usize / 8);
let mut header = Vec::new();
for v in [STANDARD_FLAGS, 0, alg_id, ALG_SHA1, key_bits, PROV_RSA_AES, 0, 0] {
header.extend_from_slice(&v.to_le_bytes());
}
// CSPName: an empty null-terminated UTF-16 string.
header.extend_from_slice(&[0, 0]);
let mut info = vec![3, 0, 2, 0];
info.extend_from_slice(&STANDARD_FLAGS.to_le_bytes());
info.extend_from_slice(&(header.len() as u32).to_le_bytes());
info.extend_from_slice(&header);
info.extend_from_slice(&(SALT as u32).to_le_bytes());
info.extend_from_slice(&salt);
info.extend_from_slice(&ecb(&key, &verifier, true)?);
info.extend_from_slice(&(SHA1_SIZE as u32).to_le_bytes());
info.extend_from_slice(&ecb(&key, &padded(&Hash::Sha1.hash(&[&verifier])), true)?);
let mut stream = (package.len() as u64).to_le_bytes().to_vec();
stream.extend_from_slice(&ecb(&key, &padded(package), true)?);
let io = |e: std::io::Error| bad(format!("couldn't write the compound file ({e})"));
let mut comp = cfb::CompoundFile::create_with_version(cfb::Version::V3, Cursor::new(Vec::new())).map_err(io)?;
comp.create_stream("/EncryptionInfo").map_err(io)?.write_all(&info).map_err(io)?;
comp.create_stream("/EncryptedPackage").map_err(io)?.write_all(&stream).map_err(io)?;
comp.flush().map_err(io)?;
Ok(comp.into_inner().into_inner())
}
/// Refuse a password Word wouldn't accept: empty, or longer than [`MAX_PASSWORD_CHARS`].
pub fn check_password(password: &str) -> Result<(), DocxError> {
if password.is_empty() {
@@ -497,6 +646,45 @@ fn derive_key(hash: Hash, hn: &[u8], block_key: &[u8], len: usize) -> Zeroizing<
k
}
/// Standard encryption's key (§2.3.4.7): the SHA-1 password hash after 50,000 iterations, hashed
/// with block number 0; then X1 = SHA-1 of that hash XOR-ed into 64 bytes of 0x36, X2 the same
/// with 0x5C, and the key is the first `len` bytes of X1 + X2.
fn standard_key(salt: &[u8], password: &str, len: usize) -> Zeroizing<Vec<u8>> {
let hn = iterate(Hash::Sha1, salt, password, STANDARD_SPIN_COUNT);
let hfinal = Zeroizing::new(Hash::Sha1.hash(&[&hn, &0u32.to_le_bytes()]));
let x = |fill: u8| {
let mut buf = Zeroizing::new([fill; 64]);
for (b, h) in buf.iter_mut().zip(hfinal.iter()) {
*b ^= h;
}
Zeroizing::new(Hash::Sha1.hash(&[&buf[..]]))
};
let mut key = Zeroizing::new(x(0x36).to_vec());
key.extend_from_slice(&x(0x5C));
key.truncate(len);
key
}
/// AES-ECB without padding (Standard encryption, §2.3.4.7–§2.3.4.9): `data` must be whole blocks.
fn ecb(key: &[u8], data: &[u8], encrypt: bool) -> Result<Vec<u8>, DocxError> {
let aes = Aes::new(key)?;
let (blocks, rest) = data.as_chunks::<BLOCK>();
if !rest.is_empty() {
return Err(bad("encrypted data isn't a whole number of blocks"));
}
let mut out = Vec::with_capacity(data.len());
for b in blocks {
let mut x = aes::Block::from(*b);
if encrypt {
aes.encrypt(&mut x);
} else {
aes.decrypt(&mut x);
}
out.extend_from_slice(&x);
}
Ok(out)
}
/// An initialization vector (§2.3.4.12): H(salt + blockKey), or the salt itself, truncated to
/// the block size or padded with 0x36.
fn make_iv(hash: Hash, salt: &[u8], block_key: Option<&[u8]>, block_size: usize) -> Vec<u8> {
@@ -643,7 +831,6 @@ fn parse_info(info: &[u8]) -> Result<Info, DocxError> {
};
match (major, minor) {
(4, 4) => {}
(2..=4, 2) => return Err(bad("this file uses Standard encryption (Office 2007), which WordCraft can't open yet")),
(3 | 4, 3) => return Err(bad("this file uses extensible encryption, which WordCraft can't open")),
_ => return Err(bad(format!("unknown encryption version {major}.{minor}"))),
}
@@ -992,4 +1179,184 @@ mod tests {
assert!(!is_encrypted(&fake));
assert!(decrypt(&fake, Some("pw")).is_err());
}
/// Standard encryption's key derivation (§2.3.4.7) for the password "Password1234_" and the
/// salt 00 01 … 0F. The expected keys were computed outside WordCraft (Python's hashlib)
/// following the specification's steps, so the encryptor and decryptor can't share a mistake.
#[test]
fn standard_key_matches_an_independent_derivation() {
let salt: Vec<u8> = (0..16).collect();
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
assert_eq!(hex(&standard_key(&salt, "Password1234_", 16)), "340fc93a8e2154db8304d055df81cebc");
assert_eq!(hex(&standard_key(&salt, "Password1234_", 32)), "340fc93a8e2154db8304d055df81cebcef5e89fb8e5eb520efdca76f3330e032");
assert_eq!(standard_key(&salt, "Password1234_", 24).len(), 24);
// AES-ECB against FIPS-197 Appendix C.1 and C.3.
let pt: Vec<u8> = (0..16u8).map(|i| i * 0x11).collect();
let k128: Vec<u8> = (0..16).collect();
let k256: Vec<u8> = (0..32).collect();
assert_eq!(hex(&ecb(&k128, &pt, true).unwrap()), "69c4e0d86a7b0430d8cdb78070b4c55a");
assert_eq!(hex(&ecb(&k256, &pt, true).unwrap()), "8ea2b7ca516745bfeafc49904b496089");
assert_eq!(ecb(&k256, &ecb(&k256, &pt, true).unwrap(), false).unwrap(), pt);
assert!(ecb(&k128, &pt[..15], false).is_err());
}
/// Standard encryption (Word 2007's AES-ECB method) opens with the right password, for each
/// AES key size, and says when the password is missing or wrong — as agile files do.
#[test]
fn standard_encryption_round_trips() {
let pkg = zip_package();
for bits in [128, 192, 256] {
let enc = encrypt_standard_for_tests(&pkg, "pässwörd 🔑", bits).unwrap();
assert!(is_encrypted(&enc));
assert_eq!(decrypt(&enc, Some("pässwörd 🔑")), Ok(pkg.clone()), "AES-{bits}");
assert_eq!(decrypt(&enc, None), Err(DocxError::PasswordRequired));
assert_eq!(decrypt(&enc, Some("Pässwörd 🔑")), Err(DocxError::WrongPassword));
assert_eq!(crate::read(&enc), Err(DocxError::PasswordRequired));
}
let enc = encrypt_standard_for_tests(&pkg, "pw", 128).unwrap();
let doc = crate::read_with_password(&enc, Some("pw")).unwrap();
assert!(doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Top secret plans"));
// Sizes on and off the block boundary, and empty.
for n in [0usize, 1, 15, 16, 17, SEGMENT + 3] {
let data: Vec<u8> = (0..n).map(|i| (i * 7) as u8).collect();
assert_eq!(decrypt(&encrypt_standard_for_tests(&data, "x", 256).unwrap(), Some("x")).unwrap(), data);
}
assert!(encrypt_standard_for_tests(&pkg, "pw", 64).is_err());
assert!(encrypt_standard_for_tests(&pkg, "", 128).is_err());
}
/// A Standard-encrypted file assembled byte by byte from [MS-OFFCRYPTO] §2.3.4.5, §2.3.2, §2.3.3
/// and §2.3.4.4 with literal field values, without the test encryptor, so a layout mistake
/// shared by the encryptor and the reader can't pass. Only the key derivation and AES-ECB
/// (pinned by `standard_key_matches_an_independent_derivation`) are reused.
fn hand_built_standard(payload: &[u8], password: &str) -> Vec<u8> {
let salt: [u8; 16] = [0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF];
let verifier: [u8; 16] = *b"verifier-16bytes";
let key = standard_key(&salt, password, 16);
let mut hash = sha1::Sha1::digest(verifier).to_vec();
hash.resize(32, 0); // the hash, zero-padded to two AES blocks
let csp: Vec<u8> = "WordCraft test provider".encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect();
let mut header = Vec::new();
header.extend_from_slice(&0x24u32.to_le_bytes()); // Flags: fCryptoAPI | fAES
header.extend_from_slice(&0u32.to_le_bytes()); // SizeExtra
header.extend_from_slice(&0x660Eu32.to_le_bytes()); // AlgID: AES-128
header.extend_from_slice(&0x8004u32.to_le_bytes()); // AlgIDHash: SHA-1
header.extend_from_slice(&128u32.to_le_bytes()); // KeySize, in bits
header.extend_from_slice(&0x18u32.to_le_bytes()); // ProviderType: AES
header.extend_from_slice(&0u32.to_le_bytes()); // Reserved1
header.extend_from_slice(&0u32.to_le_bytes()); // Reserved2
header.extend_from_slice(&csp); // CSPName: UTF-16LE, NUL-terminated
let mut info = Vec::new();
info.extend_from_slice(&3u16.to_le_bytes()); // Version major
info.extend_from_slice(&2u16.to_le_bytes()); // Version minor
info.extend_from_slice(&0x24u32.to_le_bytes()); // Flags: fCryptoAPI | fAES
info.extend_from_slice(&(header.len() as u32).to_le_bytes()); // HeaderSize
info.extend_from_slice(&header);
info.extend_from_slice(&16u32.to_le_bytes()); // SaltSize
info.extend_from_slice(&salt); // Salt
info.extend_from_slice(&ecb(&key, &verifier, true).unwrap()); // EncryptedVerifier
info.extend_from_slice(&20u32.to_le_bytes()); // VerifierHashSize (SHA-1)
info.extend_from_slice(&ecb(&key, &hash, true).unwrap()); // EncryptedVerifierHash
let mut padded = payload.to_vec();
padded.resize(payload.len().next_multiple_of(16), 0);
let mut package = (payload.len() as u64).to_le_bytes().to_vec(); // StreamSize
package.extend_from_slice(&ecb(&key, &padded, true).unwrap());
package.extend_from_slice(&[0xEE, 0xEE, 0xEE]); // trailing padding the spec allows
cfb_with(&info, &package)
}
#[test]
fn standard_package_built_by_hand_from_the_spec_opens() {
let payload: Vec<u8> = (0..37u8).map(|i| i.wrapping_mul(5)).collect();
let file = hand_built_standard(&payload, "pw");
assert!(is_encrypted(&file));
assert_eq!(decrypt(&file, Some("pw")), Ok(payload.clone()));
assert_eq!(decrypt(&file, Some("PW")), Err(DocxError::WrongPassword));
assert_eq!(decrypt(&file, None), Err(DocxError::PasswordRequired));
// The test encryptor writes the same fixed fields: version and flags (bytes 0..8) and the
// header's eight words (12..44); HeaderSize differs with the CSP name, the rest is random.
let (hand, _) = streams(&file);
let (made, _) = streams(&encrypt_standard_for_tests(&payload, "pw", 128).unwrap());
assert_eq!(hand[..8], made[..8]);
assert_eq!(hand[12..44], made[12..44]);
}
/// The two streams of an encrypted package.
fn streams(file: &[u8]) -> (Vec<u8>, Vec<u8>) {
let mut comp = cfb::CompoundFile::open(Cursor::new(file)).unwrap();
let mut info = Vec::new();
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
let mut package = Vec::new();
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut package).unwrap();
(info, package)
}
#[test]
fn hostile_standard_encryption_info_errors_without_panicking() {
let data = b"a small package".to_vec();
let (info, package) = streams(&encrypt_standard_for_tests(&data, "pw", 128).unwrap());
// Layout: version 0, flags 4, header size 8, header 12 (flags, sizeExtra, algId 20,
// algIdHash 24, keySize 28, provider, 2 reserved, an empty CSP name), then the verifier at
// 46: saltSize, salt 50, encrypted verifier 66, verifierHashSize 82, its hash 86..118.
assert_eq!(info.len(), 118);
let put = |at: usize, v: u32| {
let mut i = info.clone();
i[at..at + 4].copy_from_slice(&v.to_le_bytes());
i
};
let mut cases: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for n in 0..info.len() {
cases.push((info[..n].to_vec(), package.clone()));
}
for v in [0, 4, 31, 0x1000, u32::MAX] {
cases.push((put(8, v), package.clone()));
}
// No AES, no CryptoAPI, an external (extensible) provider.
for v in [F_CRYPTO_API, F_AES, STANDARD_FLAGS | F_EXTERNAL, 0] {
cases.push((put(4, v), package.clone()));
}
// RC4, MD5, key sizes that don't fit the algorithm.
cases.push((put(20, 0x6801), package.clone()));
cases.push((put(24, 0x8003), package.clone()));
for v in [0, 64, 192, 256, u32::MAX] {
cases.push((put(28, v), package.clone()));
}
for v in [0, 15, 17, u32::MAX] {
cases.push((put(46, v), package.clone()));
}
for v in [0, 16, 32, u32::MAX] {
cases.push((put(82, v), package.clone()));
}
// The package: truncated, an absurd StreamSize, a StreamSize past the data, data that
// isn't whole blocks.
for n in [0, 4, 8, 9, package.len() - 1] {
cases.push((info.clone(), package[..n].to_vec()));
}
for size in [u64::MAX, 1 << 40, package.len() as u64] {
let mut p = package.clone();
p[..8].copy_from_slice(&size.to_le_bytes());
cases.push((info.clone(), p));
}
for (i, p) in cases {
let file = cfb_with(&i, &p);
assert!(decrypt(&file, Some("pw")).is_err());
let _ = crate::read_with_password(&file, Some("pw"));
assert!(crate::read(&file).is_err());
}
// Version 2.2 and 4.2 are Standard too; trailing bytes after the last block are ignored.
for v in [2u8, 4] {
let mut i = info.clone();
i[0] = v;
assert_eq!(decrypt(&cfb_with(&i, &package), Some("pw")).unwrap(), data);
}
let mut p = package.clone();
p.extend_from_slice(&[1, 2, 3]);
assert_eq!(decrypt(&cfb_with(&info, &p), Some("pw")).unwrap(), data);
// AlgID and AlgIDHash 0 mean "from the flags": AES-128 and SHA-1.
assert_eq!(decrypt(&cfb_with(&put(20, 0), &package), Some("pw")).unwrap(), data);
assert_eq!(decrypt(&cfb_with(&put(24, 0), &package), Some("pw")).unwrap(), data);
}
}
+99 -1
View File
@@ -437,7 +437,47 @@ fn xml_fragment<'a>(ct: &str, bytes: &'a [u8]) -> Option<&'a str> {
Some(rest) => rest.split_once("?>").map(|(_, r)| r)?.trim_start(),
None => s,
};
(s.starts_with('<') && !s.contains("<!DOCTYPE")).then_some(s)
(s.starts_with('<') && !s.contains("<!DOCTYPE") && one_element(s)).then_some(s)
}
/// Whether `s` is exactly one well-formed element, with only whitespace, comments and processing
/// instructions around it. Kept bytes are inlined into `pkg:xmlData` only then; anything else could
/// close the wrapper and add parts of its own, so it goes out as `pkg:binaryData` (#3742).
fn one_element(s: &str) -> bool {
let mut r = quick_xml::Reader::from_str(s);
r.config_mut().check_comments = true;
let (mut depth, mut roots) = (0usize, 0usize);
loop {
let attrs_ok = |e: &BytesStart| e.attributes().all(|a| a.is_ok());
match r.read_event() {
Ok(Event::Start(e)) => {
if !attrs_ok(&e) || (depth == 0 && roots > 0) {
return false;
}
roots += usize::from(depth == 0);
depth += 1;
}
Ok(Event::Empty(e)) => {
if !attrs_ok(&e) || (depth == 0 && roots > 0) {
return false;
}
roots += usize::from(depth == 0);
}
Ok(Event::End(_)) => match depth.checked_sub(1) {
Some(d) => depth = d,
None => return false,
},
Ok(Event::Text(t)) if depth == 0 => {
if !t.iter().all(u8::is_ascii_whitespace) {
return false;
}
}
Ok(Event::CData(_) | Event::GeneralRef(_)) if depth == 0 => return false,
Ok(Event::Decl(_) | Event::DocType(_)) | Err(_) => return false,
Ok(Event::Eof) => return depth == 0 && roots == 1,
Ok(_) => {}
}
}
}
#[cfg(test)]
@@ -494,6 +534,64 @@ mod tests {
assert!(matches!(unpack(package(xml::MAX_DEPTH + 1).as_bytes()), Err(DocxError::Limit(_))));
}
#[test]
fn kept_xml_that_would_break_out_of_its_part_is_written_in_base64() {
// A part kept from a file is written back as it was read; inlined as is, markup that
// closes the wrapper adds parts of its own (here a second core properties part, read
// before the real one) (#3742).
let evil = format!(
"<a/></pkg:xmlData></pkg:part><pkg:part pkg:name=\"/docProps/core.xml\" pkg:contentType=\"application/vnd.openxmlformats-package.core-properties+xml\"><pkg:xmlData><cp:coreProperties xmlns:cp=\"{}\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:title>Injected</dc:title></cp:coreProperties></pkg:xmlData></pkg:part><pkg:part pkg:name=\"/x.xml\"><pkg:xmlData><b>",
"http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
);
let w_ns = "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"";
let rel = |id: &str, kind: &str, target: &str| format!("<Relationship Id=\"{id}\" Type=\"{kind}\" Target=\"{target}\"/>");
let rels = format!(
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">{}{}</Relationships>",
rel("rId1", crate::package::rt::OFFICE_DOC, "word/document.xml"),
rel("rId2", crate::package::rt::CUSTOM_XML, "customXml/item1.xml")
);
let types = "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"xml\" ContentType=\"application/xml\"/></Types>";
let z = crate::package::zip_entries(&[
("[Content_Types].xml".into(), types.as_bytes().to_vec()),
("_rels/.rels".into(), rels.into_bytes()),
("word/document.xml".into(), format!("<w:document {w_ns}><w:body><w:p/></w:body></w:document>").into_bytes()),
("customXml/item1.xml".into(), evil.clone().into_bytes()),
])
.unwrap();
let mut doc = crate::read(&z).unwrap();
assert!(doc.passthrough.values().any(|b| b.as_slice() == evil.as_bytes()), "the part is kept as read");
doc.core.title = "Real".into();
let flat = write(&doc).unwrap();
let text = String::from_utf8_lossy(&flat);
assert_eq!(text.matches("pkg:name=\"/docProps/core.xml\"").count(), 1, "{text}");
let back = read(&flat).unwrap();
assert_eq!(back.core.title, "Real");
assert!(back.passthrough.values().any(|b| b.as_slice() == evil.as_bytes()), "the part survives, in base64");
}
#[test]
fn only_one_well_formed_element_is_inlined() {
let ct = "application/xml";
for ok in ["<a/>", "<?xml version=\"1.0\"?>\n<a x=\"1\"><b>t &amp; <![CDATA[c]]></b></a>\n<!-- end -->", "<!-- c --><a/><?pi x?>"] {
assert!(xml_fragment(ct, ok.as_bytes()).is_some(), "{ok}");
}
for bad in [
"<a/><b/>",
"<a></b>",
"<a>",
"<a/></x>",
"<a/>text",
"<a/>&amp;",
"<a x=1/>",
"<a/><?xml version=\"1.0\"?>",
"<a></a></pkg:xmlData>",
"<a><!-- x -- y --></a>",
"<a/><!-- x--->",
] {
assert!(xml_fragment(ct, bad.as_bytes()).is_none(), "{bad}");
}
}
#[test]
fn repeated_and_nameless_parts_count_against_the_size_limits() {
let (mut files, mut types, mut total) = (BTreeMap::new(), Vec::new(), 0u64);
+79
View File
@@ -4,6 +4,8 @@
//! back. [`embedded_fonts`] hands their font files to whoever lays the document out, undoing the
//! obfuscation Word applies to them (ECMA-376 Part 1, 17.8.1).
use std::collections::HashSet;
use wordcraft_doc::Document;
use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART};
@@ -11,6 +13,11 @@ use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART};
/// Most font files taken from one document.
const MAX_FONTS: usize = 64;
/// Most bytes of font files copied out of one document: one part's limit, however many
/// references the font table makes (each reference is a copy; 64 of them to one large part
/// were 64 copies, #3692).
const MAX_FONT_BYTES: u64 = crate::package::MAX_PART;
/// The font table's references to embedded font files, one per style.
const EMBEDS: [&str; 4] = ["w:embedRegular", "w:embedBold", "w:embedItalic", "w:embedBoldItalic"];
@@ -28,6 +35,12 @@ pub struct EmbeddedFont {
/// fonts: that is up to the font database. Empty for a document read from anything but a package
/// with a font table.
pub fn embedded_fonts(doc: &Document) -> Vec<EmbeddedFont> {
fonts_within(doc, MAX_FONT_BYTES)
}
/// [`embedded_fonts`], copying at most `budget` bytes of font files.
fn fonts_within(doc: &Document, mut budget: u64) -> Vec<EmbeddedFont> {
let budget_total = budget;
let Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else {
return Vec::new();
};
@@ -36,6 +49,9 @@ pub fn embedded_fonts(doc: &Document) -> Vec<EmbeddedFont> {
return Vec::new();
};
let mut out = Vec::new();
// A reference repeating one already taken (same file, key and name) gives the same font.
let mut taken: HashSet<(&str, [u8; 16], &str)> = HashSet::new();
let mut over_budget = 0usize;
let embeds = table
.children("w:font")
.flat_map(|f| f.els().filter(|e| EMBEDS.contains(&e.name.as_str())).map(move |e| (f.attr("w:name").unwrap_or(""), e)));
@@ -61,12 +77,25 @@ pub fn embedded_fonts(doc: &Document) -> Vec<EmbeddedFont> {
},
None => [0; 16],
};
if !taken.insert((rel.target.as_str(), key, name)) {
continue;
}
// Checked before the copy is made.
let Some(left) = budget.checked_sub(bytes.len() as u64) else {
over_budget += 1;
continue;
};
budget = left;
let mut data = bytes.to_vec();
for (i, b) in data.iter_mut().take(32).enumerate() {
*b ^= key.get(i % 16).copied().unwrap_or(0);
}
out.push(EmbeddedFont { name: name.to_string(), data });
}
if over_budget > 0 {
// Once for the document, not once per font.
log::warn!("docx: embedded fonts past the {budget_total} byte budget; {over_budget} of them are not loaded");
}
out
}
@@ -83,3 +112,53 @@ fn font_key(guid: &str) -> Option<[u8; 16]> {
}
Some(key)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
/// A document whose font table makes `refs` references (`(font name, part)`) to font parts
/// `parts` (`(name, size)`).
fn doc_with(refs: &[(&str, &str)], parts: &[(&str, usize)]) -> Document {
let mut table = String::from(
r#"<w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
);
let mut manifest = format!("P\t{FONT_TABLE_PART}\tapplication/xml\n");
for (i, (font, part)) in refs.iter().enumerate() {
table.push_str(&format!(r#"<w:font w:name="{font}"><w:embedRegular r:id="rId{i}"/></w:font>"#));
manifest.push_str(&format!("R\t{FONT_TABLE_PART}\trId{i}\tfont\t{part}\tI\n"));
}
table.push_str("</w:fonts>");
let mut doc = Document::default();
doc.passthrough.insert(FONT_TABLE_PART.into(), Arc::new(table.into_bytes()));
doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest.into_bytes()));
for (part, size) in parts {
doc.passthrough.insert((*part).into(), Arc::new(vec![7u8; *size]));
}
doc
}
#[test]
fn a_font_referenced_again_is_copied_once() {
// Each reference was its own copy: the 64 references allowed to one 256 MB part were
// 16 GB of copies (#3692).
let refs = vec![("Body", "word/fonts/a.odttf"); MAX_FONTS];
let fonts = embedded_fonts(&doc_with(&refs, &[("word/fonts/a.odttf", 1000)]));
assert_eq!(fonts.len(), 1);
}
#[test]
fn font_copies_stop_at_the_byte_budget() {
let refs: Vec<(String, &str)> = (0..MAX_FONTS).map(|i| (format!("Font {i}"), "word/fonts/a.odttf")).collect();
let refs: Vec<(&str, &str)> = refs.iter().map(|(n, p)| (n.as_str(), *p)).collect();
let doc = doc_with(&refs, &[("word/fonts/a.odttf", 1000)]);
let (fonts, warned) = crate::test_log::warnings(|| fonts_within(&doc, 3500));
assert_eq!(fonts.len(), 3, "three copies fit");
let budget: Vec<_> = warned.iter().filter(|w| w.contains("byte budget")).collect();
assert_eq!(budget.len(), 1, "one warning for the document, not one per font: {warned:?}");
assert!(budget.iter().all(|w| w.contains(&format!("{} of them", MAX_FONTS - 3))), "{budget:?}");
assert_eq!(embedded_fonts(&doc).len(), MAX_FONTS, "well within the real budget");
}
}
+4 -2
View File
@@ -16,6 +16,8 @@ mod fonts;
mod package;
mod read;
mod smart_art;
#[cfg(test)]
mod test_log;
mod units;
mod wordml2003;
mod wordml2003_write;
@@ -25,8 +27,8 @@ mod xml;
pub use bibliography::{MAX_SOURCES, Sources, read_sources};
pub use chart_spec::{chart_items, chart_xml};
pub use crypt::{
DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_with_spin_count,
is_encrypted, protection_hash,
DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_standard_for_tests,
encrypt_with_spin_count, is_encrypted, protection_hash,
};
pub use flat::{XmlKind, sniff_xml, write as write_flat};
pub use fonts::{EmbeddedFont, embedded_fonts};
+127 -13
View File
@@ -1,6 +1,7 @@
//! OPC package access: bounded zip reading, relationships, part names.
use std::collections::{BTreeMap, HashMap};
use std::collections::hash_map::Entry;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::{Cursor, Read, Write};
use std::sync::Arc;
@@ -191,6 +192,9 @@ pub fn rel_is(t: &str, full: &str) -> bool {
/// picture, an embedded object) is handed over rather than copied.
pub struct Package {
files: BTreeMap<String, Arc<Vec<u8>>>,
/// Lowercased part name → its stored spelling (the first in name order): part names are
/// case-insensitive (ECMA-376 Part 2 §6.2.2.3), so every spelling is one part.
lower: HashMap<String, String>,
}
impl Package {
@@ -223,24 +227,60 @@ impl Package {
let room = max_part.min(max_total.saturating_sub(total));
let mut buf = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize);
let read = (&mut f).take(room + 1).read_to_end(&mut buf);
// Charged whether or not the read succeeds: an entry inflates in full before its CRC
// is checked, so failed entries would otherwise inflate without limit (#3723).
total = total.saturating_add(buf.len() as u64);
if buf.len() as u64 > room {
return Err(DocxError::Limit(format!("part {name} decompresses past the size limit")));
}
if let Err(e) = read {
// A corrupt entry: keep going unless it's a part we can't do without.
log::warn!("docx: zip entry {name} unreadable: {e}");
continue;
}
if buf.len() as u64 > room {
return Err(DocxError::Limit(format!("part {name} decompresses past the size limit")));
}
total = total.saturating_add(buf.len() as u64);
files.insert(name, Arc::new(buf));
}
Ok(Package { files })
Ok(Package::new(files))
}
/// A package from parts already in memory (a Flat OPC file, a converted document), by part
/// name without the leading `/`.
pub fn from_parts(files: BTreeMap<String, Vec<u8>>) -> Package {
Package { files: files.into_iter().map(|(name, bytes)| (name, Arc::new(bytes))).collect() }
Package::new(files.into_iter().map(|(name, bytes)| (name, Arc::new(bytes))).collect())
}
fn new(mut files: BTreeMap<String, Arc<Vec<u8>>>) -> Package {
let mut lower: HashMap<String, String> = HashMap::new();
let mut collides = false;
for name in files.keys() {
match lower.entry(name.to_ascii_lowercase()) {
Entry::Occupied(_) => collides = true,
Entry::Vacant(v) => {
v.insert(name.clone());
}
}
}
if !collides {
return Package { files, lower };
}
// Entries whose names differ only in case are one part, so every spelling of the name
// reaches the same stored entry (#3704). The spelling kept is the one a relationship
// names, so the part the document points at is the one read; the first in byte order only
// when no relationship names any of them.
let named = rel_targets(&files);
for name in files.keys().rev().filter(|n| named.contains(n.as_str())) {
lower.insert(name.to_ascii_lowercase(), name.clone());
}
let before = files.len();
files.retain(|name, _| lower.get(&name.to_ascii_lowercase()) == Some(name));
let dropped = before.saturating_sub(files.len());
log::warn!("docx: {dropped} zip entries are other spellings of parts already read; they are left out");
Package { files, lower }
}
/// The stored spelling of part `name` (leading `/` ignored; case-insensitive).
pub fn canonical(&self, name: &str) -> Option<&str> {
self.lower.get(&name.trim_start_matches('/').to_ascii_lowercase()).map(String::as_str)
}
/// Part bytes by name (leading `/` ignored; case-insensitive fallback).
@@ -251,11 +291,7 @@ impl Package {
/// [`Package::get`], as the shared bytes themselves: for a part the document keeps whole, which
/// then costs no copy (a picture document's pictures used to be held twice while it opened).
pub fn get_shared(&self, name: &str) -> Option<&Arc<Vec<u8>>> {
let n = name.trim_start_matches('/');
if let Some(v) = self.files.get(n) {
return Some(v);
}
self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v)
self.canonical(name).and_then(|n| self.files.get(n))
}
/// [`Package::xml`] for the main document part: malformed XML is an error, not a truncated read.
@@ -289,7 +325,14 @@ impl Package {
for r in root.els().filter(|e| e.local() == "Relationship") {
let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) else { continue };
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
let resolved = if external { target.to_string() } else { resolve(dir, target) };
// Internal targets name the part as stored, so a part reached under several spellings
// is kept, cached and written once (#3704).
let resolved = if external {
target.to_string()
} else {
let path = resolve(dir, target);
self.canonical(&path).map_or(path, str::to_string)
};
rels.list.push(Rel { id: id.to_string(), kind: r.attr("Type").unwrap_or("").to_string(), target: resolved, external });
}
rels
@@ -364,6 +407,24 @@ fn split_dir(part: &str) -> (&str, &str) {
}
}
/// Every internal part name the relationship parts in `files` target, spelled as they spell it.
fn rel_targets(files: &BTreeMap<String, Arc<Vec<u8>>>) -> HashSet<String> {
let mut named = HashSet::new();
for (name, bytes) in files {
// `dir/_rels/file.rels` relates `dir/file`, so its targets resolve against `dir`.
let Some(at) = name.rfind("_rels/").filter(|_| name.to_ascii_lowercase().ends_with(".rels")) else { continue };
let dir = name.get(..at).unwrap_or("").trim_end_matches('/');
let Ok(root) = xml::parse(bytes) else { continue };
for r in root.els().filter(|e| e.local() == "Relationship") {
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
if let (Some(target), false) = (r.attr("Target"), external) {
named.insert(resolve(dir, target));
}
}
}
named
}
/// Resolve a relative target against a source directory into a package path.
pub fn resolve(dir: &str, target: &str) -> String {
let target = target.split('#').next().unwrap_or(target);
@@ -420,6 +481,39 @@ mod tests {
zip_entries(&e).unwrap()
}
#[test]
fn case_colliding_entries_are_one_part() {
let z = zip_of(&[("word/media/Image.png", b"upper".to_vec()), ("word/media/image.png", b"lower".to_vec())]);
let p = Package::open(&z).unwrap();
for spelling in ["word/media/Image.png", "word/media/image.png", "/WORD/MEDIA/IMAGE.PNG"] {
assert_eq!(p.canonical(spelling), Some("word/media/Image.png"), "{spelling}");
assert_eq!(p.get(spelling), Some(&b"upper"[..]), "{spelling}");
}
assert_eq!(p.files.len(), 1);
}
#[test]
fn the_spelling_a_relationship_names_is_kept() {
// The first spelling in byte order ("Image.png") was kept even when the document's
// relationship named the other one, so the picture shown was not the one it pointed at.
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="image" Target="media/image.png"/></Relationships>"#;
let z = zip_of(&[
("word/_rels/document.xml.rels", rels.as_bytes().to_vec()),
("word/media/Image.png", b"upper".to_vec()),
("word/media/image.png", b"lower".to_vec()),
("word/media/Other.png", b"first".to_vec()),
("word/media/other.png", b"second".to_vec()),
]);
let (p, warned) = crate::test_log::warnings(|| Package::open(&z).unwrap());
assert_eq!(p.canonical("word/media/IMAGE.png"), Some("word/media/image.png"));
assert_eq!(p.get("word/media/Image.png"), Some(&b"lower"[..]));
assert_eq!(p.get("word/media/OTHER.PNG"), Some(&b"first"[..]), "no relationship names it: first in byte order");
assert_eq!(p.files.len(), 3);
let folded: Vec<_> = warned.iter().filter(|w| w.contains("other spellings")).collect();
assert_eq!(folded.len(), 1, "one warning for the package: {warned:?}");
assert!(folded.iter().all(|w| w.contains("2 zip entries")), "{folded:?}");
}
#[test]
fn decompression_bomb_is_rejected() {
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
@@ -429,6 +523,26 @@ mod tests {
assert!(Package::open_limited(&z, 10_000_000, 10_000_000).is_ok());
}
#[test]
fn entries_that_fail_their_check_still_count_against_the_total() {
// Each entry inflates in full before its CRC is checked; a failed one was dropped without
// being charged, so any number of them inflated (#3723).
let names: Vec<String> = (0..20).map(|i| format!("p{i}.bin")).collect();
let entries: Vec<(&str, Vec<u8>)> = names.iter().map(|n| (n.as_str(), vec![0u8; 1_000_000])).collect();
let mut z = zip_of(&entries);
let mut patched = 0;
for sig in [[0x50u8, 0x4B, 0x01, 0x02], [0x50, 0x4B, 0x03, 0x04]] {
let crc_at = if sig[2] == 1 { 16 } else { 14 };
let found: Vec<usize> = z.windows(4).enumerate().filter(|(_, w)| *w == sig).map(|(i, _)| i).collect();
for pos in found {
z[pos + crc_at] ^= 0xFF;
patched += 1;
}
}
assert_eq!(patched, 40);
assert!(matches!(Package::open_limited(&z, 2_000_000, 4_000_000), Err(DocxError::Limit(_))));
}
#[test]
fn lying_declared_size_is_rejected_or_bounded() {
let mut z = zip_of(&[("a.xml", b"<a/>".to_vec())]);
+6 -1
View File
@@ -1212,6 +1212,7 @@ impl Reader<'_> {
s.compat_mode = m.clamp(11, 99);
}
s.sum_paragraph_spacing = k.children("w:doNotUseHTMLParagraphAutoSpacing").any(on_off);
s.do_not_expand_shift_return = k.children("w:doNotExpandShiftReturn").any(on_off);
}
"w:trackRevisions" => s.track_changes = on_off(k),
"w:doNotTrackMoves" => s.track_moves = !on_off(k),
@@ -1362,6 +1363,10 @@ impl Reader<'_> {
let mut by_para: HashMap<String, u32> = HashMap::new();
let mut next_id = 0u32;
let mut entries: BTreeMap<u32, Comment> = BTreeMap::new();
// Ids given before this part, looked up rather than scanned for every generated id: a
// scan per comment made many non-numeric ids quadratic (#3692). Ids given here are in
// `entries`.
let given: HashSet<u32> = self.comment_map.values().copied().collect();
for c in root.children("w:comment") {
if self.doc.parts.len() >= MAX_PARTS {
break;
@@ -1373,7 +1378,7 @@ impl Reader<'_> {
let id = match file_id.parse::<u32>() {
Ok(v) if !entries.contains_key(&v) => v,
_ => {
while entries.contains_key(&next_id) || self.comment_map.values().any(|v| *v == next_id) {
while entries.contains_key(&next_id) || given.contains(&next_id) {
next_id += 1;
}
next_id
+15 -9
View File
@@ -724,22 +724,28 @@ impl Reader<'_> {
if sc.fields.iter().any(|f| f.phase == Phase::Instr) {
return;
}
let c = match k.attr("w:type") {
let ty = k.attr("w:type").map(|t| t.trim().to_ascii_lowercase());
let c = match ty.as_deref() {
Some("page") => "\u{000C}",
Some("column") => "\u{000E}",
_ => "\n",
};
self.emit_text(sc, pb, c, props);
}
"w:cr" => {
if !sc.fields.iter().any(|f| f.phase == Phase::Instr) {
self.emit_text(sc, pb, "\n", props);
}
"w:cr" if !sc.fields.iter().any(|f| f.phase == Phase::Instr) => {
self.emit_text(sc, pb, "\n", props);
}
"w:cr" => {}
"w:noBreakHyphen" => self.emit_text(sc, pb, "\u{2011}", props),
"w:softHyphen" => self.emit_text(sc, pb, "\u{00AD}", props),
"w:sym" => {
let ch = k.attr("w:char").and_then(|h| u32::from_str_radix(h.trim(), 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20);
let h_str = k.attr("w:char").map(|h| {
let s = h.trim();
let s = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s);
let s = s.strip_prefix("U+").or_else(|| s.strip_prefix("u+")).unwrap_or(s);
s.strip_prefix("#x").or_else(|| s.strip_prefix("#X")).unwrap_or(s)
});
let ch = h_str.and_then(|h| u32::from_str_radix(h, 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20);
if let Some(ch) = ch {
let mut p = props.clone();
// The symbol's own font, kept apart from the run's (ECMA-376 §17.3.3.30).
@@ -783,7 +789,7 @@ impl Reader<'_> {
}
}
"w:footnoteReference" | "w:endnoteReference" => {
let foot = k.name == "w:footnoteReference";
let foot = k.local() == "footnoteReference";
let fid = k.attr("w:id").and_then(int).unwrap_or(i64::MIN);
let map = if foot { &self.footnotes } else { &self.endnotes };
if let Some(id) = map.get(&fid).copied() {
@@ -794,7 +800,7 @@ impl Reader<'_> {
}
// The note's own number at the start of its text: a reference to the note being read.
"w:footnoteRef" | "w:endnoteRef" => {
let kind = if k.name == "w:footnoteRef" { NoteKind::Footnote } else { NoteKind::Endnote };
let kind = if k.local() == "footnoteRef" { NoteKind::Footnote } else { NoteKind::Endnote };
if let Some((nk, id)) = self.current_note
&& nk == kind
{
@@ -2291,7 +2297,7 @@ fn css_string(v: &str) -> String {
out.push(d);
}
// One whitespace after the escape is its terminator, not part of the value.
if chars.clone().next().is_some_and(|w| w.is_whitespace()) {
if chars.clone().next().is_some_and(char::is_whitespace) {
chars.next();
}
}
+36
View File
@@ -0,0 +1,36 @@
//! Test helper: the warnings this crate logs, captured per test thread, so a test can check that
//! a limit which leaves something out says so.
use std::cell::RefCell;
struct Capture;
thread_local! {
static LINES: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
}
impl log::Log for Capture {
fn enabled(&self, m: &log::Metadata) -> bool {
m.level() <= log::Level::Warn
}
fn log(&self, r: &log::Record) {
if self.enabled(r.metadata()) {
LINES.with(|l| l.borrow_mut().push(r.args().to_string()));
}
}
fn flush(&self) {}
}
static CAPTURE: Capture = Capture;
/// `f`'s result and the warnings logged on this thread while it ran.
pub(crate) fn warnings<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
// Only this logger is ever installed in a test binary; a second call keeps it.
let _ = log::set_logger(&CAPTURE);
log::set_max_level(log::LevelFilter::Warn);
LINES.with(|l| l.borrow_mut().clear());
let out = f();
(out, LINES.with(|l| std::mem::take(&mut *l.borrow_mut())))
}
+161 -3
View File
@@ -48,6 +48,10 @@ pub(crate) struct EmbedWriter<'d> {
pub parts: Vec<(String, Vec<u8>, String, PartRels)>,
/// Leave out the main document's kept glossary (it's written from the model instead).
pub drop_glossary: bool,
/// Kept parts written with these bytes instead of their own (see [`Self::scrub_glossary`]).
scrubbed: HashMap<String, Vec<u8>>,
/// A chart past the chart limits has been written (and the loss logged, once per save).
chart_cut: bool,
}
impl<'d> EmbedWriter<'d> {
@@ -68,7 +72,7 @@ impl<'d> EmbedWriter<'d> {
"[content_types].xml",
]
.iter()
.map(|s| s.to_string())
.map(ToString::to_string)
.collect();
taken.insert(VBA_PROJECT_PART.to_ascii_lowercase());
// The bibliography writer uses these names when sources are present (#532).
@@ -93,6 +97,8 @@ impl<'d> EmbedWriter<'d> {
markup_bytes: MAX_MARKUP_BYTES,
parts: Vec::new(),
drop_glossary: false,
scrubbed: HashMap::new(),
chart_cut: false,
}
}
@@ -230,6 +236,44 @@ impl<'d> EmbedWriter<'d> {
})
}
/// Have the main document's kept glossary, and the parts it reaches, written without the
/// personal information (`names`) and dates the privacy settings remove (#3692). False when a
/// part's markup can't be rewritten: the glossary is then written from the model.
pub fn scrub_glossary(&mut self, names: bool, dates: bool) -> bool {
let Some(roots) = self.passthrough.get(crate::package::UNMODELLED_ROOTS) else { return true };
let mut todo: Vec<String> = String::from_utf8_lossy(roots)
.lines()
.take(MAX_EMBEDDED_RELS * 2)
.filter_map(|line| match line.split('\t').collect::<Vec<_>>().as_slice() {
[source, kind, target] if *source == "D" && rel_is(kind, rt::GLOSSARY) => Some(target.to_string()),
_ => None,
})
.collect();
let mut seen: HashSet<String> = HashSet::new();
while let Some(p) = todo.pop() {
if seen.len() >= MAX_EMBEDDED_PARTS || !seen.insert(p.clone()) {
continue;
}
let (Some(info), Some(bytes)) = (self.manifest.parts.get(&p), self.passthrough.get(&p)) else { continue };
todo.extend(info.rels.iter().filter(|r| !r.external).map(|r| r.target.clone()));
if !(info.content_type.ends_with("xml") || p.to_ascii_lowercase().ends_with(".xml")) {
continue;
}
match scrub_private(bytes, names, dates) {
Some(b) => {
self.scrubbed.insert(p, b);
}
None => {
log::warn!(
"docx: the glossary's part {p} can't be cleaned of personal information; the glossary is written from its entries, without what WordCraft doesn't model"
);
return false;
}
}
}
true
}
/// What copying the parts reached from `roots` costs against the copy budget.
fn tree_cost(&self, roots: &[&str]) -> u64 {
let mut seen: HashSet<&str> = HashSet::new();
@@ -283,8 +327,9 @@ impl<'d> EmbedWriter<'d> {
rels.add_with_id(&r.id, &r.kind, &relative(&new, &target), false);
}
let ct = if info.content_type.is_empty() { "application/octet-stream".to_string() } else { info.content_type };
let bytes = self.scrubbed.get(&orig).cloned().unwrap_or_else(|| bytes.to_vec());
written.push(self.parts.len());
self.parts.push((new, bytes.to_vec(), ct, rels));
self.parts.push((new, bytes, ct, rels));
}
Some(name)
}
@@ -408,7 +453,12 @@ impl super::Writer<'_> {
/// related from the story part through `rels`.
pub(super) fn chart_graphic(&mut self, spec: &wordcraft_doc::chart::ChartSpec, rels: &mut PartRels) -> String {
let name = self.embeds.fresh("word/charts/chart", ".xml");
let bytes = crate::chart_spec::chart_xml(spec).into_bytes();
let (xml, cut) = crate::chart_spec::chart_part(spec);
if cut && !self.embeds.chart_cut {
self.embeds.chart_cut = true;
log::warn!("docx: a chart has more series or points than a chart part holds; the rest are left out of the file");
}
let bytes = xml.into_bytes();
self.embeds.parts.push((name.clone(), bytes, crate::chart_spec::CHART_CT.to_string(), PartRels::default()));
let id = rels.add(crate::package::rt::CHART, &relative(STORY_PART, &name), false);
let mut w = xml::W::default();
@@ -640,6 +690,80 @@ fn patch_rel_ids(bytes: &[u8], ids: &HashMap<String, String>) -> Vec<u8> {
out
}
/// `bytes` (kept XML) without the personal information and dates the privacy settings remove, as
/// the writer leaves them out of the parts it writes: annotation authors become "Author" and
/// comment initials "A" (`names`); annotation dates and UTC dates go (`dates`); and with `names`,
/// a people part's authors become "Author" and their presence information goes. Attributes are
/// matched by namespace, so any prefix is caught. `None` when the markup can't be rewritten
/// (malformed, or not UTF-8).
fn scrub_private(bytes: &[u8], names: bool, dates: bool) -> Option<Vec<u8>> {
use std::borrow::Cow;
use quick_xml::events::Event;
use quick_xml::events::attributes::Attribute;
use quick_xml::name::{QName, ResolveResult};
use quick_xml::reader::NsReader;
use quick_xml::writer::Writer;
const WML: &[u8] = b"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const WML_STRICT: &[u8] = b"http://purl.oclc.org/ooxml/wordprocessingml/main";
const W15: &[u8] = b"http://schemas.microsoft.com/office/word/2012/wordml";
// UTF-16 (or other wide encodings) would pass through unread.
if bytes.contains(&0) || bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) {
return None;
}
let ns_is = |r: &ResolveResult, ns: &[u8]| matches!(r, ResolveResult::Bound(n) if n.as_ref() == ns);
let wml = |r: &ResolveResult| ns_is(r, WML) || ns_is(r, WML_STRICT);
let mut r = NsReader::from_reader(bytes);
r.config_mut().check_end_names = false;
let mut out = Writer::new(Vec::with_capacity(bytes.len()));
let mut buf = Vec::new();
let mut skip = Vec::new();
loop {
buf.clear();
let (res, ev) = r.read_resolved_event_into(&mut buf).ok()?;
let (in_wml, in_w15) = (wml(&res), ns_is(&res, W15));
let ev = match ev {
Event::Eof => break,
Event::Start(ref e) | Event::Empty(ref e) if names && in_w15 && e.local_name().as_ref() == b"presenceInfo" => {
if matches!(ev, Event::Start(_)) {
let end = e.name().as_ref().to_vec();
r.read_to_end_into(QName(&end), &mut skip).ok()?;
skip.clear();
}
continue;
}
Event::Start(ref e) | Event::Empty(ref e) => {
let mut el = e.to_owned();
el.clear_attributes();
for a in e.attributes().with_checks(false) {
let a = a.ok()?;
let (ares, local) = r.resolver_mut().resolve_attribute(a.key);
// An unprefixed attribute of a WordprocessingML element is read as its own.
let wml_attr = wml(&ares) || (matches!(ares, ResolveResult::Unbound) && in_wml);
let value: Option<&[u8]> = match local.as_ref() {
b"author" if names && (wml_attr || ns_is(&ares, W15)) => Some(super::ANONYMOUS_AUTHOR.as_bytes()),
b"initials" if names && wml_attr => Some(super::ANONYMOUS_INITIALS.as_bytes()),
b"date" if dates && wml_attr => None,
b"dateUtc" if dates => None,
_ => {
el.push_attribute(a);
continue;
}
};
if let Some(v) = value {
el.push_attribute(Attribute { key: a.key, value: Cow::Borrowed(v) });
}
}
if matches!(ev, Event::Start(_)) { Event::Start(el) } else { Event::Empty(el) }
}
ev => ev,
};
out.write_event(ev).ok()?;
}
Some(out.into_inner())
}
/// A VML `style` with the object's size, rotation and flips, and its offset when it floats
/// (`position:absolute`).
fn vml_style_with(style: &str, w: f32, h: f32, float: &Float) -> String {
@@ -709,6 +833,40 @@ fn valid_part_name(name: &str) -> String {
mod tests {
use super::*;
#[test]
fn charts_cut_to_the_limits_on_save_say_so_once() {
// A model deserialised as is can hold more than a chart part keeps; the save left the rest
// out without a word (review of #3872).
use std::sync::Arc;
use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, MAX_SERIES};
use wordcraft_doc::graphic::{Graphic, GraphicKind};
use wordcraft_doc::{CharProps, InlineObject, Pos};
let chart = |series: usize| {
let spec = ChartSpec {
kind: ChartType::Column,
categories: vec!["a".into(), "b".into()],
series: (0..series).map(|i| ChartSeries { name: format!("S{i}"), values: vec![Some(1.0); 2] }).collect(),
title: None,
legend: None,
data_labels: false,
};
let graphic = Graphic { kind: GraphicKind::Chart, w: 100.0, h: 100.0, chart: Some(Arc::new(spec)), ..Default::default() };
InlineObject::Graphic { w: 100.0, h: 100.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) }
};
let save = |charts: Vec<InlineObject>| {
let mut doc = wordcraft_doc::Document::new();
for c in charts {
doc.insert_object(&Pos::body(0, 0), c, &CharProps::default()).unwrap();
}
crate::test_log::warnings(|| crate::write(&doc).unwrap()).1
};
let cut = |w: &Vec<String>| w.iter().filter(|w| w.contains("chart")).count();
let warned = save(vec![chart(MAX_SERIES + 1), chart(MAX_SERIES + 5), chart(2)]);
assert_eq!(cut(&warned), 1, "one warning for the save, not one per chart: {warned:?}");
let warned = save(vec![chart(3)]);
assert_eq!(cut(&warned), 0, "nothing left out: {warned:?}");
}
#[test]
fn data_part_drawing_ids_are_rewritten_once() {
let ids: HashMap<String, String> = [("rId9".to_string(), "rId2".to_string()), ("rId2".to_string(), "rId3".to_string())].into_iter().collect();
+11 -2
View File
@@ -272,7 +272,13 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
// The glossary: kept verbatim while its entries are as read and its parts were kept, else
// written from the model (before the notes: references its entries hold are collected too).
if glossary::edited(doc) || (!doc.glossary.is_empty() && !wr.embeds.kept_glossary()) {
// When the privacy settings ask for changes, its kept parts are written without the names and
// dates of its tracked changes and comments (#3692), or from the model if they can't be.
let (names, dates) = (doc.settings.remove_personal_info, doc.settings.remove_date_time);
if glossary::edited(doc)
|| (!doc.glossary.is_empty() && !wr.embeds.kept_glossary())
|| ((names || dates) && !wr.embeds.scrub_glossary(names, dates))
{
wr.embeds.drop_glossary = true;
wr.glossary(&mut entries, &mut overrides, &mut rels);
}
@@ -1083,6 +1089,9 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O
}
w.open("w:compat", &[]);
// CT_Compat: the older switches come before the `w:compatSetting`s.
if doc.settings.do_not_expand_shift_return {
w.empty("w:doNotExpandShiftReturn", &[]);
}
if doc.settings.sum_paragraph_spacing {
w.empty("w:doNotUseHTMLParagraphAutoSpacing", &[]);
}
@@ -1228,7 +1237,7 @@ fn theme_xml(doc: &Document) -> Vec<u8> {
/// The author the saved file gives a comment or tracked change when the document removes personal
/// information on save (`w:removePersonalInformation`), as Word does.
pub(crate) const ANONYMOUS_AUTHOR: &str = "Author";
const ANONYMOUS_INITIALS: &str = "A";
pub(crate) const ANONYMOUS_INITIALS: &str = "A";
/// The `w:author` written for an annotation by `author`: "Author" when the document removes
/// personal information on save, "Unknown" when it has none.
+38
View File
@@ -15,6 +15,8 @@ use crate::DocxError;
pub const MAX_DEPTH: usize = 256;
/// Maximum number of elements in one part.
pub const MAX_ELEMENTS: usize = 8_000_000;
/// Maximum number of attributes on one element (#3702).
pub const MAX_ATTRS: usize = 1024;
/// Known namespaces and the canonical prefix we use for them (read and write).
pub const NAMESPACES: &[(&str, &str)] = &[
@@ -360,6 +362,9 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
};
let mut el = El { name, attrs: Vec::new(), kids: Vec::new() };
for a in e.attributes().with_checks(false) {
if el.attrs.len() >= MAX_ATTRS {
return Err(DocxError::Limit("too many attributes on one element".into()));
}
let Ok(a) = a else { continue };
let key = a.key;
let raw_key = key.as_ref();
@@ -669,6 +674,39 @@ mod tests {
assert!(parse(s.as_bytes()).is_err());
}
/// One start tag carrying 200,000 attributes (distinct or duplicate names) is refused or
/// truncated instead of allocated: a 59 KB `.docx` used to cost 611 MB (#3702, WC-06).
#[test]
fn too_many_attributes_on_one_element_is_capped() {
// The cap the issue suggests; a fix may pick a lower one, never a higher one.
const CAP: usize = 1024;
let distinct: String = (0..200_000).map(|i| format!(" a{i}=\"\"")).collect();
let duplicate = " a=\"\"".repeat(200_000);
for (label, attrs) in [("distinct", distinct), ("duplicate", duplicate)] {
let x = format!("<w:p xmlns:w=\"{}\"{attrs}/>", NAMESPACES[0].1);
match parse(x.as_bytes()) {
Err(DocxError::Limit(_)) => {}
Ok(p) => assert!(p.attrs.len() <= CAP, "{label}: kept {} attributes", p.attrs.len()),
Err(e) => panic!("{label}: expected a Limit error or a capped element, got {e}"),
}
}
// Real-world attribute counts are untouched.
let x = format!(
r#"<w:p xmlns:w="{}" w:rsidR="00A1" w:rsidRDefault="00A1" w:rsidP="00A1" w:rsidRPr="00A1"><w:rPr><w:rFonts w:ascii="A" w:hAnsi="B" w:eastAsia="C" w:cs="D" w:asciiTheme="minorHAnsi" w:hAnsiTheme="minorHAnsi" w:eastAsiaTheme="minorEastAsia" w:cstheme="minorBidi" w:hint="eastAsia"/></w:rPr></w:p>"#,
NAMESPACES[0].1
);
let p = parse(x.as_bytes()).unwrap();
assert_eq!(p.attrs.len(), 4);
let fonts = p.child("w:rPr").and_then(|r| r.child("w:rFonts")).unwrap();
assert_eq!(fonts.attrs.len(), 9);
assert_eq!(fonts.attr("w:cstheme"), Some("minorBidi"));
// Far more than any real producer writes, still well under any sane cap.
let many: String = (0..256).map(|i| format!(" a{i}=\"{i}\"")).collect();
let p = parse(format!("<p{many}/>").as_bytes()).unwrap();
assert_eq!(p.attrs.len(), 256);
assert_eq!(p.attr("a255"), Some("255"));
}
#[test]
fn garbage_is_error_not_panic() {
assert!(parse(b"").is_err());
+62
View File
@@ -48,6 +48,40 @@ fn read_body(body: &str) -> Document {
wordcraft_docx::read(&docx(body, &[], &[])).unwrap()
}
#[test]
fn repeated_style_ids_use_last() {
let styles = format!(
r#"<w:styles {W_NS}>
<w:style w:type="paragraph" w:styleId="Repeated"><w:name w:val="First"/>
<w:pPr><w:spacing w:after="240"/></w:pPr><w:rPr><w:b/><w:smallCaps/><w:sz w:val="28"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Repeated"><w:name w:val="Last"/>
<w:pPr><w:spacing w:before="120"/></w:pPr><w:rPr><w:i/><w:caps/><w:sz w:val="20"/></w:rPr>
</w:style></w:styles>"#
);
let bytes = docx(
r#"<w:p><w:pPr><w:pStyle w:val="Repeated"/></w:pPr><w:r><w:t>Original style probe</w:t></w:r></w:p>"#,
&[("styles", "styles", "styles.xml")],
&[("word/styles.xml", &styles)],
);
let mut document = wordcraft_docx::read(&bytes).unwrap();
for round in 0..=2 {
let style = document.styles.get("Repeated").unwrap();
assert_eq!(style.name, "Last");
assert_eq!(style.chr.size, Some(10.0));
assert_eq!(style.chr.italic, Some(true));
assert_eq!(style.chr.bold, None);
assert_eq!(style.chr.caps, Some(true));
assert_eq!(style.chr.small_caps, None);
assert_eq!(style.para.space_before, Some(6.0));
assert_eq!(style.para.space_after, None);
assert_eq!(document.styles.styles.iter().filter(|style| style.id == "Repeated").count(), 1);
if round < 2 {
document = wordcraft_docx::read(&wordcraft_docx::write(&document).unwrap()).unwrap();
}
}
}
#[test]
fn paragraph_default_presence_survives_repeated_saves() {
// Omission leaves paragraph defaults application-defined (ISO 29500 pPrDefault).
@@ -339,6 +373,18 @@ fn pcl6_symbol_roundtrip() {
}
}
#[test]
fn sym_hex_prefixes_and_break_type_casing() {
let body = r##"
<w:p>
<w:r><w:sym w:font="Wingdings" w:char="0xF0FC"/><w:sym w:font="Symbol" w:char="U+F061"/><w:sym w:font="Webdings" w:char="#xF061"/></w:r>
<w:r><w:br w:type="Page"/><w:br w:type="Column"/><w:br w:type="textWrapping"/></w:r>
</w:p>"##;
let d = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
let p = paras(&d);
assert_eq!(p[0].plain_text(), "\u{F0FC}\u{F061}\u{F061}\u{C}\u{E}\n");
}
#[test]
fn alternate_content_choice_and_fallback() {
let body = r#"
@@ -3572,6 +3618,22 @@ fn html_auto_spacing_off_is_read_and_kept() {
assert_eq!(settings(""), (false, false));
}
/// `w:doNotExpandShiftReturn` in the settings' `w:compat`: justified lines ending in a manual line
/// break keep their spacing (#3924).
#[test]
fn do_not_expand_shift_return_is_read_and_kept() {
let settings = |compat: &str| {
let s = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings {W_NS}><w:compat>{compat}</w:compat></w:settings>"#);
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings", "settings.xml")];
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/settings.xml", &s)])).unwrap();
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
(d.settings.do_not_expand_shift_return, again.settings.do_not_expand_shift_return)
};
assert_eq!(settings("<w:doNotExpandShiftReturn/>"), (true, true));
assert_eq!(settings(r#"<w:doNotExpandShiftReturn w:val="0"/>"#), (false, false));
assert_eq!(settings(""), (false, false));
}
/// A right triangle (`rtTriangle`) has its right angle at the bottom left: a cover page's white
/// corner cut-outs drawn as isosceles triangles pointed the wrong way.
#[test]
+125
View File
@@ -234,6 +234,131 @@ fn edited_glossary_is_written_from_the_model() {
assert_eq!(twice.get(&target), files.get(&target));
}
#[test]
fn an_unchanged_glossary_follows_the_privacy_settings() {
// An untouched glossary was written back byte for byte, so the names and dates of its
// tracked changes survived a save that removes personal information (#3692).
let tracked = r#"<w:docPart><w:docPartPr><w:name w:val="Clause"/><w:category><w:name w:val="General"/><w:gallery w:val="docParts"/></w:category></w:docPartPr><w:docPartBody><w:p><w:ins w:id="1" w:author="Quill Ramsey" w:date="2026-03-04T05:06:07Z"><w:r><w:t>Added clause</w:t></w:r></w:ins></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(tracked)),
],
);
let doc = wordcraft_docx::read(&bytes).unwrap();
let as_read = save(&doc);
let kept = text(&as_read, &glossary_target(&as_read).expect("a glossary"));
assert!(kept.contains("Quill Ramsey"), "the fixture's glossary is kept verbatim: {kept}");
for (personal, dates) in [(true, false), (false, true), (true, true)] {
let mut private = doc.clone();
private.settings.remove_personal_info = personal;
private.settings.remove_date_time = dates;
let files = save(&private);
let g = text(&files, &glossary_target(&files).expect("a glossary"));
assert!(g.contains("Added clause"), "{g}");
assert_eq!(g.contains("Quill Ramsey"), !personal, "{g}");
assert_eq!(g.contains("2026-03-04T05:06:07Z"), !dates, "{g}");
}
}
#[test]
fn a_private_save_keeps_the_glossary_entries_the_model_passed_over() {
// The privacy settings had the glossary written from the model, which holds only the
// entries the reader took: one without a name was lost, and with no other entry the whole
// glossary went. The kept parts are now written without the names and dates instead.
let nameless = r#"<w:docPart><w:docPartPr><w:category><w:name w:val="General"/><w:gallery w:val="docParts"/></w:category></w:docPartPr><w:docPartBody><w:p><w:ins w:id="1" w:author="Quill Ramsey" w:date="2026-03-04T05:06:07Z"><w:r><w:t>Nameless entry</w:t></w:r></w:ins><w:commentRangeStart w:id="0"/><w:r><w:t>noted</w:t></w:r><w:commentRangeEnd w:id="0"/><w:r><w:commentReference w:id="0"/></w:r></w:p></w:docPartBody></w:docPart>"#;
// The comments part binds WordprocessingML to another prefix: names are matched by namespace.
let comments = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><x:comments xmlns:x="{W}"><x:comment x:id="0" x:author="Quill Ramsey" x:date="2026-03-04T05:06:07Z" x:initials="QR"><x:p><x:r><x:t>A note</x:t></x:r></x:p></x:comment></x:comments>"#
);
let bytes = package(
&[("rId7", GLOSSARY_REL, "glossary/document.xml")],
&[
(
"[Content_Types].xml",
content_types(&[
("/word/glossary/document.xml", GLOSSARY_CT),
("/word/glossary/comments.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"),
]),
),
("word/glossary/document.xml", glossary_xml(nameless)),
("word/glossary/_rels/document.xml.rels", rels(&[("rId1", &format!("{REL}/comments"), "comments.xml")])),
("word/glossary/comments.xml", comments),
],
);
let doc = wordcraft_docx::read(&bytes).unwrap();
assert!(doc.glossary.is_empty(), "the reader passes over an entry without a name");
for (personal, dates) in [(true, false), (false, true), (true, true)] {
let mut private = doc.clone();
private.settings.remove_personal_info = personal;
private.settings.remove_date_time = dates;
let files = save(&private);
let target = glossary_target(&files).expect("the glossary survives");
let g = text(&files, &target);
assert!(g.contains("Nameless entry"), "{g}");
assert_eq!(g.contains("Quill Ramsey"), !personal, "{g}");
assert_eq!(g.contains("2026-03-04T05:06:07Z"), !dates, "{g}");
let c = files.iter().find(|(n, _)| n.starts_with("word/glossary/comments")).map(|(_, b)| String::from_utf8_lossy(b).into_owned());
let c = c.expect("the glossary's comments survive");
assert!(c.contains("A note"), "{c}");
assert_eq!(c.contains("Quill Ramsey"), !personal, "{c}");
assert_eq!(c.contains("x:initials=\"QR\""), !personal, "{c}");
assert_eq!(c.contains("2026-03-04T05:06:07Z"), !dates, "{c}");
}
}
#[test]
fn a_kept_glossary_that_cant_be_rewritten_privately_is_written_from_the_model() {
// UTF-16 markup isn't rewritten in place: the model, which applies the settings, is written.
let tracked = r#"<w:docPart><w:docPartPr><w:name w:val="Clause"/><w:category><w:name w:val="General"/><w:gallery w:val="docParts"/></w:category></w:docPartPr><w:docPartBody><w:p><w:ins w:id="1" w:author="Quill Ramsey" w:date="2026-03-04T05:06:07Z"><w:r><w:t>Added clause</w:t></w:r></w:ins></w:p></w:docPartBody></w:docPart>"#;
let utf16: Vec<u8> = [0xFF, 0xFE].into_iter().chain(glossary_xml(tracked).encode_utf16().flat_map(u16::to_le_bytes)).collect();
let bytes = zip(&[
("_rels/.rels", rels(&[("rIdMain", &format!("{REL}/officeDocument"), "word/document.xml")]).into_bytes()),
(
"word/document.xml",
format!(r#"<w:document xmlns:w="{W}"><w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p></w:body></w:document>"#).into_bytes(),
),
("word/_rels/document.xml.rels", rels(&[("rId7", GLOSSARY_REL, "glossary/document.xml")]).into_bytes()),
("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)]).into_bytes()),
("word/glossary/document.xml", utf16),
]);
let mut doc = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(doc.glossary.len(), 1);
doc.settings.remove_personal_info = true;
let (files, warned) = warnings(|| save(&doc));
let g = text(&files, &glossary_target(&files).expect("a glossary"));
assert!(g.contains("Added clause") && !g.contains("Quill Ramsey"), "{g}");
// What the model doesn't hold is lost: the save says so (review of #3872).
assert_eq!(warned.iter().filter(|w| w.contains("written from its entries")).count(), 1, "{warned:?}");
}
/// `f`'s result and the warnings logged on this thread while it ran.
fn warnings<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
use std::cell::RefCell;
struct Capture;
thread_local! {
static LINES: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
}
impl log::Log for Capture {
fn enabled(&self, m: &log::Metadata) -> bool {
m.level() <= log::Level::Warn
}
fn log(&self, r: &log::Record) {
if self.enabled(r.metadata()) {
LINES.with(|l| l.borrow_mut().push(r.args().to_string()));
}
}
fn flush(&self) {}
}
static CAPTURE: Capture = Capture;
let _ = log::set_logger(&CAPTURE);
log::set_max_level(log::LevelFilter::Warn);
LINES.with(|l| l.borrow_mut().clear());
let out = f();
(out, LINES.with(|l| std::mem::take(&mut *l.borrow_mut())))
}
/// The relationships part of a part in `word/`.
fn glossary_rels(part: &str) -> String {
let (dir, file) = part.rsplit_once('/').unwrap();
+93
View File
@@ -150,6 +150,21 @@ fn many_fields_and_huge_attribute_values() {
assert!(d.plain_text(wordcraft_doc::StoryRef::Body).contains("later"));
}
/// A paragraph with 2,000,000 attributes deflates to ~15 KB. Opening it is refused with a
/// limit error, or the extras are dropped and the text survives (#3702, WC-06).
#[test]
fn millions_of_attributes_on_one_paragraph_are_capped() {
let attrs: String = (0..2_000_000).map(|i| format!(" a{i}=\"\"")).collect();
let x = format!("<w:document {W_NS}><w:body><w:p{attrs}><w:r><w:t>kept</w:t></w:r></w:p></w:body></w:document>");
let start = std::time::Instant::now();
match read(&doc_zip(x.as_bytes())) {
Err(DocxError::Limit(_)) => {}
Ok(d) => assert!(d.plain_text(wordcraft_doc::StoryRef::Body).contains("kept")),
Err(e) => panic!("expected a Limit error or a document, got {e}"),
}
assert!(start.elapsed() < std::time::Duration::from_secs(10), "took {:?}", start.elapsed());
}
/// A small valid document.xml to mutate.
fn base_xml() -> String {
format!(
@@ -258,6 +273,49 @@ proptest! {
}
}
/// The streams of a Standard-encrypted package (gap 9), and a compound file holding them.
fn standard_streams() -> (Vec<u8>, Vec<u8>) {
use std::io::Read;
let file = wordcraft_docx::encrypt_standard_for_tests(&sample(), "pw", 128).unwrap();
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(file)).unwrap();
let mut info = Vec::new();
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
let mut package = Vec::new();
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut package).unwrap();
(info, package)
}
fn compound(info: &[u8], package: &[u8]) -> Vec<u8> {
let mut comp = cfb::CompoundFile::create(std::io::Cursor::new(Vec::new())).unwrap();
comp.create_stream("/EncryptionInfo").unwrap().write_all(info).unwrap();
comp.create_stream("/EncryptedPackage").unwrap().write_all(package).unwrap();
comp.flush().unwrap();
comp.into_inner().into_inner()
}
proptest! {
// Each case that parses runs the 50,000-spin key derivation, so fewer cases.
#![proptest_config(ProptestConfig { cases: 48, ..ProptestConfig::default() })]
#[test]
fn mutated_standard_encryption_never_panics(ops in proptest::collection::vec((0u8..3, any::<prop::sample::Index>(), any::<u8>(), any::<bool>()), 1..8)) {
let (mut info, mut package) = standard_streams();
for (k, idx, byte, in_package) in ops {
let x = if in_package { &mut package } else { &mut info };
let at = idx.index(x.len() + 1);
match k {
0 => { if at < x.len() { x[at] = byte; } }
1 => { x.truncate(at); }
_ => { x.splice(at..at, std::iter::repeat_n(byte, usize::from(byte % 9))); }
}
}
let file = compound(&info, &package);
let _ = wordcraft_docx::decrypt(&file, Some("pw"));
let _ = wordcraft_docx::read_with_password(&file, Some("pw"));
prop_assert!(read(&file).is_err());
}
}
#[test]
fn long_header_reference_chain_is_cut_off() {
const HDR_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
@@ -284,3 +342,38 @@ fn long_header_reference_chain_is_cut_off() {
assert!(!d.parts.is_empty());
assert!(d.parts.len() <= 8, "followed {} chained headers", d.parts.len());
}
/// #3642: a compound file whose DIFAT names one FAT sector thousands of times is refused by the
/// encrypted-package sniff (reached for any `.docx` name) before `cfb` builds its FAT.
#[test]
fn a_repeating_difat_is_refused_before_cfb_opens_it() {
let f = wordcraft_doc::cfb_guard::repeating_difat_sample();
assert!(!wordcraft_docx::is_encrypted(&f));
assert!(matches!(wordcraft_docx::decrypt(&f, None), Err(e) if e.to_string().contains("FAT sectors in a file of")));
}
#[test]
fn comments_with_non_numeric_ids_open_in_linear_time() {
// Each generated id was checked against every id given so far, so a comments part with many
// ids that aren't numbers took time quadratic in their count (#3692).
const RELS_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
const COMMENTS_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
let n = 49_000;
let mut comments = format!("<w:comments {W_NS}>");
for i in 0..n {
comments.push_str(&format!(r#"<w:comment w:id="c{i}" w:author="A"><w:p/></w:comment>"#));
}
comments.push_str("</w:comments>");
let doc = format!("<w:document {W_NS}><w:body><w:p><w:r><w:t>body</w:t></w:r></w:p></w:body></w:document>");
let rels = format!(r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rId1" Type="{COMMENTS_REL}" Target="comments.xml"/></Relationships>"#);
let z = zip(&[
("_rels/.rels", ROOT_RELS.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/_rels/document.xml.rels", rels.as_bytes()),
("word/comments.xml", comments.as_bytes()),
]);
let d = read(&z).unwrap();
assert_eq!(d.comments.len(), n);
let ids: std::collections::HashSet<_> = d.comments.keys().collect();
assert_eq!(ids.len(), n, "every comment has its own id");
}
+46
View File
@@ -357,3 +357,49 @@ fn extended_properties_are_kept_without_words_counts_and_personal_ones_follow_th
assert!(!private.contains("Company") && !private.contains("Manager"), "{private}");
assert!(private.contains("<Template>Normal.dotm</Template>"));
}
/// `name` with its letters' case flipped by the bits of `i`.
fn spelling(name: &str, i: u32) -> String {
let mut bit = 0;
name.chars()
.map(|c| {
if !c.is_ascii_alphabetic() {
return c;
}
bit += 1;
if i >> (bit - 1) & 1 == 1 { c.to_ascii_uppercase() } else { c.to_ascii_lowercase() }
})
.collect()
}
#[test]
fn a_part_reached_under_many_spellings_is_kept_and_written_once() {
// Part names are case-insensitive (ECMA-376 Part 2 §6.2.2.3): 64 relationships naming one picture
// and one unmodelled part in 64 casings are two parts, not 128 copies on save (#3704).
let ns = r#"xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture""#;
let n = 64;
let mut body = String::new();
let mut doc_rels = Vec::new();
for i in 0..n {
body.push_str(&format!(
r#"<w:p><w:r><w:drawing><wp:inline {ns}><wp:extent cx="9525" cy="9525"/><wp:docPr id="{}" name="p"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rIdP{i}"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#,
i + 1
));
doc_rels.push((format!("rIdP{i}"), format!("{REL}/image"), spelling("media/image1.png", i)));
doc_rels.push((format!("rIdU{i}"), "urn:example:future-relationship".to_string(), spelling("unknownPart.xml", i)));
}
let doc_rels: Vec<(&str, &str, &str)> = doc_rels.iter().map(|(a, b, c)| (a.as_str(), b.as_str(), c.as_str())).collect();
let picture = format!("\u{89}PNG\r\n\u{1a}\n{}", "0".repeat(64 * 1024));
let unknown = format!(r#"<x:thing xmlns:x="urn:example:future">{}</x:thing>"#, "x".repeat(64 * 1024));
let ct = CT.replace("<Default Extension=\"jpeg\"", "<Default Extension=\"png\" ContentType=\"image/png\"/><Default Extension=\"jpeg\"");
let bytes =
package(&body, &doc_rels, &[], &[("[Content_Types].xml", &ct), ("word/media/image1.png", &picture), ("word/unknownPart.xml", &unknown)]);
let doc = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(doc.media.len(), 1, "{:?}", doc.media.keys());
let files = save(&doc);
let named = |s: &str| files.keys().filter(|k| k.to_ascii_lowercase().contains(s)).count();
assert_eq!(named("unknownpart"), 1, "{:?}", files.keys());
assert_eq!(files.keys().filter(|k| k.starts_with("word/media/")).count(), 1, "{:?}", files.keys());
let written: usize = files.values().map(Vec::len).sum();
assert!(written < 4 * 64 * 1024, "{written} bytes written");
}
+10
View File
@@ -8,6 +8,12 @@ description = "WordCraft engine: session, selection, undo, the command registry
[lints]
workspace = true
[features]
# Front-end producer tests need to simulate a globally exhausted IME revision allocator. It only
# ever makes receipt classification fail closed. Never enable this for a production build; the
# UI's dev-dependencies enable it for its tests.
test-support = []
[dependencies]
wordcraft-geom = { workspace = true, features = ["image"] }
wordcraft-doc = { workspace = true }
@@ -36,6 +42,10 @@ js-sys = "0.3"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
getrandom = { workspace = true }
[target.'cfg(unix)'.dependencies]
# O_NONBLOCK, so a path swapped for a named pipe can't block `open` (#3666).
libc = { workspace = true }
[dev-dependencies]
encoding_rs = { workspace = true }
zip = { workspace = true }
+72 -47
View File
@@ -313,6 +313,22 @@ mod tests {
}
}
/// The per-case shape/geometry of one scaled-group reroute case.
struct ScaledGroupSpec {
w: f32,
h: f32,
kind: ShapeKind,
rot: f32,
flip_h: bool,
flip_v: bool,
}
impl ScaledGroupSpec {
fn new(w: f32, h: f32, kind: ShapeKind, rot: f32, flip_h: bool, flip_v: bool) -> Self {
Self { w, h, kind, rot, flip_h, flip_v }
}
}
fn visual_ends(group: &InlineObject) -> ((f32, f32), (f32, f32)) {
let InlineObject::Group { w, h, float, .. } = group else { panic!("group") };
let rects = group.group_rects(0.0, 0.0, *w, *h);
@@ -327,6 +343,61 @@ mod tests {
(turn((x, y)), turn((x + w, y + h)))
}
/// One scaled-group case: rerouting keeps the visual free end and the resolved sites.
fn assert_scaled_group_reroute(spec: ScaledGroupSpec, free: Option<ConnEnd>, free_start: bool) {
let attached = Some(ConnEnd { id: 7, site: 3 });
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
let group = InlineObject::Group {
w: spec.w,
h: spec.h,
ch_w: 200.0,
ch_h: 300.0,
float: Float { rot: 25.0, flip_h: true, ..Default::default() },
canvas: None,
children: vec![
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, ShapeExtra { id: 7, ..Default::default() }, Float::default()) },
GroupChild {
x: 10.0,
y: 30.0,
obj: shape(
spec.kind,
ShapeExtra { start, end, ..Default::default() },
Float { rot: spec.rot, flip_h: spec.flip_h, flip_v: spec.flip_v, ..Default::default() },
),
},
],
};
// Rendering scales each child rectangle before applying its own spin.
let before = visual_ends(&group);
let expected = if free_start { before.0 } else { before.1 };
let mut s = Session::new(wordcraft_doc::Document::new());
let pos = s.doc.start_of(StoryRef::Body);
s.doc.para_mut(pos.story, &pos.path).unwrap().insert_object(pos.off, group, &Default::default()).unwrap();
assert!(reroute(&mut s));
let group = s.doc.para_at(&pos).unwrap().object_at(pos.off).unwrap();
let after = visual_ends(group);
let actual = if free_start { after.0 } else { after.1 };
assert!(
(actual.0 - expected.0).abs() < 0.001 && (actual.1 - expected.1).abs() < 0.001,
"{}x{}, {:?}, {}, {}, {}, {}: {:?} != {:?}",
spec.w,
spec.h,
spec.kind,
spec.rot,
spec.flip_h,
spec.flip_v,
free_start,
actual,
expected
);
let InlineObject::Group { children, .. } = group else { panic!("group") };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!("connector") };
let routed = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
assert_eq!(if free_start { routed.1 } else { routed.0 }, (140.0, 210.0), "resolved sites stay in child coordinates");
assert_eq!(float.rot, 0.0);
assert!(!reroute(&mut s), "rerouting must settle");
}
#[test]
fn rerouting_scaled_group_preserves_visual_free_and_unresolved_ends() {
for (w, h) in [(200.0, 300.0), (400.0, 600.0), (400.0, 150.0)] {
@@ -335,53 +406,7 @@ mod tests {
for (flip_h, flip_v) in [(false, false), (true, false), (false, true), (true, true)] {
for free in [None, Some(ConnEnd { id: 404, site: 0 }), Some(ConnEnd { id: 7, site: 99 })] {
for free_start in [false, true] {
let attached = Some(ConnEnd { id: 7, site: 3 });
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
let group = InlineObject::Group {
w,
h,
ch_w: 200.0,
ch_h: 300.0,
float: Float { rot: 25.0, flip_h: true, ..Default::default() },
canvas: None,
children: vec![
GroupChild {
x: 100.0,
y: 200.0,
obj: shape(ShapeKind::Rectangle, ShapeExtra { id: 7, ..Default::default() }, Float::default()),
},
GroupChild {
x: 10.0,
y: 30.0,
obj: shape(
kind,
ShapeExtra { start, end, ..Default::default() },
Float { rot, flip_h, flip_v, ..Default::default() },
),
},
],
};
// Rendering scales each child rectangle before applying its own spin.
let before = visual_ends(&group);
let expected = if free_start { before.0 } else { before.1 };
let mut s = Session::new(wordcraft_doc::Document::new());
let pos = s.doc.start_of(StoryRef::Body);
s.doc.para_mut(pos.story, &pos.path).unwrap().insert_object(pos.off, group, &Default::default()).unwrap();
assert!(reroute(&mut s));
let group = s.doc.para_at(&pos).unwrap().object_at(pos.off).unwrap();
let after = visual_ends(group);
let actual = if free_start { after.0 } else { after.1 };
assert!(
(actual.0 - expected.0).abs() < 0.001 && (actual.1 - expected.1).abs() < 0.001,
"{w}x{h}, {kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {actual:?} != {expected:?}"
);
let InlineObject::Group { children, .. } = group else { panic!("group") };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!("connector") };
let routed = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
assert_eq!(if free_start { routed.1 } else { routed.0 }, (140.0, 210.0), "resolved sites stay in child coordinates");
assert_eq!(float.rot, 0.0);
assert!(!reroute(&mut s), "rerouting must settle");
assert_scaled_group_reroute(ScaledGroupSpec::new(w, h, kind, rot, flip_h, flip_v), free, free_start);
}
}
}
+55 -6
View File
@@ -1,7 +1,7 @@
//! Design tab: themes, style sets, colours, fonts, paragraph spacing, watermark, page colour, borders.
use serde_json::{Value, json};
use wordcraft_doc::props::{Border, BorderStyle, Borders, LineSpacing, Rgb, TextColor};
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, LineSpacing, Rgb, TextColor};
use wordcraft_doc::{Watermark, styles};
use super::sel_result;
@@ -67,6 +67,8 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"name": "default|basic|lines|shaded|casual|centered|minimalist|title"}"#),
CommandSpec::new("design.paragraphSpacing", "Paragraph Spacing", "Design › Document Formatting", para_spacing)
.params(r#"{"value": "default|none|compact|tight|open|relaxed|double"}"#),
CommandSpec::new("design.scaleFontSizes", "Scale Font Sizes", "Design › Document Formatting", scale_font_sizes)
.params(r#"{"delta": pt (-1638..1638, not 0) | "percent": number (-99..1000, not 0; 10 = +10%)} (exactly one)"#),
CommandSpec::new("design.watermark", "Watermark", "Design › Page Background", watermark)
.params(r#"{"text"?: string, "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB"}"#),
CommandSpec::new("design.pageColor", "Page Color", "Design › Page Background", |s, v| {
@@ -169,11 +171,19 @@ fn style_set(s: &mut Session, v: &Value) -> CmdResult {
_ => {}
}
}
// Keep custom styles and fonts.
let customs: Vec<_> = s.doc.styles.styles.iter().filter(|x| !x.builtin).cloned().collect();
fresh.styles.extend(customs);
fresh.default_chr.font = s.doc.styles.default_chr.font.clone();
s.doc.styles = fresh;
// Overlay the set onto the document's sheet: every other style, the latent styles and the
// document defaults stay as they are (no set defines its own defaults).
for mut st in fresh.styles {
match s.doc.styles.styles.iter_mut().find(|x| x.id == st.id) {
Some(old) => {
st.default = old.default;
st.locked = old.locked;
st.builtin = old.builtin;
*old = st;
}
None => s.doc.styles.styles.push(st),
}
}
let (h, b) = (s.doc.settings.major_font.clone(), s.doc.settings.minor_font.clone());
set_fonts(s, &h, &b);
set_accent(s, accent);
@@ -196,6 +206,45 @@ fn para_spacing(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// Grow or shrink every style's font size at once. Only explicit style sizes change (including
/// table style parts); styles that inherit one (via `based_on` or the defaults) follow, and runs
/// keep their own direct formatting.
fn scale_font_sizes(s: &mut Session, v: &Value) -> CmdResult {
let (delta, percent) = (p::f32(v, "delta"), p::f32(v, "percent"));
// A field that is present but not a number is an error, not "absent".
if (v.get("delta").is_some() && delta.is_none()) || (v.get("percent").is_some() && percent.is_none()) {
return Err(CmdError::Params("`delta` and `percent` must be numbers".into()));
}
if delta.is_some() == percent.is_some() {
return Err(CmdError::Params("give exactly one of `delta` (points) or `percent`".into()));
}
if delta.is_some_and(|d| !(-1638.0..=1638.0).contains(&d) || d == 0.0) || percent.is_some_and(|x| !(-99.0..=1000.0).contains(&x) || x == 0.0) {
return Err(CmdError::Params("`delta` must be non-zero within ±1638 pt and `percent` non-zero within -99..1000".into()));
}
let factor = 1.0 + percent.unwrap_or(0.0) / 100.0;
let scale = |size: f32| ((size * factor + delta.unwrap_or(0.0)).clamp(1.0, 1638.0) * 2.0).round() / 2.0;
let scale_chr = |c: &mut CharProps| {
c.size = c.size.map(scale);
c.size_cs = c.size_cs.map(scale);
};
let st = &mut s.doc.styles;
// An unset document default resolves to the fallback size; make it explicit so inheriting styles follow.
st.default_chr.size = Some(scale(st.default_chr.size.unwrap_or(wordcraft_doc::props::DEFAULT_FONT_SIZE)));
st.default_chr.size_cs = st.default_chr.size_cs.map(scale);
for style in &mut st.styles {
scale_chr(&mut style.chr);
if let Some(t) = style.table.as_mut() {
for c in [&mut t.header_chr, &mut t.band_chr, &mut t.first_col_chr, &mut t.last_col_chr, &mut t.total_chr, &mut t.col_band_chr] {
scale_chr(c);
}
for part in t.regions.values_mut() {
scale_chr(&mut part.chr);
}
}
}
sel_result(s)
}
fn watermark(s: &mut Session, v: &Value) -> CmdResult {
if p::bool(v, "remove").unwrap_or(false) {
s.doc.settings.watermark = None;
+5 -2
View File
@@ -142,7 +142,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"tab"?: "find|replace|goto" (default replace)}"#)
.pure(),
CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all)
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards), …options of edit.find}"#),
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards; with regex also $1 ${n} ${name} $0 $$ \\1 \\n (paragraph mark) \\t), …options of edit.find}"#),
CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto)
.key("Mod+Alt+G / F5")
.params(r#"{"page"?: n, "pageBy"?: int (pages forward, negative back, from the caret's page), "bookmark"?: string (selects its text), "paragraph"?: n}; none opens Find and Replace on its Go To tab"#)
@@ -459,7 +459,10 @@ pub(crate) fn search_full(s: &Session, story: StoryRef, with_rep: bool) -> Resul
.configure(meta::Config::new().nfa_size_limit(Some(1 << 20)))
.build(&c.pattern)
.map_err(|e| CmdError::Params(format!("bad pattern: {e}")))?;
let pieces = with_rep.then(|| find_pattern::parse_replace(&f.replace, &opts));
let pieces = with_rep.then(|| {
let index = |name: &str| re.group_info().to_index(regex_automata::PatternID::ZERO, name);
find_pattern::resolve_names(find_pattern::parse_replace(&f.replace, &opts), &index)
});
let paths = s.doc.para_paths(story);
// Paragraphs read as one text: consecutive siblings when the pattern spans paragraphs.
let mut chunks: Vec<std::ops::Range<usize>> = Vec::new();
+481 -49
View File
@@ -10,7 +10,7 @@ use wordcraft_doc::para::OBJ;
use wordcraft_doc::{InlineObject, Pos};
use super::{delete_selection, sel_result, split_para};
use crate::{CmdError, CmdResult, CommandSpec, MathEdit, Selection, Session, p};
use crate::{CmdError, CmdResult, CommandSpec, ImeEditOutcome, MathEdit, Selection, Session, p};
fn editing(s: &Session) -> Option<&'static str> {
if s.math.is_none() { Some("not editing an equation") } else { None }
@@ -20,6 +20,9 @@ fn near_equation(s: &Session) -> Option<&'static str> {
if s.math.is_some() || equation_at_caret(s).is_some() { None } else { Some("no equation at the caret") }
}
/// Builds the equation command table: insert, edit and exit, typing, structures, symbols, caret
/// movement, deletion (including bounded surrounding deletion), enter, conversion, input format,
/// normal text, display, justification, numbering, set and get, gallery and structure actions.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("insert.equation", "Equation", "Insert › Symbols", insert)
@@ -40,6 +43,9 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"dir": "left|right|up|down|home|end|next|prev"}"#),
CommandSpec::new("equation.backspace", "Delete Back in Equation", "Equation", |s, _| delete(s, true)).when(editing),
CommandSpec::new("equation.delete", "Delete in Equation", "Equation", |s, _| delete(s, false)).when(editing),
CommandSpec::new("equation.deleteSurrounding", "Delete Around Caret in Equation", "Equation", delete_surrounding)
.when(editing)
.params(r#"{"before": n, "after": n}"#),
CommandSpec::new("equation.enter", "Enter in Equation", "Equation", enter).when(editing),
CommandSpec::new("equation.convert", "Convert", "Equation › Conversions", convert)
.when(near_equation_or_all)
@@ -100,21 +106,73 @@ fn equation_at_caret(s: &Session) -> Option<Pos> {
None
}
/// What one equation edit changed. `content_changed` gates an IME receipt; `state_changed`
/// covers style, representation and lazy-normalization changes that must poison a mixed frame.
#[derive(Clone, Copy, Debug, Default)]
struct EquationEffect {
content_changed: bool,
state_changed: bool,
}
impl EquationEffect {
/// Merges another edit's effect flags, so a change on either side marks the merged effect.
fn merge(&mut self, other: Self) {
self.content_changed |= other.content_changed;
self.state_changed |= other.state_changed;
}
}
/// Records the edit as a classified IME outcome; any content change also counts as a state
/// change, and `transformed` marks automatic rewrites.
fn apply_effect(s: &mut Session, effect: EquationEffect, transformed: bool) {
s.set_ime_edit_outcome(ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed || effect.content_changed,
transformed,
selection_changed: false,
});
}
/// Records a classified IME outcome with no content or state change, distinguishing a real
/// no-op from a command that never classified its edit.
fn classified_noop(s: &mut Session) {
s.set_ime_edit_outcome(ImeEditOutcome { classified: true, ..ImeEditOutcome::default() });
}
/// Run `f` on the equation at `at`; keeps its linear text in step and drops the source OMML
/// (it no longer matches).
fn with_eq<R>(s: &mut Session, at: &Pos, f: impl FnOnce(&mut Math, &mut bool) -> R) -> Result<R, CmdError> {
///
/// The logical before-state is captured *after* lazy `nodes` materialization: an imported
/// equation that only gets normalized is a state change, never an IME content change.
fn with_eq<R>(s: &mut Session, at: &Pos, f: impl FnOnce(&mut Math, &mut bool) -> R) -> Result<(R, EquationEffect), CmdError> {
let para = s.doc.para_mut(at.story, &at.path)?;
let Some(InlineObject::Equation { linear, display, math }) = para.object_at_mut(at.off) else {
return Err(CmdError::Failed("no equation there".into()));
};
if math.nodes.is_empty() && !linear.is_empty() && !math.linear {
let normalized = math.nodes.is_empty() && !linear.is_empty() && !math.linear;
if normalized {
math.nodes = parse_linear(linear);
}
let nodes_before = math.nodes.clone();
let linear_mode_before = math.linear;
let jc_before = math.jc;
let display_before = *display;
let cached_linear_before = linear.clone();
let omml_before_nonempty = !math.omml.is_empty();
let r = f(math, display);
*linear = if math.linear { plain(&math.nodes) } else { to_linear(&math.nodes) };
let new_linear = if math.linear { plain(&math.nodes) } else { to_linear(&math.nodes) };
let content_changed = math.nodes != nodes_before || math.linear != linear_mode_before;
let state_changed = normalized
|| content_changed
|| math.jc != jc_before
|| *display != display_before
|| omml_before_nonempty
|| new_linear != cached_linear_before;
*linear = new_linear;
math.omml.clear();
para.touch();
Ok(r)
Ok((r, EquationEffect { content_changed, state_changed }))
}
/// All the text of an equation's runs (its linear form while shown linear).
@@ -163,6 +221,9 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
insert_nodes_as_equation(s, nodes, v)
}
/// Clears any editing session, deletes the selection, creates an equation object (display on an
/// empty line unless the request overrides it), optionally opens editing at the first
/// placeholder, and reports a content change.
fn insert_nodes_as_equation(s: &mut Session, nodes: Arg, v: &Value) -> CmdResult {
s.math = None;
let props = s.typing_props();
@@ -178,14 +239,27 @@ fn insert_nodes_as_equation(s: &mut Session, nodes: Arg, v: &Value) -> CmdResult
let obj = InlineObject::Equation { linear, display, math: Math { nodes, ..Default::default() } };
let end = s.doc.insert_object(&at, obj, &props)?;
if p::bool(v, "edit").unwrap_or(true) {
let Some(session) = crate::next_math_session() else {
return Err(CmdError::Failed("no equation editing session available".into()));
};
s.sel = Selection::caret(at.clone());
s.math = Some(MathEdit { at, pos: caret });
s.math = Some(MathEdit { at, pos: caret, session });
} else {
s.sel = Selection::caret(end);
}
// Creating an equation inserts visible content, even when its tree is initially empty.
s.set_ime_edit_outcome(ImeEditOutcome {
classified: true,
content_changed: true,
state_changed: true,
transformed: false,
selection_changed: false,
});
result(s)
}
/// Starts editing the equation at the caret, materializing lazy nodes and clamping the requested
/// position, and returns the equation and caret.
fn edit(s: &mut Session, v: &Value) -> CmdResult {
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let nodes = match s.doc.para_at(&at).and_then(|p| p.object_at(at.off)) {
@@ -200,8 +274,11 @@ fn edit(s: &mut Session, v: &Value) -> CmdResult {
};
let pos =
v.get("pos").and_then(|p| serde_json::from_value::<MathPos>(p.clone()).ok()).unwrap_or_else(|| MathPos::new(Vec::new(), me::units(&nodes)));
let Some(session) = crate::next_math_session() else {
return Err(CmdError::Failed("no equation editing session available".into()));
};
s.sel = Selection::caret(at.clone());
s.math = Some(MathEdit { pos: me::clamp(&nodes, &pos), at });
s.math = Some(MathEdit { pos: me::clamp(&nodes, &pos), at, session });
result(s)
}
@@ -218,20 +295,30 @@ fn current(s: &Session) -> Result<MathEdit, CmdError> {
s.math.clone().ok_or_else(|| CmdError::Disabled("not editing an equation".into()))
}
/// Caps typing at `MAX_EQUATION_TYPE_CHARS`; empty text is a classified no-op. Linear form
/// inserts literal runs, while built-up form applies AutoCorrect and build-up before spaces and
/// relational characters.
fn type_text(s: &mut Session, v: &Value) -> CmdResult {
let text = p::req_str(v, "text")?.to_string();
let m = current(s)?;
if text.is_empty() {
// No normalization, no linear-cache rewrite, no revision: an empty commit is a no-op.
classified_noop(s);
return result(s);
}
let latex = s.math_latex;
let style = TypeStyle { nor: s.math_normal_text };
let mut pos = m.pos.clone();
with_eq(s, &m.at, |math, _| {
let mut transformed = false;
let (_, effect) = with_eq(s, &m.at, |math, _| {
if math.linear {
// Linear form is plain text: no build-up.
let text: String = text.chars().take(crate::MAX_EQUATION_TYPE_CHARS).collect();
pos = me::insert_text(&mut math.nodes, &pos, &text, TypeStyle::default());
set_literal(&mut math.nodes);
return;
}
for c in text.chars().take(10_000) {
for c in text.chars().take(crate::MAX_EQUATION_TYPE_CHARS) {
let rel = matches!(wordcraft_doc::math::math_class(c), wordcraft_doc::math::MClass::Rel);
if !style.nor && (c == ' ' || c == '\t' || rel) {
let mut changed = false;
@@ -243,6 +330,9 @@ fn type_text(s: &mut Session, v: &Value) -> CmdResult {
pos = p;
changed = true;
}
if changed {
transformed = true;
}
if c == ' ' && changed {
continue;
}
@@ -253,9 +343,9 @@ fn type_text(s: &mut Session, v: &Value) -> CmdResult {
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, transformed);
result(s)
}
/// Runs of a linear-form equation are literal text.
fn set_literal(nodes: &mut Arg) {
for n in nodes.iter_mut() {
@@ -265,6 +355,8 @@ fn set_literal(nodes: &mut Arg) {
}
}
/// Inserts a gallery, linear or LaTeX structure: a new equation when none is being edited,
/// literal text in linear form, or nodes at the caret in built-up form.
fn insert_structure(s: &mut Session, v: &Value) -> CmdResult {
let nodes = nodes_from(v).ok_or_else(|| CmdError::Params("give an id, linear or latex".into()))?;
let Some(m) = s.math.clone() else {
@@ -272,7 +364,7 @@ fn insert_structure(s: &mut Session, v: &Value) -> CmdResult {
return insert_nodes_as_equation(s, nodes, &json!({}));
};
let mut pos = m.pos.clone();
with_eq(s, &m.at, |math, _| {
let (_, effect) = with_eq(s, &m.at, |math, _| {
if math.linear {
pos = me::insert_text(&mut math.nodes, &pos, &to_linear(&nodes), TypeStyle::default());
set_literal(&mut math.nodes);
@@ -283,9 +375,12 @@ fn insert_structure(s: &mut Session, v: &Value) -> CmdResult {
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
result(s)
}
/// Rejects an empty character; outside editing creates an equation, otherwise inserts the
/// character at the math caret, marked literal in linear form.
fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult {
let c = p::req_str(v, "char")?.to_string();
if c.is_empty() {
@@ -296,7 +391,7 @@ fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult {
};
let mut pos = m.pos.clone();
let style = TypeStyle { nor: s.math_normal_text };
with_eq(s, &m.at, |math, _| {
let (_, effect) = with_eq(s, &m.at, |math, _| {
pos = me::insert_text(&mut math.nodes, &pos, &c, style);
if math.linear {
set_literal(&mut math.nodes);
@@ -305,6 +400,7 @@ fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult {
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
result(s)
}
@@ -317,6 +413,8 @@ fn nodes_of(s: &Session, at: &Pos) -> Option<Arg> {
}
}
/// Validates the direction, commits an AutoCorrect or build-up triggered by leaving a slot as one
/// undoable edit, moves or clamps the math caret, and exits past either end.
fn move_caret(s: &mut Session, v: &Value) -> CmdResult {
let m = current(s)?;
let dir = p::str(v, "dir").unwrap_or("right");
@@ -324,48 +422,49 @@ fn move_caret(s: &mut Session, v: &Value) -> CmdResult {
return Err(CmdError::Params(format!("unknown direction {dir}")));
}
// Leaving a slot finishes what was typed there (AutoCorrect names, build-up). That is worked
// out on a copy: plain navigation must leave the equation (and its source OMML) untouched,
// and a real change is a document edit — undoable and marking the document dirty (#446, #447).
// out on a local copy: plain navigation must leave the equation (and its source OMML)
// untouched, and a real change is a document edit — undoable and marking the document dirty
// (#446, #447). Any failure happens before the single commit, so no rollback is needed.
let shown_linear = matches!(
s.doc.para_at(&m.at).and_then(|p| p.object_at(m.at.off)),
Some(InlineObject::Equation { math, .. }) if math.linear
);
if !s.math_normal_text
&& !shown_linear
&& let Some(before) = nodes_of(s, &m.at)
{
let mut nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
let mut pos = m.pos.clone();
if !s.math_normal_text && !shown_linear {
let latex = s.math_latex;
let mut nodes = before.clone();
let mut pos = m.pos.clone();
let before = nodes.clone();
let mut candidate = nodes.clone();
let mut candidate_pos = pos.clone();
let mut changed = false;
if let Some(p) = me::autocorrect(&mut nodes, &pos) {
pos = p;
if let Some(p) = me::autocorrect(&mut candidate, &candidate_pos) {
candidate_pos = p;
changed = true;
}
if let Some(p) = me::build_up(&mut nodes, &pos, latex) {
pos = p;
if let Some(p) = me::build_up(&mut candidate, &candidate_pos, latex) {
candidate_pos = p;
changed = true;
}
if changed && nodes != before {
pos = candidate_pos;
if changed && candidate != before {
// `equation.move` is a pure command, so record the edit here.
s.checkpoint("Build Up");
with_eq(s, &m.at, |math, _| math.nodes = nodes)?;
let (_, effect) = with_eq(s, &m.at, |math, _| math.nodes = candidate.clone())?;
apply_effect(s, effect, true);
// `equation.move` is pure for ordinary navigation, so `Session::run` does not touch
// the document for us. A build-up is a real edit: mark it dirty and refresh layout.
s.touch();
if let Some(mm) = s.math.as_mut() {
mm.pos = pos;
}
nodes = candidate;
}
}
let m = current(s)?;
let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
let next = match dir {
"left" => me::move_left(&nodes, &m.pos),
"right" => me::move_right(&nodes, &m.pos),
"up" => Some(me::move_vertical(&nodes, &m.pos, true).unwrap_or(m.pos.clone())),
"down" => Some(me::move_vertical(&nodes, &m.pos, false).unwrap_or(m.pos.clone())),
"home" => Some(me::home_end(&nodes, &m.pos, false)),
"end" => Some(me::home_end(&nodes, &m.pos, true)),
"next" | "prev" => Some(me::next_placeholder(&nodes, &m.pos, dir == "prev").unwrap_or(m.pos.clone())),
"left" => me::move_left(&nodes, &pos),
"right" => me::move_right(&nodes, &pos),
"up" => Some(me::move_vertical(&nodes, &pos, true).unwrap_or(pos.clone())),
"down" => Some(me::move_vertical(&nodes, &pos, false).unwrap_or(pos.clone())),
"home" => Some(me::home_end(&nodes, &pos, false)),
"end" => Some(me::home_end(&nodes, &pos, true)),
"next" | "prev" => Some(me::next_placeholder(&nodes, &pos, dir == "prev").unwrap_or(pos.clone())),
other => return Err(CmdError::Params(format!("unknown direction {other}"))),
};
match next {
@@ -379,34 +478,96 @@ fn move_caret(s: &mut Session, v: &Value) -> CmdResult {
None => exit(s, &json!({"before": dir == "left"})),
}
}
/// With the caret in an empty equation, removes the whole equation as a content change;
/// otherwise deletes backward or forward, updates the caret and records the effect.
fn delete(s: &mut Session, back: bool) -> CmdResult {
let m = current(s)?;
let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
// An empty equation goes away entirely.
// An empty equation goes away entirely: removing the object is a content change.
if nodes.is_empty() {
s.math = None;
s.sel = Selection { anchor: m.at.clone(), focus: Pos { off: m.at.off + OBJ.len_utf8(), ..m.at.clone() } };
let at = delete_selection(s)?;
s.sel = Selection::caret(at);
s.set_ime_edit_outcome(ImeEditOutcome {
classified: true,
content_changed: true,
state_changed: true,
transformed: false,
selection_changed: false,
});
return sel_result(s);
}
let mut pos = m.pos.clone();
with_eq(s, &m.at, |math, _| {
let (_, effect) = with_eq(s, &m.at, |math, _| {
pos = if back { me::delete_back(&mut math.nodes, &pos) } else { me::delete_forward(&mut math.nodes, &pos) };
})?;
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
result(s)
}
/// Delete a bounded number of equation units on each side of the caret in one transaction.
///
/// The requested steps are simulated on a local tree and caret. Any step that cannot make
/// progress refuses the whole request before the document is touched; the commit (if any) is a
/// single command and a single undo step.
fn delete_surrounding(s: &mut Session, v: &Value) -> CmdResult {
let m = current(s)?;
let before = p::u64(v, "before").and_then(|n| usize::try_from(n).ok()).unwrap_or(usize::MAX);
let after = p::u64(v, "after").and_then(|n| usize::try_from(n).ok()).unwrap_or(usize::MAX);
if before.saturating_add(after) == 0 {
return Err(CmdError::Params("nothing to delete".into()));
}
if before > crate::MAX_EQUATION_DELETE_UNITS || after > crate::MAX_EQUATION_DELETE_UNITS {
return Err(CmdError::Params(format!("at most {} units on each side", crate::MAX_EQUATION_DELETE_UNITS)));
}
let mut nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
let mut pos = m.pos.clone();
for _ in 0..before {
let prev_nodes = nodes.clone();
let prev_pos = pos.clone();
pos = me::delete_back(&mut nodes, &pos);
if nodes == prev_nodes {
// Walking into the next argument is not a deletion. A requested unit that only
// moves the caret must refuse the whole request, or a later step can apply against
// a tree the platform's surrounding snapshot did not describe.
return Err(CmdError::Failed(if pos == prev_pos {
"the equation cannot delete that many units".into()
} else {
"the equation delete made no progress".into()
}));
}
}
for _ in 0..after {
let prev_nodes = nodes.clone();
let prev_pos = pos.clone();
pos = me::delete_forward(&mut nodes, &pos);
if nodes == prev_nodes {
return Err(CmdError::Failed(if pos == prev_pos {
"the equation cannot delete that many units".into()
} else {
"the equation delete made no progress".into()
}));
}
}
let (_, effect) = with_eq(s, &m.at, |math, _| math.nodes = nodes)?;
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
Ok(json!({"before": before, "after": after, "changed": true}))
}
/// In built-up form runs AutoCorrect, build-up and equation numbering; when nothing changes,
/// leaves the equation and splits a new paragraph.
fn enter(s: &mut Session, _: &Value) -> CmdResult {
let m = current(s)?;
let latex = s.math_latex;
let mut pos = m.pos.clone();
let mut changed = false;
with_eq(s, &m.at, |math, _| {
let (_, effect) = with_eq(s, &m.at, |math, _| {
if math.linear {
return;
}
@@ -427,6 +588,7 @@ fn enter(s: &mut Session, _: &Value) -> CmdResult {
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, true);
return result(s);
}
// Nothing to build: Enter ends the equation and starts a new paragraph.
@@ -434,6 +596,13 @@ fn enter(s: &mut Session, _: &Value) -> CmdResult {
let after = Pos { off: m.at.off + OBJ.len_utf8(), ..m.at };
let new = split_para(s, &after)?;
s.sel = Selection::caret(new);
s.set_ime_edit_outcome(ImeEditOutcome {
classified: true,
content_changed: true,
state_changed: true,
transformed: false,
selection_changed: false,
});
sel_result(s)
}
@@ -452,11 +621,14 @@ fn convert_math(math: &mut Math, professional: bool, latex: bool) {
}
}
/// Converts one equation or all equations between built-up and linear form, honoring LaTeX mode,
/// merging every effect and resetting the math caret.
fn convert(s: &mut Session, v: &Value) -> CmdResult {
let professional = p::str(v, "to").map(|t| !t.eq_ignore_ascii_case("linear")).unwrap_or(true);
let latex = s.math_latex;
if p::bool(v, "all").unwrap_or(false) {
let mut n = 0;
let mut effect = EquationEffect::default();
let stories: Vec<wordcraft_doc::StoryRef> =
std::iter::once(wordcraft_doc::StoryRef::Body).chain(s.doc.parts.keys().map(|k| wordcraft_doc::StoryRef::Part(*k))).collect();
for story in stories {
@@ -468,7 +640,8 @@ fn convert(s: &mut Session, v: &Value) -> CmdResult {
.unwrap_or_default();
for off in offs {
let at = Pos { story, path: path.clone(), off };
with_eq(s, &at, |math, _| convert_math(math, professional, latex))?;
let (_, one) = with_eq(s, &at, |math, _| convert_math(math, professional, latex))?;
effect.merge(one);
n += 1;
}
}
@@ -477,26 +650,32 @@ fn convert(s: &mut Session, v: &Value) -> CmdResult {
m.pos = MathPos::default();
}
s.clamp_selection();
apply_effect(s, effect, false);
return Ok(json!({"converted": n}));
}
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let mut end = 0;
with_eq(s, &at, |math, _| {
let (_, effect) = with_eq(s, &at, |math, _| {
convert_math(math, professional, latex);
end = me::units(&math.nodes);
})?;
if let Some(m) = s.math.as_mut() {
m.pos = MathPos::new(Vec::new(), end);
}
apply_effect(s, effect, false);
result(s)
}
/// Toggles or sets the equation's inline or display mode.
fn set_display(s: &mut Session, v: &Value) -> CmdResult {
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
with_eq(s, &at, |_, display| *display = p::bool(v, "value").unwrap_or(!*display))?;
let (_, effect) = with_eq(s, &at, |_, display| *display = p::bool(v, "value").unwrap_or(!*display))?;
apply_effect(s, effect, false);
result(s)
}
/// Maps `jc` to the math justification, with unknown values falling back to center-group, and
/// forces display mode.
fn justify(s: &mut Session, v: &Value) -> CmdResult {
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let jc = match p::str(v, "jc").unwrap_or("centerGroup") {
@@ -505,10 +684,11 @@ fn justify(s: &mut Session, v: &Value) -> CmdResult {
"center" => MathJc::Center,
_ => MathJc::CenterGroup,
};
with_eq(s, &at, |math, display| {
let (_, effect) = with_eq(s, &at, |math, display| {
math.jc = jc;
*display = true;
})?;
apply_effect(s, effect, false);
result(s)
}
@@ -516,7 +696,7 @@ fn justify(s: &mut Session, v: &Value) -> CmdResult {
fn number(s: &mut Session, v: &Value) -> CmdResult {
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let mut pos_reset = false;
with_eq(s, &at, |math, display| {
let (_, effect) = with_eq(s, &at, |math, display| {
let numbered = matches!(math.nodes.as_slice(), [MNode::EqArr { rows }] if rows.iter().any(|r| wordcraft_doc::math::has_number_mark(r)));
let want = p::bool(v, "value").unwrap_or(!numbered);
if want && !numbered {
@@ -539,6 +719,7 @@ fn number(s: &mut Session, v: &Value) -> CmdResult {
m.pos = MathPos::default();
}
s.clamp_selection();
apply_effect(s, effect, false);
result(s)
}
@@ -562,17 +743,20 @@ fn without_number(row: Arg) -> Arg {
out
}
/// Replaces the equation's nodes from linear or LaTeX input, forces built-up form, and puts the
/// caret at the new end.
fn set(s: &mut Session, v: &Value) -> CmdResult {
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let nodes = nodes_from(v).ok_or_else(|| CmdError::Params("give linear or latex".into()))?;
let end = me::units(&nodes);
with_eq(s, &at, |math, _| {
let (_, effect) = with_eq(s, &at, |math, _| {
math.nodes = nodes;
math.linear = false;
})?;
if let Some(m) = s.math.as_mut() {
m.pos = MathPos::new(Vec::new(), end);
}
apply_effect(s, effect, false);
result(s)
}
@@ -601,6 +785,8 @@ fn structure_actions(s: &mut Session, _: &Value) -> CmdResult {
Ok(json!({"actions": acts}))
}
/// Applies a named structure action at an explicit or inferred level, refusing an action that
/// does not apply at the caret.
fn structure(s: &mut Session, v: &Value) -> CmdResult {
let m = current(s)?;
let action = p::req_str(v, "action")?.to_string();
@@ -614,11 +800,12 @@ fn structure(s: &mut Session, v: &Value) -> CmdResult {
.ok_or_else(|| CmdError::Params(format!("{action} doesn't apply here")))?,
};
let mut pos = None;
with_eq(s, &m.at, |math, _| pos = me::apply_structure(&mut math.nodes, &m.pos, level, &action))?;
let (_, effect) = with_eq(s, &m.at, |math, _| pos = me::apply_structure(&mut math.nodes, &m.pos, level, &action))?;
let pos = pos.ok_or_else(|| CmdError::Params(format!("{action} doesn't apply here")))?;
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
result(s)
}
@@ -858,14 +1045,24 @@ mod tests {
assert!(document_xml(&s).contains(">bx<"));
}
/// A Left move that builds up `a/b` makes the document dirty, advances layout and IME revisions,
/// reports a classified transformed change, and Undo restores the literal run; plain navigation
/// leaves no undo step.
#[test]
fn navigation_build_up_is_an_undoable_edit() {
// #447: building `a/b` up on a Left move left the document clean and Undo did nothing.
let mut s = imported("<m:r><m:t>a/b</m:t></m:r>");
assert!(!s.dirty);
let layout_rev = s.rev();
let ime_rev = revision(&mut s);
s.run("equation.move", &json!({"dir": "left"})).unwrap();
// Every command replaces the last outcome; sample the edit before the read-only query.
let outcome = s.ime_edit_outcome();
assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], "\\frac{a}{b}");
assert!(s.dirty, "build-up marks the document dirty");
assert!(s.rev() > layout_rev, "build-up refreshes the layout revision");
assert!(outcome.classified && outcome.content_changed && outcome.state_changed && outcome.transformed, "{outcome:?}");
assert!(revision(&mut s) > ime_rev, "build-up advances the unavailable IME revision");
s.run("edit.undo", &json!({})).unwrap();
s.run("equation.edit", &json!({})).unwrap();
assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], "a/b", "Undo restores the plain run");
@@ -941,4 +1138,239 @@ mod tests {
assert_eq!(subs, ["i", "j"], "{nodes:#?}");
assert!(nodes.iter().all(|n| matches!(n, MNode::Script { .. }) || matches!(n, MNode::Run(r) if r.text.trim().is_empty())), "{nodes:#?}");
}
/// Returns the session's current IME state revision, allocating a new one when the caret state changed.
fn revision(s: &mut Session) -> u64 {
s.ime_state_revision().expect("an IME state revision")
}
/// Clears the stored node tree of the equation currently being edited, leaving its linear text
/// and lazy state in place.
fn clear_nodes(s: &mut Session) {
let at = s.math.as_ref().expect("editing").at.clone();
if let Some(InlineObject::Equation { math, .. }) = s.doc.para_mut(at.story, &at.path).expect("paragraph").object_at_mut(at.off) {
math.nodes.clear();
}
}
/// Backspace at the start of an equation is classified with no content or state change,
/// allocates no revision and leaves the linear text intact.
#[test]
fn equation_backspace_at_start_is_a_classified_noop() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "x+y"})).unwrap();
s.run("equation.move", &json!({"dir": "home"})).unwrap();
let before = revision(&mut s);
s.run("equation.backspace", &json!({})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified);
assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}");
assert_eq!(revision(&mut s), before, "a no-op allocates no revision");
assert_eq!(linear(&s), "x+y");
}
/// Materializing an imported equation's lazy nodes is a state change only: the outcome is
/// classified, content is unchanged, and the revision advances.
#[test]
fn lazy_normalization_is_state_not_content() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "x+1"})).unwrap();
clear_nodes(&mut s);
// Editing parses the local tree but leaves the stored equation lazy.
s.run("equation.edit", &json!({})).unwrap();
s.run("equation.move", &json!({"dir": "home"})).unwrap();
let before = revision(&mut s);
s.run("equation.backspace", &json!({})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified);
assert!(!outcome.content_changed, "the normalized tree was already logical content");
assert!(outcome.state_changed, "materializing the cached nodes is a state change");
assert!(revision(&mut s) > before, "state changes advance the revision");
}
/// Empty typing is a classified no-op in built-up and linear form, without a new revision.
#[test]
fn empty_equation_type_is_a_classified_noop_in_both_modes() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "x"})).unwrap();
let before = revision(&mut s);
s.run("equation.type", &json!({"text": ""})).unwrap();
assert!(s.ime_edit_outcome().classified);
assert!(!s.ime_edit_outcome().content_changed && !s.ime_edit_outcome().state_changed);
assert_eq!(revision(&mut s), before, "built-up empty type is a no-op");
s.run("equation.convert", &json!({"to": "linear"})).unwrap();
let before = revision(&mut s);
s.run("equation.type", &json!({"text": ""})).unwrap();
assert!(!s.ime_edit_outcome().content_changed && !s.ime_edit_outcome().state_changed);
assert_eq!(revision(&mut s), before, "linear empty type is a no-op");
}
/// Typing `a/b ` or `\alpha ` reports both a content change and a transformed automatic rewrite.
#[test]
fn automatic_equation_rewrites_are_transformed() {
for text in ["a/b ", "\\alpha "] {
let mut s = session();
s.run("insert.equation", &json!({})).unwrap();
s.run("equation.type", &json!({"text": text})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.content_changed, "{text}: {outcome:?}");
assert!(outcome.transformed, "{text}: {outcome:?}");
}
}
/// Justification and display changes advance the revision and report a state, not content,
/// change.
#[test]
fn equation_style_changes_are_state_changes_without_content() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "x"})).unwrap();
let before = revision(&mut s);
s.run("equation.justify", &json!({"jc": "left"})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified && outcome.state_changed && !outcome.content_changed, "{outcome:?}");
assert!(revision(&mut s) > before);
let before = revision(&mut s);
s.run("equation.display", &json!({"value": false})).unwrap();
assert!(s.ime_edit_outcome().state_changed && !s.ime_edit_outcome().content_changed);
assert!(revision(&mut s) > before);
}
/// Converting every equation reports one classified effect that merges their content and state
/// changes.
#[test]
fn convert_all_merges_every_equations_effect() {
let mut s = session();
s.run("document.setText", &json!({"text": "\r"})).unwrap();
s.run("insert.equation", &json!({"linear": "a/b"})).unwrap();
s.run("equation.convert", &json!({"to": "linear"})).unwrap();
s.run("equation.exit", &json!({})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.equation", &json!({"linear": "c/d"})).unwrap();
s.run("equation.exit", &json!({})).unwrap();
s.run("equation.convert", &json!({"to": "professional", "all": true})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}");
}
/// A bounded deletion around the caret removes the requested units in one undo step and reports
/// a content change.
#[test]
fn equation_delete_surrounding_is_one_transaction_and_one_undo_step() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "abcd"})).unwrap();
let depth = s.undo_depth();
s.run("equation.deleteSurrounding", &json!({"before": 2, "after": 0})).unwrap();
assert_eq!(linear(&s), "ab");
assert_eq!(s.undo_depth(), depth + 1, "one undo step, not one per unit");
assert!(s.ime_edit_outcome().classified && s.ime_edit_outcome().content_changed);
}
/// An impossible bounded deletion applies no prefix, adds no undo step, keeps the session and
/// caret, and leaves no classified outcome.
#[test]
fn equation_delete_surrounding_refuses_partial_progress() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "ab"})).unwrap();
let session_token = s.math.as_ref().unwrap().session;
let at = s.math.as_ref().unwrap().at.clone();
let depth = s.undo_depth();
let err = s.run("equation.deleteSurrounding", &json!({"before": 3, "after": 0})).unwrap_err();
assert!(err.to_string().contains("cannot delete"), "{err}");
assert_eq!(linear(&s), "ab", "the whole request is refused, no prefix applied");
assert_eq!(s.undo_depth(), depth, "no undo step for a refused transaction");
let m = s.math.as_ref().expect("still editing");
assert_eq!(m.session, session_token);
assert_eq!(m.at, at);
assert!(!s.ime_edit_outcome().classified, "a refusal has no outcome");
assert_eq!(
s.last_command.as_ref().map(|(id, _)| id.as_str()),
Some("equation.deleteSurrounding"),
"only the pre-dispatch Repeat record remains"
);
}
/// Zero or oversized counts and deletions from an empty equation are refused without mutation;
/// exactly deleting all text leaves the empty equation object.
#[test]
fn equation_delete_surrounding_bounds_and_empty_equations() {
let mut s = session();
s.run("insert.equation", &json!({})).unwrap();
assert!(s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 0})).is_err(), "zero counts are refused");
assert!(s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 0})).is_err(), "an empty equation refuses");
assert!(s.math.is_some(), "the empty equation object stays");
s.run("equation.type", &json!({"text": "a"})).unwrap();
let depth = s.undo_depth();
assert!(s.run("equation.deleteSurrounding", &json!({"before": 65, "after": 0})).is_err());
assert!(s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 65})).is_err());
assert_eq!(linear(&s), "a");
assert_eq!(s.undo_depth(), depth);
s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 0})).unwrap();
assert_eq!(linear(&s), "", "exactly deleting the last unit leaves the equation object");
assert!(s.math.is_some());
}
/// A forward deletion that would only step into another argument is refused whole, leaving the
/// text and undo depth unchanged (the caret is not asserted here).
#[test]
fn equation_delete_surrounding_refuses_a_caret_only_step() {
let mut s = session();
s.run("insert.equation", &json!({"latex": "\\frac{a}{b}"})).unwrap();
// At the end of the numerator a forward delete only steps into the denominator: the
// tree does not change, so a request that would report progress is refused whole.
s.math.as_mut().expect("editing").pos = MathPos { path: vec![(0, 0)], off: 1 };
let before = linear(&s);
let depth = s.undo_depth();
let err = s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 1})).unwrap_err();
assert!(err.to_string().contains("no progress"), "{err}");
assert_eq!(linear(&s), before);
assert_eq!(s.undo_depth(), depth);
}
/// A deletion that really erases on one side cannot buy a caret-only step on the other:
/// the mixed request is refused whole, so a following IME commit cannot land in the
/// argument the platform never asked about.
#[test]
fn equation_delete_surrounding_refuses_a_mixed_caret_only_step() {
let mut s = session();
s.run("insert.equation", &json!({"latex": "\\frac{a}{b}"})).unwrap();
// At the end of the numerator. `before: 1` erases `a`, but `after: 1` would only step
// into the denominator (the report's mixed-direction reproducer).
s.math.as_mut().expect("editing").pos = MathPos { path: vec![(0, 0)], off: 1 };
let before = linear(&s);
let pos = s.math.as_ref().expect("editing").pos.clone();
let depth = s.undo_depth();
let err = s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 1})).unwrap_err();
assert!(err.to_string().contains("no progress"), "{err}");
assert_eq!(linear(&s), before, "the successful-looking prefix is not applied");
assert_eq!(s.undo_depth(), depth, "no undo step for a refused mixed request");
assert_eq!(s.math.as_ref().expect("editing").pos, pos, "the caret stays where the request found it");
}
/// Typing more than the cap inserts exactly `MAX_EQUATION_TYPE_CHARS` characters in both linear
/// and built-up form.
#[test]
fn equation_type_caps_linear_and_built_up_input() {
for linear_mode in [false, true] {
let mut s = session();
s.run("insert.equation", &json!({})).unwrap();
if linear_mode {
s.run("equation.convert", &json!({"to": "linear"})).unwrap();
}
let text = "a".repeat(crate::MAX_EQUATION_TYPE_CHARS + 5);
s.run("equation.type", &json!({"text": text})).unwrap();
assert_eq!(linear(&s).chars().count(), crate::MAX_EQUATION_TYPE_CHARS, "linear={linear_mode}");
}
}
/// Deleting one character forward removes it and moves the math caret to the start of the
/// remaining text.
#[test]
fn equation_delete_surrounding_moves_the_caret_for_a_one_step_delete() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "abc"})).unwrap();
s.run("equation.move", &json!({"dir": "home"})).unwrap();
s.run("equation.deleteSurrounding", &json!({"after": 1, "before": 0})).unwrap();
assert_eq!(linear(&s), "bc");
assert_eq!(s.math.as_ref().unwrap().pos, MathPos::new(Vec::new(), 0));
}
}
+75 -17
View File
@@ -79,6 +79,10 @@ fn rename(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"name": name, "path": s.path}))
}
/// Builds the file and document command table: rename, new, open, save, save as and encryption,
/// PDF/PNG/print output and print settings, close, properties and info, author and privacy
/// settings, and document inspection and text access. Recovery, compatibility, sharing, autosave,
/// `file.newFromTemplate` and `file.saveTemplate` are registered in [`super::tools::specs`].
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("file.rename", "Rename Document", "File › Info", rename).params(r#"{"name": string (document name; its extension is kept, so the name may be given with or without it)}"#).pure(),
@@ -137,8 +141,11 @@ pub fn specs() -> Vec<CommandSpec> {
sel_result(s)
})
.key("Mod+W / Mod+F4")
.params(r#"{"force"?: bool (desktop app's control channel and MCP bridge only: without it a document with unsaved changes is an error and stays open; with it the changes are discarded. Headless sessions never ask)}"#)
.params(r#"{"force"?: bool (desktop app's control channel and MCP bridge only: without it a document with unsaved changes is an error and stays open; with it the changes are discarded. Headless sessions never ask; file.closeDocument closes a document there)}"#)
.pure(),
CommandSpec::new("file.closeDocument", "Close Document", "Agents", close_document)
.params(r#"{"id"?: n (an open document, see view.switchWindows; default the active one), "force"?: bool (discard unsaved changes; without it a document with unsaved changes is an error and stays open)} → {"closed": id, "active": id, "documents": [{"id", "title", "path", "dirty", "active"}]}; closing the last document leaves a new blank one"#)
.pure(),
CommandSpec::new("file.properties", "Properties", "File › Info", properties)
.params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?, "custom"?: {name: string | null (removes)}}"#),
CommandSpec::new("file.info", "Info", "File", info).pure(),
@@ -188,6 +195,10 @@ pub fn specs() -> Vec<CommandSpec> {
let text = p::req_str(v, "text")?;
let d = Document::from_text(text);
s.doc.body = d.body;
// The body is new even though the document id stays: a live IME composition (or an
// equation editing session at the same offset) belongs to the old content.
s.note_body_replaced();
s.math = None;
s.sel = crate::Selection::caret(s.doc.start_of(StoryRef::Body));
sel_result(s)
})
@@ -200,7 +211,8 @@ fn privacy(s: &Session) -> Value {
json!({"removePersonalInfo": s.doc.settings.remove_personal_info, "removeDateTime": s.doc.settings.remove_date_time})
}
/// Create a blank document in the saved proofing language, or use the requested template.
/// Open a new document beside the open ones and make it active: a blank one in the saved
/// proofing language, or the requested template.
fn new(s: &mut Session, v: &Value) -> CmdResult {
let doc = match p::str(v, "template").unwrap_or("blank") {
"sample" => crate::sample::sample_document(),
@@ -209,14 +221,13 @@ fn new(s: &mut Session, v: &Value) -> CmdResult {
"report" => crate::sample::report(),
_ => normal_document(s),
};
s.set_document(doc);
s.path = None;
s.add_document(doc);
sel_result(s)
}
/// A blank document on the Normal template: the region's paper (#838), written in the editing
/// language (the templates' text is English).
fn normal_document(s: &Session) -> Document {
pub(crate) fn normal_document(s: &Session) -> Document {
let mut d = s.blank_document();
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref()) {
d.styles.default_chr.lang = Some(lang.clone());
@@ -224,12 +235,22 @@ fn normal_document(s: &Session) -> Document {
d
}
/// Open a document, using the saved proofing default only for plain text and Markdown.
/// Open a document, using the saved proofing default only for plain text and Markdown. A file
/// that is already open becomes the active document instead (`"alreadyOpen": true`). The file
/// replaces an untouched blank document; otherwise it opens beside the open documents.
fn open(s: &mut Session, v: &Value) -> CmdResult {
let Some(path) = p::str(v, "path") else {
s.push_ui_request(json!({"open": "openFile"}));
return sel_result(s);
};
if p::str(v, "data").is_none()
&& let Some(id) = s.find_open(std::path::Path::new(path))
{
s.activate(id)?;
return Ok(
json!({"path": s.path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count(), "encrypted": s.password.is_some(), "alreadyOpen": true, "id": id}),
);
}
let password = p::str(v, "password");
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
@@ -252,7 +273,13 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
{
doc.styles.default_chr.lang = Some(lang.clone());
}
s.set_document(doc);
match s.used_because() {
None => s.set_document(doc),
Some(why) => {
log::info!("file.open: opened beside document {}, not in its place: {why}", s.document_id());
s.add_opened_document(doc);
}
}
if let Some(z) = s.doc.settings.zoom {
super::view::restore_zoom(s, z);
}
@@ -261,7 +288,26 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
if encrypted {
s.password = password.map(crate::io::Password::new);
}
Ok(json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count(), "encrypted": encrypted}))
Ok(
json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count(), "encrypted": encrypted, "alreadyOpen": false, "id": s.document_id()}),
)
}
/// Close a document with no window to ask about it (agents, headless sessions).
fn close_document(s: &mut Session, v: &Value) -> CmdResult {
let id = match v.get("id").filter(|x| !x.is_null()) {
None => s.document_id(),
Some(x) => x.as_u64().ok_or_else(|| CmdError::Params("`id` must be an open document's id".into()))?,
};
if !s.is_open(id) {
return Err(CmdError::Params(format!("no open document has id {id}")));
}
let dirty = s.documents().iter().any(|d| d.id == id && d.dirty);
if dirty && p::bool(v, "force") != Some(true) {
return Err(CmdError::Failed("the document has unsaved changes: save it first, or pass \"force\": true to discard them".into()));
}
s.close_document(id)?;
Ok(json!({"closed": id, "active": s.document_id(), "documents": s.documents()}))
}
/// File › Info › Protect Document › Encrypt with Password: the password Word documents are saved
@@ -416,15 +462,25 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
let Some(m) = custom.as_object() else {
return Err(CmdError::Params("`custom` must be an object of name → string or null".into()));
};
// Removals first: a case-only rename sends the old spelling as null and the new one
// with its value, and `set_custom_prop` matches names case-insensitively, so setting
// first would keep the old spelling (object keys are sorted, either spelling may lead).
for (name, val) in m {
if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME {
return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters")));
if val.is_null() {
s.doc.remove_custom_prop(name);
}
}
for (name, val) in m {
match val {
Value::Null => {
s.doc.remove_custom_prop(name);
// Removals took the name as stored above: a file opened from an older Word may
// hold one longer than today's limit, and it must still be deletable (#2982).
Value::Null => {}
Value::String(x) if x.len() <= MAX_CUSTOM_VALUE => {
if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME {
return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters")));
}
s.doc.set_custom_prop(name, x);
}
Value::String(x) if x.len() <= MAX_CUSTOM_VALUE => s.doc.set_custom_prop(name, x),
_ => return Err(CmdError::Params(format!("custom property `{name}` must be a string (≤ {MAX_CUSTOM_VALUE} bytes) or null"))),
}
}
@@ -436,10 +492,12 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
Ok(out)
}
/// Longest custom property name (Word's limit).
const MAX_CUSTOM_NAME: usize = 255;
/// Largest custom property value we accept from a command.
const MAX_CUSTOM_VALUE: usize = 1024 * 1024;
/// Longest custom property name (Word's limit). The Custom Properties dialog validates with it
/// too (#2982).
pub const MAX_CUSTOM_NAME: usize = 255;
/// Largest custom property value we accept from a command. The Custom Properties dialog
/// validates with it too (#2982).
pub const MAX_CUSTOM_VALUE: usize = 1024 * 1024;
fn info(s: &mut Session, _: &Value) -> CmdResult {
let l = s.layout();
+87 -3
View File
@@ -345,15 +345,20 @@ pub enum Piece {
Lit(String),
/// `^&`: the text found.
Found,
/// `\n` with wildcards: group `n`.
/// `\n` with wildcards, or `$n` / `${n}` / `\n` with a regex: group `n` (0 is the match).
Group(usize),
/// `${name}` / `$name` with a regex: a named group, resolved to a [`Piece::Group`] once the
/// pattern is built ([`resolve_names`]); one left unresolved expands to nothing.
Named(String),
/// `^c`: the clipboard.
Clip,
}
/// Read Replace with text: `^` codes (unless `literal`), and `\1`–`\9` with wildcards.
/// Read Replace with text: `^` codes (unless `literal`), `\1`–`\9` with wildcards, and with a
/// regex also `$n`, `${n}`, `${name}`, `$name`, `$$`, `\n` (paragraph mark), `\t` and `\\`.
pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
if o.literal || o.regex {
// With a regex, `special: false` turns off only the `^` codes; group references still expand.
if o.literal && !o.regex {
return vec![Piece::Lit(with.to_string())];
}
let chars: Vec<char> = with.chars().collect();
@@ -367,6 +372,22 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
let mut i = 0;
while let Some(&c) = chars.get(i) {
i += 1;
if o.regex && (c == '$' || c == '\\') {
match regex_ref(&chars, i, c) {
Some((Ref::Char(ch), used)) => {
cur.push(ch);
i += used;
}
Some((Ref::Group(pc), used)) => {
flush(&mut cur, &mut out);
out.extend(pc);
i += used;
}
// Not a reference or an escape: the character as written.
None => cur.push(c),
}
continue;
}
if c == '\\' && o.wildcards {
match chars.get(i) {
Some(d) if d.is_ascii_digit() && *d != '0' => {
@@ -384,6 +405,7 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
}
}
if c == '^'
&& !o.literal
&& let Some(&n) = chars.get(i)
{
if n == '&' || n == 'c' {
@@ -409,6 +431,67 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
out
}
/// What a `$` or `\` starts in regex Replace with text.
enum Ref {
/// An escape: `$$`, `\n`, `\t`, `\\`.
Char(char),
/// A group reference; `None` for a number too large to be a group (expands to nothing).
Group(Option<Piece>),
}
/// A group named by its number or name, as the `regex` crate reads it.
fn group_ref(name: &str) -> Option<Piece> {
if name.bytes().all(|b| b.is_ascii_digit()) { name.parse().ok().map(Piece::Group) } else { Some(Piece::Named(name.to_string())) }
}
/// Read a regex reference or escape after `c` (`$` or `\`) at `chars[i..]`: what it is and how
/// many characters it uses after `c`, or `None` when `c` is a plain character.
fn regex_ref(chars: &[char], i: usize, c: char) -> Option<(Ref, usize)> {
let next = *chars.get(i)?;
if c == '\\' {
return match next {
'n' => Some((Ref::Char(PARA_MARK), 1)),
't' => Some((Ref::Char('\t'), 1)),
// `\$` and `\^` keep a `$` or `^` as typed (no group, no `^` code).
'\\' | '$' | '^' => Some((Ref::Char(next), 1)),
'1'..='9' => Some((Ref::Group(next.to_digit(10).map(|d| Piece::Group(d as usize))), 1)),
_ => None,
};
}
if next == '$' {
return Some((Ref::Char('$'), 1));
}
let rest = chars.get(i..).unwrap_or_default();
if next == '{' {
// `${…}`: everything up to the closing brace; unclosed or empty, the `$` is literal.
let close = rest.iter().position(|&ch| ch == '}')?;
let name: String = rest.get(1..close)?.iter().collect();
if name.is_empty() {
return None;
}
return Some((Ref::Group(group_ref(&name)), close + 1));
}
// `$name`: the longest run of letters, digits and `_`.
let len = rest.iter().take_while(|ch| ch.is_ascii_alphanumeric() || **ch == '_').count();
if len == 0 {
return None;
}
let name: String = rest.get(..len)?.iter().collect();
Some((Ref::Group(group_ref(&name)), len))
}
/// Turn named group references into numbered ones with `index` (a name's group number);
/// an unknown name is dropped, so it expands to nothing.
pub fn resolve_names(pieces: Vec<Piece>, index: &dyn Fn(&str) -> Option<usize>) -> Vec<Piece> {
pieces
.into_iter()
.filter_map(|p| match p {
Piece::Named(n) => index(&n).map(Piece::Group),
p => Some(p),
})
.collect()
}
/// Searched text as it is inserted again: marks become paragraph breaks.
fn as_inserted(s: &str) -> String {
s.replace(SECT_MARK, "\r")
@@ -423,6 +506,7 @@ pub fn expand(pieces: &[Piece], found: &str, group: &dyn Fn(usize) -> Option<Str
Piece::Found => out.push_str(&as_inserted(found)),
Piece::Group(n) => out.push_str(&as_inserted(&group(*n).unwrap_or_default())),
Piece::Clip => out.push_str(&clip.replace("\r\n", "\r").replace('\n', "\r")),
Piece::Named(_) => {}
}
}
out
+35
View File
@@ -43,6 +43,15 @@ fn read_font_file(path: &str) -> Result<Vec<u8>, CmdError> {
crate::io::read_file_capped(path, wordcraft_fonts::MAX_FONT_FILE_BYTES as u64, "font file").map_err(CmdError::Failed)
}
/// Adding or removing a font writes to the user font folder: automation confined to a write root
/// (#769) may do that only when the folder is inside it.
fn check_font_store(dir: Option<&std::path::Path>) -> Result<(), CmdError> {
match dir {
Some(dir) => crate::file_access::check_write(&dir.to_string_lossy()).map_err(CmdError::Failed),
None => Ok(()),
}
}
fn add(s: &mut Session, v: &Value) -> CmdResult {
if p::str(v, "url").is_some() {
return Err(CmdError::Failed(
@@ -71,6 +80,7 @@ fn add(s: &mut Session, v: &Value) -> CmdResult {
s.push_ui_request(json!({"open": "addFont"}));
return Ok(json!({"pending": true}));
};
check_font_store(FontDb::global().user_font_dir().as_deref())?;
let r = FontDb::global().install_font(&name, &bytes).map_err(|e| CmdError::Failed(e.to_string()))?;
// Text already set in the family (shown in a fallback until now) takes the new font.
s.relayout();
@@ -100,8 +110,33 @@ fn list(_: &mut Session, v: &Value) -> CmdResult {
fn remove(s: &mut Session, v: &Value) -> CmdResult {
let family = p::str(v, "family").filter(|f| !f.trim().is_empty()).ok_or_else(|| CmdError::Params("family is required".into()))?;
check_font_store(FontDb::global().user_font_dir().as_deref())?;
let gone = FontDb::global().remove_user_font(family.trim()).map_err(|e| CmdError::Failed(e.to_string()))?;
s.relayout();
s.status = format!("Removed {}", family.trim());
Ok(json!({"removed": gone.iter().map(font_json).collect::<Vec<_>>()}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn confined_automation_changes_only_a_font_store_inside_its_write_root() {
let root = std::env::temp_dir().join(format!("wordcraft-font-store-{}", std::process::id()));
let outside = std::env::temp_dir().join(format!("wordcraft-font-store-out-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let write = crate::file_access::AutomationRoots::new(None, Some(&root)).unwrap();
let read_only = crate::file_access::AutomationRoots::new(Some(&root), None).unwrap();
crate::file_access::confine(write.as_ref(), || {
assert!(check_font_store(None).is_ok(), "no folder: the font lasts for the session");
assert!(check_font_store(Some(&root.join("fonts"))).is_ok());
assert!(check_font_store(Some(&outside)).is_err());
});
crate::file_access::confine(read_only.as_ref(), || assert!(check_font_store(Some(&root)).is_err()));
assert!(check_font_store(Some(&outside)).is_ok(), "unconfined: the person at the keyboard");
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&outside);
}
}
+34 -26
View File
@@ -401,19 +401,9 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
if let Some(rows) = column_rows(s) {
for (a, b) in rows {
let Some(p) = s.doc.para_at(&a) else { continue };
if a == b || (a.off == 0 && b.off == p.len()) {
seen = true;
if !matches(p.props.style.as_deref(), &p.mark) {
return false;
}
}
for (r, c) in p.run_ranges() {
if r.end > a.off && r.start < b.off {
seen = true;
if !matches(p.props.style.as_deref(), c) {
return false;
}
}
let mark = a == b || (a.off == 0 && b.off == p.len());
if !span_matches(p, mark, a.off, b.off, &matches, &mut seen) {
return false;
}
}
if seen {
@@ -426,19 +416,9 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
let from = if path == a.path { a.off } else { 0 };
let to = if path == b.path { b.off } else { p.len() };
// Match format_range: fully selected paragraphs include their mark.
if to == p.len() && (from == 0 || path != a.path) {
seen = true;
if !matches(p.props.style.as_deref(), &p.mark) {
return false;
}
}
for (r, c) in p.run_ranges() {
if r.end > from && r.start < to {
seen = true;
if !matches(p.props.style.as_deref(), c) {
return false;
}
}
let mark = to == p.len() && (from == 0 || path != a.path);
if !span_matches(p, mark, from, to, &matches, &mut seen) {
return false;
}
}
}
@@ -446,6 +426,34 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
seen || resolved(s).iter().all(|r| r.highlight == h.rgb())
}
/// Whether the paragraph's mark (when `mark`) and the runs overlapping `from..to` all match
/// `matches`; `seen` records that something was checked.
fn span_matches(
p: &wordcraft_doc::Paragraph,
mark: bool,
from: usize,
to: usize,
matches: &impl Fn(Option<&str>, &CharProps) -> bool,
seen: &mut bool,
) -> bool {
let style = p.props.style.as_deref();
if mark {
*seen = true;
if !matches(style, &p.mark) {
return false;
}
}
for (r, c) in p.run_ranges() {
if r.end > from && r.start < to {
*seen = true;
if !matches(style, c) {
return false;
}
}
}
true
}
/// Explicit palette/automation colours set highlighting; the shortcut (no colour) toggles
/// the last choice off only when the whole formatting target already has that colour.
fn highlight(s: &mut Session, v: &Value) -> CmdResult {
+28 -4
View File
@@ -2,7 +2,7 @@
use serde_json::{Value, json};
use wordcraft_doc::para::{Float, InlineObject, ShapeKind, Wrap};
use wordcraft_doc::props::{Align, CharProps, Rgb, TabAlign, TabStop, TextColor};
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};
@@ -586,18 +586,42 @@ fn hf_part(s: &mut Session, header: bool) -> u32 {
id
}
/// Three independently editable cells spanning the section's text width.
fn three_column_hf(s: &Session, style: &str) -> wordcraft_doc::Blocks {
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
// The section `section_mut` would pick, read without making anything unique.
let sections = s.doc.sections();
let own = sections.iter().position(|(end, _)| block <= *end).filter(|i| i + 1 < sections.len()).and_then(|i| sections.get(i));
let width = own.map_or_else(|| s.doc.last_section.text_width(), |(_, sec)| sec.text_width());
let mut table = Table::new(1, 3, width);
table.props.style = Some("TableNormal".into());
table.props.width = Some(width);
table.props.fixed = true;
// Explicit nil borders also override borders inherited from the document's table style.
table.props.borders = Some(Borders::all(Border::default()));
for row in &mut table.rows {
for (cell, align) in row.cells.iter_mut().zip([Align::Left, Align::Center, Align::Right]) {
let mut para = Paragraph::new().styled(style);
para.props.align = Some(align);
cell.blocks = vec![para_block(para)];
}
}
// Keep a trailing paragraph so the story can also be edited after the table.
vec![std::sync::Arc::new(Block::Table(table)), para_block(Paragraph::new().styled(style))]
}
fn header_footer(s: &mut Session, v: &Value, header: bool) -> CmdResult {
let id = hf_part(s, header);
let style = if header { "Header" } else { "Footer" };
if let Some(text) = p::str(v, "text") {
let blocks = text.split('\n').map(|l| para_block(Paragraph::with_text(l, CharProps::default()).styled(style))).collect();
s.doc.set_story(StoryRef::Part(id), blocks)?;
} else if p::str(v, "preset") == Some("blankThree") {
let blocks = three_column_hf(s, style);
s.doc.set_story(StoryRef::Part(id), blocks)?;
} else if let Some(preset) = p::str(v, "preset") {
let mut para = Paragraph::new().styled(style);
match preset {
"blankThree" => {
para.insert_text(0, "[Type here]\t[Type here]\t[Type here]", &CharProps::default())?;
}
"title" => {
para.insert_text(0, if s.doc.core.title.is_empty() { "[Document title]" } else { &s.doc.core.title }, &CharProps::default())?;
para.props.align = Some(Align::Center);
+71 -22
View File
@@ -522,7 +522,8 @@ fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult {
}
/// Parse CSV (RFC 4180-ish: quotes, doubled quotes, commas/semicolons/tabs).
pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
/// The field separator: whichever of `,` `;` and tab the first line (outside quotes) uses most.
fn csv_separator(text: &str) -> char {
let first = text.lines().next().unwrap_or("");
let mut quoted = false;
let mut unquoted = String::new();
@@ -533,7 +534,20 @@ pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
unquoted.push(c);
}
}
let sep = [',', ';', '\t'].into_iter().max_by_key(|c| unquoted.matches(*c).count()).unwrap_or(',');
[',', ';', '\t'].into_iter().max_by_key(|c| unquoted.matches(*c).count()).unwrap_or(',')
}
/// Ends the current row: kept when it has any non-empty field, otherwise dropped.
fn push_csv_row(rows: &mut Vec<Vec<String>>, row: &mut Vec<String>) {
if row.iter().any(|x| !x.is_empty()) {
rows.push(std::mem::take(row));
} else {
row.clear();
}
}
pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
let sep = csv_separator(text);
let mut rows = Vec::new();
let mut row = Vec::new();
let mut cell = String::new();
@@ -560,11 +574,7 @@ pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
chars.next();
}
row.push(std::mem::take(&mut cell));
if row.iter().any(|x| !x.is_empty()) {
rows.push(std::mem::take(&mut row));
} else {
row.clear();
}
push_csv_row(&mut rows, &mut row);
} else {
cell.push(c);
}
@@ -573,9 +583,7 @@ pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
}
}
row.push(cell);
if row.iter().any(|x| !x.is_empty()) {
rows.push(row);
}
push_csv_row(&mut rows, &mut row);
rows
}
@@ -785,7 +793,13 @@ fn merge_fields(doc: &Document) -> Vec<String> {
}
fn mergefield_name(instr: &str) -> Option<String> {
let rest = instr.trim().strip_prefix("MERGEFIELD")?.trim();
// The field type ignores case and ends at a space or a quote, so `MERGEFIELDX` is another field.
let t = instr.trim();
let (kind, rest) = t.split_at_checked(t.find(|c: char| c.is_whitespace() || c == '"').unwrap_or(t.len()))?;
if !kind.eq_ignore_ascii_case("MERGEFIELD") {
return None;
}
let rest = rest.trim();
let name = if let Some(r) = rest.strip_prefix('"') { r.split('"').next().unwrap_or("") } else { rest.split_whitespace().next().unwrap_or("") };
Some(name.to_string())
}
@@ -840,11 +854,15 @@ enum Act {
Skip,
}
/// The merge field a rule tests: the name in its first nested field, if that is a MERGEFIELD.
fn rule_field(t: &str) -> Option<String> {
mergefield_name(t.split_once('{').and_then(|(_, r)| r.split('}').next())?)
}
/// Comparison of a rule condition `{ MERGEFIELD X } op "value" …` (IF, SKIPIF, NEXTIF) for
/// `row`: whether it holds, and the quoted texts after the value (IF's true / false text).
fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) {
let inner = t.split_once('{').and_then(|(_, r)| r.split('}').next()).unwrap_or("");
let field = mergefield_name(inner).unwrap_or_default();
let field = rule_field(t).unwrap_or_default();
let rest = t.split_once('}').map(|(_, r)| r).unwrap_or("");
let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '<'..='>')).collect();
let mut quoted = rest.split('"').skip(1).step_by(2);
@@ -887,7 +905,7 @@ fn act(s: &Session, instr: &str, row: usize) -> Option<Act> {
"SKIPIF" if condition(s, t, row).0 => Act::Skip,
"NEXTIF" | "SKIPIF" => Act::Text(String::new()),
// IF { MERGEFIELD X } = "v" "then" "else" (an IF on other fields isn't ours to touch).
"IF" if t.contains("MERGEFIELD") => {
"IF" if rule_field(t).is_some() => {
let (holds, texts) = condition(s, t, row);
Act::Text(texts.get(if holds { 0 } else { 1 }).copied().unwrap_or("").to_string())
}
@@ -904,7 +922,7 @@ fn placeholder(instr: &str) -> Option<String> {
"ADDRESSBLOCK" => Some("«AddressBlock»".into()),
"GREETINGLINE" => Some("«GreetingLine»".into()),
"NEXT" | "NEXTIF" | "SKIPIF" | "MERGEREC" => Some(String::new()),
"IF" if instr.contains("MERGEFIELD") => Some(String::new()),
"IF" if rule_field(instr).is_some() => Some(String::new()),
_ => None,
}
}
@@ -1086,10 +1104,8 @@ fn finish(s: &mut Session, v: &Value) -> CmdResult {
crate::io::save_path(std::path::Path::new(path), &out).map_err(CmdError::Failed)?;
return Ok(json!({"records": count, "path": path}));
}
s.set_document(out);
s.path = None;
s.add_document(out);
s.dirty = true;
s.merge.preview = false;
Ok(json!({"records": count}))
}
@@ -1251,8 +1267,7 @@ fn envelopes(s: &mut Session, v: &Value) -> CmdResult {
let mut d = Document::new();
d.last_section = sect;
d.body = blocks;
s.set_document(d);
s.path = None;
s.add_document(d);
Ok(json!({"width": w, "height": h}))
}
@@ -1484,8 +1499,7 @@ fn labels(s: &mut Session, v: &Value) -> CmdResult {
}
}
d.body = vec![std::sync::Arc::new(Block::Table(t)), para_block(Paragraph::new())];
s.set_document(d);
s.path = None;
s.add_document(d);
let _ = Pos::body(0, 0);
Ok(json!({"labels": if single.is_some() { 1 } else { rows * cols }, "filled": made, "product": prod.id}))
}
@@ -1546,6 +1560,41 @@ mod tests {
assert_eq!(r, vec![vec!["Name, first, last", "City"], vec!["Ann", "Paris"]]);
}
/// Field types ignore case (ECMA-376 Part 1, 17.16.1), and `MERGEFIELDX` is another field.
#[test]
fn merge_field_types_ignore_case() {
assert_eq!(mergefield_name(" mergefield City ").as_deref(), Some("City"));
assert_eq!(mergefield_name(r#"MergeField "First Name""#).as_deref(), Some("First Name"));
assert_eq!(mergefield_name(r#"MERGEFIELD"City""#).as_deref(), Some("City"));
assert_eq!(mergefield_name("MERGEFIELDX City"), None);
assert_eq!(placeholder("mergefield City").as_deref(), Some("«City»"));
assert_eq!(placeholder(r#"if { mergefield City } = "x" "y" "z""#).as_deref(), Some(""));
let mut s = Session::new(Document::new());
s.run("mailings.recipients", &json!({"rows": [{"Name": "Alpha"}, {"Name": "Beta"}]})).unwrap();
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
s.run("insert.field", &json!({"instr": "mergefield Name", "result": ""})).unwrap();
s.run("insert.field", &json!({"instr": r#"if { mergefield Name } = "Beta" " (b)" """#, "result": ""})).unwrap();
s.run("mailings.finish", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "Alpha\nBeta (b)");
}
/// An IF is a merge rule only when the field it tests is a MERGEFIELD: the word in a quoted
/// value doesn't make `IF { PAGE } = "mergefield" …` one.
#[test]
fn an_if_on_another_field_is_not_a_merge_rule() {
let other = r#"IF { PAGE } = "mergefield" "yes" "no""#;
assert_eq!(placeholder(other), None);
assert_eq!(placeholder(r#"IF { PAGE } = { MERGEFIELD City } "yes" "no""#), None);
assert_eq!(placeholder(r#"IF { MERGEFIELD City } = "mergefield" "yes" "no""#).as_deref(), Some(""));
let mut s = Session::new(Document::new());
s.run("mailings.recipients", &json!({"rows": [{"Name": "Alpha"}]})).unwrap();
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
s.run("insert.field", &json!({"instr": "mergefield Name", "result": ""})).unwrap();
s.run("insert.field", &json!({"instr": other, "result": " kept"})).unwrap();
s.run("mailings.finish", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "Alpha kept");
}
#[test]
fn merge_end_to_end() {
let mut s = Session::new(Document::new());
+100 -15
View File
@@ -150,16 +150,43 @@ pub fn selected_paths(s: &Session) -> Vec<wordcraft_doc::Path> {
s.doc.para_paths_in(&a, &b)
}
/// Delete the selection (respecting track changes); returns the collapsed position.
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
/// What one selection deletion actually did, for IME outcome classification.
#[derive(Clone, Debug, Default)]
pub(crate) struct SelectionDelete {
pub at: Pos,
pub content_changed: bool,
pub state_changed: bool,
}
/// Delete the selection (respecting track changes), reporting whether committed content changed.
///
/// Under Track Changes an all-already-deleted range is a real no-op: no revision record is
/// created and the effect says so.
pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete, CmdError> {
let (a, b) = s.sel.ordered();
if a == b {
return Ok(a);
return Ok(SelectionDelete { at: a, content_changed: false, state_changed: false });
}
controls::check_delete(s, &a, &b)?;
let at = if s.doc.settings.track_changes { track_delete(s, &a, &b)? } else { s.doc.delete_range(&a, &b)? };
s.sel = crate::Selection::caret(at.clone());
Ok(at)
let effect = if s.doc.settings.track_changes {
if !track_delete_would_change(s, &a, &b) {
SelectionDelete { at: a.clone(), content_changed: false, state_changed: false }
} else {
let rid = new_revision(s, RevisionKind::Delete);
let deleted = track_delete_as(s, &a, &b, rid)?;
SelectionDelete { at: deleted.at, content_changed: deleted.changed, state_changed: deleted.changed }
}
} else {
let at = s.doc.delete_range(&a, &b)?;
SelectionDelete { at, content_changed: true, state_changed: true }
};
s.sel = crate::Selection::caret(effect.at.clone());
Ok(effect)
}
/// Delete the selection (respecting track changes); returns the collapsed position.
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
delete_selection_effect(s).map(|e| e.at)
}
/// A revision record for the current author.
@@ -217,15 +244,47 @@ pub fn remove_empty_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc:
Ok(true)
}
/// Whether a tracked deletion of `a..b` would change anything: any non-deleted run intersects
/// the range, or a removable/markable paragraph mark lies inside it.
pub(crate) fn track_delete_would_change(s: &Session, a: &Pos, b: &Pos) -> bool {
let paths = s.doc.paths_between(a, b);
for path in &paths {
let Some(p) = s.doc.para(a.story, path) else { continue };
let from = if *path == a.path { a.off } else { 0 };
let to = if *path == b.path { b.off } else { p.len() };
if p.run_ranges().any(|(r, c)| r.end > from && r.start < to && c.del.is_none()) {
return true;
}
if *path != b.path && has_next_para(s, a.story, path) && p.mark.del.is_none() {
return true;
}
}
false
}
/// What one tracked deletion did.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct TrackDeleteEffect {
pub at: Pos,
/// At least one run was removed/marked deleted, or a paragraph mark was removed/marked.
pub changed: bool,
}
/// Tracked deletion: own insertions are removed, other text is marked deleted.
///
/// Preflights first: an all-already-deleted range does nothing and creates no revision record.
pub(crate) fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<Pos, CmdError> {
if !track_delete_would_change(s, a, b) {
return Ok(a.clone());
}
let rid = new_revision(s, RevisionKind::Delete);
track_delete_as(s, a, b, rid)
track_delete_as(s, a, b, rid).map(|e| e.at)
}
/// [`track_delete`] marking the deleted text with revision `rid`.
pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Result<Pos, CmdError> {
pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Result<TrackDeleteEffect, CmdError> {
let author = s.author.clone();
let mut changed = false;
// Remove text this author inserted (it never existed for the reader); mark the rest.
let paths = s.doc.paths_between(a, b);
for path in paths.iter().rev() {
@@ -248,6 +307,7 @@ pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Re
} else {
para.format(x, y, &|c| c.del = Some(rid))?;
}
changed = true;
}
// The paragraph mark is inside the range unless this is its last paragraph. Like text,
// a mark this author inserted is removed (the paragraphs join again), any other is
@@ -264,14 +324,27 @@ pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Re
p.mark.del = Some(rid);
p.touch();
}
changed = true;
}
}
}
Ok(a.clone())
Ok(TrackDeleteEffect { at: a.clone(), changed })
}
/// What one text insertion/deletion commit changed, for IME outcome classification.
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct InsertEffect {
pub content_changed: bool,
pub state_changed: bool,
}
/// 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(|_| ())
}
/// [`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)
}
@@ -279,30 +352,38 @@ pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
/// 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)
insert_text(s, text, false).map(|_| ())
}
fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> {
/// Inserts typed or pasted text at the caret: resolves field ends and content-control placeholders,
/// deletes the selection, applies tracked-change insertions, and splits paragraphs at every typed
/// line break or pasted paragraph separator. A pasted separator keeps the list semantics of pasted
/// 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> {
let mut props = s.typing_props();
if s.sel.is_collapsed() {
s.sel = crate::Selection::caret(s.doc.past_field_ends(&s.sel.focus));
}
// Content controls: a placeholder is replaced, locked ones refuse.
if let Some(p) = controls::prepare_edit(s)? {
let prepared = controls::prepare_edit(s)?;
let placeholder_changed = prepared.is_some();
if let Some(p) = prepared {
props = p;
}
// Pasted text is text: pending properties left by AutoCorrect don't give it a symbol's font (#2036).
if !enter {
props = props.without_symbol();
}
delete_selection(s)?;
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;
// Its direction comes from its own letters, as on screen, not from the run it's typed into:
// Latin typed into reopened Hebrew must not be saved as right-to-left (#1543). The DOCX
// writer marks the right-to-left pieces itself.
props.rtl = None;
if s.doc.settings.track_changes {
if s.doc.settings.track_changes && !text.is_empty() {
props.ins = Some(new_revision(s, RevisionKind::Insert));
props.del = None;
} else {
@@ -311,6 +392,7 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
}
let mut at = s.sel.focus.clone();
let mut first = true;
let mut inserted = false;
// Typed line breaks are the Enter key between the lines: in a one-line control it does
// nothing, so the lines run together and the control stays one paragraph (pasted text keeps
// its breaks, as before).
@@ -318,10 +400,12 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
for part in text.split(['\r', '\u{2029}']) {
if !first && !no_breaks {
at = split_para_as(s, &at, enter)?;
inserted = true;
}
first = false;
let part = part.replace('\u{000B}', "\n");
if !part.is_empty() {
inserted = true;
let starts_para = direction_unset(s, &at);
at = s.doc.insert_text(&at, &part, &props)?;
// Typing Arabic, Hebrew… as the first letters of a paragraph whose direction nothing
@@ -338,7 +422,8 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
}
s.sel = crate::Selection::caret(at);
s.goal_x = None;
Ok(())
let content_changed = deletion.content_changed || inserted;
Ok(InsertEffect { content_changed, state_changed: deletion.state_changed || content_changed || placeholder_changed })
}
/// Is the paragraph at `at` still without letters (empty, or only spaces, digits and
+1 -1
View File
@@ -58,7 +58,7 @@ pub fn cut_tracked(s: &mut Session) -> Result<bool, CmdError> {
}
s.doc.revisions.push(Revision::new(RevisionKind::Delete, s.author.clone(), now_iso()));
let rid = (s.doc.revisions.len() - 1) as u32;
let at = track_delete_as(s, &a, &b, rid)?;
let at = track_delete_as(s, &a, &b, rid)?.at;
s.sel = Selection::caret(at);
// Only the author's own insertions were cut: they are gone, not moved.
if in_use(s, rid) {
+44 -27
View File
@@ -1396,27 +1396,13 @@ fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize {
!seen.get(at(x, y)).copied().unwrap_or(true) && rgba.get_pixel_checked(x, y).is_some_and(|p| close(p.0))
};
let mut seen = vec![false; (w as usize) * (h as usize)];
let mut stack: Vec<(u32, u32)> = Vec::new();
for x in 0..w {
stack.push((x, 0));
stack.push((x, h - 1));
}
for y in 0..h {
stack.push((0, y));
stack.push((w - 1, y));
}
let mut stack = border_pixels(w, h);
let mut most = stack.len();
while let Some((x, y)) = stack.pop() {
if !open(rgba, &seen, x, y) {
continue;
}
let (mut x0, mut x1) = (x, x);
while x0 > 0 && open(rgba, &seen, x0 - 1, y) {
x0 -= 1;
}
while x1 + 1 < w && open(rgba, &seen, x1 + 1, y) {
x1 += 1;
}
let (x0, x1) = run_span(x, y, w, |xi, yi| open(rgba, &seen, xi, yi));
for xi in x0..=x1 {
if let Some(s) = seen.get_mut(at(xi, y)) {
*s = true;
@@ -1426,23 +1412,54 @@ fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize {
}
}
for ny in [y.checked_sub(1), y.checked_add(1).filter(|&n| n < h)].into_iter().flatten() {
let mut xi = x0;
while xi <= x1 {
if open(rgba, &seen, xi, ny) {
stack.push((xi, ny));
while xi <= x1 && open(rgba, &seen, xi, ny) {
xi += 1;
}
} else {
xi += 1;
}
}
push_open_runs(&mut stack, x0, x1, ny, |xi, yi| open(rgba, &seen, xi, yi));
}
most = most.max(stack.len());
}
most
}
/// Every pixel on the image's edge, the flood-fill seeds.
fn border_pixels(w: u32, h: u32) -> Vec<(u32, u32)> {
let mut stack = Vec::new();
for x in 0..w {
stack.push((x, 0));
stack.push((x, h - 1));
}
for y in 0..h {
stack.push((0, y));
stack.push((w - 1, y));
}
stack
}
/// The horizontal run of open pixels around `(x, y)`, both ends included.
fn run_span(x: u32, y: u32, w: u32, open: impl Fn(u32, u32) -> bool) -> (u32, u32) {
let (mut x0, mut x1) = (x, x);
while x0 > 0 && open(x0 - 1, y) {
x0 -= 1;
}
while x1 + 1 < w && open(x1 + 1, y) {
x1 += 1;
}
(x0, x1)
}
/// Queues the first pixel of each open run in row `ny` between `x0` and `x1`.
fn push_open_runs(stack: &mut Vec<(u32, u32)>, x0: u32, x1: u32, ny: u32, open: impl Fn(u32, u32) -> bool) {
let mut xi = x0;
while xi <= x1 {
if open(xi, ny) {
stack.push((xi, ny));
while xi <= x1 && open(xi, ny) {
xi += 1;
}
} else {
xi += 1;
}
}
}
fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> Result<image::DynamicImage, CmdError> {
let (w, h) = grown(img.width(), img.height(), border).ok_or_else(too_large)?;
let mut out = canvas(w, h, color)?;
+2
View File
@@ -263,6 +263,8 @@ fn folder_setting(key: &str, f: &str, local_only: bool) -> Result<Option<String>
if !path.is_absolute() {
return Err(CmdError::Params(format!("`{key}`: {f:?} isn't a full path")));
}
// Its files are read from now on, and on every start: only from inside the read root (#3737).
crate::file_access::check_read(&path.to_string_lossy()).map_err(CmdError::Failed)?;
if !path.is_dir() {
return Err(CmdError::Failed(format!("no folder at {}", path.display())));
}
+46 -40
View File
@@ -460,6 +460,51 @@ pub(crate) fn move_shown(s: &mut Session, up: bool) -> Option<CmdResult> {
Some(sel_result(s))
}
/// Keeps collapsed headings collapsed after an edit: each one moves to the nearest heading with the
/// same text that is still free.
fn remap_collapsed_headings(s: &mut Session) {
let hashes: Vec<Option<u64>> = (0..s.doc.body.len()).map(|i| text_hash(&s.doc, i)).collect();
let levels = ol::levels(&s.doc);
// The headings not yet taken, by text hash.
let mut by_hash: HashMap<u64, BTreeSet<usize>> = HashMap::new();
for (j, h) in hashes.iter().enumerate() {
if let Some(h) = h
&& levels.get(j).is_some_and(|l| *l < BODY)
{
by_hash.entry(*h).or_default().insert(j);
}
}
let mut used = BTreeSet::new();
let mut out = Vec::new();
for (i, h) in std::mem::take(&mut s.view.outline.collapsed) {
let free = |j: usize, used: &BTreeSet<usize>| levels.get(j).is_some_and(|l| *l < BODY) && !used.contains(&j);
let same = |j: usize| hashes.get(j).copied().flatten() == Some(h);
let found = if free(i, &used) && same(i) {
Some(i)
} else {
// The nearest free heading with the same text (the earlier one on a tie).
let nearest = by_hash.get(&h).and_then(|set| {
let below = set.range(..=i).next_back().copied();
let above = set.range(i..).next().copied();
match (below, above) {
(Some(b), Some(a)) => Some(if a.abs_diff(i) < b.abs_diff(i) { a } else { b }),
(b, a) => b.or(a),
}
});
nearest.or_else(|| free(i, &used).then_some(i))
};
if let Some(j) = found {
used.insert(j);
if let Some(set) = hashes.get(j).copied().flatten().and_then(|hj| by_hash.get_mut(&hj)) {
set.remove(&j);
}
out.push((j, hashes.get(j).copied().flatten().unwrap_or(h)));
}
}
out.sort_unstable();
s.view.outline.collapsed = out;
}
/// After every command: collapsed headings are found again where edits moved them (by their
/// text; one whose text changed in place stays collapsed), and in Outline view a caret the
/// outline doesn't show (in collapsed text, or past a body paragraph's first line) moves on to
@@ -467,46 +512,7 @@ pub(crate) fn move_shown(s: &mut Session, up: bool) -> Option<CmdResult> {
/// `changed` says whether the document changed (its revision moved); if not, nothing moved.
pub(crate) fn after_command(s: &mut Session, before: &Selection, changed: bool) {
if changed && !s.view.outline.collapsed.is_empty() {
let hashes: Vec<Option<u64>> = (0..s.doc.body.len()).map(|i| text_hash(&s.doc, i)).collect();
let levels = ol::levels(&s.doc);
// The headings not yet taken, by text hash.
let mut by_hash: HashMap<u64, BTreeSet<usize>> = HashMap::new();
for (j, h) in hashes.iter().enumerate() {
if let Some(h) = h
&& levels.get(j).is_some_and(|l| *l < BODY)
{
by_hash.entry(*h).or_default().insert(j);
}
}
let mut used = BTreeSet::new();
let mut out = Vec::new();
for (i, h) in std::mem::take(&mut s.view.outline.collapsed) {
let free = |j: usize, used: &BTreeSet<usize>| levels.get(j).is_some_and(|l| *l < BODY) && !used.contains(&j);
let same = |j: usize| hashes.get(j).copied().flatten() == Some(h);
let found = if free(i, &used) && same(i) {
Some(i)
} else {
// The nearest free heading with the same text (the earlier one on a tie).
let nearest = by_hash.get(&h).and_then(|set| {
let below = set.range(..=i).next_back().copied();
let above = set.range(i..).next().copied();
match (below, above) {
(Some(b), Some(a)) => Some(if a.abs_diff(i) < b.abs_diff(i) { a } else { b }),
(b, a) => b.or(a),
}
});
nearest.or_else(|| free(i, &used).then_some(i))
};
if let Some(j) = found {
used.insert(j);
if let Some(set) = hashes.get(j).copied().flatten().and_then(|hj| by_hash.get_mut(&hj)) {
set.remove(&j);
}
out.push((j, hashes.get(j).copied().flatten().unwrap_or(h)));
}
}
out.sort_unstable();
s.view.outline.collapsed = out;
remap_collapsed_headings(s);
}
if s.view.mode != ViewMode::Outline || !s.sel.is_collapsed() || s.sel.focus.story != StoryRef::Body {
return;
+10 -1
View File
@@ -406,6 +406,8 @@ fn check_formatting(s: &Session, id: &str, params: &Value) -> Result<(), CmdErro
| "table.modifyStyle"
| "table.deleteStyle"
| "tools.updateStyles"
// Copies style definitions in from another file, locked flags included (#3692).
| "styles.copyFrom"
)
// Attach Template updates the styles from it when asked (`linkStyles` is only a setting).
|| (id == "tools.attachTemplate" && p::bool(params, "update") == Some(true));
@@ -817,7 +819,8 @@ mod tests {
}
/// Formatting limited to styles: attaching a template refuses to update the styles from it
/// (it would bypass the Update Styles block); attaching without updating still works.
/// (it would bypass the Update Styles block), nor can Copy Styles; attaching without
/// updating still works.
#[test]
fn formatting_restriction_refuses_attach_template_update() {
let mut t = Document::from_text("Template.");
@@ -831,6 +834,12 @@ mod tests {
let e = s.run("tools.attachTemplate", &json!({"path": "Report.dotx", "update": true, "data": data})).unwrap_err().to_string();
assert!(e.contains("formatting is limited"), "{e}");
assert_eq!(s.doc, before);
// Copying styles in replaces their definitions, locked flag included (#3692).
s.doc.styles.styles.iter_mut().filter(|st| st.id == "Heading1").for_each(|st| st.locked = true);
let before = s.doc.clone();
let e = s.run("styles.copyFrom", &json!({"path": "Report.dotx", "styles": ["Heading 1"], "data": data})).unwrap_err().to_string();
assert!(e.contains("formatting is limited"), "{e}");
assert_eq!(s.doc, before);
s.run("tools.attachTemplate", &json!({"path": "Report.dotx"})).unwrap();
assert_eq!(s.doc.settings.attached_template.as_deref(), Some("Report.dotx"));
}
+39 -38
View File
@@ -606,6 +606,44 @@ fn resolve_sel_then(s: &mut Session, v: &Value, accept: bool) -> CmdResult {
resolve_sel(s, accept, p::bool(v, "next") == Some(true))
}
/// The span of the tracked change around `off` in `p`: its run, joined across the comment anchors
/// that split one revision's text into several runs.
fn revision_span(p: &Paragraph, off: usize) -> Option<std::ops::Range<usize>> {
let runs: Vec<_> = p.run_ranges().collect();
let i = runs.iter().position(|(r, c)| r.start <= off && off <= r.end && (c.ins.is_some() || c.del.is_some() || c.fmt_change.is_some()))?;
let (mut start, mut end) = (runs[i].0.start, runs[i].0.end);
let key = (runs[i].1.ins, runs[i].1.del);
if key == (None, None) {
return Some(start..end);
}
// Comment anchors split a revision's text into several runs; the revision goes on past them.
let is_anchor = |k: usize| matches!(p.object_at(runs[k].0.start), Some(InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }));
let same = |k: usize| (runs[k].1.ins, runs[k].1.del) == key;
let mut k = i;
while k > 0 {
if same(k - 1) {
k -= 1;
} else if is_anchor(k - 1) && k > 1 && same(k - 2) {
k -= 2;
} else {
break;
}
start = runs[k].0.start;
}
let mut k = i;
while k + 1 < runs.len() {
if same(k + 1) {
k += 1;
} else if is_anchor(k + 1) && k + 2 < runs.len() && same(k + 2) {
k += 2;
} else {
break;
}
end = runs[k].0.end;
}
Some(start..end)
}
fn resolve_sel(s: &mut Session, accept: bool, next: bool) -> CmdResult {
let (a, b) = s.sel.ordered();
// A caret right before a tracked paragraph mark (and no tracked text around it): that mark.
@@ -616,44 +654,7 @@ fn resolve_sel(s: &mut Session, accept: bool, next: bool) -> CmdResult {
let (a, b) = if a == b {
// The change at the caret: the run around it, else the paragraph mark right after it,
// else the paragraph's formatting, else the table cell's.
let found = s.doc.para_at(&a).and_then(|p| {
let runs: Vec<_> = p.run_ranges().collect();
let i = runs
.iter()
.position(|(r, c)| r.start <= a.off && a.off <= r.end && (c.ins.is_some() || c.del.is_some() || c.fmt_change.is_some()))?;
let (mut start, mut end) = (runs[i].0.start, runs[i].0.end);
let key = (runs[i].1.ins, runs[i].1.del);
if key == (None, None) {
return Some(start..end);
}
// Comment anchors split a revision's text into several runs; the revision goes on past them.
let is_anchor =
|k: usize| matches!(p.object_at(runs[k].0.start), Some(InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }));
let same = |k: usize| (runs[k].1.ins, runs[k].1.del) == key;
let mut k = i;
while k > 0 {
if same(k - 1) {
k -= 1;
} else if is_anchor(k - 1) && k > 1 && same(k - 2) {
k -= 2;
} else {
break;
}
start = runs[k].0.start;
}
let mut k = i;
while k + 1 < runs.len() {
if same(k + 1) {
k += 1;
} else if is_anchor(k + 1) && k + 2 < runs.len() && same(k + 2) {
k += 2;
} else {
break;
}
end = runs[k].0.end;
}
Some(start..end)
});
let found = s.doc.para_at(&a).and_then(|p| revision_span(p, a.off));
let para_fmt = s.doc.para_at(&a).is_some_and(|p| p.props.fmt_change.is_some() || p.props.num_change.is_some());
let mark_fmt = s.doc.para_at(&a).is_some_and(|p| a.off == p.len() && p.mark.fmt_change.is_some());
match found {
+5 -3
View File
@@ -35,7 +35,8 @@ fn read_aloud(s: &mut Session, v: &Value) -> CmdResult {
if let Some(b) = p::bool(v, "skipCitations") {
s.read_aloud.skip_citations = b;
}
if s.read_aloud.state() != State::Stopped {
// A reading of this document pauses or carries on; one of another document gives way.
if s.read_aloud.state() != State::Stopped && s.read_aloud.document == Some(s.document_id()) {
s.read_aloud.play_pause();
return status(s, v);
}
@@ -54,12 +55,13 @@ fn read_aloud(s: &mut Session, v: &Value) -> CmdResult {
if queue.is_empty() {
return Err(CmdError::Failed("there's nothing to read".into()));
}
s.read_aloud.document = Some(s.document_id());
s.read_aloud.start(queue);
status(s, v)
}
fn play_pause(s: &mut Session, v: &Value) -> CmdResult {
if s.read_aloud.progress().1 == 0 {
if s.read_aloud.progress().1 == 0 || s.read_aloud.document != Some(s.document_id()) {
return read_aloud(s, v);
}
s.read_aloud.play_pause();
@@ -92,7 +94,7 @@ fn skip_citations(s: &mut Session, v: &Value) -> CmdResult {
let on = p::bool(v, "value").unwrap_or(!s.read_aloud.skip_citations);
if on != s.read_aloud.skip_citations {
s.read_aloud.skip_citations = on;
if let Some(cur) = s.read_aloud.current() {
if let Some(cur) = s.read_aloud.current().filter(|_| s.read_aloud.document == Some(s.document_id())) {
let paused = s.read_aloud.state() == State::Paused;
let queue = speech::sentences(&s.doc, &cur.start, None, on);
if !queue.is_empty() {
+80 -12
View File
@@ -185,17 +185,22 @@ fn load_other(name: &str, data: Option<&str>, base: Option<&Path>) -> Result<Doc
if name.len() > MAX_PATH {
return Err(CmdError::Params("`path` is too long".into()));
}
let local = local_path(name, base).ok_or_else(|| CmdError::Failed(format!("`{name}` is not a local file")))?;
let ext = local.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
// With the bytes given nothing is read, so the name only gives the format: no path is
// resolved, and a document on a share (or a `\\?\` path) can still use a relative name.
let file = match data {
Some(_) => PathBuf::from(name),
None => local_path(name, base).ok_or_else(|| CmdError::Failed(format!("`{name}` is not a local file")))?,
};
let ext = file.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
if matches!(ext.as_str(), "" | "txt" | "text") {
return Err(CmdError::Params(format!("`{name}` is not a document or template with styles")));
}
match data {
Some(d) => {
let bytes = super::insert::base64_decode(d).ok_or_else(|| CmdError::Params("bad base64".into()))?;
crate::io::open_bytes(&local.to_string_lossy(), &bytes).map_err(CmdError::Failed)
crate::io::open_bytes(name, &bytes).map_err(CmdError::Failed)
}
None => crate::io::open_path(&local).map_err(CmdError::Failed),
None => crate::io::open_path(&file).map_err(CmdError::Failed),
}
}
@@ -231,18 +236,34 @@ pub fn local_path(target: &str, base: Option<&Path>) -> Option<PathBuf> {
return None;
}
let p = PathBuf::from(&path);
if p.is_relative()
&& !is_windows_absolute(&path)
&& let Some(dir) = base.and_then(Path::parent)
{
return Some(dir.join(p));
}
Some(p)
let p = match base.and_then(Path::parent) {
Some(dir) if p.is_relative() && !is_windows_absolute(&path) => dir.join(p),
_ => p,
};
// Checked again once joined: the document's own folder may be on another machine (#3698).
(!is_network_path(&p.to_string_lossy())).then_some(p)
}
/// Does `p` name another machine or a device, whatever separators it mixes: `\\host\share`,
/// `//host`, `\\?\UNC\…`, `\\.\UNC\…`, other devices, and the NT namespace `\??\…`. The
/// verbatim, device and NT prefixes are local when a drive letter follows (`\\?\C:\…`,
/// `\\.\C:\…`, `\??\C:\…`, #3698). Purely lexical, so it is the same on every platform.
pub(crate) fn is_network_path(p: &str) -> bool {
let sep = |c: Option<&u8>| matches!(c, Some(b'\\' | b'/'));
let b = p.as_bytes();
matches!((b.first(), b.get(1)), (Some(b'\\' | b'/'), Some(b'\\' | b'/')))
// `\\?\`, `\\.\` or `\??\`, then `X:` and the end or a separator.
let prefix = sep(b.first())
&& sep(b.get(3))
&& match (b.get(1), b.get(2)) {
(Some(b'?'), Some(b'?')) => true,
(c, Some(b'?' | b'.')) => sep(c),
_ => false,
};
let drive = b.get(4).is_some_and(u8::is_ascii_alphabetic) && b.get(5) == Some(&b':') && (b.len() == 6 || sep(b.get(6)));
if prefix {
return !drive;
}
sep(b.first()) && sep(b.get(1))
}
fn is_windows_absolute(p: &str) -> bool {
@@ -489,6 +510,29 @@ mod tests {
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
/// Ultrareview of #3698: with the template's bytes given (`data`) nothing is read from a path,
/// so a document opened from a share or a `\\?\` path can still attach, update and organise
/// styles from a template named relative to it. Without the bytes, the share is never read.
#[test]
fn template_bytes_need_no_local_path() {
let data = crate::cmd::insert::base64_encode(&wordcraft_docx::write(&template()).unwrap());
for doc_path in ["//fileserver/share/Letter.docx", "//?/C:/docs/Letter.docx"] {
let mut s = Session::new(Document::new());
s.path = Some(PathBuf::from(doc_path));
s.run("tools.attachTemplate", &json!({"path": "Report.dotx", "data": data, "update": true})).unwrap();
assert_eq!(s.doc.styles.get("Heading1").unwrap().chr.size, Some(31.0), "{doc_path}");
s.run("tools.updateStyles", &json!({"path": "Report.dotx", "data": data})).unwrap();
let listed = s.run("styles.organizer", &json!({"path": "Report.dotx", "data": data})).unwrap();
assert!(listed["file"]["styles"].as_array().unwrap().iter().any(|x| x["name"] == "Callout"), "{doc_path}");
s.run("styles.copyFrom", &json!({"path": "Report.dotx", "data": data, "styles": ["Callout"]})).unwrap();
assert!(s.doc.styles.get("Callout").is_some(), "{doc_path}");
}
let mut s = Session::new(Document::new());
s.path = Some(PathBuf::from("//fileserver/share/Letter.docx"));
let err = s.run("tools.updateStyles", &json!({"path": "Report.dotx"})).unwrap_err().to_string();
assert!(err.contains("not a local file"), "{err}");
}
#[test]
fn template_references_become_local_paths() {
assert_eq!(local_path("file:///C:/Users/A%20B/T.dotx", None), Some(PathBuf::from("C:/Users/A B/T.dotx")));
@@ -504,9 +548,33 @@ mod tests {
"file://evil.example/share/T.dotx",
"file:////evil.example/share/T.dotx",
"FILE://Evil.Example/T.dotx",
// The NT namespace (#3698), with any separators.
"\\??\\UNC\\evil.example\\share\\T.dotx",
"/??/UNC/evil.example/share/T.dotx",
"\\??/UNC\\evil.example/share/T.dotx",
"\\??\\GLOBALROOT\\Device\\Mup\\evil.example\\share\\T.dotx",
"\\\\.\\UNC\\evil.example\\share\\T.dotx",
] {
assert_eq!(local_path(remote, Some(Path::new("/docs/Letter.docx"))), None, "{remote}");
assert_eq!(local_path(remote, None), None, "{remote} without a folder");
}
// A relative reference from a document whose folder is on another machine (#3698).
assert_eq!(local_path("T.dotx", Some(Path::new("//evil.example/share/Letter.docx"))), None);
assert_eq!(local_path("file:T.dotx", Some(Path::new("//evil.example/share/Letter.docx"))), None);
// A drive in the NT namespace is local.
assert!(!is_network_path("\\??\\C:\\Templates\\T.dotx"));
assert!(!is_network_path("\\??\\C:"));
assert!(is_network_path("\\??\\C:evil"));
// Verbatim and device paths with a drive are local; with `UNC` or anything else they aren't.
for local in ["\\\\?\\C:\\Templates\\T.dotx", "\\\\.\\C:\\Templates\\T.dotx", "//?/C:/Templates/T.dotx", "\\\\?\\C:"] {
assert!(!is_network_path(local), "{local}");
}
for remote in
["\\\\?\\UNC\\host\\share\\T.dotx", "//./UNC/host/share/T.dotx", "\\\\host\\share\\T.dotx", "\\\\?\\C:evil", "\\\\.\\PhysicalDrive0"]
{
assert!(is_network_path(remote), "{remote}");
}
assert_eq!(local_path("T.dotx", Some(Path::new("//?/C:/docs/Letter.docx"))), Some(PathBuf::from("//?/C:/docs/T.dotx")));
assert_eq!(percent_decode("100%"), "100%");
assert_eq!(percent_decode("%zz%4"), "%zz%4");
}
+187 -43
View File
@@ -5,12 +5,13 @@ use wordcraft_doc::para::{COLUMN_BREAK, NB_HYPHEN, NBSP, OBJ, PAGE_BREAK, SOFT_H
use wordcraft_doc::props::{Border, BorderStyle, NumRef};
use wordcraft_doc::{Block, ListKind, Path, Pos, StoryRef, Table};
use super::{delete_selection, non_mac, sel_result, split_para, type_text};
use super::{delete_selection, delete_selection_effect, non_mac, sel_result, split_para, type_text, type_text_effect};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
/// The text-entry commands: typing, Enter, breaks, Tab, deleting and typed symbols.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string}"#),
CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string, "raw"?: bool, "paragraphs"?: bool} (paragraphs: each line a paragraph, one undo step, at most 10000 lines)"#),
CommandSpec::new("text.newParagraph", "New Paragraph", "Editing", new_paragraph).key("Enter"),
CommandSpec::new("text.lineBreak", "Line Break", "Editing", |s, _| ins_char(s, '\n')).key("Shift+Enter"),
CommandSpec::new("text.pageBreak", "Page Break", "Insert › Pages", |s, _| ins_char(s, PAGE_BREAK)).key("Mod+Enter"),
@@ -96,14 +97,30 @@ fn typing_options(s: &mut Session, text: &str) {
}
}
/// `text.insert`: type `text` at the selection as the keyboard does (AutoCorrect and
/// AutoFormat unless `raw`), or, with `paragraphs`, line by line with Enter between. A single
/// trigger character records the IME outcome, and list, AutoCorrect, dash and double-space
/// AutoFormat run as a separate undo step when they rewrite the paragraph.
fn insert(s: &mut Session, v: &Value) -> CmdResult {
let text = p::req_str(v, "text")?;
let raw = p::bool(v, "raw").unwrap_or(false);
if p::bool(v, "paragraphs").unwrap_or(false) {
return insert_paragraphs(s, text);
}
typing_options(s, text);
let t = if raw { None } else { autoformat(s, text) };
type_text(s, t.as_deref().unwrap_or(text))?;
let smart_quote = if raw { None } else { autoformat(s, text) };
let was_collapsed = s.sel.is_collapsed();
let effect = type_text_effect(s, smart_quote.as_deref().unwrap_or(text))?;
let mut outcome = crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: smart_quote.is_some(),
selection_changed: false,
};
let trigger = text.chars().count() == 1 && text.chars().all(|c| c == ' ' || ",.;:!?".contains(c));
if raw || !trigger {
s.set_ime_edit_outcome(outcome);
return sel_result(s);
}
// The paragraph as typed, so an automatic change can be undone on its own.
@@ -114,8 +131,22 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
if !listed {
super::tools::autocorrect(s).inspect_err(|_| s.autocap = None)?;
}
if text == " " && !listed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.dashes {
dash_autoformat(s).inspect_err(|_| s.autocap = None)?;
let dashed = text == " "
&& !listed
&& s.prefs.autocorrect.enabled
&& s.prefs.autocorrect.dashes
&& dash_autoformat(s).inspect_err(|_| s.autocap = None)?;
if text == " " && !listed && was_collapsed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.double_space_period {
double_space_period(s)?;
}
// Compare the paragraph with the one exactly as typed. This is the actual effect of
// AutoCorrect (including formatting-only superscripts and links), not an undo label that
// may have been left by an earlier step. It also covers the double-space period.
let rewritten = typed.as_ref().is_some_and(|(para, _)| s.doc.para_at(&f) != Some(para));
if listed || dashed || rewritten {
outcome.content_changed = true;
outcome.state_changed = true;
outcome.transformed = true;
}
if let Some((para, sel)) = typed
&& s.doc.para_at(&f) != Some(&para)
@@ -133,6 +164,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
}
}
s.autocap.take_if(|c| c.depth == u64::MAX);
s.set_ime_edit_outcome(outcome);
sel_result(s)
}
@@ -187,11 +219,32 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
Ok(true)
}
/// "word -- word" → en dash, "word--word" → em dash (on the space after).
fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
/// "word" + two spaces → "word. ": the second Space turns the first into a period, after a
/// letter, a digit or a closing bracket or quote; not after other punctuation, at the start of a
/// paragraph, after a tab, or for a third space (the text before them ends in a period by then).
fn double_space_period(s: &mut Session) -> Result<(), CmdError> {
let f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return Ok(()) };
let Some(before) = para.text.get(..f.off) else { return Ok(()) };
let Some(word) = before.strip_suffix(" ") else { return Ok(()) };
if !word.chars().next_back().is_some_and(|c| c.is_alphanumeric() || ")]}\"'\u{201D}\u{2019}\u{00BB}\u{203A}".contains(c)) {
return Ok(());
}
// The period takes the formatting of the space it replaces.
let at = word.len();
let p = s.doc.para_mut(f.story, &f.path)?;
let props = p.props_at(at + 1).clone().without_symbol();
p.delete(at, at + 1)?;
p.insert_text(at, ".", &props)?;
s.sel = Selection::caret(f);
Ok(())
}
/// "word -- word" → en dash, "word--word" → em dash (on the space after).
fn dash_autoformat(s: &mut Session) -> Result<bool, CmdError> {
let f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return Ok(false) };
let Some(before) = para.text.get(..f.off) else { return Ok(false) };
if before.ends_with(' ')
&& let Some(i) = before.trim_end().rfind(" -- ")
&& before.trim_end().get(i + 4..).is_some_and(|w| !w.is_empty() && !w.contains(' '))
@@ -202,6 +255,7 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
p.delete(i + 1, i + 3)?;
p.insert_text(i + 1, "\u{2013}", &props)?;
s.sel = Selection::caret(Pos { off: f.off + 1, ..f });
return Ok(true);
} else if before.ends_with(' ')
&& let Some(i) = before.trim_end().rfind(" - ")
&& !before.trim_end()[i + 3..].contains(' ')
@@ -211,6 +265,7 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
p.delete(i + 1, i + 2)?;
p.insert_text(i + 1, "\u{2013}", &props)?;
s.sel = Selection::caret(Pos { off: f.off + 2, ..f });
return Ok(true);
} else if before.ends_with(' ')
&& let Some(i) = before.trim_end().rfind("--")
&& i > 0
@@ -221,8 +276,9 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
p.delete(i, i + 2)?;
p.insert_text(i, "\u{2014}", &props)?;
s.sel = Selection::caret(Pos { off: f.off + 1, ..f });
return Ok(true);
}
Ok(())
Ok(false)
}
/// An AutoFormat that Enter applies to the paragraph just finished.
@@ -663,17 +719,58 @@ fn sample_text(s: &Session) -> Option<String> {
Some(text)
}
/// `text.newParagraph`: Enter, unless the selection is in a control that takes no paragraph break.
fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
// Enter in a one-line plain text control (or a check box or list) does nothing, as in Word.
if super::controls::refuses_paragraph(s) {
return sel_result(s);
}
enter(s, true)?;
sel_result(s)
}
/// Most lines `text.insert` with `paragraphs` types in one call, for every caller (control
/// channel, CLI, MCP): each line is an Enter, with its AutoFormat and AutoCorrect, so the work
/// grows with the line count. More is refused before anything is typed (#3692).
pub const MAX_TYPED_PARAGRAPHS: usize = 10_000;
/// `text.insert` with `paragraphs`: each line typed as it is (`raw`), and between lines the
/// paragraph break Enter makes ([`enter`]: its AutoFormat and AutoCorrect), all in one command, so
/// one undo step instead of a command and a whole-document snapshot per line (#3692). Where
/// Enter would do nothing (a one-line control, a check box, a list) the text is refused and,
/// like any failed command, nothing of it stays. More than [`MAX_TYPED_PARAGRAPHS`] lines are
/// refused before anything is typed.
fn insert_paragraphs(s: &mut Session, text: &str) -> CmdResult {
let text = text.replace("\r\n", "\n");
let lines = text.split(['\n', '\r', '\u{2029}']).count();
if lines > MAX_TYPED_PARAGRAPHS {
return Err(CmdError::Params(format!("text.insert: `text` has {lines} lines; type at most {MAX_TYPED_PARAGRAPHS} paragraphs per call")));
}
typing_options(s, &text);
for (i, line) in text.split(['\n', '\r', '\u{2029}']).enumerate() {
if i > 0 {
if super::controls::refuses_paragraph(s) {
return Err(CmdError::Disabled("text.insert: this content control takes no paragraph breaks".into()));
}
enter(s, false)?;
}
if !line.is_empty() {
type_text(s, line)?;
}
}
sel_result(s)
}
/// Enter's paragraph break once a control has allowed it: `=rand()` sample text, AutoFormat of a
/// border or table line, AutoCorrect of the word it ends, else a plain split. With `undo_step`
/// an AutoFormat is recorded as its own undo step after the plain Enter, as in Word; without it
/// (typed paragraphs, one undo step for all) no extra snapshot is taken.
fn enter(s: &mut Session, undo_step: bool) -> Result<(), CmdError> {
super::controls::prepare_edit(s)?;
if let Some(text) = sample_text(s) {
let end = s.sel.focus.clone();
s.sel = Selection { anchor: Pos { off: 0, ..end.clone() }, focus: end };
type_text(s, &text)?;
return sel_result(s);
return type_text(s, &text);
}
let mut at = delete_selection(s)?;
let auto = border_line(s, &at)
@@ -683,19 +780,24 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
if let Some(auto) = auto {
// Word: Undo right after reverts only the AutoFormat, so the typed characters come back
// and the Enter stays. Record that plain Enter as its own undo step.
let (doc, sel) = (s.doc.clone(), s.sel.clone());
let new = split_para(s, &at)?;
s.sel = Selection::caret(new);
let plain_doc = std::mem::replace(&mut s.doc, doc);
let plain_sel = std::mem::replace(&mut s.sel, sel);
let plain = if undo_step {
let (doc, sel) = (s.doc.clone(), s.sel.clone());
let new = split_para(s, &at)?;
s.sel = Selection::caret(new);
Some((std::mem::replace(&mut s.doc, doc), std::mem::replace(&mut s.sel, sel)))
} else {
None
};
match auto {
EnterAutoFormat::Border(line) => apply_border_line(s, &at, line)?,
EnterAutoFormat::Table(cols) => apply_table_line(s, &at, &cols)?,
EnterAutoFormat::Heading(level) => apply_heading_line(s, &at, level)?,
}
s.goal_x = None;
s.push_undo("AutoFormat", plain_doc, plain_sel);
return sel_result(s);
if let Some((plain_doc, plain_sel)) = plain {
s.push_undo("AutoFormat", plain_doc, plain_sel);
}
return Ok(());
}
// Enter finishes a word like a space does ("teh" → "the", sentence capitals, links).
if s.sel.is_collapsed() && at.off > 0 {
@@ -722,7 +824,7 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
s.sel = Selection::caret(new);
s.goal_x = None;
s.push_undo("AutoFormat", plain_doc, plain_sel);
return sel_result(s);
return Ok(());
}
super::tools::autocorrect_word(s, true)?;
s.autocap.take_if(|c| c.depth == u64::MAX);
@@ -731,7 +833,7 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
let new = split_para(s, &at)?;
s.sel = Selection::caret(new);
s.goal_x = None;
sel_result(s)
Ok(())
}
/// In a table: next cell (adds a row at the end). At the start of a list item: demote. With "Set
@@ -773,8 +875,12 @@ fn tab(s: &mut Session, _: &Value) -> CmdResult {
if s.sel.is_collapsed() && tab_indent_on(s) && tab_indents(s, &f) {
return indent_by_tab(s, &f);
}
if !s.sel.is_collapsed() && s.sel.anchor.path != s.sel.focus.path {
// Multiple paragraphs selected: increase indent.
let whole_para = !s.sel.is_collapsed() && s.sel.anchor.path == s.sel.focus.path && {
let (a, b) = s.sel.ordered();
a.off == 0 && s.doc.para_at(&a).is_some_and(|p| b.off == p.len())
};
if !s.sel.is_collapsed() && (s.sel.anchor.path != s.sel.focus.path || whole_para) {
// Several paragraphs or a whole paragraph selected: increase indent.
return super::para::indent(s, &Value::Null);
}
ins_char(s, '\t')
@@ -793,7 +899,7 @@ fn tab_indent_on(s: &Session) -> bool {
fn tab_indents(s: &mut Session, f: &Pos) -> bool {
let Some(para) = s.doc.para_at(f).filter(|p| !p.is_empty()) else { return false };
if f.off == 0 {
return !s.doc.styles.resolve_para(&para.props).numbering.is_some_and(|n| n.num != 0);
return s.doc.styles.resolve_para(&para.props).numbering.is_none_or(|n| n.num == 0);
}
let l = s.layout();
l.line_bounds(f, s.page_hint).is_some_and(|(a, _)| a == *f)
@@ -857,11 +963,40 @@ fn backspace(s: &mut Session, v: &Value) -> CmdResult {
Ok(r)
}
/// Deletes the selection or the character before the caret. At a paragraph start it removes list
/// numbering while keeping the text position, takes a List Paragraph out of the list, outdents,
/// joins the previous paragraph or moves into a preceding table; the command wrapper restores a
/// content-control placeholder.
/// The first Backspace at a numbered paragraph's start removes the number but keeps the item's
/// indent (Word): the text stays where it was, so the indent becomes the paragraph's own.
fn remove_number(s: &mut Session, f: &Pos, n: NumRef) -> CmdResult {
let text_at = s.doc.numbering.level(n.num, n.level).map(|l| l.indent);
let p = s.doc.para_mut(f.story, &f.path)?;
if p.props.style.as_deref() == Some("ListParagraph") {
if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) {
p.props.indent_left = text_at;
}
if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) {
p.props.indent_first = Some(0.0);
}
}
p.props.numbering = Some(NumRef { num: 0, level: 0 });
p.touch();
sel_result(s)
}
fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
s.goal_x = None;
s.pending = None;
if !s.sel.is_collapsed() {
delete_selection(s)?;
let effect = delete_selection_effect(s)?;
s.set_ime_edit_outcome(crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: false,
selection_changed: false,
});
return sel_result(s);
}
let f = s.sel.focus.clone();
@@ -872,26 +1007,19 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
c.note_backspace(&a, &f);
}
s.sel = Selection { anchor: a, focus: f };
delete_selection(s)?;
let effect = delete_selection_effect(s)?;
s.set_ime_edit_outcome(crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: false,
selection_changed: false,
});
return sel_result(s);
}
// At the start of a paragraph.
if para.props.numbering.is_some_and(|n| n.num != 0) {
// First Backspace removes the number but keeps the item's indent (Word): the text stays
// where it was, so the indent becomes the paragraph's own (`num: 0` alone drops it).
let text_at = para.props.numbering.and_then(|n| s.doc.numbering.level(n.num, n.level)).map(|l| l.indent);
let p = s.doc.para_mut(f.story, &f.path)?;
if p.props.style.as_deref() == Some("ListParagraph") {
if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) {
p.props.indent_left = text_at;
}
if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) {
p.props.indent_first = Some(0.0);
}
}
p.props.numbering = Some(NumRef { num: 0, level: 0 });
p.touch();
return sel_result(s);
if let Some(n) = para.props.numbering.filter(|n| n.num != 0) {
return remove_number(s, &f, n);
}
if para.props.style.as_deref() == Some("ListParagraph") {
// The second one takes it out of the list's indent instead of joining paragraphs.
@@ -952,11 +1080,20 @@ fn delete(s: &mut Session, v: &Value) -> CmdResult {
Ok(r)
}
/// Deletes the selection or the character after the caret, joins the next paragraph, handles a
/// following table and removes a trailing section break; tracked changes keep their marks.
fn delete_inner(s: &mut Session, _: &Value) -> CmdResult {
s.goal_x = None;
s.pending = None;
if !s.sel.is_collapsed() {
delete_selection(s)?;
let effect = delete_selection_effect(s)?;
s.set_ime_edit_outcome(crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: false,
selection_changed: false,
});
return sel_result(s);
}
let f = s.sel.focus.clone();
@@ -964,7 +1101,14 @@ fn delete_inner(s: &mut Session, _: &Value) -> CmdResult {
if f.off < para.len() {
let b = Pos { off: para.next_boundary(f.off), ..f.clone() };
s.sel = Selection { anchor: f, focus: b };
delete_selection(s)?;
let effect = delete_selection_effect(s)?;
s.set_ime_edit_outcome(crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: false,
selection_changed: false,
});
return sel_result(s);
}
// At the end: join with the next paragraph in the same container.
+91 -23
View File
@@ -114,6 +114,9 @@ pub struct AutoCorrectPrefs {
/// letter or digit makes it Heading 1 (Heading 2 or 3 after one or two leading tabs).
/// Off by default.
pub heading_styles: bool,
/// A second Space after a word, number or closing bracket or quote turns the first into a
/// period (macOS's "Add period with double-space"); off by default.
pub double_space_period: bool,
/// The user's entries: added, or replacing a built-in one.
pub entries: Vec<(String, String)>,
/// Built-in entries the user deleted.
@@ -152,6 +155,7 @@ impl Default for AutoCorrectPrefs {
border_lines: true,
tab_indent: false,
heading_styles: false,
double_space_period: false,
entries: Vec::new(),
removed: Vec::new(),
exceptions: Vec::new(),
@@ -344,6 +348,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
("borderLines", &mut ac.border_lines),
("tabIndent", &mut ac.tab_indent),
("headingStyles", &mut ac.heading_styles),
("doubleSpacePeriod", &mut ac.double_space_period),
("autoAddFirstLetter", &mut ac.auto_add_first_letter),
("autoAddInitialCaps", &mut ac.auto_add_initial_caps),
] {
@@ -420,6 +425,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
"borderLines": ac.border_lines,
"tabIndent": ac.tab_indent,
"headingStyles": ac.heading_styles,
"doubleSpacePeriod": ac.double_space_period,
"autoAddFirstLetter": ac.auto_add_first_letter,
"autoAddInitialCaps": ac.auto_add_initial_caps,
"entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::<Vec<_>>(),
@@ -429,6 +435,11 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
}))
}
/// Builds the tools, review, file and insertion command table: Repeat and macros; AutoCorrect,
/// keyboard and ribbon customization and search; Compare, accessibility, inspection, protection,
/// versions, recovery, templates and autosave; compatibility, sharing, Quick Parts, fields,
/// signatures, objects and spreadsheets; header/footer, table and numbering settings; language,
/// markup, editor and selection commands; and design and view options.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("edit.repeat", "Repeat", "Quick Access Toolbar", |s, _| {
@@ -457,7 +468,7 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options)
.params(
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "doubleSpacePeriod"?: bool (Add a period with double-space; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
)
.pure(),
CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard)
@@ -521,6 +532,10 @@ pub fn specs() -> Vec<CommandSpec> {
s.doc.settings.protection = protection;
s.doc.settings.protect_hash = protect_hash;
s.doc.settings.protect_formatting = protect_formatting;
// The restored document reuses the open document's id; the body and any equation
// editing session are replaced.
s.note_body_replaced();
s.math = None;
s.touch();
s.clamp_selection();
}
@@ -536,8 +551,7 @@ pub fn specs() -> Vec<CommandSpec> {
let mut doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
// As in Word, the new document is attached to the template it came from (#2852).
doc.settings.attached_template = Some(path.to_string());
s.set_document(doc);
s.path = None;
s.add_document(doc);
s.dirty = true;
sel_result(s)
})
@@ -1332,6 +1346,8 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
s.doc = doc;
s.doc.ensure_nonempty();
s.note_body_replaced();
s.math = None;
}
let level = p::str(v, "level").unwrap_or("word");
let opts = CompareOptions {
@@ -1355,7 +1371,8 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
// Tables match as a whole, by their text.
Block::Table(_) => format!("\u{0}table\u{0}{}", opts.key(&block_text(b, 0))),
};
let ops = lcs_ops(&old.iter().map(key).collect::<Vec<_>>(), &new.iter().map(key).collect::<Vec<_>>());
let mut budget = MAX_COMPARE_CELLS;
let ops = lcs_ops(&old.iter().map(key).collect::<Vec<_>>(), &new.iter().map(key).collect::<Vec<_>>(), &mut budget);
let mut out: Vec<Arc<Block>> = Vec::new();
let (mut i, mut j) = (0usize, 0usize);
let mut changes = 0usize;
@@ -1391,7 +1408,7 @@ 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(pa, pb, ins, del, &opts))),
(Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(pa, pb, ins, del, &opts, &mut budget))),
_ => {
if let Some(x) = a {
out.push(Arc::new(mark_block(x, &|c| c.del = Some(del), 0)));
@@ -1412,6 +1429,8 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
}
s.doc.body = out;
s.doc.ensure_nonempty();
s.note_body_replaced();
s.math = None;
if show_in != "original" || p::str(v, "original").is_some() {
// The result is a new, untitled document: saving it never overwrites either file.
s.path = None;
@@ -1467,15 +1486,26 @@ enum Op {
/// compared as deleted and inserted, never dropped.
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).
fn lcs_ops<T: PartialEq>(a: &[T], b: &[T]) -> Vec<Op> {
/// middle (bounded size). The middle's table is charged to `budget`; when it doesn't fit, the
/// middle is deleted and inserted whole.
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 ops = vec![Op::Same; pre];
let (n, m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS));
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() {
@@ -1523,12 +1553,13 @@ const NOISE_MATCH_LEN: usize = 1;
/// stripped before running its table, so it's left untouched regardless of length; only a short
/// match produced by the table's own backtracking, which can only ever be interior, is a
/// candidate.
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Keep,
Fold,
}
fn collapse_noise_matches(ops: &[Op]) -> Vec<Op> {
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Keep,
Fold,
}
let mut runs: Vec<(Op, usize, Kind)> = Vec::new();
let mut k = 0;
while k < ops.len() {
@@ -1555,12 +1586,7 @@ fn collapse_noise_matches(ops: &[Op]) -> Vec<Op> {
}
let region = &runs[start..i];
if region.iter().any(|(_, _, kind)| *kind == Kind::Fold) {
// Flatten the region: every original character first (in its own order), then every
// revised character (in its own order) — one clean delete, one clean insert.
let dels: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Del { *len } else { 0 }).sum();
let inss: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Ins { *len } else { 0 }).sum();
out.extend(std::iter::repeat_n(Op::Del, dels));
out.extend(std::iter::repeat_n(Op::Ins, inss));
flatten_region(&mut out, region);
} else {
for (op, len, _) in region {
out.extend(std::iter::repeat_n(*op, *len));
@@ -1570,6 +1596,15 @@ fn collapse_noise_matches(ops: &[Op]) -> Vec<Op> {
out
}
/// Flattens a region with a folded run: every original character first (in its own order), then
/// every revised character (in its own order) — one clean delete, one clean insert.
fn flatten_region(out: &mut Vec<Op>, region: &[(Op, usize, Kind)]) {
let dels: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Del { *len } else { 0 }).sum();
let inss: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Ins { *len } else { 0 }).sum();
out.extend(std::iter::repeat_n(Op::Del, dels));
out.extend(std::iter::repeat_n(Op::Ins, inss));
}
/// Words with the spaces after them ("The ", "cat "), or single characters.
fn tokens(s: &str, characters: bool) -> Vec<String> {
if characters {
@@ -1589,11 +1624,11 @@ fn tokens(s: &str, characters: bool) -> Vec<String> {
v
}
fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions) -> Paragraph {
fn word_diff(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;
let (ta, tb) = (tokens(&at, chars), tokens(&bt, chars));
let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::<Vec<_>>(), &tb.iter().map(|t| opts.key(t)).collect::<Vec<_>>());
let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::<Vec<_>>(), &tb.iter().map(|t| opts.key(t)).collect::<Vec<_>>(), 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.
@@ -2242,12 +2277,12 @@ mod tests {
fn collapse_noise_matches_merges_short_interior_runs_but_keeps_prefix_suffix() {
// "01.09" vs "19.10": a pure LCS finds a scrambled chain of single-character matches
// ('1', '.', '0') threaded through what a reader expects to be one clean replacement.
let ops = lcs_ops(&['0', '1', '.', '0', '9'], &['1', '9', '.', '1', '0']);
let ops = lcs_ops(&['0', '1', '.', '0', '9'], &['1', '9', '.', '1', '0'], &mut { u64::MAX });
assert!(ops.contains(&Op::Same), "the raw LCS does find spurious matches here, or this test proves nothing");
assert_eq!(collapse_noise_matches(&ops), vec![Op::Del, Op::Del, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Ins, Op::Ins, Op::Ins]);
// A plain single-character replacement flanked by a real common prefix and suffix has no
// interior match to begin with (the DP's middle is pure delete/insert), so it's untouched.
let ops = lcs_ops(&['a', 'b', 'c', 'x', 'd', 'e', 'f'], &['a', 'b', 'c', 'y', 'd', 'e', 'f']);
let ops = lcs_ops(&['a', 'b', 'c', 'x', 'd', 'e', 'f'], &['a', 'b', 'c', 'y', 'd', 'e', 'f'], &mut { u64::MAX });
assert_eq!(collapse_noise_matches(&ops), ops);
}
@@ -2265,6 +2300,39 @@ mod tests {
);
}
#[test]
fn compare_bounds_total_diff_work() {
// Many long, slightly different paragraphs: each pair alone fits one comparison table,
// but the work over all pairs must stay bounded. Pairs past the budget are compared
// whole (deleted and inserted), and every pair still counts as a change.
let words = 3000;
let paras = 12;
let doc = |rev: bool| -> String {
(0..paras)
.map(|p| (0..words).map(|w| if w % 10 == 0 && rev { format!("x{p}w{w}") } else { format!("p{p}w{w}") }).collect::<Vec<_>>().join(" "))
.collect::<Vec<_>>()
.join("\n")
};
let (original, revised) = (doc(false), doc(true));
let mut s = Session::new(Document::from_text(&original));
let r = s.run("review.compare", &json!({"text": revised})).unwrap();
assert_eq!(r["changes"], paras);
// A pair diffed word by word keeps most of its text unmarked; one replaced whole keeps
// only its common start and end.
let word_diffed = |b: &Arc<Block>| {
let p = b.as_para().unwrap();
let same: usize = p.run_ranges().filter(|(_, c)| c.ins.is_none() && c.del.is_none()).map(|(r, _)| r.len()).sum();
same * 2 > p.text.len()
};
assert!(word_diffed(&s.doc.body[0]));
let diffed = s.doc.body.iter().filter(|b| word_diffed(b)).count() as u64;
let cells = (words as u64) * (words as u64);
assert!(diffed * cells <= 50_000_000 + cells, "{diffed} pairs diffed word by word");
assert!(!word_diffed(s.doc.body.last().unwrap()));
s.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), revised);
}
#[test]
fn macros_record_and_run() {
let mut s = Session::new(Document::new());
+26
View File
@@ -109,6 +109,10 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"value"?: "light" | "dark" | "system"; without it, toggles light and dark}"#)
.pure(),
CommandSpec::new("view.stylesPane", "Styles Pane", "Home › Styles", |s, v| toggle(s, v, |x| &mut x.styles_pane)).pure(),
// Where the Styles pane sits: docked right or left, or floating (#1756). No `dock` reads it.
CommandSpec::new("view.stylesPaneDock", "Styles Pane Position", "Home › Styles", styles_pane_dock)
.params(r#"{"dock"?: "left" | "right" | "float"}"#)
.pure(),
CommandSpec::new("view.commentsPane", "Comments Pane", "Review › Comments", |s, v| toggle(s, v, |x| &mut x.comments_pane)).pure(),
CommandSpec::new("view.accessibilityPane", "Accessibility Pane", "Review › Accessibility", |s, v| {
toggle(s, v, |x| &mut x.accessibility_pane)
@@ -128,6 +132,9 @@ pub fn specs() -> Vec<CommandSpec> {
sel_result(s)
})
.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"#)
.pure(),
CommandSpec::new("view.split", "Split", "View › Window", |s, _| {
s.push_ui_request(json!({"toggle": "split"}));
sel_result(s)
@@ -182,6 +189,16 @@ fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult {
Ok(json!({"mode": m, "readMode": read}))
}
fn styles_pane_dock(s: &mut Session, v: &Value) -> CmdResult {
if let Some(d) = v.get("dock") {
let Some(dock) = d.as_str().and_then(crate::StylesPaneDock::parse) else {
return Err(CmdError::Params("dock must be left, right or float".into()));
};
s.view.styles_pane_dock = dock;
}
Ok(json!({"dock": s.view.styles_pane_dock.name()}))
}
fn read_column_width(s: &mut Session, v: &Value) -> CmdResult {
let width = match v.get("value").and_then(Value::as_str) {
Some("auto") => None,
@@ -290,3 +307,12 @@ fn zoom(s: &mut Session, v: &Value) -> CmdResult {
}
Ok(json!({"zoom": s.view.zoom, "fit": s.view.fit}))
}
/// 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()) {
let id = x.as_u64().ok_or_else(|| CmdError::Params("`id` must be an open document's id".into()))?;
s.activate(id)?;
}
Ok(json!({"documents": s.documents()}))
}
+41 -2
View File
@@ -10,6 +10,9 @@
//! is a link that leads nowhere).
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
//! `/work`), without regard to case on Windows.
//! - Writing replaces or removes a directory entry, so for write access the entry itself (its
//! folder's links followed, its own name as given) must lie inside the root as well: a link
//! outside the root that leads into it is not a way to write outside (#3735).
//! - Read and write access are separate: a root given alone grants only its own kind of access.
//!
//! Roots are in force on a thread inside [`confine`], and in the whole process after
@@ -95,7 +98,14 @@ impl AutomationRoots {
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
};
};
if inside(&resolve(path)?, folder) {
// Writing replaces or removes the directory entry `path` names (a safe save renames its
// temporary file over it, a rename removes it): that entry must be inside the root too,
// not only what a link there leads to (#3735).
let entry_inside = || match access {
Access::Read => Ok(true),
Access::Write => resolve_entry(path).map(|entry| inside(&entry, folder)),
};
if inside(&resolve(path)?, folder) && entry_inside()? {
return Ok(());
}
let root = shown.display();
@@ -201,9 +211,27 @@ fn resolve(path: &str) -> Result<PathBuf, String> {
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
}
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
resolve_absolute(path, &absolute)
}
/// The directory entry `path` names: its folder resolved as [`resolve`] resolves it, then its own
/// name as given, so a link there is the entry itself, not what it leads to. That entry is what
/// replacing or removing the file changes.
fn resolve_entry(path: &str) -> Result<PathBuf, String> {
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
let (Some(folder), Some(name)) = (absolute.parent(), absolute.file_name()) else {
return Err(format!("automation path rejected: not a file name: {path}"));
};
let mut entry = resolve_absolute(path, folder)?;
entry.push(name);
Ok(entry)
}
/// [`resolve`] for `absolute`, `path` made absolute (`path` as given, for messages).
fn resolve_absolute(path: &str, absolute: &Path) -> Result<PathBuf, String> {
// The names below the nearest existing folder, last first.
let mut missing: Vec<OsString> = vec![];
let mut at = absolute.as_path();
let mut at = absolute;
loop {
match std::fs::canonicalize(at) {
Ok(mut found) => {
@@ -382,6 +410,17 @@ mod tests {
// A link to a folder inside the root stays inside.
assert!(made(&base.join("in/sub"), &base.join("in/alias")));
assert_eq!(roots.check(&s(base.join("in/alias/x.docx")), Access::Read), Ok(()));
// A link outside the write root that leads into it: writing would replace the link,
// outside (#3735). One inside that leads to a file inside is fine.
std::fs::write(base.join("out/doc.docx"), "x").unwrap();
assert!(made(&base.join("out/doc.docx"), &base.join("outside/alias.docx")));
let e = roots.check(&s(base.join("outside/alias.docx")), Access::Write).unwrap_err();
assert!(e.contains("outside the write root"), "the link itself is outside: {e}");
assert!(made(&base.join("out/doc.docx"), &base.join("out/alias.docx")));
assert_eq!(roots.check(&s(base.join("out/alias.docx")), Access::Write), Ok(()));
// A folder link outside that leads into the root: the entry is inside, as the system opens it.
assert!(made(&base.join("out"), &base.join("outside/to-out")));
assert_eq!(roots.check(&s(base.join("outside/to-out/new.docx")), Access::Write), Ok(()));
// A root given through a link is the folder it leads to.
assert!(made(&base.join("in"), &base.join("in-link")));
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
+89 -21
View File
@@ -113,6 +113,20 @@ pub fn is_empty_file(_path: &std::path::Path) -> bool {
false
}
/// The canonical form of `path` (symbolic links and `.`/`..` resolved), for telling whether two
/// spellings name one file; `None` when it can't be resolved or is outside the readable roots.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn canonical(path: &std::path::Path) -> Option<std::path::PathBuf> {
crate::file_access::check_read(&path.to_string_lossy()).ok()?;
std::fs::canonicalize(path).ok()
}
/// [`canonical`]: on the web files come through the browser, never by path.
#[cfg(target_arch = "wasm32")]
pub(crate) fn canonical(_path: &std::path::Path) -> Option<std::path::PathBuf> {
None
}
/// Can files with this extension be saved with a password (Word's packages)?
pub fn can_encrypt(name: &str) -> bool {
is_word_package(&ext_of(name))
@@ -146,7 +160,8 @@ fn open_binary_doc(bytes: &[u8], password: Option<&str>) -> Result<(Document, bo
}
/// [`open_bytes`] for a file that may be password-protected: a Word document encrypted with a
/// password ([MS-OFFCRYPTO] agile encryption, or the Word 97-2003 schemes) is decrypted with
/// password ([MS-OFFCRYPTO] agile or Standard encryption, under any Word extension, or the
/// Word 97-2003 schemes) is decrypted with
/// `password` first. Also says
/// whether the file was encrypted. Without the password needed the error satisfies
/// [`needs_password`]; with a wrong one, [`wrong_password`].
@@ -506,7 +521,7 @@ impl LocalImages {
left.map(|_| ())
}
/// The canonical path `src` names, if it's inside the folder.
/// The canonical path `src` names, if it's inside the folder and may be read.
fn resolve(&self, src: &str) -> Option<std::path::PathBuf> {
let src = src.split(['#', '?']).next().unwrap_or("");
if src.is_empty() || src.starts_with("//") || src.split_once(':').is_some_and(|(scheme, _)| !scheme.contains('/')) {
@@ -520,6 +535,9 @@ impl LocalImages {
let dir = self.dir.as_ref()?;
// Canonical paths have `..` and symlinks resolved, so this keeps both inside the folder.
let path = std::fs::canonicalize(dir.join(rel)).ok()?;
// The folder is the one the HTML path names, which may be a link from outside the
// automation read root; the picture itself must be inside it (#3736).
crate::file_access::check_read(&path.to_string_lossy()).ok()?;
path.starts_with(dir).then_some(path)
}
}
@@ -527,21 +545,67 @@ impl LocalImages {
/// Up to `limit + 1` bytes of the regular file at `path` (more than `limit` means too large), or
/// `None` for anything else. A named pipe blocks when opened and a device reports a length of 0
/// and may never end, so the path is checked before opening, the opened handle is checked again,
/// and the read is bounded whatever length the file reports.
/// and the read is bounded whatever length the file reports. Public for the app (a copied picture
/// file, Paste Special).
#[cfg(not(target_arch = "wasm32"))]
fn read_regular_file(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
use std::io::Read;
if !std::fs::metadata(path).ok()?.is_file() {
pub fn read_regular_file(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
let (file, meta) = open_regular_file(path).ok()?;
read_bounded(file, &meta, limit).ok()
}
#[cfg(target_arch = "wasm32")]
pub fn read_regular_file(_path: &std::path::Path, _limit: u64) -> Option<Vec<u8>> {
None
}
/// The bytes of the regular file at `path` when it holds at most `limit` of them; `None` for
/// anything else. A file whose opened handle reports more than `limit` bytes is refused before
/// anything is read; the read stays bounded for a file that grows after the check. Public for the
/// app (a copied picture file, Paste Special).
#[cfg(not(target_arch = "wasm32"))]
pub fn read_regular_file_within(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
let (file, meta) = open_regular_file(path).ok()?;
if meta.len() > limit {
return None;
}
let file = std::fs::File::open(path).ok()?;
let meta = file.metadata().ok()?;
read_bounded(file, &meta, limit).ok().filter(|b| b.len() as u64 <= limit)
}
#[cfg(target_arch = "wasm32")]
pub fn read_regular_file_within(_path: &std::path::Path, _limit: u64) -> Option<Vec<u8>> {
None
}
/// The regular file at `path`, opened once, with the opened handle's metadata. The path is
/// checked before opening (a named pipe blocks in `open` until a writer appears) and the handle
/// after (the path may have been swapped in between); anything but a regular file is "not a file".
#[cfg(not(target_arch = "wasm32"))]
fn open_regular_file(path: &std::path::Path) -> Result<(std::fs::File, std::fs::Metadata), String> {
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
if !meta.is_file() {
return None;
return Err(format!("{}: not a file", path.display()));
}
let mut open = std::fs::OpenOptions::new();
open.read(true);
// A path swapped for a named pipe since the check above would block `open`; non-blocking
// opens it at once (no effect on a regular file) and the handle check below refuses it.
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::custom_flags(&mut open, libc::O_NONBLOCK);
let file = open.open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let meta = file.metadata().map_err(|e| format!("{}: {e}", path.display()))?;
if !meta.is_file() {
return Err(format!("{}: not a file", path.display()));
}
Ok((file, meta))
}
/// Up to `limit + 1` bytes of `file` (more than `limit` means too large), however long it says it is.
#[cfg(not(target_arch = "wasm32"))]
fn read_bounded(file: std::fs::File, meta: &std::fs::Metadata, limit: u64) -> std::io::Result<Vec<u8>> {
use std::io::Read;
let mut out = Vec::with_capacity(usize::try_from(meta.len().min(limit)).unwrap_or(0));
file.take(limit.saturating_add(1)).read_to_end(&mut out).ok()?;
Some(out)
file.take(limit.saturating_add(1)).read_to_end(&mut out)?;
Ok(out)
}
/// Largest file [`read_file`] reads whole — the same 2 GB ceiling [`open_path_with`] gives a
@@ -563,29 +627,33 @@ pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
}
/// [`read_file`] for a file that must be a regular file of at most `max` bytes, checked before
/// anything is read (a font named by the user, which could be any size).
/// anything is read (a font named by the user, which could be any size). The file is opened once
/// and the read stops past `max`, so a file swapped or grown after the check is refused too (#3666).
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result<Vec<u8>, String> {
crate::file_access::check_read(path)?;
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
if !meta.is_file() {
return Err(format!("{path}: not a file"));
}
let too_large = || format!("{path}: the {what} is larger than {} MB", max >> 20);
let (file, meta) = open_regular_file(std::path::Path::new(path))?;
if meta.len() > max {
return Err(format!("{path}: the {what} is larger than {} MB", max >> 20));
return Err(too_large());
}
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
let bytes = read_bounded(file, &meta, max).map_err(|e| format!("{path}: {e}"))?;
if bytes.len() as u64 > max {
return Err(too_large());
}
Ok(bytes)
}
/// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation
/// read root when there is one: enough to tell a file's format by its start.
/// read root when there is one: enough to tell a file's format by its start. Only a regular file
/// is opened: a named pipe would block the open until a writer appeared (#3666).
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file_head(path: &str, max: u64) -> Result<Vec<u8>, String> {
use std::io::Read;
crate::file_access::check_read(path)?;
let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
let (file, _) = open_regular_file(std::path::Path::new(path))?;
let mut head = Vec::new();
f.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?;
file.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?;
Ok(head)
}
+35 -1
View File
@@ -28,8 +28,38 @@ pub use io::Password;
pub use keymap::KeyMap;
pub use ribbon::RibbonLayout;
pub use session::{
CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, blank_document, set_system_locale,
CellRange, ColumnBlock, DocInfo, DocState, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, StylesPaneDock, UNDO_LIST_MAX,
ViewState, WindowView, blank_document, next_math_session, set_system_locale,
};
/// The outcome a command reports for IME receipt classification.
///
/// A command that can be called by an input method must classify its committed effects. Only a
/// classified content change without an automatic rewrite may mint an IME receipt; any other
/// committed state change still advances the IME state revision and therefore makes a mixed
/// frame `Other` rather than certifying it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ImeEditOutcome {
/// The handler explicitly classified the committed effects.
pub classified: bool,
/// IME-visible content changed. Only this gates an IME receipt.
pub content_changed: bool,
/// Any committed document state changed, including style or representation changes.
pub state_changed: bool,
/// An automatic engine rewrite was applied (AutoCorrect, build-up, AutoFormat).
pub transformed: bool,
/// The caret or selection changed. Filled in by [`Session::run`].
pub selection_changed: bool,
}
/// The largest text one `equation.type` call inserts, in characters. The engine truncates to this
/// in both linear and built-up modes; the UI refuses an IME commit above it instead of inserting a
/// prefix.
pub const MAX_EQUATION_TYPE_CHARS: usize = 10_000;
/// The largest number of unit steps one `equation.deleteSurrounding` request may take on either
/// side of the caret. A hostile IME count must not become an unbounded command.
pub const MAX_EQUATION_DELETE_UNITS: usize = 64;
pub use wordcraft_doc as doc;
pub use wordcraft_geom::Region;
pub use wordcraft_layout as layout;
@@ -170,8 +200,12 @@ mod tests;
#[cfg(test)]
mod tests_bidi;
#[cfg(test)]
mod tests_documents;
#[cfg(test)]
mod tests_fonts;
#[cfg(test)]
mod tests_ime_noop;
#[cfg(test)]
mod tests_revision_checks;
#[cfg(test)]
mod tests_typing;
+118 -39
View File
@@ -14,7 +14,9 @@ pub struct RecoveryInfo {
#[cfg(not(target_arch = "wasm32"))]
mod desktop;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use desktop::Recovery;
use desktop::{Applied, Request};
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use desktop::{DocRecovery, Recovery, Slot};
impl Session {
/// The desktop host supplies the profile folder and a thread-safe wakeup.
@@ -24,10 +26,13 @@ impl Session {
self.recovery = Some(Recovery::start(root, wake)?);
Ok(())
}
/// Copies left by an earlier run (or another window's documents that have closed), newest
/// first. The copies this session's open documents keep are not listed: they are not lost.
pub fn recovery_documents(&self) -> Vec<RecoveryInfo> {
#[cfg(not(target_arch = "wasm32"))]
if let Some(r) = &self.recovery {
return r.documents.clone();
let held = |id: &str| self.recovery_slots().any(|(_, _, s)| s.id.as_deref() == Some(id)) || r.closing().any(|c| c == id);
return r.documents.iter().filter(|d| !held(&d.id)).cloned().collect();
}
Vec::new()
}
@@ -52,23 +57,19 @@ impl Session {
}
None
}
/// A delayed restore can't replace edits made while it was loading. Restores have no path.
/// Called every frame: takes in the worker's answer, opens a restored copy, and queues the
/// next job. Every open document's copy is kept, the parked ones' too, without making them
/// active: a frame compares each document's revision and timer, and clones a document only
/// when its copy is due.
pub fn recovery_tick(&mut self) {
#[cfg(not(target_arch = "wasm32"))]
{
let Some(mut r) = self.recovery.take() else { return };
if let Some((snapshot, expected)) = r.poll() {
if expected == (self.document_id(), self.rev()) && self.password.is_none() {
let id = snapshot.info.id;
self.set_document(snapshot.document);
self.path = None;
self.dirty = true;
r.adopt(id, self.document_id(), self.rev());
} else {
r.error = Some("Recovery cancelled: the document changed while the copy was loading.".into());
}
if let Some(e) = r.receive(false) {
self.apply_recovery(&mut r, e);
}
if let Err(e) = r.tick(self, std::time::Instant::now()) {
self.open_held_restore(&mut r);
if let Err(e) = r.tick(self.recovery_docs(), std::time::Instant::now()) {
r.error = Some(e);
}
if let Some(e) = &r.error {
@@ -77,46 +78,119 @@ impl Session {
self.recovery = Some(r);
}
}
#[cfg(not(target_arch = "wasm32"))]
fn apply_recovery(&mut self, r: &mut Recovery, e: desktop::Event) {
match r.apply(e) {
// Opened by the next tick, never in the middle of a command.
Applied::Loaded(snapshot, request) => r.held = Some((snapshot, request)),
applied => {
for d in self.recovery_docs() {
if let Applied::Failed { document, .. } = &applied
&& *document != d.id
{
continue;
}
d.slot.apply(&applied);
}
}
}
}
/// A restored copy opens as another document, or replaces the untouched blank document that
/// asked for it once that document is active, so no work is ever replaced. The restored
/// document keeps its copy in the record it came from.
#[cfg(not(target_arch = "wasm32"))]
fn open_held_restore(&mut self, r: &mut Recovery) {
let Some((_, request)) = &r.held else { return };
let request = *request;
if request.replace && self.is_open(request.document) && request.document != self.document_id() {
return;
}
let Some((snapshot, _)) = r.held.take() else { return };
let id = snapshot.info.id.clone();
if !request.replace {
self.add_document(snapshot.document);
} else if request.document == self.document_id() && request.revision == self.rev() && self.password.is_none() {
self.set_document(snapshot.document);
self.path = None;
} else {
r.error = Some("Recovery cancelled: the document changed while the copy was loading.".into());
return;
}
self.dirty = true;
self.recovery_slot.adopt(id, self.rev());
}
pub fn recovery_wait(&self) -> Option<std::time::Duration> {
#[cfg(not(target_arch = "wasm32"))]
if let Some(r) = &self.recovery
&& self.dirty
&& self.password.is_none()
&& !r.busy
{
return r.wait(self.document_id(), self.rev());
if let Some(r) = &self.recovery {
return r.wait(self.recovery_slots());
}
None
}
/// Waits for recovery to finish anything already under way, discarding this session's own
/// copy first if it no longer needs one (the document was saved, say). Desktop hosts call
/// this right before the process exits, the same as they wait out pending print jobs:
/// otherwise a save just before close can race the process exiting before a now-redundant
/// recovery copy actually gets removed, so the next launch wrongly offers to recover a
/// document that was in fact saved cleanly.
/// Waits for recovery to finish anything already under way, then deletes the copies no
/// open document needs: closed documents', and those of documents saved or protected since
/// their last copy. Dirty documents keep theirs. Desktop hosts call this right before the
/// process exits, the same as they wait out pending print jobs: otherwise a save just before
/// close can race the process exiting before a now-redundant recovery copy actually gets
/// removed, so the next launch wrongly offers to recover a document that was in fact saved
/// cleanly.
pub fn finish_recovery(&mut self) {
#[cfg(not(target_arch = "wasm32"))]
if let Some(mut r) = self.recovery.take() {
if let Err(e) = r.finish(self) {
if let Some(e) = r.receive(true) {
self.apply_recovery(&mut r, e);
}
let mut discards: Vec<(String, Option<u64>)> = r.take_closed().into_iter().map(|id| (id, None)).collect();
discards.extend(self.recovery_docs().filter(|d| !d.wants_copy).filter_map(|d| d.slot.id.clone().map(|id| (id, Some(d.id)))));
for (id, document) in discards {
match r.discard_now(&id, document) {
Ok(e) => self.apply_recovery(&mut r, e),
Err(e) => r.error = Some(e),
}
}
if let Some(e) = &r.error {
log::warn!("recovery: {e}");
}
self.recovery = Some(r);
}
}
/// The user discarded the active document's unsaved changes without closing it (Don't Save
/// when quitting): its recovery copy goes too, and another is made only after a further edit.
pub fn discard_recovery_copy(&mut self) {
#[cfg(not(target_arch = "wasm32"))]
if let Some(r) = &mut self.recovery
&& let Some(id) = self.recovery_slot.id.take()
{
r.close(id);
self.recovery_slot.revision = Some(self.rev());
}
}
/// Only an explicit protect/save action waits for the worker and removes plaintext copies.
/// A failure prevents applying the password; typing never waits.
pub(crate) fn clear_recovery_before_password(&mut self) -> Result<(), CmdError> {
#[cfg(not(target_arch = "wasm32"))]
{
let document = self.document_id();
if let Some(r) = &mut self.recovery
&& r.document_is(document)
{
r.purge().map_err(CmdError::Failed)?;
}
if let Some(mut r) = self.recovery.take() {
let result = self.purge_recovery(&mut r);
self.recovery = Some(r);
result.map_err(CmdError::Failed)?;
}
Ok(())
}
/// Waits out the job under way (it may be this document's copy being written), then deletes
/// the active document's copy.
#[cfg(not(target_arch = "wasm32"))]
fn purge_recovery(&mut self, r: &mut Recovery) -> Result<(), String> {
if let Some(e) = r.receive(true) {
self.apply_recovery(r, e);
}
let Some(id) = self.recovery_slot.id.clone() else { return Ok(()) };
let e = r.discard_now(&id, Some(self.document_id()))?;
self.apply_recovery(r, e);
if self.recovery_slot.id.is_some() {
return Err(r.error.clone().unwrap_or_else(|| "the recovery copy could not be removed".into()));
}
self.recovery_slot.revision = None;
Ok(())
}
pub(crate) fn recover(&mut self, v: &Value) -> CmdResult {
let restore = v.get("restore");
let discard = v.get("discard");
@@ -126,14 +200,19 @@ impl Session {
if restore.is_some() || discard.is_some() || v.get("refresh").is_some() {
#[cfg(not(target_arch = "wasm32"))]
{
let expected = (self.document_id(), self.rev());
let request =
Request { document: self.document_id(), revision: self.rev(), replace: self.is_untouched_blank() && self.password.is_none() };
// Only listed copies: never one an open document keeps.
let listed = self.recovery_documents();
let known = |id: Option<&str>, what: &str| -> Result<String, CmdError> {
let id = id.ok_or_else(|| CmdError::Params(format!("{what} must be a recovery id")))?;
if listed.iter().any(|d| d.id == id) { Ok(id.to_string()) } else { Err(CmdError::Failed("no such recovery copy".into())) }
};
let r = self.recovery.as_mut().ok_or_else(|| CmdError::Disabled("local recovery requires the desktop host".into()))?;
if let Some(id) = restore {
r.restore(id.as_str().ok_or_else(|| CmdError::Params("restore must be a recovery id".into()))?, expected)
.map_err(CmdError::Failed)?;
r.restore(&known(id.as_str(), "restore")?, request).map_err(CmdError::Failed)?;
} else if let Some(id) = discard {
r.discard(id.as_str().ok_or_else(|| CmdError::Params("discard must be a recovery id".into()))?, expected)
.map_err(CmdError::Failed)?;
r.discard(&known(id.as_str(), "discard")?).map_err(CmdError::Failed)?;
} else if v.get("refresh").and_then(Value::as_bool) == Some(true) {
r.refresh().map_err(CmdError::Failed)?;
} else {
+438 -183
View File
@@ -1,5 +1,4 @@
use super::RecoveryInfo;
use crate::Session;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs::{self, File, OpenOptions};
@@ -373,34 +372,123 @@ impl<W: Write> Write for Limited<W> {
}
}
enum Job {
Save(Box<Snapshot>),
/// Write `document`'s copy.
Save(Box<Snapshot>, u64),
Scan,
Load(String, (u64, u64)),
Discard(String),
Load(String, Request),
/// Delete a record: an open document's copy it no longer needs, a closed document's, or one
/// from an earlier run that the user discarded (`None`).
Discard(String, Option<u64>),
}
enum Outcome {
Done,
Discarded(String),
Loaded(Box<Snapshot>, (u64, u64)),
Loaded(Box<Snapshot>, Request),
}
struct Event {
pub(crate) struct Event {
documents: Vec<RecoveryInfo>,
outcome: Result<Outcome, String>,
/// The failure would repeat for the same document; see [`SaveError`].
permanent: bool,
/// The open document a failed save or discard was for.
document: Option<u64>,
/// The record a failed discard was for.
discard: Option<String>,
}
/// Who asked for a restore, and whether the copy replaces that document (an untouched blank
/// one) or opens as another document.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Request {
pub document: u64,
pub revision: u64,
pub replace: bool,
}
/// One open document's recovery state: the record its copy is kept in, the revision that copy
/// holds, and when the next copy may be written. Each open document has its own, parked with it.
#[derive(Clone, Debug)]
pub(crate) struct Slot {
pub id: Option<String>,
pub revision: Option<u64>,
pub due: Instant,
/// A failed discard of this document's copy is tried again from then on.
pub retry: Option<Instant>,
}
impl Default for Slot {
fn default() -> Self {
Self { id: None, revision: None, due: Instant::now() + INTERVAL, retry: None }
}
}
impl Slot {
/// The document now keeps its copy in record `id`, which holds `revision`.
pub fn adopt(&mut self, id: String, revision: u64) {
self.id = Some(id);
self.revision = Some(revision);
self.due = Instant::now() + INTERVAL;
self.retry = None;
}
}
/// A worker report, for the session to apply to its documents.
pub(crate) enum Applied {
Nothing,
/// Record `id` is gone: no document keeps its copy there any more.
Discarded(String),
/// A restored copy, to open.
Loaded(Box<Snapshot>, Request),
/// Writing or discarding `document`'s copy failed.
Failed {
document: u64,
discard: bool,
permanent: bool,
},
}
impl Slot {
/// Take in a report about this document's copy.
pub fn apply(&mut self, applied: &Applied) {
match applied {
Applied::Discarded(id) if self.id.as_deref() == Some(id) => {
self.id = None;
self.retry = None;
}
Applied::Failed { discard, permanent, .. } => {
let now = Instant::now();
if *discard {
self.retry = Some(now + INTERVAL);
} else if !permanent {
// A transient failure (a lock, an I/O error) is retried at the next
// interval; one the snapshot itself causes waits for the next edit.
self.revision = None;
}
self.due = now + INTERVAL;
}
_ => {}
}
}
}
/// What [`Recovery::tick`] reads from an open document, and the document's [`Slot`].
pub(crate) struct DocRecovery<'a> {
pub id: u64,
/// Dirty and without a password: the document should have a current copy.
pub wants_copy: bool,
pub rev: u64,
pub doc: &'a Document,
pub path: Option<&'a Path>,
pub slot: &'a mut Slot,
}
/// The process's recovery worker: one thread and one store for every open document, so records
/// are locked once per process and the list of copies is the same whichever document is shown.
pub(crate) struct Recovery {
tx: mpsc::SyncSender<Job>,
rx: mpsc::Receiver<Event>,
/// Every record the store found or wrote, this process's own included.
pub documents: Vec<RecoveryInfo>,
pub ready: bool,
pub busy: bool,
pub error: Option<String>,
document: Option<u64>,
id: Option<String>,
revision: Option<u64>,
due: Instant,
alive: bool,
/// Records of closed documents waiting to be deleted, and whether that is queued.
closed: Vec<(String, bool)>,
/// A loaded copy that replaces its (untouched blank) document once that document is active.
pub held: Option<(Box<Snapshot>, Request)>,
}
impl Recovery {
pub fn start(root: PathBuf, wake: Arc<dyn Fn() + Send + Sync>) -> Result<Self, String> {
@@ -412,47 +500,47 @@ impl Recovery {
let mut store = match Store::new(root) {
Ok(s) => s,
Err(e) => {
let _ = events.send(Event { documents: Vec::new(), outcome: Err(e), permanent: false });
let _ = events.send(Event { documents: Vec::new(), outcome: Err(e), permanent: false, document: None, discard: None });
wake();
return;
}
};
let initial = store.scan().map(|_| Outcome::Done);
if events.send(Event { documents: store.inventory(), outcome: initial, permanent: false }).is_err() {
if events.send(Event { documents: store.inventory(), outcome: initial, permanent: false, document: None, discard: None }).is_err() {
return;
}
wake();
while let Ok(job) = jobs.recv() {
let mut permanent = false;
let (mut document, mut discard) = (None, None);
let result = match job {
Job::Save(s) => store.save(*s).map(|_| Outcome::Done).map_err(|e| {
permanent = e.permanent;
e.message
}),
Job::Save(s, d) => {
document = Some(d);
store.save(*s).map(|_| Outcome::Done).map_err(|e| {
permanent = e.permanent;
e.message
})
}
Job::Scan => store.scan().map(|_| Outcome::Done),
Job::Load(id, expected) => store.load(&id).map(|s| Outcome::Loaded(Box::new(s), expected)),
Job::Discard(id) => store.discard(&id).map(|_| Outcome::Discarded(id)),
Job::Load(id, request) => store.load(&id).map(|s| Outcome::Loaded(Box::new(s), request)),
Job::Discard(id, d) => {
document = d;
let r = store.discard(&id).map(|_| Outcome::Discarded(id.clone()));
discard = Some(id);
r
}
};
if events.send(Event { documents: store.inventory(), outcome: result, permanent }).is_err() {
if result.is_ok() {
(document, discard) = (None, None);
}
if events.send(Event { documents: store.inventory(), outcome: result, permanent, document, discard }).is_err() {
break;
}
wake();
}
})
.map_err(|e| e.to_string())?;
Ok(Self {
tx,
rx,
documents: Vec::new(),
ready: false,
busy: true,
error: None,
document: None,
id: None,
revision: None,
due: Instant::now() + INTERVAL,
alive: true,
})
Ok(Self { tx, rx, documents: Vec::new(), ready: false, busy: true, error: None, alive: true, closed: Vec::new(), held: None })
}
fn queue(&mut self, job: Job) -> Result<(), String> {
if !self.alive {
@@ -466,33 +554,23 @@ impl Recovery {
self.error = None;
Ok(())
}
pub fn poll(&mut self) -> Option<(Snapshot, (u64, u64))> {
match self.rx.try_recv() {
/// The worker's answer to the job under way: waits for it when `block`, else only takes one
/// that has arrived.
pub fn receive(&mut self, block: bool) -> Option<Event> {
let e = if block {
if !self.busy {
return None;
}
self.rx.recv().map_err(|_| mpsc::TryRecvError::Disconnected)
} else {
self.rx.try_recv()
};
match e {
Ok(e) => {
self.busy = false;
self.ready = true;
self.documents = e.documents;
let event_permanent = e.permanent;
match e.outcome {
Ok(Outcome::Done) => None,
Ok(Outcome::Discarded(id)) => {
if self.id.as_deref() == Some(&id) {
self.id = None;
}
None
}
Ok(Outcome::Loaded(s, expected)) => Some((*s, expected)),
Err(e) => {
self.error = Some(e);
// A transient failure (a lock, an I/O error) is retried at the next
// interval; one the snapshot itself causes waits for the next edit.
if !event_permanent {
self.revision = None;
}
self.due = Instant::now() + INTERVAL;
None
}
}
self.documents.clone_from(&e.documents);
Some(e)
}
Err(mpsc::TryRecvError::Empty) => None,
Err(mpsc::TryRecvError::Disconnected) => {
@@ -505,140 +583,144 @@ impl Recovery {
}
}
}
pub fn adopt(&mut self, id: String, document: u64, revision: u64) {
self.id = Some(id);
self.document = Some(document);
self.revision = Some(revision);
self.due = Instant::now() + INTERVAL;
/// Run `job` now and wait for its answer.
fn run_now(&mut self, job: Job) -> Result<Event, String> {
self.queue(job)?;
self.receive(true).ok_or_else(|| "recovery worker is unavailable".to_string())
}
pub fn tick(&mut self, s: &Session, now: Instant) -> Result<(), String> {
if self.document != Some(s.document_id()) {
self.document = Some(s.document_id());
self.id = None;
self.revision = None;
/// Discard record `id` now (the worker must be idle) and wait for it.
pub fn discard_now(&mut self, id: &str, document: Option<u64>) -> Result<Event, String> {
self.run_now(Job::Discard(id.into(), document))
}
/// A closed document's record: deleted at the next chance, and not listed meanwhile.
pub fn close(&mut self, id: String) {
if !self.closed.iter().any(|(c, _)| *c == id) {
self.closed.push((id, false));
}
}
/// Records the open documents of this process don't hold any more but haven't deleted yet.
pub fn closing(&self) -> impl Iterator<Item = &str> {
self.closed.iter().map(|(id, _)| id.as_str())
}
/// Closed documents' records not yet queued for deletion.
pub fn take_closed(&mut self) -> Vec<String> {
self.closed
.iter_mut()
.filter(|(_, queued)| !*queued)
.map(|(id, queued)| {
*queued = true;
id.clone()
})
.collect()
}
/// Take in what the worker reported: the record lists here, and what the session applies
/// to its documents.
pub fn apply(&mut self, e: Event) -> Applied {
match e.outcome {
Ok(Outcome::Done) => Applied::Nothing,
Ok(Outcome::Discarded(id)) => {
self.closed.retain(|(c, _)| *c != id);
Applied::Discarded(id)
}
Ok(Outcome::Loaded(s, request)) => Applied::Loaded(s, request),
Err(message) => {
self.error = Some(message);
if let Some(id) = &e.discard {
// A closed document's copy that can't be deleted stays listed, for the user
// to restore or discard.
self.closed.retain(|(c, _)| c != id);
}
match e.document {
Some(document) => Applied::Failed { document, discard: e.discard.is_some(), permanent: e.permanent },
None => Applied::Nothing,
}
}
}
}
/// Queue at most one job: a closed document's discard first, then, document by document in
/// the order given, a discard of a copy its document no longer needs (saved, closed without
/// saving, password-protected) or a new copy of a dirty document whose interval has passed.
pub fn tick<'a>(&mut self, docs: impl Iterator<Item = DocRecovery<'a>>, now: Instant) -> Result<(), String> {
if self.busy || !self.ready || !self.alive {
return Ok(());
}
if !s.dirty || s.password.is_some() {
if let Some(id) = self.id.clone() {
self.queue(Job::Discard(id))?;
if let Some(at) = self.closed.iter().position(|(_, queued)| !queued) {
let id = self.closed.get(at).map(|(id, _)| id.clone()).ok_or("recovery record list changed")?;
self.queue(Job::Discard(id, None))?;
if let Some(c) = self.closed.get_mut(at) {
c.1 = true;
}
return Ok(());
}
if self.revision == Some(s.rev()) || now < self.due {
for d in docs {
if !d.wants_copy {
if let Some(id) = d.slot.id.clone()
&& d.slot.retry.is_none_or(|t| now >= t)
{
return self.queue(Job::Discard(id, Some(d.id)));
}
continue;
}
if d.slot.revision == Some(d.rev) || now < d.slot.due {
continue;
}
let id = match &d.slot.id {
Some(id) => id.clone(),
None => new_id()?,
};
let name = d.path.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled draft".into());
let info = RecoveryInfo { id: id.clone(), name, saved_at: crate::cmd::now_iso() };
self.queue(Job::Save(Box::new(Snapshot { version: 1, info, generation: 0, document: d.doc.clone() }), d.id))?;
d.slot.id = Some(id);
d.slot.revision = Some(d.rev);
d.slot.due = now + INTERVAL;
return Ok(());
}
let id = match &self.id {
Some(id) => id.clone(),
None => new_id()?,
};
let name = s.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled draft".into());
let info = RecoveryInfo { id: id.clone(), name, saved_at: crate::cmd::now_iso() };
self.queue(Job::Save(Box::new(Snapshot { version: 1, info, generation: 0, document: s.doc.clone() })))?;
self.id = Some(id);
self.revision = Some(s.rev());
self.due = now + INTERVAL;
Ok(())
}
pub fn document_is(&self, document: u64) -> bool {
self.document == Some(document)
}
pub fn wait(&self, document: u64, revision: u64) -> Option<Duration> {
if !self.alive || !self.ready || self.busy || (self.document == Some(document) && self.revision == Some(revision)) {
/// How long until [`tick`](Self::tick) has work, for a host that repaints only on demand.
pub fn wait<'a>(&self, docs: impl Iterator<Item = (bool, u64, &'a Slot)>) -> Option<Duration> {
if !self.alive || !self.ready || self.busy {
return None;
}
Some(self.due.saturating_duration_since(Instant::now()).max(Duration::from_millis(100)))
let now = Instant::now();
let min = Duration::from_millis(100);
if self.closed.iter().any(|(_, queued)| !queued) || self.held.as_ref().is_some_and(|(_, r)| !r.replace) {
return Some(min);
}
docs.filter_map(|(wants_copy, rev, slot)| match (wants_copy, &slot.id) {
(false, Some(_)) => Some(slot.retry.map_or(min, |t| t.saturating_duration_since(now))),
(false, None) => None,
(true, _) if slot.revision == Some(rev) => None,
(true, _) => Some(slot.due.saturating_duration_since(now)),
})
.min()
.map(|d| d.max(min))
}
pub fn restore(&mut self, id: &str, expected: (u64, u64)) -> Result<(), String> {
pub fn restore(&mut self, id: &str, request: Request) -> Result<(), String> {
if !self.documents.iter().any(|d| d.id == id) {
return Err("no such recovery copy".into());
}
self.queue(Job::Load(id.into(), expected))
if self.held.is_some() {
return Err("another restored copy is waiting for its document".into());
}
self.queue(Job::Load(id.into(), request))
}
pub fn discard(&mut self, id: &str, current: (u64, u64)) -> Result<(), String> {
pub fn discard(&mut self, id: &str) -> Result<(), String> {
if !self.documents.iter().any(|d| d.id == id) {
return Err("no such recovery copy".into());
}
self.queue(Job::Discard(id.into()))?;
if self.id.as_deref() == Some(id) {
self.revision = Some(current.1);
}
Ok(())
self.queue(Job::Discard(id.into(), None))
}
pub fn refresh(&mut self) -> Result<(), String> {
self.queue(Job::Scan)
}
pub fn purge(&mut self) -> Result<(), String> {
let Some(id) = self.id.clone() else { return Ok(()) };
if self.busy {
let e = self.rx.recv().map_err(|e| e.to_string())?;
self.documents = e.documents;
self.busy = false;
self.ready = true;
if matches!(e.outcome, Ok(Outcome::Loaded(..))) {
return Err("finish recovery before protecting this document".into());
}
}
self.queue(Job::Discard(id))?;
let e = self.rx.recv().map_err(|e| e.to_string())?;
self.busy = false;
self.documents = e.documents;
e.outcome?;
self.id = None;
self.revision = None;
Ok(())
}
/// Waits out any job already in flight (an autosave queued just before shutdown, say), then,
/// if the document turns out to need no recovery copy kept (not dirty, or password-protected
/// — [`tick`](Self::tick)'s own condition), discards it and waits for that too. Desktop hosts
/// call this before the process exits, the same as the print spooler's own pending-job wait
/// (`finish_pending_jobs`): otherwise a save that lands right before close can race the
/// process exiting before the now-redundant recovery copy is actually removed, so the *next*
/// launch wrongly offers to recover a document that was in fact saved cleanly.
pub fn finish(&mut self, s: &Session) -> Result<(), String> {
let mut error = None;
if self.busy {
let e = self.rx.recv().map_err(|e| e.to_string())?;
self.documents = e.documents;
self.busy = false;
self.ready = true;
// An in-flight job failing (e.g. a stray autosave) doesn't mean the document itself
// wasn't saved cleanly by an explicit Save — still try the discard below, so one
// doesn't keep the other from doing its job; log it once there's nothing further to do.
if let Err(e) = e.outcome {
error = Some(e);
}
}
// Same guard `tick` uses: a document switch with no regular tick in between to notice and
// reset `self.id` must not let the *new* document's clean state discard the *previous*
// document's still-live recovery copy under a stale identity.
if self.document == Some(s.document_id())
&& let Some(id) = self.id.clone()
&& (!s.dirty || s.password.is_some())
{
self.queue(Job::Discard(id))?;
let e = self.rx.recv().map_err(|e| e.to_string())?;
self.busy = false;
self.documents = e.documents;
match e.outcome {
Ok(_) => {
self.id = None;
self.revision = None;
}
Err(e) => error = Some(e),
}
}
match error {
Some(e) => Err(e),
None => Ok(()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Session;
use serde_json::json;
use wordcraft_doc::StoryRef;
@@ -707,11 +789,28 @@ mod tests {
settle(&mut s);
s
}
/// Every open document's interval has passed.
fn due_now(s: &mut Session) {
for d in s.recovery_docs() {
d.slot.due = Instant::now();
}
}
fn save_now(s: &mut Session) {
s.recovery.as_mut().unwrap().due = Instant::now();
due_now(s);
s.recovery_tick();
settle(s);
}
/// The records in the profile folder: the copies on disk.
fn records(root: &Path) -> Vec<String> {
let mut ids: Vec<String> = fs::read_dir(root)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
ids.sort();
ids
}
/// #3140: a save that lands right before the app closes can race the process exiting before
/// the now-redundant recovery copy is actually removed, so the next launch wrongly offers to
@@ -725,17 +824,13 @@ mod tests {
let mut s = session(&profile.0);
s.run("document.setText", &json!({"text": "About to be saved"})).unwrap();
save_now(&mut s);
assert_eq!(s.recovery_documents().len(), 1, "a recovery copy exists for the dirty document");
assert_eq!(records(&profile.0).len(), 1, "a recovery copy exists for the dirty document");
// A real Save does this, then the app can close on the very next frame, with no regular
// recovery_tick() in between to notice `!s.dirty` and queue the discard itself.
s.dirty = false;
assert_eq!(
s.recovery_documents().len(),
1,
"confirms the race: setting dirty=false alone (what Save does) doesn't discard anything by itself"
);
assert_eq!(records(&profile.0).len(), 1, "confirms the race: setting dirty=false alone (what Save does) doesn't discard anything by itself");
s.finish_recovery();
assert!(s.recovery_documents().is_empty(), "finish_recovery should have discarded the now-redundant copy before returning");
assert!(records(&profile.0).is_empty(), "finish_recovery should have discarded the now-redundant copy before returning");
}
/// `tick` resets `self.id` the moment the session's document no longer matches what it last
@@ -749,11 +844,12 @@ mod tests {
let mut s = session(&profile.0);
s.run("document.setText", &json!({"text": "The document the user is still working on"})).unwrap();
save_now(&mut s);
assert_eq!(s.recovery_documents().len(), 1, "a recovery copy exists for the dirty document");
s.set_document(Document::new()); // e.g. File > New, with no tick() in between
assert_eq!(records(&profile.0).len(), 1, "a recovery copy exists for the dirty document");
s.set_document(Document::new()); // the document is replaced, with no tick() in between
assert!(!s.dirty, "a freshly switched-to document starts clean");
s.finish_recovery();
assert_eq!(s.recovery_documents().len(), 1, "switching to a new, unrelated document must not discard the previous one's recovery copy");
assert_eq!(records(&profile.0).len(), 1, "replacing the document must not discard the previous one's recovery copy");
assert_eq!(s.recovery_documents().len(), 1, "no open document keeps it: it is listed");
}
#[test]
@@ -767,6 +863,8 @@ mod tests {
let mut s = session(&profile.0);
s.run("document.setText", &json!({"text": "Recoverable synthetic draft"})).unwrap();
save_now(&mut s);
drop(s);
let mut s = session(&profile.0);
let id = s.recovery_documents()[0].id.clone();
confine(Some(&roots), || {
// A command cannot use a file path or choose a different storage folder.
@@ -782,9 +880,12 @@ mod tests {
assert!(s.path.is_none());
assert!(s.dirty);
assert_eq!(s.doc.plain_text(StoryRef::Body), "Recoverable synthetic draft");
s.run("file.recover", &json!({"discard": id})).unwrap();
settle(&mut s);
// The restored document keeps its copy there: it isn't listed, and can't be discarded.
assert!(s.recovery_documents().is_empty());
let err = s.run("file.recover", &json!({"discard": id})).unwrap_err();
assert!(err.to_string().contains("no such recovery copy"), "{err}");
settle(&mut s);
assert_eq!(records(&profile.0), std::slice::from_ref(&id));
});
assert_eq!(fs::read(&original).unwrap(), b"External synthetic original");
assert_eq!(fs::read_dir(&files.0).unwrap().count(), 1);
@@ -866,7 +967,7 @@ mod tests {
save_now(&mut s);
assert!(s.recovery_error().is_some());
assert!(s.recovery_wait().is_none());
s.recovery.as_mut().unwrap().due = Instant::now();
due_now(&mut s);
s.recovery_tick();
assert!(!s.recovery_busy(), "the unchanged document was snapshotted again");
assert!(s.recovery_error().is_some());
@@ -875,7 +976,7 @@ mod tests {
s.run("text.insert", &json!({"text": "!"})).unwrap();
save_now(&mut s);
assert!(s.recovery_error().is_none());
assert_eq!(s.recovery_documents().len(), 1);
assert_eq!(records(&t.0).len(), 1);
}
#[test]
@@ -957,11 +1058,16 @@ mod tests {
settle(&mut s);
assert_eq!(s.doc, expected);
assert!(s.dirty && s.path.is_none());
assert!(s.recovery_documents().is_empty(), "the restored document keeps the copy");
// A crash leaves the restored document's copy, in the same record.
drop(s);
let mut s = session(&t.0);
assert_eq!(s.recovery_documents().len(), 1);
s.run("file.recover", &json!({"discard": id})).unwrap();
settle(&mut s);
save_now(&mut s);
assert!(s.recovery_documents().is_empty(), "discard isn't undone by the next timer");
assert_eq!(s.doc, expected, "discard deletes the copy, not the editing document");
assert!(s.recovery_documents().is_empty());
assert!(records(&t.0).is_empty());
}
#[test]
@@ -987,7 +1093,7 @@ mod tests {
let saved = t.0.join("recovered.json");
s.run("file.save", &json!({"path": saved})).unwrap();
settle(&mut s);
assert!(s.recovery_documents().is_empty());
assert!(records(&root).is_empty());
assert_eq!(fs::read(file).unwrap(), original);
}
@@ -996,18 +1102,18 @@ mod tests {
let t = Temp::new();
let mut s = session(&t.0);
s.run("text.insert", &json!({"text": "Confidential synthetic draft"})).unwrap();
s.recovery.as_mut().unwrap().due = Instant::now();
due_now(&mut s);
s.recovery_tick();
assert!(s.recovery_busy());
s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap();
assert!(s.recovery_documents().is_empty());
assert!(records(&t.0).is_empty());
s.run("text.insert", &json!({"text": "More confidential edits"})).unwrap();
save_now(&mut s);
assert!(fs::read_dir(&t.0).unwrap().next().is_none());
// Removing protection makes future recovery possible again.
s.run("file.encrypt", &json!({"password": null})).unwrap();
save_now(&mut s);
assert_eq!(s.recovery_documents().len(), 1);
assert_eq!(records(&t.0).len(), 1);
}
#[test]
@@ -1016,7 +1122,7 @@ mod tests {
let mut s = session(&t.0);
s.run("text.insert", &json!({"text": "Synthetic text"})).unwrap();
save_now(&mut s);
let id = s.recovery_documents()[0].id.clone();
let id = records(&t.0).remove(0);
let dir = t.0.join(id);
// An unknown file prevents removal: don't report that plaintext was purged.
let first = Store::files(&dir).unwrap()[0].clone();
@@ -1043,6 +1149,31 @@ mod tests {
assert!(s.recovery_error().unwrap().contains("document changed"));
}
/// A restore asked for in one document lands in that document, even when another document
/// is active while the copy loads.
#[test]
fn delayed_restore_lands_in_the_document_that_asked_for_it() {
let t = Temp::new();
let mut s = session(&t.0);
s.run("text.insert", &json!({"text": "Saved draft"})).unwrap();
save_now(&mut s);
drop(s);
let mut s = session(&t.0);
let first = s.document_id();
let id = s.recovery_documents()[0].id.clone();
s.run("file.recover", &json!({"restore": id})).unwrap();
s.run("file.new", &json!({})).unwrap();
let second = s.document_id();
s.run("text.insert", &json!({"text": "Other work"})).unwrap();
settle(&mut s);
assert_eq!(s.doc.plain_text(StoryRef::Body), "Other work", "the active document is left alone");
s.activate(first).unwrap();
settle(&mut s);
assert_eq!(s.doc.plain_text(StoryRef::Body), "Saved draft");
assert_eq!(s.with_document(second, |s| s.doc.plain_text(StoryRef::Body)).unwrap(), "Other work");
assert_eq!(s.documents().len(), 2);
}
#[test]
fn opening_protected_file_and_password_save_never_leave_plaintext_copies() {
let t = Temp::new();
@@ -1065,7 +1196,7 @@ mod tests {
s.run("file.new", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "New confidential draft"})).unwrap();
save_now(&mut s);
assert_eq!(s.recovery_documents().len(), 1);
assert_eq!(records(&root).len(), 1);
let saved = t.0.join("saved.docx");
s.run("file.save", &json!({"path": saved, "password": "synthetic-password"})).unwrap();
settle(&mut s);
@@ -1073,16 +1204,140 @@ mod tests {
assert_eq!(fs::read(original).unwrap(), bytes);
}
/// Each open document keeps its own recovery copy: switching between documents doesn't start
/// a new copy of either.
#[test]
fn each_open_document_keeps_its_own_copy() {
let t = Temp::new();
let mut s = session(&t.0);
let first = s.document_id();
s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap();
save_now(&mut s);
s.run("file.new", &json!({})).unwrap();
settle(&mut s);
s.run("text.insert", &json!({"text": "Second synthetic draft"})).unwrap();
save_now(&mut s);
s.activate(first).unwrap();
s.run("text.insert", &json!({"text": ", edited"})).unwrap();
save_now(&mut s);
assert_eq!(records(&t.0).len(), 2, "one copy per document");
assert!(s.recovery_documents().is_empty(), "the copies open documents keep are not listed");
}
#[test]
fn protection_after_document_switch_does_not_discard_another_draft() {
let t = Temp::new();
let mut s = session(&t.0);
s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap();
save_now(&mut s);
let first = s.document_id();
s.run("file.new", &json!({})).unwrap();
// Protection can be commanded before the host's next recovery tick.
s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap();
settle(&mut s);
assert_eq!(s.recovery_documents().len(), 1);
assert!(s.with_document(first, |s| s.recovery_slot.id.is_some()).unwrap());
assert_eq!(records(&t.0).len(), 1, "the first draft is kept");
}
/// Two dirty documents, one of them parked since before its first copy: both get copies once
/// their interval has passed, without being made active.
#[test]
fn parked_documents_keep_their_copies() {
let t = Temp::new();
let mut s = session(&t.0);
s.run("text.insert", &json!({"text": "Parked synthetic draft"})).unwrap();
let parked = s.document_id();
s.run("file.new", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "Active synthetic draft"})).unwrap();
let active = s.document_id();
assert_ne!(parked, active);
save_now(&mut s);
assert_eq!(s.document_id(), active, "the parked document was not made active");
assert_eq!(records(&t.0).len(), 2, "both documents have copies");
// An edit to the parked document reaches its copy too.
s.with_document(parked, |s| s.run("text.insert", &json!({"text": ", edited"})).unwrap()).unwrap();
let id = s.with_document(parked, |s| s.recovery_slot.id.clone()).unwrap().unwrap();
save_now(&mut s);
drop(s);
let mut store = Store::new(t.0.clone()).unwrap();
store.scan().unwrap();
assert_eq!(load_retrying(&mut store, &id).document.plain_text(StoryRef::Body), "Parked synthetic draft, edited");
}
/// Closing a dirty document and saving another delete both their copies; a third document
/// keeps its own.
#[test]
fn closing_or_saving_a_document_deletes_only_its_copy() {
let t = Temp::new();
let files = Temp::new();
let mut s = session(&t.0);
let mut ids = Vec::new();
for text in ["Closed draft", "Saved draft", "Kept draft"] {
if !ids.is_empty() {
s.run("file.new", &json!({})).unwrap();
}
s.run("text.insert", &json!({"text": text})).unwrap();
ids.push(s.document_id());
}
save_now(&mut s);
assert_eq!(records(&t.0).len(), 3);
let kept = s.recovery_slot.id.clone().unwrap();
s.close_document(ids[0]).unwrap();
s.with_document(ids[1], |s| s.run("file.save", &json!({"path": files.0.join("saved.json")})).unwrap()).unwrap();
settle(&mut s);
assert_eq!(records(&t.0), [kept], "only the open dirty document's copy is left");
assert!(s.recovery_documents().is_empty());
}
/// A crash with two dirty documents leaves two copies; the next run lists both and restores
/// each into a document of its own, beside the work already there.
#[test]
fn a_crash_leaves_every_dirty_document_s_copy() {
let t = Temp::new();
let mut s = session(&t.0);
s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap();
s.run("file.new", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "Second synthetic draft"})).unwrap();
save_now(&mut s);
drop(s); // no finish_recovery: the process died
let mut s = session(&t.0);
let copies = s.recovery_documents();
assert_eq!(copies.len(), 2);
s.run("text.insert", &json!({"text": "Work in progress"})).unwrap();
let work = s.document_id();
for copy in &copies {
s.run("file.recover", &json!({"restore": copy.id})).unwrap();
settle(&mut s);
}
assert!(s.recovery_documents().is_empty(), "the restored documents keep their copies");
let mut texts: Vec<String> = s.documents().iter().map(|d| s.with_document(d.id, |s| s.doc.plain_text(StoryRef::Body)).unwrap()).collect();
texts.sort();
assert_eq!(texts, ["First synthetic draft", "Second synthetic draft", "Work in progress"]);
assert!(s.with_document(work, |s| s.dirty).unwrap());
}
/// A clean exit deletes the copies of documents saved or closed since their last copy, the
/// parked ones' too, and keeps the dirty ones'.
#[test]
fn finish_recovery_covers_every_open_document() {
let t = Temp::new();
let files = Temp::new();
let mut s = session(&t.0);
let mut ids = Vec::new();
for text in ["Saved draft", "Closed draft", "Dirty draft"] {
if !ids.is_empty() {
s.run("file.new", &json!({})).unwrap();
}
s.run("text.insert", &json!({"text": text})).unwrap();
ids.push(s.document_id());
}
save_now(&mut s);
assert_eq!(records(&t.0).len(), 3);
let dirty = s.recovery_slot.id.clone().unwrap();
// No tick between these and the exit.
s.with_document(ids[0], |s| s.run("file.save", &json!({"path": files.0.join("saved.json")})).unwrap()).unwrap();
s.close_document(ids[1]).unwrap();
s.finish_recovery();
assert_eq!(records(&t.0), [dirty]);
}
}
File diff suppressed because it is too large Load Diff
+657
View File
@@ -0,0 +1,657 @@
//! Several open documents in one session. The active document lives in the session's own fields,
//! where every command works on it; the others wait as [`DocState`] values and are swapped in,
//! field by field, when they become active. Swapping moves the fields: nothing is cloned, laid
//! out again or thrown away, so a host may switch documents as often as it likes.
use std::path::{Path as FsPath, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use serde::Serialize;
use serde_json::json;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, Pos, StoryRef};
use wordcraft_layout::LayoutCache;
use super::{ColumnBlock, FindState, LayoutSlots, MathEdit, Selection, Session, TypedWord, Undo, ViewState, Widened, doc_bib_style};
use crate::CmdError;
/// Document ids are unique in the process, so an id an agent or a window holds never names
/// another document, even across sessions.
static NEXT_DOCUMENT_ID: AtomicU64 = AtomicU64::new(1);
pub(super) fn next_document_id() -> u64 {
NEXT_DOCUMENT_ID.fetch_add(1, Ordering::Relaxed)
}
/// The id the next document will get: every document made after this call has an id at least
/// this big.
pub(super) fn document_id_mark() -> u64 {
NEXT_DOCUMENT_ID.load(Ordering::Relaxed)
}
/// An untitled document's name: `Document1`, `Document2`…
pub(super) fn untitled_name(n: u32) -> String {
format!("Document{}", n.max(1))
}
/// An open document that isn't the active one: every per-document field of [`Session`], waiting
/// to be swapped back in. The session's app-wide state (clipboard, preferences, macros, Quick
/// Parts, the custom dictionary, Read Aloud…) is not here.
pub struct DocState {
pub(super) doc: Document,
pub(super) sel: Selection,
pub(super) view: ViewState,
pub(super) pending: Option<CharProps>,
pub(super) path: Option<PathBuf>,
pub(super) file_name: Option<String>,
pub(super) dirty: bool,
pub(super) pending_move: Option<crate::cmd::moves::PendingMove>,
pub(super) find: FindState,
pub(super) goal_x: Option<f32>,
pub(super) page_hint: usize,
pub(super) new_header_parts: Vec<u32>,
pub(super) hf_return: Option<Selection>,
pub(super) history: Vec<Arc<Undo>>,
pub(super) undo_evicted: u64,
pub(super) redo: Vec<Arc<Undo>>,
pub(super) typing_open: bool,
pub(super) join_next: bool,
pub(super) equation_join_next: bool,
pub(super) ime_text_join: Option<Selection>,
pub(super) join_anchor: bool,
pub(super) autocap: Option<crate::cmd::tools::AutoCap>,
pub(super) rev: u64,
pub(super) document_id: u64,
pub(super) untitled: u32,
pub(super) made_rev: u64,
pub(super) ime_body_epoch: u64,
pub(super) cache: LayoutCache,
pub(super) layout: LayoutSlots,
pub(super) typing: Option<TypedWord>,
pub(super) originals: std::collections::HashMap<String, String>,
pub(super) picture_looks: std::collections::HashMap<String, crate::cmd::objects::PictureLook>,
pub(super) versions: Vec<(String, String, Document)>,
pub(super) autosave: bool,
pub(super) bib_style: String,
pub(super) merge: crate::cmd::mailings::MergeState,
pub(super) math: Option<MathEdit>,
pub(super) password: Option<crate::io::Password>,
#[cfg(not(target_arch = "wasm32"))]
pub(super) recovery_slot: crate::recovery::Slot,
pub(super) also_selected: Vec<Pos>,
pub(super) column: Option<ColumnBlock>,
pub(super) column_mode: bool,
pub(super) widened: Option<Widened>,
pub(super) edit_marks: Vec<Pos>,
pub(super) go_back_step: usize,
}
impl DocState {
/// A fresh document state: caret at the start, no history, not dirty, a new id.
pub(super) fn new(doc: Document, untitled: u32, view: ViewState) -> Self {
let start = doc.start_of(StoryRef::Body);
let bib_style = doc_bib_style(&doc);
DocState {
doc,
sel: Selection::caret(start),
view,
pending: None,
path: None,
file_name: None,
dirty: false,
pending_move: None,
find: FindState::default(),
goal_x: None,
page_hint: 0,
new_header_parts: Vec::new(),
hf_return: None,
history: Vec::new(),
undo_evicted: 0,
redo: Vec::new(),
typing_open: false,
join_next: false,
equation_join_next: false,
ime_text_join: None,
join_anchor: false,
autocap: None,
rev: 1,
document_id: next_document_id(),
untitled,
made_rev: 1,
ime_body_epoch: 0,
cache: LayoutCache::new(),
layout: Default::default(),
typing: None,
originals: Default::default(),
picture_looks: Default::default(),
versions: Vec::new(),
autosave: true,
bib_style,
merge: Default::default(),
math: None,
password: None,
#[cfg(not(target_arch = "wasm32"))]
recovery_slot: Default::default(),
also_selected: Vec::new(),
column: None,
column_mode: false,
widened: None,
edit_marks: Vec::new(),
go_back_step: 0,
}
}
/// The document's id ([`Session::document_id`] while it is active).
pub fn id(&self) -> u64 {
self.document_id
}
fn info(&self) -> DocInfo {
DocInfo {
id: self.document_id,
title: title(self.path.as_deref(), self.file_name.as_deref(), &self.doc, self.untitled),
path: self.path.clone(),
dirty: self.dirty,
active: false,
}
}
}
/// One open document, as [`Session::documents`] lists it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DocInfo {
/// The document's id: unique in the process, kept while the document is open.
pub id: u64,
/// The name the title bar shows: the file's name without its extension, else the name given
/// with Rename, else the document's Title property, else `Document1`, `Document2`… (English;
/// a front end names untitled documents in its own language).
pub title: String,
pub path: Option<PathBuf>,
/// Changed since it was opened or last saved.
pub dirty: bool,
/// The active document, the one commands work on.
pub active: bool,
}
fn title(path: Option<&FsPath>, file_name: Option<&str>, doc: &Document, untitled: u32) -> String {
match (path, file_name) {
(Some(p), _) => p.file_stem().map_or_else(|| p.to_string_lossy().to_string(), |s| s.to_string_lossy().to_string()),
(None, Some(name)) => name.to_string(),
(None, None) if !doc.core.title.is_empty() => doc.core.title.clone(),
(None, None) => untitled_name(untitled),
}
}
impl Session {
/// Swap the active document's state with `d`'s: `d` then holds the document that was active.
fn swap_doc(&mut self, d: &mut DocState) {
use std::mem::swap;
// Every field is listed, so a new one doesn't build until someone decides whether each
// open document has its own (then it goes in `DocState` and is swapped below) or the
// session shares it.
let Session {
// Per document.
doc: _,
sel: _,
view: _,
pending: _,
path: _,
file_name: _,
dirty: _,
pending_move: _,
find: _,
goal_x: _,
page_hint: _,
new_header_parts: _,
hf_return: _,
history: _,
undo_evicted: _,
redo: _,
typing_open: _,
join_next: _,
equation_join_next: _,
ime_text_join: _,
join_anchor: _,
autocap: _,
rev: _,
document_id: _,
untitled: _,
made_rev: _,
ime_body_epoch: _,
cache: _,
layout: _,
typing: _,
originals: _,
picture_looks: _,
versions: _,
autosave: _,
bib_style: _,
merge: _,
math: _,
password: _,
#[cfg(not(target_arch = "wasm32"))]
recovery_slot: _,
also_selected: _,
column: _,
column_mode: _,
widened: _,
edit_marks: _,
go_back_step: _,
// Shared by every open document.
user_dict_path: _,
clipboard: _,
clipboard_text: _,
clipboard_html: _,
clip_history: _,
registry: _,
author: _,
initials: _,
painter: _,
status: _,
last_command: _,
recording: _,
macros: _,
building_blocks: _,
ui_requests: _,
read_aloud: _,
prefs: _,
system_region: _,
keymap: _,
ribbon: _,
editing_lang: _,
math_latex: _,
math_normal_text: _,
last_highlight: _,
// Keyed by document id and revision, so one cache serves every document.
protect_facts: _,
// The input method talks to the window, whichever document it shows; the IME
// context is keyed by `document_id`.
ime_state_revision: _,
ime_state_seen: _,
ime_edit_outcome: _,
#[cfg(any(test, feature = "test-support"))]
ime_state_exhausted: _,
pending_ime_application_change: _,
ime_origin: _,
others: _,
order: _,
untitled_count: _,
#[cfg(not(target_arch = "wasm32"))]
recovery: _,
} = self;
let DocState {
doc,
sel,
view,
pending,
path,
file_name,
dirty,
pending_move,
find,
goal_x,
page_hint,
new_header_parts,
hf_return,
history,
undo_evicted,
redo,
typing_open,
join_next,
equation_join_next,
ime_text_join,
join_anchor,
autocap,
rev,
document_id,
untitled,
made_rev,
ime_body_epoch,
cache,
layout,
typing,
originals,
picture_looks,
versions,
autosave,
bib_style,
merge,
math,
password,
#[cfg(not(target_arch = "wasm32"))]
recovery_slot,
also_selected,
column,
column_mode,
widened,
edit_marks,
go_back_step,
} = d;
swap(&mut self.doc, doc);
swap(&mut self.sel, sel);
swap(&mut self.view, view);
swap(&mut self.pending, pending);
swap(&mut self.path, path);
swap(&mut self.file_name, file_name);
swap(&mut self.dirty, dirty);
swap(&mut self.pending_move, pending_move);
swap(&mut self.find, find);
swap(&mut self.goal_x, goal_x);
swap(&mut self.page_hint, page_hint);
swap(&mut self.new_header_parts, new_header_parts);
swap(&mut self.hf_return, hf_return);
swap(&mut self.history, history);
swap(&mut self.undo_evicted, undo_evicted);
swap(&mut self.redo, redo);
swap(&mut self.typing_open, typing_open);
swap(&mut self.join_next, join_next);
swap(&mut self.equation_join_next, equation_join_next);
swap(&mut self.ime_text_join, ime_text_join);
swap(&mut self.join_anchor, join_anchor);
swap(&mut self.autocap, autocap);
swap(&mut self.rev, rev);
swap(&mut self.document_id, document_id);
swap(&mut self.untitled, untitled);
swap(&mut self.made_rev, made_rev);
swap(&mut self.ime_body_epoch, ime_body_epoch);
swap(&mut self.cache, cache);
swap(&mut self.layout, layout);
swap(&mut self.typing, typing);
swap(&mut self.originals, originals);
swap(&mut self.picture_looks, picture_looks);
swap(&mut self.versions, versions);
swap(&mut self.autosave, autosave);
swap(&mut self.bib_style, bib_style);
swap(&mut self.merge, merge);
swap(&mut self.math, math);
swap(&mut self.password, password);
#[cfg(not(target_arch = "wasm32"))]
swap(&mut self.recovery_slot, recovery_slot);
swap(&mut self.also_selected, also_selected);
swap(&mut self.column, column);
swap(&mut self.column_mode, column_mode);
swap(&mut self.widened, widened);
swap(&mut self.edit_marks, edit_marks);
swap(&mut self.go_back_step, go_back_step);
}
/// Make document `id` active without anything else a switch by the user does.
pub(super) fn switch_to(&mut self, id: u64) -> Result<(), CmdError> {
if id == self.document_id {
return Ok(());
}
let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?;
let mut d = self.others.swap_remove(at);
self.swap_doc(&mut d);
self.others.push(d);
Ok(())
}
/// Take parked document `id` out of the session; false when it isn't parked here.
fn remove_parked(&mut self, id: u64) -> bool {
let Some(at) = self.others.iter().position(|d| d.document_id == id) else { return false };
let d = self.others.swap_remove(at);
self.order.retain(|d| *d != id);
self.forget_recovery_copy(&d);
true
}
/// Whether document `id` is open in this session (active or not).
pub fn is_open(&self, id: u64) -> bool {
id == self.document_id || self.others.iter().any(|d| d.document_id == id)
}
/// Every open document in the order they were opened, the active one included.
pub fn documents(&self) -> Vec<DocInfo> {
self.order
.iter()
.filter_map(|&id| {
if id == self.document_id {
Some(DocInfo {
id,
title: title(self.path.as_deref(), self.file_name.as_deref(), &self.doc, self.untitled),
path: self.path.clone(),
dirty: self.dirty,
active: true,
})
} else {
self.others.iter().find(|d| d.document_id == id).map(DocState::info)
}
})
.collect()
}
/// Make document `id` the active one, the one commands work on (nothing happens when it
/// already is). A Read Aloud reading of another document stops. An id that isn't open is an
/// error.
pub fn activate(&mut self, id: u64) -> Result<(), CmdError> {
self.switch_to(id)?;
self.stop_reading_elsewhere();
Ok(())
}
/// Make document `id` the active one, as [`Session::activate`] does, but a Read Aloud
/// reading of another document carries on: for a front end that shows each document in a
/// window of its own, and makes each window's document active to draw it. An id that isn't
/// open is an error.
pub fn switch_document(&mut self, id: u64) -> Result<(), CmdError> {
self.switch_to(id)
}
/// Open `doc` as another document and make it active; the one that was active stays open.
/// The new document starts as a replaced one does ([`Session::set_document`]): caret at the
/// start, no undo history, not dirty, no path, an untitled name (`Document2`), and the view
/// the active document had. Returns its id.
pub fn add_document(&mut self, doc: Document) -> u64 {
self.untitled_count = self.untitled_count.saturating_add(1);
self.add(doc, self.untitled_count)
}
/// [`Session::add_document`] for a document opened from a file: it doesn't use up an
/// untitled name.
pub(crate) fn add_opened_document(&mut self, doc: Document) -> u64 {
self.add(doc, 0)
}
fn add(&mut self, mut doc: Document, untitled: u32) -> u64 {
doc.ensure_nonempty();
let mut view = self.view.clone();
view.outline.collapsed.clear();
let mut d = DocState::new(doc, untitled, view);
let id = d.document_id;
self.order.push(id);
self.swap_doc(&mut d);
self.others.push(d);
self.stop_reading_elsewhere();
id
}
/// A closed document's recovery copy goes with it, as its unsaved changes do.
fn forget_recovery_copy(&mut self, d: &DocState) {
#[cfg(not(target_arch = "wasm32"))]
if let (Some(r), Some(id)) = (&mut self.recovery, &d.recovery_slot.id) {
r.close(id.clone());
}
#[cfg(target_arch = "wasm32")]
let _ = d;
}
/// What recovery reads from every open document, the active one first, with each
/// document's slot to update. Nothing is cloned or laid out.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn recovery_docs(&mut self) -> impl Iterator<Item = crate::recovery::DocRecovery<'_>> {
let active = crate::recovery::DocRecovery {
id: self.document_id,
wants_copy: self.dirty && self.password.is_none(),
rev: self.rev,
doc: &self.doc,
path: self.path.as_deref(),
slot: &mut self.recovery_slot,
};
std::iter::once(active).chain(self.others.iter_mut().map(|d| crate::recovery::DocRecovery {
id: d.document_id,
wants_copy: d.dirty && d.password.is_none(),
rev: d.rev,
doc: &d.doc,
path: d.path.as_deref(),
slot: &mut d.recovery_slot,
}))
}
/// Every open document's recovery slot, with whether it should have a current copy and
/// its revision.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn recovery_slots(&self) -> impl Iterator<Item = (bool, u64, &crate::recovery::Slot)> {
std::iter::once((self.dirty && self.password.is_none(), self.rev, &self.recovery_slot))
.chain(self.others.iter().map(|d| (d.dirty && d.password.is_none(), d.rev, &d.recovery_slot)))
}
/// Close document `id` without asking: its unsaved changes are lost (callers ask first),
/// and so is its recovery copy.
/// Closing the active document makes the one opened before it active (else the one after).
/// Closing the last open document leaves a new blank one, so a session always has an
/// active document; what a window does then is the front end's choice. An id that isn't
/// open is an error.
pub fn close_document(&mut self, id: u64) -> Result<(), CmdError> {
if id != self.document_id {
if !self.remove_parked(id) {
return Err(unknown(id));
}
return Ok(());
}
let at = self.order.iter().position(|d| *d == id);
let next = at.and_then(|i| i.checked_sub(1).and_then(|p| self.order.get(p)).or_else(|| self.order.get(i.saturating_add(1)))).copied();
match next {
Some(n) => {
self.switch_to(n)?;
self.remove_parked(id);
}
None => {
let mut doc = crate::cmd::file::normal_document(self);
doc.ensure_nonempty();
self.untitled_count = self.untitled_count.saturating_add(1);
let mut view = self.view.clone();
view.outline.collapsed.clear();
let mut fresh = DocState::new(doc, self.untitled_count, view);
self.order = vec![fresh.document_id];
self.swap_doc(&mut fresh);
self.forget_recovery_copy(&fresh);
}
}
self.stop_reading_elsewhere();
Ok(())
}
/// The active document stands in for no document at all (a Start screen's, or what is left
/// after closing the last one): an untouched blank document gives back its untitled name
/// when it is the newest, so the first document a person sees is `Document1`, the next
/// `Document2`.
pub fn release_untitled_name(&mut self) {
if self.untitled != 0 && self.untitled == self.untitled_count && self.is_untouched_blank() {
self.untitled_count = self.untitled_count.saturating_sub(1);
self.untitled = 0;
}
}
/// Whether the active document is a blank one nobody has used: no file and no name, never
/// changed, nothing to undo or redo and no text. Opening a file replaces such a document
/// rather than opening beside it.
pub fn is_untouched_blank(&self) -> bool {
self.used_because().is_none()
}
/// Why the active document doesn't count as an untouched blank one, if it doesn't.
pub(crate) fn used_because(&self) -> Option<&'static str> {
if self.path.is_some() {
Some("it has a file")
} else if self.file_name.is_some() {
Some("it has a name")
} else if self.dirty {
Some("it has unsaved changes")
} else if !self.history.is_empty() || !self.redo.is_empty() {
Some("it has undo or redo steps")
} else if self.rev != self.made_rev {
Some("it changed since it was made")
} else if self.doc.paragraph_count() > 1 || !self.doc.plain_text(StoryRef::Body).trim().is_empty() {
Some("it has text")
} else {
None
}
}
/// Run `f` with document `id` active, then make the document that was active before active
/// again (unless `f` closed it). For work addressed to a document other than the one a
/// window shows: an agent's command, a result that arrives later. Read Aloud carries on.
/// UI requests `f` makes (`ui_requests`) carry `"document": id` when that isn't the
/// document that was active. An id that isn't open is an error and `f` doesn't run.
pub fn with_document<R>(&mut self, id: u64, f: impl FnOnce(&mut Session) -> R) -> Result<R, CmdError> {
let home = self.document_id;
self.switch_to(id)?;
// The queue drops its oldest entries when full, so `f`'s requests are told apart by
// collecting them on their own rather than by position.
let earlier = std::mem::take(&mut self.ui_requests);
let r = f(self);
let made = std::mem::replace(&mut self.ui_requests, earlier);
for mut q in made {
if id != home
&& let Some(o) = q.as_object_mut()
{
o.entry("document").or_insert(json!(id));
}
self.push_ui_request(q);
}
if self.document_id != home && self.is_open(home) {
self.switch_to(home)?;
}
Ok(r)
}
/// The open document saved at `path`, if any: the same file reached by another spelling
/// (`./a.docx`, a symbolic link) counts on desktop.
pub fn find_open(&self, path: &FsPath) -> Option<u64> {
let wanted = crate::io::canonical(path);
let same = |p: &FsPath| p == path || wanted.is_some() && crate::io::canonical(p) == wanted;
self.order.iter().copied().find(|&id| {
if id == self.document_id {
self.path.as_deref().is_some_and(same)
} else {
self.others.iter().find(|d| d.document_id == id).and_then(|d| d.path.as_deref()).is_some_and(same)
}
})
}
/// Stop a Read Aloud reading of a document that isn't the active one (it highlights its
/// sentences in the document shown).
fn stop_reading_elsewhere(&mut self) {
if self.read_aloud.document.is_some_and(|d| d != self.document_id) && self.read_aloud.state() != crate::speech::State::Stopped {
self.read_aloud.stop();
}
}
/// Take the undo step `run` made for a command back out of the document it started in, when
/// the command ended up in another document (it opened or made one) and left the first
/// document's text as it was: opening a document is no edit to undo there.
pub(super) fn put_back_parked(&mut self, id: u64, before: Option<super::RunSnapshot>) {
let Some((doc, sel, history, redo, typing_open, evicted, column)) = before else { return };
if !self.is_open(id) {
return;
}
let _ = self.with_document(id, |s| {
if s.doc != doc {
return;
}
s.sel = sel;
s.history = history;
s.redo = redo;
s.typing_open = typing_open;
s.undo_evicted = evicted;
s.column = column;
});
}
}
fn unknown(id: u64) -> CmdError {
CmdError::Params(format!("no open document has id {id}"))
}
+167
View File
@@ -0,0 +1,167 @@
//! Several windows on one document. The document, its undo history, dirty flag, path and save
//! state are shared; each window has its own [`WindowView`]: where its caret is and how it shows
//! the document. The session holds one view per document (the drawn window's, for the active
//! document); a front end keeps the other windows' views and moves them in and out with
//! [`Session::take_view`] and [`Session::put_view`] as it draws each window.
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Pos, StoryRef};
use super::{ColumnBlock, MathEdit, Selection, Session, TypedWord, ViewState, Widened, next_math_session};
/// One window's view of a document: its selection and caret state, and how it shows the page
/// (zoom, view mode, panes, ruler…). Positions in it may be stale after edits made through
/// another window; [`Session::put_view`] makes them valid again.
pub struct WindowView {
sel: Selection,
view: ViewState,
pending: Option<CharProps>,
goal_x: Option<f32>,
page_hint: usize,
hf_return: Option<Selection>,
typing_open: bool,
join_next: bool,
equation_join_next: bool,
ime_text_join: Option<Selection>,
typing: Option<TypedWord>,
math: Option<MathEdit>,
also_selected: Vec<Pos>,
column: Option<ColumnBlock>,
column_mode: bool,
widened: Option<Widened>,
/// The document and its revision when the view was taken out.
document_id: u64,
rev: u64,
}
impl WindowView {
/// The document this view is of.
pub fn document_id(&self) -> u64 {
self.document_id
}
/// The view's selection.
pub fn selection(&self) -> &Selection {
&self.sel
}
/// How the view shows the document.
pub fn view(&self) -> &ViewState {
&self.view
}
}
impl Session {
/// Take the active document's view out of the session, for a window that stops being drawn.
/// The session is left with a caret at the start of the document and the default view
/// until [`Session::put_view`] puts a view back.
pub fn take_view(&mut self) -> WindowView {
use std::mem::{replace, take};
let start = self.doc.start_of(StoryRef::Body);
WindowView {
sel: replace(&mut self.sel, Selection::caret(start)),
view: take(&mut self.view),
pending: self.pending.take(),
goal_x: self.goal_x.take(),
page_hint: take(&mut self.page_hint),
hf_return: self.hf_return.take(),
typing_open: take(&mut self.typing_open),
join_next: take(&mut self.join_next),
equation_join_next: take(&mut self.equation_join_next),
ime_text_join: self.ime_text_join.take(),
typing: self.typing.take(),
math: self.math.take(),
also_selected: take(&mut self.also_selected),
column: self.column.take(),
column_mode: take(&mut self.column_mode),
widened: self.widened.take(),
document_id: self.document_id,
rev: self.rev,
}
}
/// A copy of the active document's view, for a new window on it (View › New Window).
pub fn copy_view(&self) -> WindowView {
WindowView {
sel: self.sel.clone(),
view: self.view.clone(),
pending: self.pending.clone(),
goal_x: self.goal_x,
page_hint: self.page_hint,
hf_return: self.hf_return.clone(),
// The new window hasn't typed: its first key starts an undo step of its own.
typing_open: false,
join_next: false,
equation_join_next: false,
ime_text_join: None,
typing: None,
// An equation editing session is an input method's owner: the new window's is its own.
math: self.math.clone().and_then(|m| Some(MathEdit { session: next_math_session()?, ..m })),
also_selected: self.also_selected.clone(),
column: self.column.clone(),
column_mode: self.column_mode,
widened: self.widened.clone(),
document_id: self.document_id,
rev: self.rev,
}
}
/// Make `v` the active document's view. When the document changed since `v` was taken
/// (another window edited it, or `v` is of another document), its positions are clamped into
/// the document, what only made sense for the text it was taken from (a column block, a
/// widened table selection, more selected objects, the word being typed) is dropped, and the
/// window's typing no longer joins its last undo step.
pub fn put_view(&mut self, v: WindowView) {
let WindowView {
sel,
view,
pending,
goal_x,
page_hint,
hf_return,
typing_open,
join_next,
equation_join_next,
ime_text_join,
typing,
math,
also_selected,
column,
column_mode,
widened,
document_id,
rev,
} = v;
let current = document_id == self.document_id && rev == self.rev;
self.sel = sel;
self.view = view;
self.pending = pending;
self.goal_x = goal_x;
self.page_hint = page_hint;
self.column_mode = column_mode;
self.math = math;
if current {
self.hf_return = hf_return;
self.typing_open = typing_open;
self.join_next = join_next;
self.equation_join_next = equation_join_next;
self.ime_text_join = ime_text_join;
self.typing = typing;
self.also_selected = also_selected;
self.column = column;
self.widened = widened;
return;
}
self.hf_return = hf_return.map(|s| Selection { anchor: self.doc.clamp(&s.anchor), focus: self.doc.clamp(&s.focus) });
self.typing_open = false;
self.join_next = false;
self.equation_join_next = false;
self.ime_text_join = None;
self.typing = None;
self.also_selected = Vec::new();
self.column = None;
self.widened = None;
self.goal_x = None;
self.clamp_selection();
}
}
+112 -11
View File
@@ -25,6 +25,9 @@ use wordcraft_doc::{Document, InlineObject, Pos};
/// Longest sentence handed to the speech program (longer ones are split at a space).
const MAX_SENTENCE: usize = 600;
/// Longest utterance, even with no space to split at (a long URL, a run of letters): it is cut
/// there, mid-word.
const MAX_UTTERANCE: usize = 2 * MAX_SENTENCE;
/// Most sentences in one queue.
const MAX_SENTENCES: usize = 20_000;
/// Speed limits (1.0 = the normal speed).
@@ -72,7 +75,8 @@ impl Utterance {
}
/// The sentences from `from` (to `to`, if given) in `from`'s story, in reading order.
/// With `skip_citations`, citation and bibliography fields are left out.
/// With `skip_citations`, citation and bibliography fields are left out. At most
/// [`MAX_SENTENCES`]; when that cuts the text short a warning is logged, so the stop is not silent.
pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: bool) -> Vec<Utterance> {
let story = from.story;
// Spans to leave out: whole range fields, both markers included.
@@ -128,12 +132,16 @@ pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: b
}
}
}
// Sliced per sentence; skipping from the start each time was quadratic in the paragraph.
let text_chars: Vec<char> = text.chars().collect();
for (a, b) in split_sentences(&text) {
if out.len() >= MAX_SENTENCES {
log::warn!(
"Read Aloud: the queue holds at most {MAX_SENTENCES} sentences; the rest of the text is not read (start again from where it stops)"
);
return out;
}
let chars: Vec<char> = text.chars().skip(a).take(b - a).collect();
let spoken = tidy(&chars.iter().collect::<String>());
let spoken = tidy(&text_chars.get(a..b).unwrap_or(&[]).iter().collect::<String>());
if !spoken.chars().any(char::is_alphanumeric) {
continue;
}
@@ -426,6 +434,14 @@ fn push_trimmed(chars: &[char], mut a: usize, mut b: usize, out: &mut Vec<(usize
while b > a && chars.get(b - 1).is_some_and(|c| c.is_whitespace()) {
b -= 1;
}
// No space to split at (a long URL, a run of letters or full stops): cut the span hard.
while b - a > MAX_UTTERANCE {
out.push((a, a + MAX_UTTERANCE));
a += MAX_UTTERANCE;
while a < b && chars.get(a).is_some_and(|c| c.is_whitespace()) {
a += 1;
}
}
if a < b {
out.push((a, b));
}
@@ -450,18 +466,39 @@ fn is_abbreviation(chars: &[char], start: usize, dot: usize) -> bool {
}
/// Collapse whitespace and tidy what removed citations leave behind: "( )", "[]", " ." → ".".
/// One pass, linear in `s` (#3680): a closing bracket right after its opening one (or after it
/// and a space) removes the pair, so nested empty brackets "( [ ] )" go too.
fn tidy(s: &str) -> String {
let mut t: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
loop {
let before = t.len();
for (from, to) in [("()", ""), ("[]", ""), ("( )", ""), ("[ ]", ""), (" .", "."), (" ,", ","), (" ;", ";"), (" :", ":"), (" ", " ")] {
t = t.replace(from, to);
let mut t = String::with_capacity(s.len());
for c in s.chars() {
if c.is_whitespace() {
if !t.is_empty() && !t.ends_with(' ') {
t.push(' ');
}
continue;
}
if t.len() == before {
break;
let open = match c {
')' => Some('('),
']' => Some('['),
_ => None,
};
if let Some(open) = open {
let body = t.strip_suffix(' ').unwrap_or(&t);
if let Some(rest) = body.strip_suffix(open) {
let keep = rest.len();
t.truncate(keep);
continue;
}
}
if matches!(c, '.' | ',' | ';' | ':') && t.ends_with(' ') {
t.pop();
}
t.push(c);
}
t.trim().to_string()
if t.ends_with(' ') {
t.pop();
}
t
}
/// Reading state.
@@ -514,6 +551,9 @@ pub struct ReadAloud {
pub skip_citations: bool,
/// The player window is open (it stays open after the last sentence until closed).
pub open: bool,
/// The document being read ([`crate::Session::document_id`]): the sentences' positions are
/// in it.
pub document: Option<u64>,
}
impl Default for ReadAloud {
@@ -527,6 +567,7 @@ impl Default for ReadAloud {
backend: if off { Backend::Hold } else { Backend::System },
skip_citations: true,
open: false,
document: None,
}
}
}
@@ -926,10 +967,70 @@ mod tests {
assert!(spans.iter().all(|(a, b)| b - a <= MAX_SENTENCE + 5));
}
/// A word with no space to split at, or a run of punctuation, is still cut into utterances of
/// at most `MAX_UTTERANCE` characters, covering all of it (#3680).
#[test]
fn sentences_without_spaces_are_split() {
for t in ["x".repeat(10_000), "!".repeat(10_000), format!("Go {} now.", "a.".repeat(5_000))] {
let spans = split_sentences(&t);
assert!(spans.len() >= 8, "{} spans", spans.len());
assert!(spans.iter().all(|(a, b)| a < b && b - a <= MAX_UTTERANCE));
assert_eq!(spans.first().map(|s| s.0), Some(0));
assert_eq!(spans.last().map(|s| s.1), Some(t.chars().count()));
assert!(spans.windows(2).all(|w| w[0].1 <= w[1].0));
}
}
#[test]
fn tidy_removes_citation_leftovers() {
assert_eq!(tidy("as shown ( ) ."), "as shown.");
assert_eq!(tidy("as shown [] , and"), "as shown, and");
assert_eq!(tidy(" a ( ( [ ] ) ) b ( x ) [ ) ] ; "), "a b ( x ) [ ) ];");
}
/// The tidy that ran replacements until nothing changed, kept to check the one-pass tidy gives
/// the same text.
fn tidy_by_replacing(s: &str) -> String {
let mut t: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
loop {
let before = t.len();
for (from, to) in [("()", ""), ("[]", ""), ("( )", ""), ("[ ]", ""), (" .", "."), (" ,", ","), (" ;", ";"), (" :", ":"), (" ", " ")] {
t = t.replace(from, to);
}
if t.len() == before {
break;
}
}
t.trim().to_string()
}
#[test]
fn tidy_matches_replacing_until_nothing_changes() {
let alphabet = ['(', ')', '[', ']', ' ', ' ', '\t', '\u{a0}', '.', ',', ';', ':', 'a', 'é'];
let mut seed: u64 = 0x3680;
for _ in 0..20_000 {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
let len = (seed >> 59) as usize + 1;
let s: String = (0..len)
.map(|i| {
let r = seed.rotate_left(5 * i as u32 + 1) ^ (i as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15);
alphabet[(r % alphabet.len() as u64) as usize]
})
.collect();
assert_eq!(tidy(&s), tidy_by_replacing(&s), "{s:?}");
}
}
/// Nested empty brackets are removed in one pass: 40,000 pairs took seconds when each pass
/// removed one level (#3680).
#[test]
fn tidy_is_linear_on_nested_brackets() {
let n = 40_000;
let started = std::time::Instant::now();
assert_eq!(tidy(&format!("a {}{} b", "( [".repeat(n), "] )".repeat(n))), "a b");
assert_eq!(tidy(&format!("{}x{}", "(".repeat(n), ")".repeat(n))).len(), 2 * n + 1);
// Milliseconds here; `tidy_by_replacing` (the old tidy) takes about 5 minutes on this input.
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
}
#[test]
+566 -11
View File
@@ -56,6 +56,30 @@ fn encrypted_doc_asks_for_a_password_and_rejects_a_wrong_one() {
assert!(crate::io::needs_password(&e), "{e}");
}
/// Gap 9: AES-encrypted Word files — Standard encryption (Word 2007) and agile packages — open
/// with the right password whatever Word extension they carry, and ask for one or refuse a wrong
/// one the same way.
#[test]
fn aes_encrypted_packages_open_under_any_word_extension() {
let mut doc = wordcraft_doc::Document::from_text("Old secret minutes");
doc.core.title = "Minutes".into();
let pkg = wordcraft_docx::write(&doc).unwrap();
let standard = wordcraft_docx::encrypt_standard_for_tests(&pkg, "letmein", 128).unwrap();
let agile = wordcraft_docx::encrypt_with_spin_count(&pkg, "letmein", 8).unwrap();
for (kind, bytes) in [("standard", &standard), ("agile", &agile)] {
for name in ["old.doc", "old.docx", "old.dot", "noext"] {
let (d, encrypted) = crate::io::open_bytes_with(name, bytes, Some("letmein")).unwrap_or_else(|e| panic!("{kind} {name}: {e}"));
assert!(encrypted, "{kind} {name}");
assert!(d.plain_text(StoryRef::Body).contains("Old secret minutes"), "{kind} {name}");
assert_eq!(d.core.title, "Minutes");
let e = crate::io::open_bytes_with(name, bytes, None).unwrap_err();
assert!(crate::io::needs_password(&e), "{kind} {name}: {e}");
let e = crate::io::open_bytes_with(name, bytes, Some("letmeout")).unwrap_err();
assert!(crate::io::wrong_password(&e), "{kind} {name}: {e}");
}
}
}
#[test]
fn txt_open_decodes_windows_1252_and_unmarked_utf16() {
// #433: the TXT open path sniffs encodings like the formats importer.
@@ -224,6 +248,33 @@ fn custom_dictionary_words_persist_through_their_file() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn confined_sessions_neither_save_the_dictionary_nor_choose_folders_outside_their_roots() {
// #3737 (findings 34 and WC-35): the custom dictionary file and the folder settings are
// held to the automation roots like any other path.
let base = scratch_dir("dict-roots");
for d in ["in", "out", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
let roots = crate::file_access::AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap();
let mut s = s();
let outside = base.join("outside/custom-dictionary.txt");
s.user_dict_path = Some(outside.clone());
let confined = |s: &mut Session, cmd: &str, v: serde_json::Value| crate::file_access::confine(roots.as_ref(), || s.run(cmd, &v));
assert!(confined(&mut s, "review.addToDictionary", json!({"word": "zxqengconfined"})).is_ok());
assert!(wordcraft_proof::user_dictionary().contains(&"zxqengconfined".to_string()), "kept in memory");
assert!(!outside.exists(), "nothing written outside the write root");
for key in ["dictionariesFolder", "userTemplatesFolder"] {
assert!(confined(&mut s, "file.options", json!({key: base.join("outside").to_string_lossy()})).is_err(), "{key}");
}
assert_eq!((s.prefs.dictionaries_folder.as_deref(), s.prefs.user_templates_folder.as_deref()), (None, None));
// A folder inside the read root is still taken.
let inside = base.join("in");
assert!(confined(&mut s, "file.options", json!({"userTemplatesFolder": inside.to_string_lossy()})).is_ok());
let _ = std::fs::remove_dir_all(&base);
assert_eq!(s.prefs.user_templates_folder.as_deref(), Some(inside.to_string_lossy().as_ref()));
}
#[test]
fn add_to_dictionary_takes_only_words_the_file_keeps() {
// #40: the same rule as loading the dictionary file, so nothing added is dropped on the next load.
@@ -892,6 +943,72 @@ fn find_replace_wildcards() {
assert_eq!(run(&mut s, "edit.find", json!({"text": "eg", "matchSuffix": false, "ignorePunct": true}))["count"], 2);
}
#[test]
fn regex_replace_expands_groups() {
// #4177: with Regular expressions on, Replace with expands capture groups.
let rep = |text: &str, q: &str, with: &str| rep_mode(text, q, with, json!({"regex": true}));
let names = "Smith, John and Doe, Jane";
// The issue's cases: `\n` and `$n` swap words; `${name}` names a group.
assert_eq!(rep(names, r"(\w+), (\w+)", r"\2 \1"), "John Smith and Jane Doe");
assert_eq!(rep(names, r"(\w+), (\w+)", "$2 $1"), "John Smith and Jane Doe");
assert_eq!(rep("2024-05", r"(?<y>\d{4})-", "${y}/"), "2024/05");
// Braced numbers, `$0` / `${0}` (whole match), `$$` (a dollar sign).
assert_eq!(rep("ab", "(a)(b)", "${2}${1}"), "ba");
assert_eq!(rep("ab", "a", "[$0|${0}]"), "[a|a]b");
assert_eq!(rep("5", r"(\d)", "$$$1"), "$5");
// `$name` takes the longest run of letters, digits and `_` (the regex crate's rule).
assert_eq!(rep("ab", "(?<x>a)", "$x_"), "b");
assert_eq!(rep("ab", "(?<x>a)", "${x}_"), "a_b");
assert_eq!(rep("ab", "(a)", "$1a"), "b");
// Missing, unknown and out-of-range groups expand to nothing.
assert_eq!(rep("ab", "(a)", "<$2>"), "<>b");
assert_eq!(rep("ab", "(a)", r"<\9>"), "<>b");
assert_eq!(rep("ab", "(a)", "<${nope}>"), "<>b");
assert_eq!(rep("ab", "(a)", "<$99999999999999999999999>"), "<>b");
assert_eq!(rep("ab", "(a)|(z)", "<$2>"), "<>b");
// A `$` that starts no group, and an unclosed brace, stay as written.
assert_eq!(rep("ab", "a", "$ ${x"), "$ ${xb");
// `\t` a tab, `\\` a backslash; other escapes stay as written.
assert_eq!(rep("ab", "a", r"\t\\\q"), "\t\\\\qb");
// Word's `^` codes still work.
assert_eq!(rep("ab", "a", "^&^t^&"), "a\tab");
// `\$` and `\^` keep the character as typed (CodeRabbit on #4177).
assert_eq!(rep("ab", "(a)", r"\$1\^p"), "$1^pb");
// `special: false` turns off the `^` codes only; groups still expand (CodeRabbit on #4177).
assert_eq!(rep_mode("ab", "(a)", "$1^t", json!({"regex": true, "special": false})), "a^tb");
// `\n` is a paragraph mark: it splits the paragraph, as `^p` does, in one undo step.
let mut s = s();
run(&mut s, "document.setText", json!({"text": "one, two"}));
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": r"(\w+), (\w+)", "with": r"$1\n$2", "regex": true}))["replaced"], 1);
assert_eq!(text(&s), "one\ntwo");
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2);
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "one, two");
run(&mut s, "document.setText", json!({"text": "a-b"}));
run(&mut s, "edit.replaceAll", json!({"text": "-", "with": "^p", "regex": true}));
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2);
// Plain and wildcard Replace are unchanged: `$1` is literal there.
assert_eq!(rep_mode("ab", "a", "$1", json!({})), "$1b");
assert_eq!(rep_mode("ab", "(a)", "$1\\1", json!({"wildcards": true})), "$1ab");
// Hostile Replace text never panics.
run(&mut s, "document.setText", json!({"text": "ab ab"}));
for with in ["$", "${", "${}", "${0", "$\u{e9}", "\\", "${\u{1F600}}", "$18446744073709551616", "${-1}", "$$$", "\\0\\n\\", "^"] {
let _ = s.run("edit.replaceAll", &json!({"text": "(a)(?<n>b)?", "with": with, "regex": true}));
}
}
/// The body text after Replace All of `q` with `with` in `text`, matching case, with `extra` options.
fn rep_mode(text: &str, q: &str, with: &str, extra: serde_json::Value) -> String {
let mut s = s();
run(&mut s, "document.setText", json!({"text": text}));
let mut v = json!({"text": q, "with": with, "matchCase": true});
if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) {
o.extend(e.clone());
}
run(&mut s, "edit.replaceAll", v);
self::text(&s)
}
#[test]
fn hostile_find_patterns_are_bounded() {
let mut s = s();
@@ -3864,6 +3981,36 @@ fn failed_command_leaves_document_unchanged() {
assert_eq!(text(&s), before);
}
/// Review of #3692: the line cap on typed paragraphs is the engine's, so a control-channel or
/// CLI `text.insert` is held to it as the MCP tool is. At the cap every line goes in as one undo
/// step; one line more is refused before anything is typed.
#[test]
fn typed_paragraphs_are_capped_for_every_caller() {
use crate::CmdError;
use crate::cmd::text::MAX_TYPED_PARAGRAPHS;
let mut s = s();
run(&mut s, "text.insert", json!({"text": "keep"}));
let (before, depth) = (body_texts(&s), s.undo_depth());
// "x\n" repeated ends in an empty line: one over the cap. A lone "\r" counts as a break too.
for over in ["x\n".repeat(MAX_TYPED_PARAGRAPHS), format!("{}x", "x\r\n".repeat(MAX_TYPED_PARAGRAPHS - 1)) + "\rx"] {
match s.run("text.insert", &json!({"text": over, "paragraphs": true})) {
Err(CmdError::Params(m)) => assert!(m.contains("10001 lines") && m.contains("at most 10000"), "{m}"),
r => panic!("not refused: {r:?}"),
}
assert_eq!(body_texts(&s), before, "nothing typed");
assert_eq!(s.undo_depth(), depth, "no undo step");
}
let at_cap = vec!["y"; MAX_TYPED_PARAGRAPHS].join("\r\n");
run(&mut s, "text.insert", json!({"text": at_cap, "paragraphs": true}));
let paras = body_texts(&s);
assert_eq!(paras.len(), MAX_TYPED_PARAGRAPHS);
// Each break is Enter's own, AutoCorrect included: the first sentence gets its capital.
assert_eq!((paras.first().map(String::as_str), paras.last().map(String::as_str)), (Some("Keepy"), Some("y")));
assert!(s.undo(), "undo");
assert_eq!(body_texts(&s).len(), 1, "one undo step takes every line back out");
assert!(!body_texts(&s).iter().any(|p| p.contains('y')));
}
#[test]
fn hostile_params_never_panic() {
let reg = cmd::registry();
@@ -4421,6 +4568,21 @@ fn word_count_with_an_object_selected_counts_the_document() {
assert_eq!(run(&mut s, "review.wordCount", json!({}))["words"].as_u64(), Some(1));
}
/// #2982: a property imported from a file made by an older Word may have a name longer than
/// today's limit. Such a name is still deletable (the dialog sends `null`), while setting or
/// renaming a property to it stays refused.
#[test]
fn custom_properties_delete_legacy_names_but_new_names_keep_the_limit() {
let mut s = s();
let long = "x".repeat(crate::cmd::file::MAX_CUSTOM_NAME + 1);
s.doc.custom_props.push(wordcraft_doc::CustomProp { name: long.clone(), kind: "raw".into(), value: "<data/>".into() });
let mut custom = serde_json::Map::new();
custom.insert(long.clone(), serde_json::Value::Null);
run(&mut s, "file.properties", json!({"custom": custom}));
assert!(s.doc.custom_props.is_empty(), "the legacy name could be deleted");
assert!(s.run("file.properties", &json!({"custom": { (long): "x" }})).is_err(), "a new or updated name keeps the limit");
}
#[test]
fn custom_properties_set_read_remove_and_undo() {
let mut s = s();
@@ -4438,6 +4600,37 @@ fn custom_properties_set_read_remove_and_undo() {
assert_eq!(s.doc.custom_prop("Status"), Some("draft"));
}
/// #2982: a case-only rename arrives as the old spelling with null and the new one with its
/// value. The removal must run first: object keys are sorted, so `Status` sorts before
/// `status`, and setting before removing would let the removal take the new spelling too.
#[test]
fn custom_properties_case_only_rename_keeps_the_new_spelling() {
let mut s = s();
run(&mut s, "file.properties", json!({"custom": {"status": "draft"}}));
run(&mut s, "file.properties", json!({"custom": {"Status": "draft", "status": null}}));
let props: Vec<(&str, &str)> = s.doc.custom_props.iter().map(|p| (p.name.as_str(), p.value.as_str())).collect();
assert_eq!(props, [("Status", "draft")], "the property keeps the new spelling and its value");
}
/// #2982: what the Custom Properties dialog saves (through `file.properties`) survives a .docx
/// save and reopen.
#[test]
fn custom_properties_round_trip_through_docx() {
let mut s = s();
run(&mut s, "file.properties", json!({"custom": {"Status": "draft", "Reviewer": "Ada L."}}));
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
assert_eq!(back.custom_prop("Status"), Some("draft"));
assert_eq!(back.custom_prop("Reviewer"), Some("Ada L."));
assert_eq!(back.custom_props.len(), 2);
// One call removes one and adds another; the saved file follows the document.
run(&mut s, "file.properties", json!({"custom": {"Status": null, "Owner": "Ada L."}}));
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
assert_eq!(back.custom_prop("Status"), None);
assert_eq!(back.custom_prop("Reviewer"), Some("Ada L."));
assert_eq!(back.custom_prop("Owner"), Some("Ada L."));
assert_eq!(back.custom_props.len(), 2);
}
/// `document_id` tells an edit from a replacement (the UI drops a "Save changes?" prompt
/// about a document that has been replaced).
#[test]
@@ -4454,10 +4647,10 @@ fn document_id_changes_only_when_the_document_is_replaced() {
assert_ne!(s.document_id(), second);
}
/// Envelopes, Labels and Finish & Merge make a new, untitled document, as Word does, and like New
/// its undo history starts afresh. Undo/Redo across the swap put the wrong document under a file
/// (Undo, Save As, Redo, Save wrote the envelope over the saved file), so it isn't offered; the UI
/// asks to save the replaced document first.
/// Envelopes, Labels and Finish & Merge open a new, untitled document beside the main one, as Word
/// does, and like New its undo history starts afresh. Undo/Redo across documents would put the
/// wrong document under a file (Undo, Save As, Redo, Save wrote the envelope over the saved file),
/// so it isn't offered. The main document stays open as it was, with no undo step for the command.
#[test]
fn mailings_results_are_new_untitled_documents() {
let dir = std::env::temp_dir().join(format!("wordcraft-engine-mailings-{}", std::process::id()));
@@ -4477,10 +4670,13 @@ fn mailings_results_are_new_untitled_documents() {
let before = text(&s);
let document = s.document_id();
let undo_steps = s.undo_labels().len();
run(&mut s, id, json!({}));
let result = text(&s);
assert_ne!(result, before, "{id}: the result replaced the document");
assert_ne!(result, before, "{id}: the result is the active document");
assert_ne!(s.document_id(), document, "{id}: a different document");
let main = s.with_document(document, |s| (text(s), s.undo_labels().len())).unwrap();
assert_eq!(main, (before.clone(), undo_steps), "{id}: the main document stays open as it was");
assert_eq!(s.path, None, "{id}: the result is untitled");
assert_eq!(run(&mut s, "file.save", json!({}))["saved"], false, "{id}: Save asks where (Save As)");
assert_eq!(std::fs::read(&original).unwrap(), on_disk, "{id}: the original file is untouched");
@@ -4635,6 +4831,29 @@ fn html_pictures_through_a_symlink_out_of_the_folder_are_not_loaded() {
assert!(doc.plain_text(StoryRef::Body).contains("Link"));
}
#[cfg(unix)]
#[test]
fn html_pictures_load_only_from_inside_the_read_root() {
// #3736: the HTML file is checked through its link, but its pictures load from beside the
// link. Opened through a link outside the read root, they must not come from there.
let root = scratch_dir("html-img-root");
for d in ["in", "outside"] {
std::fs::create_dir_all(root.join(d)).unwrap();
}
std::fs::write(root.join("in/page.html"), r#"<p><img src="pic.png" alt="Pic"></p>"#).unwrap();
std::fs::write(root.join("in/pic.png"), tiny_png()).unwrap();
std::fs::write(root.join("outside/pic.png"), tiny_png()).unwrap();
std::os::unix::fs::symlink(root.join("in/page.html"), root.join("outside/page.html")).unwrap();
let roots = crate::file_access::AutomationRoots::new(Some(&root.join("in")), None).unwrap();
let through_link = crate::file_access::confine(roots.as_ref(), || crate::io::open_path(&root.join("outside/page.html")));
let direct = crate::file_access::confine(roots.as_ref(), || crate::io::open_path(&root.join("in/page.html")));
let _ = std::fs::remove_dir_all(&root);
let through_link = through_link.unwrap();
assert!(through_link.media.is_empty(), "no picture from outside the read root");
assert!(through_link.plain_text(StoryRef::Body).contains("Pic"));
assert_eq!(direct.unwrap().media.len(), 1, "pictures inside the root still load");
}
/// `file.open` on a path reads only a regular file: a named pipe blocked until a writer came, and
/// `/dev/zero` read until memory ran out.
#[cfg(unix)]
@@ -4660,7 +4879,7 @@ fn opening_a_named_pipe_or_device_is_refused_without_blocking() {
let got = rx.recv_timeout(std::time::Duration::from_secs(10));
if got.is_err() {
// Unblock the stuck reader so the thread ends, then fail.
let _ = std::fs::OpenOptions::new().write(true).open(&fifo);
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
}
let _ = std::fs::remove_dir_all(&dir);
let [pipe, device] = got.expect("opening a named pipe blocked");
@@ -4689,7 +4908,7 @@ fn html_picture_that_is_a_named_pipe_is_skipped_without_blocking() {
let got = rx.recv_timeout(std::time::Duration::from_secs(10));
if got.is_err() {
// Unblock the stuck reader so the thread ends, then fail.
let _ = std::fs::OpenOptions::new().write(true).open(&fifo);
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
}
let _ = std::fs::remove_dir_all(&dir);
let doc = got.expect("opening the HTML file blocked on a named pipe").unwrap();
@@ -4719,7 +4938,7 @@ fn insert_picture_from_a_named_pipe_is_refused_without_blocking() {
let got = rx.recv_timeout(std::time::Duration::from_secs(10));
if got.is_err() {
// Unblock the stuck reader so the thread ends, then fail.
let _ = std::fs::OpenOptions::new().write(true).open(&fifo);
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
}
let _ = std::fs::remove_dir_all(&dir);
assert!(got.expect("reading a picture path blocked on a named pipe").is_err(), "a pipe is not a file to read");
@@ -4728,6 +4947,92 @@ fn insert_picture_from_a_named_pipe_is_refused_without_blocking() {
}
}
/// #3666: `read_file_capped` (fonts, citation sources, `read_file`) and `read_file_head` (the
/// Word 2003 check on saving to an `.xml` path) read only a regular file. A named pipe must be
/// refused, not opened: opening one blocks until a writer appears.
#[cfg(unix)]
#[test]
fn file_head_and_capped_reads_refuse_a_named_pipe_without_blocking() {
let dir = scratch_dir("read-helpers-fifo");
let fifo = dir.join("pipe.xml");
let made = std::process::Command::new("mkfifo").arg(&fifo).status();
if !made.is_ok_and(|s| s.success()) {
let _ = std::fs::remove_dir_all(&dir);
eprintln!("mkfifo unavailable; skipping");
return;
}
let (tx, rx) = std::sync::mpsc::channel();
let path = fifo.to_string_lossy().into_owned();
std::thread::spawn(move || {
let head = crate::io::read_file_head(&path, 64 * 1024).map(|_| ());
let capped = crate::io::read_file_capped(&path, 1 << 20, "font file").map(|_| ());
let _ = tx.send([head, capped]);
});
let got = rx.recv_timeout(std::time::Duration::from_secs(10));
if got.is_err() {
// Unblock the stuck reader (twice: each helper may be stuck in turn) so the thread ends, then
// fail. Non-blocking: with no reader left the open fails at once instead of hanging the test.
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
}
let _ = std::fs::remove_dir_all(&dir);
let [head, capped] = got.expect("reading a named pipe blocked");
assert!(head.unwrap_err().contains("not a file"));
assert!(capped.unwrap_err().contains("not a file"));
}
/// #3666: a folder is not a file to read, whichever helper reads it.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn file_head_and_capped_reads_refuse_a_folder() {
let dir = scratch_dir("read-helpers-dir");
let path = dir.to_string_lossy().into_owned();
let head = crate::io::read_file_head(&path, 64);
let capped = crate::io::read_file_capped(&path, 1 << 20, "font file");
let _ = std::fs::remove_dir_all(&dir);
assert!(head.unwrap_err().contains("not a file"));
assert!(capped.unwrap_err().contains("not a file"));
}
/// #3666: `read_file_capped` reads a file of up to `max` bytes whole and refuses a larger one;
/// `read_file_head` returns at most `max` bytes.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn file_capped_read_refuses_content_over_the_cap() {
let dir = scratch_dir("read-helpers-cap");
let file = dir.join("data.bin");
std::fs::write(&file, vec![7u8; 100]).unwrap();
let path = file.to_string_lossy().into_owned();
let exact = crate::io::read_file_capped(&path, 100, "file");
let over = crate::io::read_file_capped(&path, 99, "file");
let head = crate::io::read_file_head(&path, 10);
let whole = crate::io::read_file_head(&path, 1000);
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(exact.unwrap().len(), 100);
assert!(over.unwrap_err().contains("larger than"));
assert_eq!(head.unwrap(), vec![7u8; 10]);
assert_eq!(whole.unwrap().len(), 100);
}
/// #3666: `read_regular_file_within` (Paste Special's copied picture file) reads a file of up to
/// `limit` bytes whole and refuses one whose handle reports more, before reading any of it.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn regular_file_read_within_a_limit_refuses_a_larger_file() {
let dir = scratch_dir("read-within");
let file = dir.join("data.bin");
std::fs::write(&file, vec![7u8; 100]).unwrap();
let big = dir.join("big.bin");
std::fs::File::create(&big).unwrap().set_len(64 << 20).unwrap();
let exact = crate::io::read_regular_file_within(&file, 100);
let over = crate::io::read_regular_file_within(&file, 99);
let large = crate::io::read_regular_file_within(&big, 1 << 20);
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(exact.map(|b| b.len()), Some(100));
assert_eq!(over, None);
assert_eq!(large, None);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn html_picture_size_limit_holds_whatever_length_the_file_reports() {
@@ -4815,6 +5120,31 @@ fn styles_apply_pane_is_not_an_edit() {
assert!(s.view.styles_pane);
}
/// #1756: the Styles pane docks on the right (the default), on the left, or floats.
#[test]
fn styles_pane_docks_left_right_or_floats() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
s.dirty = false;
let undo = s.undo_labels();
assert_eq!(run(&mut s, "view.state", json!({}))["stylesPaneDock"], "right");
assert_eq!(run(&mut s, "view.stylesPaneDock", json!({})), json!({"dock": "right"}), "no dock reads it");
for dock in ["left", "float", "right"] {
assert_eq!(run(&mut s, "view.stylesPaneDock", json!({"dock": dock})), json!({"dock": dock}));
assert_eq!(run(&mut s, "view.stylesPaneDock", json!({})), json!({"dock": dock}));
assert_eq!(run(&mut s, "view.state", json!({}))["stylesPaneDock"], dock);
}
run(&mut s, "view.stylesPaneDock", json!({"dock": "left"}));
for junk in [json!({"dock": "top"}), json!({"dock": 1}), json!({"dock": null}), json!({"dock": ["left"]}), json!({"dock": "LEFT "})] {
assert!(s.run("view.stylesPaneDock", &junk).is_err(), "{junk} is an error");
assert_eq!(run(&mut s, "view.stylesPaneDock", json!({})), json!({"dock": "left"}), "{junk} changes nothing");
}
// A view setting: no undo step, the document isn't changed, and the pane's visibility stays.
assert_eq!(s.undo_labels(), undo);
assert!(!s.dirty);
assert!(!s.view.styles_pane);
}
#[test]
fn styles_apply_rejects_bad_style_params() {
let mut s = s();
@@ -5547,6 +5877,8 @@ fn one_page_is_restored_when_the_document_is_reopened() {
// A percentage is restored as a percentage, the fit mode dropped.
run(&mut other, "view.zoom", json!({"value": 80}));
run(&mut other, "file.save", json!({}));
// Opening an open file only shows it: close it first to read it again.
run(&mut s, "file.closeDocument", json!({}));
run(&mut s, "file.open", json!({"path": path}));
assert!(s.view.fit.is_empty() && !s.view.single_column);
assert!((s.view.zoom - 0.8).abs() < 1e-6);
@@ -9530,9 +9862,10 @@ fn a_rejected_comparison_keeps_the_current_versions() {
assert_eq!(s.file_name.as_deref(), Some("original.docx"));
}
/// A rejected mailing result must keep the source document's save target and versions.
/// A mailing result opens beside a protected source document, which keeps its save target,
/// versions, password and text.
#[test]
fn a_rejected_mailing_result_keeps_the_current_file_identity() {
fn a_mailing_result_beside_a_protected_document_keeps_its_file_identity() {
use wordcraft_doc::{CharProps, InlineObject};
let mut s = Session::new(wordcraft_doc::Document::from_text("Protected source."));
@@ -9550,7 +9883,9 @@ fn a_rejected_mailing_result_keeps_the_current_file_identity() {
s.password = Some(crate::io::Password::new("synthetic file password"));
s.bib_style = "Chicago".into();
assert!(s.run("mailings.envelopes", &json!({"delivery": "Recipient"})).is_err());
s.run("mailings.envelopes", &json!({"delivery": "Recipient"})).unwrap();
assert_ne!(s.document_id(), before_id, "the envelope is a new document");
s.activate(before_id).unwrap();
assert_eq!(s.doc, before);
assert_eq!(s.versions.len(), 1);
assert_eq!(s.path.as_deref(), Some(std::path::Path::new("source.docx")));
@@ -10511,3 +10846,223 @@ fn export_png_refuses_a_path_that_is_not_png() {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// The body's IME replacement epoch advances when the whole body is swapped (setText, Undo/Redo)
/// and stays put for ordinary edits; an equation editing session cannot ride across a replacement.
#[test]
fn ime_body_epoch_marks_body_replacements_not_edits() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "one"}));
let first = s.ime_body_epoch();
run(&mut s, "text.insert", json!({"text": " two"}));
assert_eq!(s.ime_body_epoch(), first, "typing is not a replacement");
run(&mut s, "document.setText", json!({"text": "same text"}));
let replaced = s.ime_body_epoch();
assert!(replaced > first, "setText replaces the body under the same document id");
assert_eq!(s.document_id(), 1, "the document id itself is unchanged");
// The same text again still replaces the body: a composition must not survive on content
// equality.
run(&mut s, "document.setText", json!({"text": "same text"}));
assert!(s.ime_body_epoch() > replaced);
// Undo swaps the body back.
let before_undo = s.ime_body_epoch();
assert!(s.undo());
assert!(s.ime_body_epoch() > before_undo, "Undo replaces the body");
}
/// Replacing the document body while an equation is being edited ends that equation editing
/// session.
#[test]
fn a_body_replacement_ends_equation_editing() {
let mut s = s();
run(&mut s, "insert.equation", json!({"linear": "x+y"}));
assert!(s.math.is_some());
run(&mut s, "document.setText", json!({"text": "x+y"}));
assert!(s.math.is_none(), "the old equation's editing session is gone");
}
/// A failed command that cleared the equation session puts it back with the document.
#[test]
fn a_failed_replacement_keeps_equation_editing() {
let mut s = s();
run(&mut s, "insert.equation", json!({"linear": "x+y"}));
let session = s.math.as_ref().map(|m| m.session);
assert!(session.is_some());
// Missing `text` is rejected before any mutation.
assert!(s.run("document.setText", &json!({})).is_err());
assert_eq!(s.math.as_ref().map(|m| m.session), session);
}
/// Track Changes: deleting a range whose text is already marked deleted is a real no-op. It
/// creates no revision record, no undo-visible change and no IME revision.
#[test]
fn deleting_an_already_deleted_tracked_range_is_a_classified_noop() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "abc"}));
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, "text.backspace", json!({}));
let revisions = s.doc.revisions.len();
let revision = s.ime_state_revision().expect("an IME revision");
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) };
run(&mut s, "text.backspace", json!({}));
let outcome = s.ime_edit_outcome();
assert!(outcome.classified, "{outcome:?}");
assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}");
assert_eq!(s.doc.revisions.len(), revisions, "no unused Delete revision is appended");
assert_eq!(s.ime_state_revision(), Some(revision), "the no-op did not move the settled caret");
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) };
assert!(s.ime_state_revision() > Some(revision), "re-selecting moves the caret, not the document");
}
/// `text.insert` classifies the automatic rewrites its own handler performs: smart quotes,
/// list autoformat and AutoCorrect are `transformed`, not the IME's literal insert.
#[test]
fn text_insert_marks_automatic_rewrites_as_transformed() {
let mut session = s();
run(&mut session, "text.insert", json!({"text": "\""}));
assert!(session.ime_edit_outcome().transformed, "smart quote: {:?}", session.ime_edit_outcome());
assert_eq!(text(&session), "\u{201C}");
let mut session = s();
for c in ["W", "o", "r", "d", " ", "(", "c", ")", " "] {
run(&mut session, "text.insert", json!({"text": c}));
}
assert!(session.ime_edit_outcome().transformed, "AutoCorrect: {:?}", session.ime_edit_outcome());
let mut session = s();
run(&mut session, "text.insert", json!({"text": "*"}));
run(&mut session, "text.insert", json!({"text": " "}));
assert!(session.ime_edit_outcome().transformed, "list AutoFormat: {:?}", session.ime_edit_outcome());
let mut session = s();
run(&mut session, "text.insert", json!({"text": "A--b"}));
run(&mut session, "text.insert", json!({"text": " "}));
assert!(session.ime_edit_outcome().transformed, "dash AutoFormat: {:?}", session.ime_edit_outcome());
assert_eq!(text(&session), "A\u{2014}b ");
let mut session = s();
run(&mut session, "tools.autocorrect", json!({"doubleSpacePeriod": true}));
for c in "Hello ".chars() {
run(&mut session, "text.insert", json!({"text": c.to_string()}));
}
let outcome = session.ime_edit_outcome();
assert!(outcome.transformed && outcome.content_changed && outcome.state_changed, "double-space period: {outcome:?}");
assert_eq!(text(&session), "Hello. ");
run(&mut session, "edit.undo", json!({}));
assert_eq!(text(&session), "Hello ", "undo restores the spaces in one step");
}
#[test]
fn style_set_keeps_imported_styles_latent_styles_and_defaults() {
use wordcraft_doc::props::{Rgb, TextColor};
use wordcraft_doc::styles::{Style, StyleKind};
for name in ["basic", "lines", "shaded", "casual", "centered", "minimalist", "title"] {
let mut s = s();
let mut body = s.doc.styles.get("Normal").unwrap().clone();
body.id = "BodyText".into();
body.name = "Body Text".into();
body.builtin = true;
body.based_on = Some("Normal".into());
body.chr.size = Some(20.0);
body.chr.color = Some(TextColor::Rgb(Rgb(0xFF, 0, 0)));
let grid = Style { id: "GridTable4".into(), name: "Grid Table 4".into(), kind: StyleKind::Table, builtin: true, ..Default::default() };
s.doc.styles.styles.push(body.clone());
s.doc.styles.styles.push(grid.clone());
s.doc.styles.latent_styles = Some("<w:latentStyles/>".into());
s.doc.styles.default_chr.lang = Some("de-DE".into());
s.doc.styles.default_chr.size = Some(11.0);
run(&mut s, "design.styleSet", json!({ "name": name }));
assert_eq!(s.doc.styles.get("BodyText"), Some(&body), "{name}: BodyText");
assert_eq!(s.doc.styles.get("GridTable4"), Some(&grid), "{name}: GridTable4");
assert_eq!(s.doc.styles.latent_styles.as_deref(), Some("<w:latentStyles/>"), "{name}");
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("de-DE"), "{name}");
assert_eq!(s.doc.styles.default_chr.size, Some(11.0), "{name}");
assert_eq!(s.doc.styles.styles.iter().filter(|x| x.id == "Heading1").count(), 1, "{name}");
let saved = crate::io::save_bytes("x.docx", &s.doc).unwrap();
let back = crate::io::open_bytes("x.docx", &saved).unwrap();
assert!(back.styles.get("BodyText").is_some(), "{name}: BodyText after round trip");
}
let mut s = s();
run(&mut s, "design.styleSet", json!({ "name": "title" }));
assert_eq!(s.doc.styles.get("Title").unwrap().chr.size, Some(36.0));
}
fn style_size(s: &Session, id: &str) -> f32 {
s.doc.styles.resolve_char(Some(id), &Default::default()).size
}
#[test]
fn scale_font_sizes_adds_points_to_every_style() {
let mut s = Session::new(crate::sample::sample_document());
let ids: Vec<String> = s.doc.styles.styles.iter().map(|x| x.id.clone()).collect();
let before: Vec<f32> = ids.iter().map(|i| style_size(&s, i)).collect();
let base = s.doc.styles.resolve_char(None, &Default::default()).size;
let runs = serde_json::to_string(&s.doc.body).unwrap();
run(&mut s, "design.scaleFontSizes", json!({"delta": 2}));
// Every style (explicit or inherited) grows by exactly 2, once.
for (i, b) in ids.iter().zip(&before) {
assert_eq!(style_size(&s, i), b + 2.0, "{i}");
}
assert_eq!(s.doc.styles.resolve_char(None, &Default::default()).size, base + 2.0);
assert_eq!(serde_json::to_string(&s.doc.body).unwrap(), runs, "runs untouched");
run(&mut s, "edit.undo", json!({}));
for (i, b) in ids.iter().zip(&before) {
assert_eq!(style_size(&s, i), *b, "undo {i}");
}
}
#[test]
fn scale_font_sizes_percent_and_clamp_and_errors() {
let mut s = Session::new(crate::sample::sample_document());
let n = style_size(&s, "Normal");
run(&mut s, "design.scaleFontSizes", json!({"percent": 10}));
assert_eq!(style_size(&s, "Normal"), (n * 1.1 * 2.0).round() / 2.0);
run(&mut s, "design.scaleFontSizes", json!({"delta": -500}));
assert_eq!(style_size(&s, "Normal"), 1.0);
for bad in [
json!({}),
json!({"delta": 1e9}),
json!({"percent": -100}),
json!({"percent": 1e9}),
json!({"delta": "x"}),
json!({"delta": 0}),
json!({"percent": 0}),
json!({"delta": 1, "percent": 10}),
json!({"delta": 2, "percent": "x"}),
json!({"percent": 10, "delta": null}),
] {
assert!(s.run("design.scaleFontSizes", &bad).is_err(), "{bad}");
}
}
#[test]
fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() {
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Style, StyleKind, styles::TablePart};
let mut s = s();
s.doc.styles.default_chr.size = None;
s.doc.styles.default_chr.size_cs = Some(10.0);
let mut ch = Style { id: "Big".into(), name: "Big".into(), kind: StyleKind::Character, ..Default::default() };
ch.chr.size = Some(20.0);
ch.chr.size_cs = Some(22.0);
let mut tb = Style { id: "Grid".into(), name: "Grid".into(), kind: StyleKind::Table, ..Default::default() };
let mut parts = wordcraft_doc::styles::TableStyleParts::default();
parts.header_chr.size = Some(14.0);
parts.regions.entry(TablePart::Band2Horz).or_default().chr.size = Some(8.0);
tb.table = Some(parts);
s.doc.styles.styles.extend([ch, tb]);
run(&mut s, "design.scaleFontSizes", json!({"delta": 2}));
let st = &s.doc.styles;
assert_eq!(st.default_chr.size, Some(13.0), "unset default is 11 pt, made explicit");
assert_eq!(st.default_chr.size_cs, Some(12.0));
let big = CharProps { style: Some("Big".into()), ..Default::default() };
assert_eq!(st.resolve_char(None, &big).size, 22.0);
assert_eq!(st.resolve_char(None, &big).size_cs, 24.0);
let grid = st.styles.iter().find(|x| x.id == "Grid").and_then(|x| x.table.as_ref()).unwrap();
assert_eq!(grid.header_chr.size, Some(16.0));
assert_eq!(grid.regions.get(&TablePart::Band2Horz).unwrap().chr.size, Some(10.0));
}
+348
View File
@@ -0,0 +1,348 @@
//! Several open documents in one session: File › New and Open open beside the others, each
//! document keeps its own text, selection, undo and find state, and work addressed to a document
//! by id lands in that document whichever one is active.
use serde_json::{Value, json};
use wordcraft_doc::StoryRef;
use crate::Session;
fn s() -> Session {
Session::new(wordcraft_doc::Document::new())
}
fn run(s: &mut Session, id: &str, v: Value) -> Value {
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"))
}
fn text(s: &Session) -> String {
s.doc.plain_text(StoryRef::Body)
}
fn ids(s: &Session) -> Vec<u64> {
s.documents().iter().map(|d| d.id).collect()
}
fn temp_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("wordcraft-engine-docs-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn new_opens_another_document_and_each_keeps_its_own_text_and_undo() {
let mut s = s();
let a = s.document_id();
run(&mut s, "text.insert", json!({"text": "Alpha"}));
run(&mut s, "file.new", json!({}));
let b = s.document_id();
assert_ne!(a, b);
assert_eq!(ids(&s), vec![a, b], "opening order");
let docs = s.documents();
assert_eq!(docs.iter().map(|d| (d.title.as_str(), d.active)).collect::<Vec<_>>(), vec![("Document1", false), ("Document2", true)]);
assert!(docs[0].dirty && !docs[1].dirty);
assert_eq!(text(&s), "", "the new document is active");
run(&mut s, "text.insert", json!({"text": "Beta"}));
s.activate(a).unwrap();
assert_eq!(text(&s), "Alpha");
assert_eq!(s.document_id(), a, "ids stay with their documents");
s.activate(a).unwrap();
s.activate(b).unwrap();
assert_eq!(text(&s), "Beta");
run(&mut s, "file.new", json!({}));
assert_eq!(s.documents()[2].title, "Document3");
}
/// Undo goes with its document: edit A, edit B, back to A, Undo takes A's edit back and leaves
/// B alone; Redo puts it back. Find state stays with its document too.
#[test]
fn undo_and_find_stay_with_their_document_across_switches() {
let mut s = s();
let a = s.document_id();
run(&mut s, "text.insert", json!({"text": "Alpha"}));
s.find.query = "Alpha".into();
let b = s.add_document(wordcraft_doc::Document::new());
assert_eq!(s.find.query, "", "a new document has its own find state");
run(&mut s, "text.insert", json!({"text": "Beta"}));
s.find.query = "Beta".into();
s.activate(a).unwrap();
assert_eq!(s.find.query, "Alpha");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "");
assert_eq!(s.with_document(b, |s| (text(s), s.undo_depth(), s.find.query.clone())).unwrap(), ("Beta".to_string(), 1, "Beta".to_string()));
run(&mut s, "edit.redo", json!({}));
assert_eq!(text(&s), "Alpha");
s.activate(b).unwrap();
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "");
assert_eq!(s.with_document(a, |s| text(s)).unwrap(), "Alpha");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn open_replaces_an_untouched_blank_document_and_otherwise_opens_beside() {
let dir = temp_dir("open");
let one = dir.join("one.txt");
let two = dir.join("two.txt");
std::fs::write(&one, "One").unwrap();
std::fs::write(&two, "Two").unwrap();
let mut s = s();
assert!(s.is_untouched_blank());
let r = run(&mut s, "file.open", json!({"path": one.to_string_lossy()}));
assert_eq!(r["alreadyOpen"], false);
assert_eq!(s.documents().len(), 1, "the untouched blank document was replaced");
let first = s.document_id();
assert_eq!(s.documents()[0].title, "one");
run(&mut s, "file.open", json!({"path": two.to_string_lossy()}));
assert_eq!(s.documents().len(), 2, "a document with a file stays open");
let second = s.document_id();
assert_eq!(text(&s), "Two");
// The same file again, by another spelling: it becomes the active document.
let again = dir.join(".").join("one.txt");
let r = run(&mut s, "file.open", json!({"path": again.to_string_lossy()}));
assert_eq!((r["alreadyOpen"].as_bool(), r["id"].as_u64()), (Some(true), Some(first)));
assert_eq!((s.document_id(), s.documents().len()), (first, 2));
assert_eq!(s.find_open(&two), Some(second));
// A failed open changes nothing.
assert!(s.run("file.open", &json!({"path": dir.join("missing.docx").to_string_lossy()})).is_err());
assert_eq!((ids(&s), s.document_id()), (vec![first, second], first));
// An edited blank document isn't replaced.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "x"}));
run(&mut s, "text.backspace", json!({}));
assert!(!s.is_untouched_blank());
run(&mut s, "file.open", json!({"path": one.to_string_lossy()}));
assert_eq!(s.documents().len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn close_activates_the_previous_document_and_the_last_leaves_a_blank_one() {
let mut s = s();
let a = s.document_id();
let b = s.add_document(wordcraft_doc::Document::from_text("B"));
let c = s.add_document(wordcraft_doc::Document::from_text("C"));
s.activate(b).unwrap();
s.close_document(b).unwrap();
assert_eq!((ids(&s), s.document_id()), (vec![a, c], a), "the one opened before it");
s.close_document(c).unwrap();
s.close_document(a).unwrap();
assert_eq!(s.documents().len(), 1);
assert!(![a, b, c].contains(&s.document_id()));
assert!(s.is_untouched_blank());
assert!(s.close_document(a).is_err(), "closed documents are gone");
// Closing the first makes the next one active.
let d = s.document_id();
let e = s.add_document(wordcraft_doc::Document::new());
s.activate(d).unwrap();
s.close_document(d).unwrap();
assert_eq!(s.document_id(), e);
}
#[test]
fn close_document_refuses_unsaved_changes_unless_forced() {
let mut s = s();
let a = s.document_id();
run(&mut s, "text.insert", json!({"text": "Draft"}));
let b = s.add_document(wordcraft_doc::Document::new());
assert!(s.run("file.closeDocument", &json!({"id": a})).is_err());
assert_eq!(ids(&s), vec![a, b], "still open");
let r = run(&mut s, "file.closeDocument", json!({}));
assert_eq!((r["closed"].as_u64(), r["active"].as_u64()), (Some(b), Some(a)));
let r = run(&mut s, "file.closeDocument", json!({"id": a, "force": true}));
assert_eq!(r["documents"].as_array().map(Vec::len), Some(1));
assert!(s.is_untouched_blank());
for bad in [json!({"id": -1}), json!({"id": "x"}), json!({"id": u64::MAX}), json!({"id": 1.5}), json!({"id": []})] {
assert!(s.run("file.closeDocument", &bad).is_err(), "{bad}");
}
assert_eq!(s.documents().len(), 1);
}
/// Work addressed to a document by id lands in it, whichever document is active, and the active
/// one is left as it was.
#[test]
fn with_document_works_on_the_given_document_and_keeps_the_active_one() {
let mut s = s();
let a = s.document_id();
run(&mut s, "text.insert", json!({"text": "Alpha"}));
let b = s.add_document(wordcraft_doc::Document::new());
run(&mut s, "text.insert", json!({"text": "Beta"}));
let (b_rev, b_undo) = (s.rev(), s.undo_depth());
let r = s.with_document(a, |s| {
s.run("text.insert", &json!({"text": " more"})).unwrap();
s.run("edit.advancedFind", &json!({})).ok();
s.document_id()
});
assert_eq!(r.unwrap(), a);
assert_eq!(s.document_id(), b);
assert_eq!((text(&s), s.rev(), s.undo_depth()), ("Beta".to_string(), b_rev, b_undo), "B untouched");
assert_eq!(s.with_document(a, |s| text(s)).unwrap(), "Alpha more");
assert!(s.ui_requests.iter().all(|q| q.get("open").is_none() || q["document"] == a), "requests name their document: {:?}", s.ui_requests);
assert!(s.with_document(u64::MAX, |_| ()).is_err());
// Closing the active document inside leaves the one it ran on active.
s.with_document(a, |s| s.close_document(b)).unwrap().unwrap();
assert_eq!(ids(&s), vec![a]);
assert_eq!(s.document_id(), a);
}
/// A Read Aloud reading names its document: switching documents stops it, and turning citation
/// skipping on in another document doesn't restart it at the first document's positions there.
#[test]
fn read_aloud_belongs_to_its_document() {
let mut s = s();
let a = s.document_id();
run(&mut s, "text.insert", json!({"text": "One sentence. Another one."}));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "review.readAloud", json!({}));
assert_eq!(s.read_aloud.document, Some(a));
let b = s.add_document(wordcraft_doc::Document::from_text("Other text."));
assert_eq!(s.read_aloud.state(), crate::speech::State::Stopped, "the reading of A stopped");
run(&mut s, "readAloud.skipCitations", json!({"value": false}));
assert_eq!(s.read_aloud.document, Some(a), "nothing restarted in B");
run(&mut s, "review.readAloud", json!({}));
assert_eq!(s.read_aloud.document, Some(b));
}
#[test]
fn switch_windows_lists_and_activates() {
let mut s = s();
let a = s.document_id();
let b = s.add_document(wordcraft_doc::Document::new());
let r = run(&mut s, "view.switchWindows", json!({}));
let list = r["documents"].as_array().unwrap();
assert_eq!(
list.iter().map(|d| (d["id"].as_u64(), d["title"].as_str(), d["active"].as_bool())).collect::<Vec<_>>(),
vec![(Some(a), Some("Document1"), Some(false)), (Some(b), Some("Document2"), Some(true))]
);
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);
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);
}
let junk = [json!(null), json!([]), json!({"id": null}), json!({"text": 5})];
for id in ["view.switchWindows", "file.closeDocument", "file.new"] {
for j in &junk {
let _ = s.run(id, j);
s.clamp_selection();
let _ = s.layout();
}
}
assert!(!s.documents().is_empty());
}
#[test]
fn document_ids_are_unique_across_sessions_and_replacements() {
let mut one = s();
let two = s();
assert_ne!(one.document_id(), two.document_id());
let before = one.document_id();
one.set_document(wordcraft_doc::Document::new());
assert_ne!(one.document_id(), before);
assert_eq!(ids(&one), vec![one.document_id()], "the replacement takes its place in the list");
}
/// A Start screen's stand-in document gives back its name: the first document a person makes is
/// `Document1`, the next `Document2`, and after closing the last one, New makes `Document2`
/// again only if no other document has taken it.
#[test]
fn a_stand_in_document_gives_back_its_untitled_name() {
let mut s = s();
let placeholder = s.document_id();
s.release_untitled_name();
run(&mut s, "file.new", json!({}));
s.close_document(placeholder).unwrap();
assert_eq!(s.documents()[0].title, "Document1");
run(&mut s, "file.new", json!({}));
assert_eq!(s.documents()[1].title, "Document2");
// A document with work keeps its name.
run(&mut s, "text.insert", json!({"text": "Work"}));
s.release_untitled_name();
run(&mut s, "file.new", json!({}));
assert_eq!(s.documents()[2].title, "Document3");
}
/// Two windows on one document: each keeps its own caret and zoom; a caret inside text another
/// window deleted is clamped, so typing there lands at the end of what is left; Undo in either
/// window undoes the document's last edit, and typing in one window never joins the other's
/// undo step.
#[test]
fn two_views_of_one_document_keep_their_own_caret_and_share_undo() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello world"}));
let mut b = s.copy_view();
let a_caret = s.sel.focus.clone();
// Window B: zoom in; its caret is after "world".
let mut a = s.take_view();
s.put_view(b);
run(&mut s, "view.zoom", json!({"value": 150}));
b = s.take_view();
s.put_view(a);
assert_eq!(s.view.zoom, 1.0, "window A keeps its zoom");
// Window A deletes " world", where B's caret is.
run(&mut s, "select.text", json!({"text": " world"}));
run(&mut s, "text.delete", json!({}));
assert_eq!(text(&s).trim(), "Hello");
a = s.take_view();
s.put_view(b);
assert!((s.view.zoom - 1.5).abs() < 1e-6, "window B keeps its zoom");
assert_eq!(s.sel.focus.off, 5, "B's caret is clamped into what is left");
run(&mut s, "text.insert", json!({"text": "!"}));
assert_eq!(text(&s).trim(), "Hello!");
// Undo in A undoes B's typing; Undo in B then undoes A's delete.
b = s.take_view();
s.put_view(a);
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s).trim(), "Hello");
a = s.take_view();
s.put_view(b);
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s).trim(), "Hello world");
// Typing in A, then B, then A again: three undo steps.
b = s.take_view();
s.put_view(a);
s.sel = crate::Selection::caret(a_caret);
run(&mut s, "text.insert", json!({"text": "x"}));
a = s.take_view();
s.put_view(b);
run(&mut s, "text.insert", json!({"text": "y"}));
b = s.take_view();
s.put_view(a);
run(&mut s, "text.insert", json!({"text": "z"}));
run(&mut s, "edit.undo", json!({}));
assert!(!text(&s).contains('z') && text(&s).contains('x') && text(&s).contains('y'), "{}", text(&s));
drop(b);
}
/// An input method's promise that the next insertion joins its replacement belongs to the
/// window that made it: another window on the document typing at the same place gets an undo
/// step of its own.
#[test]
fn input_method_joins_stay_with_their_window() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
s.join_next_ime_text_replacement();
let b = s.copy_view();
let a = s.take_view();
s.put_view(b);
run(&mut s, "text.insert", json!({"text": "y"}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s).trim(), "Hello", "B's typing is its own undo step");
drop(a);
}
+122
View File
@@ -0,0 +1,122 @@
//! Regressions where main's "a command that changes nothing is not an edit" (#23) meets the
//! migration's input-method application-change and state-revision tracking.
//!
//! Kept in its own module so the merged `tests.rs` is not touched.
use serde_json::json;
use crate::Session;
/// Creates an empty-document session for the IME no-op regressions.
fn s() -> Session {
Session::new(wordcraft_doc::Document::new())
}
/// Runs a command in the test session, panicking with the command id on error.
fn run(s: &mut Session, id: &str, v: serde_json::Value) -> serde_json::Value {
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"))
}
/// An *unclassified* no-op (`text.backspace` at the very start of the body) is not an edit and
/// must not look like one to the input method: no effect flags, no application-change signal, no
/// settled-revision move, and no history/dirty/revision side effects.
#[test]
fn a_no_op_command_is_not_an_ime_state_change() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "x"}));
run(&mut s, "caret.docStart", json!({}));
assert!(s.pending.is_none(), "caret movement dropped any pending formatting");
s.take_pending_ime_application_change();
let revision = s.ime_state_revision().expect("an IME revision");
let rev = s.rev();
let depth = s.undo_depth();
let dirty = s.dirty;
run(&mut s, "text.backspace", json!({}));
let outcome = s.ime_edit_outcome();
assert!(!outcome.classified, "the handler leaves this no-op unclassified: {outcome:?}");
assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}");
assert!(!s.take_pending_ime_application_change(), "a no-op is not an application change");
assert_eq!(s.ime_state_revision(), Some(revision), "the settled IME revision did not move");
assert_eq!((s.undo_depth(), s.dirty, s.rev()), (depth, dirty, rev));
}
/// `document.setText` with identical text replaces the body under the same document id, so the
/// IME replacement epoch must advance — but because nothing observable changed it is not an edit
/// and must not signal an application-origin change (main's #23 no-op rule).
#[test]
fn a_same_text_set_text_advances_the_body_epoch_without_an_application_change() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Same body"}));
s.take_pending_ime_application_change();
let epoch = s.ime_body_epoch();
let document_id = s.document_id();
let rev = s.rev();
let depth = s.undo_depth();
run(&mut s, "document.setText", json!({"text": "Same body"}));
assert!(s.ime_body_epoch() > epoch, "the body is replaced again under the same document id");
assert_eq!(s.document_id(), document_id, "the document id itself is unchanged");
assert!(!s.take_pending_ime_application_change(), "an identical setText is not an application change");
assert_eq!((s.undo_depth(), s.rev()), (depth, rev));
}
/// The IME body replacement promise (`DeleteSurrounding`, then the commit that replaces the
/// erased text) joins only a `text.insert` at the selection the deletion left. Tab, an ordinary
/// command, consumes it: it is no IME replacement receipt itself, and the commit after it owns a
/// new Undo step instead of joining the deletion.
#[test]
fn tab_between_an_ime_replacement_and_its_commit_keeps_the_steps_apart() {
let text = |s: &Session| s.doc.plain_text(wordcraft_doc::StoryRef::Body);
// The promise itself joins the commit at the unchanged selection.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "ab"}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(text(&s), "a", "DeleteSurrounding erased the b");
s.join_next_ime_text_replacement();
let depth = s.undo_depth();
run(&mut s, "text.insert", json!({"text": "c"}));
assert_eq!(text(&s), "ac");
assert_eq!(s.undo_depth(), depth, "the commit joins the deletion's step");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "ab", "one Undo restores the deletion and its commit");
// Tab consumes the promise: the commit no longer joins the deletion.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "ab"}));
run(&mut s, "text.backspace", json!({}));
s.join_next_ime_text_replacement();
let depth = s.undo_depth();
run(&mut s, "text.tab", json!({}));
let outcome = s.ime_edit_outcome();
assert!(!outcome.classified, "Tab is not an IME replacement receipt: {outcome:?}");
assert_eq!(s.undo_depth(), depth + 1, "Tab owns its Undo step");
run(&mut s, "text.insert", json!({"text": "c"}));
assert_eq!(text(&s), "a\tc");
assert_eq!(s.undo_depth(), depth + 2, "the commit after Tab starts its own Undo step");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "a\t", "Undo takes back only the commit");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "a", "then the Tab");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "ab", "and the IME deletion on its own");
// Returning directly to the promised selection must not resurrect a promise consumed by
// Tab. Do not use a caret command here: that command would itself consume a leaked promise.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "ab"}));
run(&mut s, "text.backspace", json!({}));
s.join_next_ime_text_replacement();
let promised_selection = s.sel.clone();
run(&mut s, "text.tab", json!({}));
let depth = s.undo_depth();
s.sel = promised_selection;
run(&mut s, "text.insert", json!({"text": "c"}));
assert_eq!(text(&s), "ac\t");
assert_eq!(s.undo_depth(), depth + 1, "returning to the old selection cannot reuse the consumed promise");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "a\t", "only the new insert is undone");
}
+83
View File
@@ -3303,3 +3303,86 @@ fn highlight_partial_column_ignores_unselected_marks() {
assert_eq!(p.mark.highlight, Some(Highlight::Yellow));
}
}
#[test]
fn tab_with_a_whole_paragraph_selected_indents_it() {
let mut s = typed("Hello world");
run(&mut s, "select.paragraph", json!({}));
run(&mut s, "text.tab", json!({}));
assert_eq!(paras(&s), vec![para("Hello world", None)]);
assert_eq!(indents(&s).0, Some(36.0));
}
#[test]
fn tab_with_a_partial_selection_still_inserts_a_tab() {
let mut s = typed("Hello world");
run(&mut s, "select.text", json!({"text": "ello"}));
run(&mut s, "text.tab", json!({}));
assert_eq!(paras(&s), vec![para("H\t world", None)]);
}
/// Tab with a whole paragraph selected indents it as one Undo step: the selection stays while
/// the indent is applied, and Undo restores both the indentation and that same selection. A
/// partial selection still types a tab character (the test above), and Tab in a table cell still
/// navigates (the table tests above).
#[test]
fn tab_with_a_whole_paragraph_selected_undoes_indent_and_selection_together() {
let mut s = typed("Hello world");
run(&mut s, "select.paragraph", json!({}));
let selected = s.sel.clone();
let depth = s.undo_depth();
run(&mut s, "text.tab", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello world", "Tab indents, it types no tab");
assert_eq!(s.sel, selected, "the whole paragraph stays selected");
assert_eq!(indents(&s), (Some(36.0), None));
assert_eq!(s.undo_depth(), depth + 1, "one paragraph, one Undo step");
run(&mut s, "edit.undo", json!({}));
assert_eq!(indents(&s), (None, None), "Undo takes the indent back");
assert_eq!(s.sel, selected, "and restores the selection the indent was applied to");
assert_eq!(paras(&s), vec![para("Hello world", None)]);
}
/// #2848: "Add a period with double-space" (an AutoFormat As You Type switch, off by default):
/// a second Space after a word, a number or a closing bracket or quote turns the first into a
/// period. Undo puts the two spaces back; other punctuation, a lone space and a run of three
/// spaces are left as typed.
#[test]
fn double_space_adds_a_period_when_asked() {
let with_option = |keys: &str| {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"doubleSpacePeriod": true}));
typ(&mut s, keys);
s
};
let text = |s: &Session| s.doc.plain_text(StoryRef::Body);
// Off unless asked for.
assert_eq!(text(&typed("Hello world")), "Hello world");
assert!(!s().prefs.autocorrect.double_space_period);
// On: the second space makes the period, and the next sentence starts with a capital.
assert_eq!(text(&with_option("Hello world")), "Hello. world");
assert_eq!(text(&with_option("Hello world ")), "Hello. World ");
assert_eq!(text(&with_option("See page 5 Then")), "See page 5. Then");
assert_eq!(text(&with_option("(it is done) Next")), "(it is done). Next");
assert_eq!(text(&with_option("He said \"stop\" Next")), "He said “stop”. Next");
// Undo brings the two spaces back, in one step.
let mut t = with_option("Hello ");
assert_eq!(text(&t), "Hello. ");
run(&mut t, "edit.undo", json!({}));
assert_eq!(text(&t), "Hello ");
// Left alone: other punctuation before the spaces, a lone space, spaces at the start of a
// paragraph or after a tab, and a third space.
for keys in ["Hello. x", "Hello, x", "Hello? x", "Hello; x", "Hello: x", "Hello x", " x", "\n x", "a\t x"] {
let want = typed(keys);
assert_eq!(text(&with_option(keys)), text(&want), "{keys:?}");
}
assert_eq!(text(&with_option("Hello x")), "Hello. x");
// A selection typed over, and the master switch.
let mut t = with_option("Hello world");
run(&mut t, "select.all", json!({}));
run(&mut t, "text.insert", json!({"text": " "}));
assert_eq!(text(&t), " ");
let mut t = s();
run(&mut t, "tools.autocorrect", json!({"doubleSpacePeriod": true, "enabled": false}));
typ(&mut t, "Hello world");
assert_eq!(text(&t), "Hello world");
}

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