Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor ac2fa7bbb7 fix(flatpak): allow reading launcher version pins 2026-10-30 18:42:00 -04:00
Dylan M. Taylor 087d330a24 fix(flatpak): allow reading launcher version pins 2026-10-30 18:40:59 -04:00
Martin BallandClaude Opus 5.5 5b5810fe1f CI: Scorecard hardening — release token write only on the release job, SECURITY.md, patched ctutils (#4975)
OpenSSF Scorecard (v5.6.0, 4.0/10) flagged:
- Token-Permissions: release.yml granted contents: write at the top level.
  Now read at the top, write on the release job only (same effective access).
- Security-Policy: no SECURITY.md. Added one pointing to GitHub private
  vulnerability reporting (already enabled on the repo).
- Vulnerabilities: ctutils 0.4.3 -> 0.4.7 (RUSTSEC-2026-0385); clipboard-win
  5.4.1 -> 5.4.3 (yanked).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:00:27 -04:00
Dylan M. Taylor 3b7f7ed10b UPD.1: honor app pretend versions in update checks (#4930) 2026-10-30 02:37:34 -04:00
Dylan M. Taylor 5d063c5bc2 Update prompt shows the app icon and accent color instead of a letter (#4925)
UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#3f5fe0, its colour in ArtCraft Launcher). The updater, the self-update
helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the
prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer).

craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434).
2026-10-30 01:28:23 -04:00
Harrison BoothandBrandon Thomas 1cbb027962 docs: refresh the architecture inventory (#4772)
* Docs: refresh architecture inventory

* wc-upkeep-6: document standalone control-key layer

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:14:53 -04:00
ReesandBrandon Thomas a57dd81dba Copy selected objects along an axis with Ctrl+Shift (#4764) (#4775)
* #4764: copy selected objects along an axis with Ctrl+Shift

* #4775: preserve aligned copies through scrolling and secondary selection

* docs: keep the gaps.md note in the status line and revision table

* docs: drop a stray blank line

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:14:26 -04:00
ReesandBrandon Thomas a4b1682b41 Index laid-out objects during connector rerouting (#3631) (#4622)
* #3631: index laid-out objects during connector rerouting

* docs: keep the gaps.md note in the status line and revision table

* docs: drop a stray blank line

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:13:32 -04:00
2f78615291 #4720: Chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes (#4726)
Chart and diagram text was shaped in one face, the document's body font,
so Chinese legends and axis labels showed boxes while body text was fine.
Characters the face lacks now go to a fallback face, one face per run
when one covers it all, as in body text; a bold fallback face without a
bold style is emboldened.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:12:55 -04:00
9632646c5e Bibliography: keep a source's corporate author (#4848) (#4898)
* #4848: corporate authors in bibliographies

A Word source whose author is an organisation (Create Source › Corporate Author)
stores it as `b:Corporate`, which the reader ignored: the source opened with no
author, citations fell back to the title and bibliography entries started with
the year. A bare name typed into Author was split into "Organization, W. H.".

- Source gains `corporate`, read from and written as `b:Corporate` (in place of a
  name list: a role holds one or the other, ECMA-376 CT_NameOrCorporateType).
- Citations and bibliography entries write it as it is in every style; it gives
  the source's tag and its place in an author-sorted list, and untagged sources
  are matched by it.
- APA puts its full stop after a group author ("World Health Organization.
  (2021).") instead of relying on the initials for it.
- Create Source has a Corporate Author box under Author; Edit Source opens with
  it checked when the source has one.

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

* Match untagged sources by corporate author before personal author

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:12:17 -04:00
ReesandBrandon Thomas 18ae8b2150 DOCX: preserve custom-property bookmark links (#4767) (#4771)
* M12: preserve custom-property bookmark links (#4767)

* M12: preserve bookmark links across property renames (#4767)

* M12: align custom-property preservation status dates (#4767)

* docs: keep the gaps.md note in the status line and revision table

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:11:27 -04:00
9a172e2241 Table Properties: row, column and cell settings follow the selection (#4857) (#4896)
Row, column and cell settings applied to the caret's row, column and cell only, so
selecting the whole table and unticking "Allow row to break across pages" changed
one row and the others still split across pages.

- table.properties applies row height and rule, allow break and header row to every
  selected row, the column width to every selected column, and the cell width and
  vertical alignment to every selected cell (the caret's with nothing selected), in
  one undo step.
- The dialog shows "Allow row to break" and "Repeat as header row" as mixed when the
  selected rows differ; left alone they change nothing, clicked they set every row.
- The row-height rule box is named by "Specify height" for screen readers.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:10:33 -04:00
ReesandBrandon Thomas db2b3cab99 Home › Font: step each selected run from its own size (#4903) (#4910)
* M2 #4903: step each selected run from its own font size

* docs: keep the gaps.md note in the status line and revision table

* docs: drop a stray blank line

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:09:39 -04:00
665ded2800 Cross-reference: captions in a style based on Caption (#4411) (#4881)
* #4411: captions in a style based on Caption are cross-reference targets

Cross-reference and Table of Figures found captions only by the exact
Caption style, so a caption restyled with a style based on Caption (a
label's own "Caption Table") was left out: of five tableX captions,
only the two still in Caption were offered. A caption is now a
paragraph whose style is Caption or based on it.

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

* #4411: test captions two styles from Caption and the Table type

Covers a style based on a style based on Caption, a Table caption in
such a style offered under the built-in Table type, and a caption
label used only in a style not based on Caption staying out of the
cross-reference types.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:09:01 -04:00
Yasas SriandBrandon Thomas d40e0eeb47 #4086: highlighting at a caret leaves the word alone (#4701)
* #4086: highlighting at a caret leaves the word alone

* #4086: the highlight toggle reads pending caret formatting; word formatting updates it

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:08:31 -04:00
Harrison BoothandBrandon Thomas 2e790d549c Backspace after a floating shape's anchor keeps the shape (#4592) (#4641)
* Backspace after a floating shape's anchor joins paragraphs and keeps the shape (#4592)

* wc-upkeep-6: protect Backspace joins and markers

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-30 00:07:53 -04:00
ReesandBrandon Thomas 74b2cbb5c9 Backspace: keep Arabic base letters when removing marks (#4757) (#4761)
* 4757: keep Arabic base letters when removing marks

* docs: keep the gaps.md note in the status line and revision table

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:53:06 -04:00
ReesandBrandon Thomas d39be3bab8 Avoid repeated reference scans when converting notes (#4750) (#4752)
* Fix #4750: walk note references and formatting runs together

* docs: keep the gaps.md note in the status line and revision table

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:52:43 -04:00
vikoabastianandBrandon Thomas f2debdecff #3674: PDF export writes a glyph id above 65535 as .notdef instead of failing (#4554)
* #3674: PDF export writes a glyph id above 65535 as .notdef instead of failing

Shaping gives 32-bit glyph ids (a format-12 cmap can map a character to
any of them), and the PDF exporter passed them to krilla unchecked.
krilla's CID font takes 16-bit ids and unwraps the conversion
(vendor/krilla/src/text/cid.rs:161 at craft-libs b926b5c0), so one such
glyph failed the whole export natively and aborted the tab on wasm.

The three places that build a KrillaGlyph (body text, metafile text and
the text watermark) now go through glyph_id(), which writes an id that
doesn't fit as glyph 0. The glyph keeps its place and its text range, so
the rest of the run is unchanged.

Regression test: glyph_id_above_u16_exports lays out a paragraph, sets
one glyph's id to 70000 and exports the layout. It failed before with
"PDF export failed internally: ... TryFromIntError(PosOverflow)".

* #3674: the replaced glyph keeps its place, not its text

krilla writes no ToUnicode entry for glyph 0, so a glyph written as
.notdef doesn't extract: "Wide glyph" with the W's id set to 70000
extracts as "\u{FFFD}ide glyph". The rest of the run extracts as
before. The comment on glyph_id() said the glyph kept its text.

* Adapt glyph_id imports to main's exporter text module

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:52:04 -04:00
Harrison BoothandBrandon Thomas 48f5a905b1 DOCX: keep run-level snapToGrid and webHidden when saving (#4863)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:51:39 -04:00
ReesandBrandon Thomas 4abb77aa70 DOCX: avoid repeated searches when writing comment IDs (#4841)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:50:49 -04:00
e855daf279 Built-in heading styles have their outline level without w:outlineLvl, so the Navigation pane lists them (#4696) (#4706)
* #4696: built-in heading styles have their outline level without w:outlineLvl

Some producers write "heading 1"…"heading 9" in styles.xml without w:outlineLvl. Word
gives those built-in styles their outline level anyway (checked: Word for Windows reports
OutlineLevel 1 and 2, whatever the style id), so their headings show in the Navigation
pane. WordCraft read no level, and the Headings tab stayed empty.

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

* #4696: review — w:customStyle="on" marks a custom style too

A custom style named "heading 4" with customStyle="on" (ST_OnOff) was taken for Word's
built-in heading and got an outline level.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:50:27 -04:00
f774f6f5c0 #4548: comment ranges inside footnotes and endnotes are kept on read (#4550)
* #4548: comment ranges inside footnotes and endnotes are kept on read

read_opened read footnotes.xml and endnotes.xml before comments.xml, so
a comment range inside a note looked its id up in an empty comment map
and was dropped. Comments are now read first. read_comments uses nothing
the notes set up.

Regression test: tests/it/note_comments.rs (synthetic markup).

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

* docs: file-format-parity row for comment ranges in notes (#4548)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:49:56 -04:00
5b952b2463 RTF: keep Windows and Enhanced Metafile pictures (#4259) (#4648)
The RTF reader only recognised \pngblip and \jpegblip, so a \pict
holding \wmetafile8 (how Word writes pasted and floating pictures) or
\emfblip was dropped and the document opened without its pictures.
Keep them as wmf/emf media when the bytes are a metafile (the extension
comes from the bytes; raster bytes go through the usual image path,
anything else is dropped), sized from \picwgoal/\pichgoal and the scale,
else from \picw/\pich (0.01 mm, or pixels under \wmetafile1; zero or
negative falls back to the default). The metafile renderer already
draws them and the DOCX writer saves them as .wmf/.emf.

RTF export dropped WMF/EMF pictures as well (it re-encoded every other
format through the raster decoder): it now writes \emfblip with the raw
bytes and \wmetafile8 without the placeable header, \picw/\pich in
0.01 mm.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:43:55 -04:00
81833cfc33 Keep Markdown emphasis markers flanking next to punctuation (#4801)
Found while testing crates/formats/src/markdown/export.rs:167-182 (inlines_md_impl, emphasis marker emission)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:43:16 -04:00
ReesandBrandon Thomas 48545479b0 Markdown import: bound nested reference-label work (#4659)
* M1 #4658: bound Markdown reference-label work

* #4658: align Markdown progress status dates

* docs: keep ROADMAP's second status line as on main

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:42:49 -04:00
ReesandBrandon Thomas 990b57a1e8 HTML: preserve preformatted lines with CR input newlines (#4810) (#4815)
* Formats: preserve HTML input newlines (#4810)

* docs: fold the #4810 notes into the existing status lines

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:42:07 -04:00
0debb7afda Keep -- and '' apart across text runs in LaTeX export (#4822)
Found while testing crates/formats/src/latex/export.rs:402-444 (escape_text, `prev` resets per call) and :230-237 (inlines, runs concatenated)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:41:10 -04:00
bc437049fa Fix HTML import leaking <title> text into body outside <head> (#4725)
Found while testing crates/formats/src/html/builder.rs:338-344 (Builder::text)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:40:50 -04:00
jayzed80andBrandon Thomas 6e8a420d28 fix(formats): HTML <font size> offsets no longer overflow at the integer limits (#4755)
* fix(formats): saturate HTML <font size> offsets so extreme values cannot overflow

* test(formats): assert the saturated HTML font sizes, not only that the text survives

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:40:21 -04:00
64ebfda971 Reject '+' signs in 8-digit hex colours in parse_color (#4653)
Found while testing crates/formats/src/model/color.rs:45-47 (parse_color, 8-digit hex branch)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:40:01 -04:00
c99552c958 Carry rounded ISO duration seconds into minutes and hours (#4555)
Found while testing crates/formats/src/sheet/date.rs:136-152 (iso_time, PT duration branch)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 23:39:32 -04:00
Brandon ThomasandClaude Opus 5.5 250a9c5164 ci: Zotero peer check judges an unaccepted connection by its listening socket
The Linux same-user check read the uid of the listener's side of the
connection from /proc/net/tcp. Until the listener accepts it, that row has
no socket file (inode 0) and reads uid 0 whoever listens, so a Zotero that
hadn't accepted yet was refused as "another user (uid 0)". The ui-egui
Zotero tests failed about one run in three on a busy machine because of it.

Rows without a socket file (unaccepted connections, TIME_WAIT) are now
skipped, and the listening row on the listener's address stands in for the
connection's own; two users listening there is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:33:41 -04:00
dhruvLearner8andBrandon Thomas 1be606de03 Insert › Shapes shows the basic shapes as an icon grid (#4349) (#4739)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:41:10 -04:00
685b420104 Ctrl+V pastes a clipboard picture again with egui 0.37 (#4787)
* Ctrl+V pastes a clipboard picture again with egui 0.37

Since the egui 0.37 migration, egui-winit keeps both the Mod+V press and
its release, so the canvas never sees the V release it pasted a clipboard
picture on. Outside Wayland, egui-winit instead sends Event::PasteImage
when the clipboard holds a picture and no text, which the canvas ignored.
Ctrl+V pasted nothing while the ribbon and context-menu Paste still worked.

Answer PasteImage with edit.paste, which reads the picture through the host
like the ribbon's Paste. The web build sends no PasteImage, so nothing
pastes twice there.

Part of #4782

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

* Mark the clipboard picture as pasted on PasteImage so a later V release cannot paste twice

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:39:17 -04:00
4fcc409c8b AutoCorrect Options opens with no document open (#4585) (#4626)
File › Options › Proofing › AutoCorrect Options did nothing on the Start
screen: tools.autocorrect wasn't in works_without_document, so the click
was dropped. Its options are user preferences (session.prefs), like the
other File › Options settings, so it now works without a document.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:37:44 -04:00
bd85c72f39 Shading buttons show the last picked colour (#4412) (#4505)
* Shading buttons show the last picked colour as their bar (#4412)

The paragraph and table Shading buttons always drew the icon's yellow bar,
whatever colour a click applied. They now draw the remembered colour as the
bar, as Font Color and Text Highlight Color do (#4087), on an icon without a
bar of its own.

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

* docs: ui-parity row for the Shading button colour (#4412)

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:36:42 -04:00
8a964609c4 Format Painter: double-click a word to paint it (#4410) (#4502)
With the painter armed, only dragging across the text applied the copied
formatting: a double-click (a word) or triple-click (a paragraph) selected
the text and returned before the painter was used. Both paths now go through
one helper, so the click paints what it selected and the painter goes off.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:34:44 -04:00
LucienandBrandon Thomas 4a12ab9a19 Layout › Arrange › Group stays on while a group is selected, so Ungroup is reachable (#4229) (#4440)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:34:01 -04:00
LucienandBrandon Thomas 41d5284fa8 Options: a blank User Name stays blank after a restart (#4368) (#4421)
* Options: a blank User Name stays blank after a restart (#4368)

* docs: ui-parity row for the blank User Name (#4368)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:33:21 -04:00
931324e049 WordArt gallery: hand the keyboard back to the page after a pick (#4061) (#4358)
Inserting WordArt selects its placeholder text, so typing should replace it. From the Insert
gallery the first keystrokes went nowhere: the gallery's cells (and the Text Fill, Text Outline,
Glow and Transform galleries beside them) ran their command with `app.run` instead of
`run_from_ribbon`, so the canvas never asked for focus back and the page ignored the keys.
They now go through `run_from_ribbon` like every menu item.

Regression test renders the style gallery, picks a style and checks the page asks for the
keyboard; it fails without the change.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:32:41 -04:00
983f1e0267 docx: pin Standard-encryption key derivation to a real-world package (#4472)
Adds the key that Apache POI's Excel-written test file
protected_passtika.xlsx (password "tika") derives, computed from
[MS-OFFCRYPTO] §2.3.4.7 independently of the implementation, so real
Word 2007 files stay pinned.

Also adds the contributor's people.toml entry.

Co-authored-by: Bra1nZ <bra1nz@astraoffice.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:24:23 -04:00
.CooCooandBrandon Thomas 39ae5f6778 Layout parity: field results take \* DollarText, \* Hex and \* ArabicDash (#4361)
* Layout parity: field results take \* DollarText, \* Hex and \* ArabicDash

docs/layout-parity.md gets a row for the three field switches that #4226
added, a revision entry and a new status-line clause. The row describes
main as it is after 8c035fbb, where \* ArabicDash formats any number
field and table formulas, not PAGE alone.

* Layout parity: dollar text keeps its digits only past 999,999.99

Cardinal and ordinal text keep their digits past 999,999. `\* DollarText` writes
results up to 999,999.99 (crates/layout/src/fields.rs dollar_text).

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:22:55 -04:00
5714408dd8 M14: validate window fallback geometry and avoid duplicate symbol names (#4343)
* M14: validate window fallback geometry and avoid duplicate symbol names

Verified regression cases and full cargo xtask ci before publication.

* M14: document review helpers and regression contracts

Documentation-only follow-up; full cargo xtask ci passed.

---------

Co-authored-by: kalmarr <680152+kalmarr@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:20:19 -04:00
Dylan M. Taylor 964a7bc5a8 CI: workflows only call craft-common-actions at @main (#4919)
AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call
craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and
link or open a craft-common-actions issue (a PR only for a small fix that is safe for
every app and does not increase CI time); legacy jobs
aren't added to and move only as their own task, staying until a shared workflow runs them.

windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on
craft-common-actions main and the workflow (and the actions it calls) is identical
there, so nothing changes in behaviour.
2026-10-29 22:17:20 -04:00
SimoneandLucenx9 3508cac3b0 Prevent X11 crashes from NUL in document titles (#3634) (#4301)
Ported onto current main (title_stem now lives in app/frame.rs; the window
title is built in windows.rs).

Co-authored-by: Lucenx9 <185146821+Lucenx9@users.noreply.github.com>
2026-10-29 22:15:58 -04:00
8148a239e6 Content controls: resolve lone table-open markers in linear time (#3826) (#4227)
Count table opens in a single linear pass instead of rescanning
earlier events for every lone table-open marker (O(events) instead of
O(lone * events)).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:04:29 -04:00
acccf913ab Traditional Chinese interface: draw hanzi to the Taiwan standard with an installed TC font (#3380)
* i18n: Traditional Chinese interface draws hanzi with an installed TC font

The zh-hant interface shared the Simplified Chinese font order, so its first
CJK face was the embedded Noto Sans CJK SC and traditional characters were
drawn with mainland glyph shapes (說, 資, 這, 為, 戶).

On the desktop, a Traditional Chinese interface now puts an installed
Traditional Chinese font before every CJK face: PingFang TC on macOS,
Microsoft JhengHei on Windows, Noto Sans CJK TC on Linux. Noto Sans CJK SC
stays after it as the fallback. Without an installed TC font, and on wasm,
the order is unchanged.

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

* Credits: add TobyWu666 to contributors/people.toml

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

* i18n: find Traditional Chinese fonts at the root of a font folder

Linux user font folders (~/.fonts, ~/.local/share/fonts) often hold font
files directly; the Traditional Chinese candidates only named files in
distribution subfolders, so those fonts were missed (CodeRabbit review).

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

* i18n: skip the Traditional Chinese font test without embedded catalogs

Since the catalogs come from craft-i18n (#4072), a build without
CRAFT_I18N_DIR is English only, so the test now skips like the other
catalog tests. Also rustfmt hant_font_chars.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 22:03:12 -04:00
dc48046057 #1103: ignore macOS launch arguments and name the real cause when a file won't open (#2622)
* #1103: ignore macOS launch arguments and name the real cause when a file won't open

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

* #1103: filter launch args on macOS only; leave Backstage after a failed Finder open

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

* #1103: leave Backstage after a failed drag-and-drop open

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

* #1103: name the file when an open fails after the Save changes prompt

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

* #1103: report failed opens from every deferred path in execute_user

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

* #1103: keep the named failed-open message after a browser save

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

* #1103: only opens held back by a prompt or save leave Backstage on failure

A direct open from Recent keeps File open and reports there (#2938).

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

* #1103: re-apply open-failure reporting on main's split ui-egui modules

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

* Qualify i18n paths in report_open_failure after merging main

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 21:58:32 -04:00
22e99fc2b7 Tables can be copied and cut: whole tables, rows and cells (#1744) (#1784)
* #1744: WIP

* #1744: Select Table copies and cuts the whole table

Copy of a whole-table selection used to keep only the cells' paragraphs
(copy_blocks flattened a cross-cell range), and Cut only cleared the text.
Now Copy puts the table on the clipboard and Cut removes the table.

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

* #1744: selected table rows and cells copy as a table; Cut clears their text

Copy of whole rows, cells of a row or a block of cells puts a table of just
those cells on the clipboard (Table::slice). Cut of rows or cells clears their
text and keeps the rows and cells, as in Word (Word check A).

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

* #1744: review fixes

Table::slice widens every row to a merged cell partly selected (no ragged
copy); an empty selected table or cell copies and cuts; a text selection over
exactly a nested table copies it; one selected_table per Cut; tests for
slice merges/widths/controls, tracked and locked cuts, and the text route.

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

* #1744: Cut clears every cell Copy takes; Spike stays off for empty cell selections

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

* #1744: a copied part of a percentage-width table keeps a point width

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

* ROADMAP: tidy merged header

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

* ROADMAP: keep only the #1744 rows over main's version

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

* ROADMAP: remove conflict markers; keep only the #1744 rows over main's version

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

* #1744: rename the range Cut helper clear_range_cells (main added clear_cells)

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

* Remove conflict markers from ROADMAP.md; leave main's header line alone

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

* cargo fmt

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

* Import Path in table structure after merging main

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 21:47:27 -04:00
Fin WarmanandBrandon Thomas f658c872a2 Tables: share spanning minima by preferred widths (#1883)
* Tables: share span minima by preferred widths (#1883)

* 1222: Match nested preferred span weights

* docs: word the #1883 layout-parity entries

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 21:40:41 -04:00
Fin WarmanandBrandon Thomas a066fe5f13 Keep zero-sized paragraph frames in normal flow (#2810)
* 411: Keep zero-sized paragraph frames in normal flow

* M2.1: keep frame controls consistent with document defaults (#2810)

* M2.1: check the final frame round trip (#2810)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-29 21:38:01 -04:00
Dylan M. Taylor ccb8ce8494 packaging: Flatpak finds and prints to CUPS printers (#4909)
* packaging: Flatpak finds and prints to CUPS printers

The Print dialog listed no printers in the Flatpak: the runtime has libcups but not
lpstat/lp, the sandbox couldn't reach the CUPS socket, and driverless network printers
(lpstat -e) need Avahi. Bundle lpstat/lp/lpoptions from CUPS 2.4.20 (Apache-2.0,
statically linked, licence installed) and grant --socket=cups and
--system-talk-name=org.freedesktop.Avahi in both manifests.

* packaging: Flatpak reads the user's CUPS settings (~/.cups, read-only)

Review (CodeRabbit): without it the sandbox's lp/lpstat ignore the user's default
printer and saved options (lpoptions) and a print server named in their client.conf.

* packaging: Flatpak CUPS tools reach the host socket when client.conf names a custom path

Review: Flatpak mounts the host's CUPS socket at /var/run/cups/cups.sock whatever
path ~/.cups/client.conf or CUPS_SERVER names, so lp/lpstat/lpoptions now run through
a wrapper that points a socket path missing in the sandbox there. Server names
(remote servers) and sockets that exist are left alone. Tested in the sandbox:
CUPS_SERVER=/run/custom/cups.sock lists the host's printers through the wrapper and
none without it; a host name is passed through unchanged.

* packaging: redirect a custom CUPS socket only when it is the one Flatpak mounted

Review (CodeRabbit): Flatpak picks the socket it mounts at /var/run/cups/cups.sock
with stricter rules than CUPS (flatpak-run-cups.c: a socket CUPS_SERVER, else the
first line spelled exactly "ServerName /path" in ~/.cups/client.conf, else the
default), while CUPS reads the keyword in any case and takes the last ServerName. The
wrapper now redirects only when CUPS' socket is the one Flatpak chose, so a job can't
reach a different server; otherwise CUPS fails on the missing path.

* packaging: never redirect a CUPS_SERVER the sandbox was given

Review (CodeRabbit): Flatpak picks the socket it mounts from the host's CUPS_SERVER,
so one set by flatpak run --env or an override may not be what it mounted. The
wrapper now leaves any CUPS_SERVER alone and redirects only a client.conf socket that
matches Flatpak's choice; otherwise CUPS fails on the missing path instead of
reaching a different server.

* packaging: point CUPS at the mounted socket only when it is the one Flatpak chose, else fail

Review (CodeRabbit): with a custom socket listed first and the default last in
~/.cups/client.conf, Flatpak mounts the custom one at /var/run/cups/cups.sock while
CUPS picks the last line, finds that path, and talks to the custom server. The
wrapper now compares CUPS' socket with Flatpak's choice even when the path exists:
equal, CUPS is pointed at the mount; different, the tool fails with an error instead
of reaching another server.
2026-10-29 18:48:31 -04:00
skylar taylor-barrick 165d5725dc Merge pull request #4894 from OPUZZLES/opuzzles/pilot-recipient-label-sheets
Labels: continue selected recipients across sheets (#4880)
2026-10-29 14:18:49 -06:00
OPUZZLES 01a1bf3921 M9: assert unused recipient labels are empty (#4880) 2026-10-29 18:14:46 +01:00
OPUZZLES 5a9e414d1e M9: generate all recipient label sheets (#4880) 2026-10-29 17:49:08 +01:00
Dylan M. Taylor aadb077e07 Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds (#4760)
Add packaging/freebsd/package.sh, matching the sibling craft apps. The release
workflow cross-compiles aarch64 FreeBSD in an x86_64 VM with CARGO_BUILD_TARGET
set; the script builds with --target, reads binaries from the target directory,
names the archive after the target arch, and checks ELF/FreeBSD/arch instead of
running binaries that cannot run on the host.
2026-10-28 15:53:55 -04:00
Dylan M. Taylor 9724f15c3d Grant network access so update checks can reach GitHub (#4741) 2026-10-28 13:36:48 -04:00
Brandon ThomasandClaude Opus 5.5 3de6ff95af Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:52:06 -04:00
Brandon ThomasandClaude Opus 5.5 65bb8b3709 macOS packaging: retry stapler when Apple's CloudKit drops the connection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:52:00 -04:00
Dylan M. Taylor c4fafc2154 Enforce Rust coding standards via Clippy (#4501)
* Refactor doc for strict Clippy checks

* Refactor docx for strict Clippy checks

* Refactor engine for strict Clippy checks

* Refactor fonts for strict Clippy checks

* Refactor formats for strict Clippy checks

* Refactor layout for strict Clippy checks

* Refactor mcp for strict Clippy checks

* Refactor pdf for strict Clippy checks

* Refactor proof for strict Clippy checks

* Refactor render for strict Clippy checks

* Refactor ui canvas for strict Clippy checks

* Refactor ui dialogs for strict Clippy checks

* Refactor ui ribbon for strict Clippy checks

* Refactor ui support for strict Clippy checks

* Refactor wordcraft-cli for strict Clippy checks

* Enforce workspace Clippy standards and document lint policy

* Deflake the IME epoch test

* Fix platform-gated imports for wasm builds
2026-10-27 22:00:30 -04:00
Dylan M. Taylor df5a59275d Fix platform-gated imports for wasm builds 2026-10-28 01:24:26 +00:00
Dylan M. Taylor ab91e9f96c Deflake the IME epoch test 2026-10-28 01:06:49 +00:00
Dylan M. Taylor 1c8f6e9257 Enforce workspace Clippy standards and document lint policy 2026-10-28 01:06:48 +00:00
Dylan M. Taylor bc05149bd9 Refactor wordcraft-cli for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 51c70e1606 Refactor ui support for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor ac2cc0c4ef Refactor ui ribbon for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 9c0f61356c Refactor ui dialogs for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 260a629e9e Refactor ui canvas for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 1ecb22c67d Refactor render for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 36f6244cd4 Refactor proof for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor ffaeda9316 Refactor pdf for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 815d3f0032 Refactor mcp for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 937c7e532d Refactor layout for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 7976368de7 Refactor formats for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 0436139c57 Refactor fonts for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor a2435388e5 Refactor engine for strict Clippy checks 2026-10-28 01:06:48 +00:00
Dylan M. Taylor 75d30d69b4 Refactor docx for strict Clippy checks 2026-10-28 01:06:47 +00:00
Dylan M. Taylor 1d3e6d9ca8 Refactor doc for strict Clippy checks 2026-10-28 01:06:47 +00:00
aiongg d522bfd8b1 Merge pull request #4492 from Jeremy8776/feat/install-scope-policy
Windows installation policy: WordCraft
2026-10-27 17:08:58 -04:00
aiongg 0bd3420553 ci: use storytold/craft-common-actions at the merged installer-policy commit 2026-10-27 20:51:47 +00:00
Jeremy 54e1b1e7f3 fix(installer): check the exact architecture-specific machine folder
The pre-signing check matched any folder named Program Files, so it rejected
a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit
root for an x86 package and accepted a different app name. Build the expected
folder from the package architecture's resolved root and the policy name.
2026-10-27 19:13:02 +00:00
aiongg 4b8b06fe03 Merge pull request #4602 from Silvertree2010/perf/export-speed
Speed up PDF export by about 24 %
2026-10-27 13:41:24 -04:00
NetterSilverandClaude Opus 5.5 de3bfaf049 Keep harfrust shape plans per face
Each shaping call let harfrust compile a new shape plan (the face's
lookups for the run's script, direction, language and features), which
took about as long as shaping a short run. Each thread now keeps up to 64
plans (the oldest dropped to make room), found by face and harfrust's
ShapePlanKey::matches, and passes the plan to the shaper. A kept plan is
only used for the face it was made for: the face id (from one process-wide
counter, so no two faces share it) together with the font bytes' address,
length and face index. The plan is the one the shaper would have made for
the buffer, so the glyphs are the same; tests shape with kept and fresh
plans and compare, and check the per-thread bound.

PDF export of the export_bench document (259 pages), interleaved with main
(A/A within 1 %): median 421 ms -> 319 ms (-24.2 %), measured on a loaded
machine; on a quieter run earlier 294 ms -> 224 ms (-23.8 %). PNG export of
page 1 (which lays out the whole document): about -34 %. The PDF and PNG
bytes are identical to main's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:39:37 +01:00
aiongg f8e327b068 Merge pull request #3936 from benandez/fix/3907-section-marks
#3907: continuous sections running off a page go onto the next; empty section marks in a row stack to the page bottom
2026-10-27 12:37:46 -04:00
aiongg 50a8ebe307 Merge pull request #4387 from benandez/fix/3872-followup
#3872 follow-up: font table found by its stored spelling; privacy scrub keeps attribute values well-formed
2026-10-27 12:37:26 -04:00
aiongg f270dbd3b0 Merge pull request #4060 from benandez/fix/3995-thai-test-guard
#3995: Thai one-face fallback tests skip unless a Thai font with Latin is installed
2026-10-27 12:37:05 -04:00
Jeremy ffa35d62db fix(installer): resolve the wizard scope and folder correctly
Real msiexec runs (per-user and all-users, silent and wizard) found two defects
that the metadata fixtures cannot see:

- "Only for me" published ALLUSERS=2, so FindRelatedProducts treated the run as
  per-machine and skipped the existing per-user copy. An interactive upgrade
  left two registered products and uninstall left files behind. The page now
  leaves ALLUSERS unset for a per-user choice.
- Windows Installer resolves Program Files once, for the context it started in,
  so choosing "Everyone" in the wizard installed into the user profile with a
  machine-wide registration. The wizard now sets the new-install folder itself
  after the choice (Program Files, Program Files (x86) for 32-bit, or the
  per-user Programs folder). Silent installs are unchanged.

check-install-policy.ps1 now fails if either regresses.
2026-10-27 15:32:42 +00:00
aiongg 436d444ef0 fix(ui): dropdown menus grow back after a search with no matches (#4577)
* ui: craft_ui::ComboBox everywhere and the combo arrow popup on craft_ui::combo::Menu

Repin craft-libs to aiongg/craft-libs 62bb0e1d (dropdown regrow). Ban egui::ComboBox in clippy.toml.

* chore(deps): craft-libs 60d60d48 (dropdown fixes)

* chore(deps): craft-libs 6af63481 (dropdown fixes)

* chore(deps): craft-libs 4d7b82f7 (dropdown fixes)

* chore(deps): craft-libs 92d392cf (#387 merged)
2026-10-27 10:27:21 -04:00
NetterSilverandClaude Opus 5.5 ffa127c615 Skip per-character table lookups for plain text in layout and PDF export
Shortcuts that give the same answers as the lookups they skip:

- is_rtl: nothing before U+0590 is right to left, so Latin, Greek and
  Cyrillic text skips the bidi class search.
- strong_rtl and is_mark: ASCII letters are the only strong ASCII
  characters, and no ASCII character is a mark.
- cluster_starts: in ASCII text every character starts a grapheme except LF
  after CR, and no ASCII character joins a cluster.
- display lines: a glyph run's vectors and text are sized up front, and the
  symbol-font check is made once per run instead of once per cluster.
- PDF glyph runs: the baseline (the y most glyphs share) is only worked
  out for italic runs, the only ones that use it, and a run on one baseline
  returns it without counting.

Tests check each shortcut against the full lookup (every code point for the
bidi classes, every ASCII pair for the clusters).

PDF export of the export_bench document, interleaved with main: median
293 ms -> 241 ms (-18 % with the previous commit). The PDF bytes are
identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:39:09 +01:00
NetterSilverandClaude Opus 5.5 8cdc172171 Lay out the paragraph after a kept heading once
A paragraph with keep with next measures the paragraph after it before it is
placed (next_first_line). That measurement called layout_para directly, so
every paragraph following a heading was laid out twice in each layout pass:
once to measure it and once, through the cache, when it was placed.

The measurement now goes through the layout cache under the key
para_labelled uses (Ctx::para_ahead) when its inputs are the same: no
proofing, no typed word, not inside a text box, no inline text boxes that
resize, the body's tracked-deletion view. Otherwise it is laid out on its
own as before. It counts nothing (list labels, equation numbers and the
page-total flag are restored), so pagination is unchanged.

Adds crates/engine/examples/export_bench.rs, which saves a long generated
document (the sample document 200 times, 259 pages) through io::save_bytes
and prints wall times:

  cargo run --release -p wordcraft-engine --example export_bench -- pdf 200 9

PDF export on that document, interleaved with main (Ryzen, 16 threads):
median 286 ms -> 256 ms (-10.6 %). The PDF bytes are identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:39:09 +01:00
Jeremy d603983a61 docs(installer): note that the 32-bit all-users folder is Program Files (x86); tidy wording 2026-10-27 12:25:21 +00:00
Jeremy 72b8818ea6 packaging(windows): ship one dual-scope MSI
The package is dual-scope, so the separate -windows-user MSI was the same package under a
second name. Build and policy-check the single plain-named MSI before signing, which is the
name every sibling app, the launcher and the shared windows-arm64 workflow use. Replace the
loop test with one that checks the MSI is policy-checked once, before it is signed.
2026-10-27 12:17:18 +00:00
Jeremy 3031870ce3 docs(installer): describe the per-user default install location and scope 2026-10-27 12:09:04 +00:00
Jeremy 1b0a59d4d2 fix(installer): harden install-policy checks and packaging-lint workflow 2026-10-27 12:02:00 +00:00
Jeremy 8edd6cb8eb fix(installer): address scope and ARM64 review feedback 2026-10-27 12:01:59 +00:00
Jeremy 89a059fb2d feat(installer): default to a per-user ArtCraft folder 2026-10-27 12:01:59 +00:00
Martin BallandClaude Opus 5.5 1b485e1f90 #3907: CodeRabbit — a kept paragraph is measured against the next page's body (a different first-page header)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:24:50 +00:00
Martin BallandClaude Opus 5.5 751eb4b204 #3907: CodeRabbit — a stacked mark past the page bottom draws no line number either
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:39:26 +00:00
Martin BallandClaude Opus 5.5 5225569d44 #3907: review — an empty mark taking no room, or stacked past the page bottom, draws no list label; an empty mark after a text paragraph closing a section stays quiet
dpkruse's Genuine Steps Certificate: a bulleted text paragraph closes a
two-column continuous section, then an empty bulleted one closes another.
Word draws no bullet for the empty one; it is now quiet (takes no room) and
draws no label, still counted. A stacked mark that doesn't fit keeps its
line for the caret but draws nothing below the margin (CodeRabbit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 10:04:05 +00:00
Martin BallandClaude Opus 5.5 f2fe7654e9 #3907: review — a kept paragraph low in a continuous section moves to the next page; a page break before a stacked mark keeps the new page's bottom
CodeRabbit (local): keep-lines compared its height with the room below the
mid-page section start, so a paragraph that fits on a new page split instead.
It now compares with the next column, or the next page's body when this is the
last column. A stacked mark whose page break before starts a page no longer
gets the previous page's bottom back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-27 09:56:44 +00:00
Martin BallandClaude Opus 5.5 7757828295 #3907: continuous sections running off a page go onto the next; empty marks in a row stack to the page bottom
A continuous section moves the column top down below the text above. at_top()
then held at every new section start, so a paragraph that did not fit was
placed anyway, as on an empty page. A run of one-line continuous sections ran
off the bottom of page 1 (100 of them reached 2,480 pt down a 792 pt page).
The overflow checks for paragraphs now use fresh(): at the top with nothing
above on this page. at_top() still drives the spacing rules, unchanged.

Empty paragraphs that close a continuous section right after another
section's mark took no room. Measured in Word on synthetic files ("Start", N
such marks, "End"): 10 put "End" about 9 cm down page 1; 50, 100 and 200 all
put it on the first line of page 2, the same with 24 pt marks or no paragraph
spacing. So each takes its line while it fits, then stays at the page's
bottom. A mark right after text stays quiet, as measured before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-27 09:56:35 +00:00
Martin BallandClaude Opus 5.5 04ebc4ed42 #3995: Thai one-face fallback tests skip unless a Thai font with Latin is installed
Noto Sans Thai and its siblings have no Latin letters or digits, so on a
machine whose only Thai fonts are Noto no face covers the test run and
fallback_for_run rightly returns None. The guard now asks whether the Thai
substitute covers the whole run, which doesn't go through fallback_for_run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
2026-10-27 09:52:32 +00:00
Martin BallandClaude Opus 5.5 cd683a9fd1 #3872: review — privacy rewrite keeps attribute values as written, escaping only quotes
Unescaping and escaping again turned &#10; and &#9; into raw characters that read
back as spaces. The raw value of well-formed markup holds no `<` and every `&` starts
a reference, so only a `"` from a single-quoted value is replaced with &quot;.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2
2026-10-27 09:51:52 +00:00
Martin BallandClaude Opus 5.5 932a2e8955 #3872: review — font table found by its stored spelling; privacy rewrite escapes attribute values
- A package storing WORD/FONTTABLE.XML (part names are case-insensitive) loaded no
  embedded fonts and a save dropped the table and every font: lookups used the exact
  constant spelling. They now find the key case-insensitively.
- scrub_private re-pushed attribute values raw (quick-xml push_attribute does not
  escape), so a value read from single quotes became malformed XML. The value is now
  unescaped and escaped again.
- rel_targets matches _rels/ case-insensitively.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2
2026-10-27 09:51:52 +00:00
aiongg 2cf1d6c7ae Merge pull request #4489 from aiongg/layout
refactor: shared module layout (craft-contrib#20)
2026-10-26 21:32:09 -04:00
skylar taylor-barrick f54ea731cc Merge pull request #4487 from storytold/fix/nextest-0.9.150
ci: require nextest 0.9.150
2026-10-26 17:35:44 -06:00
aiongg 0404b4199a test: file_access scan stops only at inline test modules 2026-10-26 22:10:26 +00:00
aiongg 95ba3d07b4 docs: update paths of moved files 2026-10-26 21:16:49 +00:00
aiongg 835704f870 refactor(engine): move session::ime_state to the crate root as ime 2026-10-26 21:16:49 +00:00
aiongg 39336fe844 refactor(engine): move session::snapshot_types to the crate root as snapshot 2026-10-26 21:16:49 +00:00
aiongg 5a311f4615 refactor(engine): move session::prefs to the crate root as prefs 2026-10-26 21:16:49 +00:00
aiongg 8f1f3c6132 refactor(engine): move session::view to the crate root as view 2026-10-26 21:16:49 +00:00
aiongg e027a4244b refactor(engine): move session::doc_state to the crate root as doc_state 2026-10-26 21:16:49 +00:00
aiongg 3c4d7adc87 refactor(engine): move session::locale to the crate root as locale 2026-10-26 21:16:49 +00:00
aiongg cfa90220d9 refactor(engine): split session.rs into session/ 2026-10-26 21:16:49 +00:00
aiongg 8f10bd1b9f refactor(engine): split cmd/table.rs into table/ 2026-10-26 21:16:49 +00:00
aiongg a82063e50a refactor(engine): split cmd/insert.rs into insert/ 2026-10-26 21:16:49 +00:00
aiongg 342de16153 refactor(engine): split cmd/text.rs into text/ 2026-10-26 21:16:49 +00:00
aiongg d66f34b774 refactor(engine): split cmd/references.rs into references/ 2026-10-26 21:16:49 +00:00
aiongg 0a6c7605f6 refactor(engine): split cmd/para.rs into para/ 2026-10-26 21:16:49 +00:00
aiongg fbc95e1e10 refactor(engine): split cmd/citations.rs into citations/ 2026-10-26 21:16:49 +00:00
aiongg fac4b550c1 refactor(engine): split cmd/mailings.rs into mailings/ 2026-10-26 21:16:49 +00:00
aiongg cc728757f5 refactor(engine): split cmd/review.rs into review/ 2026-10-26 21:16:49 +00:00
aiongg 3d458a08b6 refactor(engine): split cmd/tools.rs into tools/ 2026-10-26 21:16:49 +00:00
aiongg dabc31eb83 refactor(engine): move cmd/tools tests to tools/tests.rs 2026-10-26 21:16:49 +00:00
aiongg 03f5df4bba refactor(engine): split cmd/objects.rs into objects/ 2026-10-26 21:16:49 +00:00
aiongg b8ddca30a1 refactor(docx): split read/chart.rs into read/chart/ 2026-10-26 21:16:49 +00:00
aiongg 80d8a9952a refactor(docx): split write/story.rs into write/story/ 2026-10-26 21:16:49 +00:00
aiongg 125fdc65ef refactor(docx): split read/story.rs into read/story/ 2026-10-26 21:16:49 +00:00
aiongg b1a94d4d4b refactor(docx): split write.rs into write/ 2026-10-26 21:16:49 +00:00
aiongg 0a4f5b8e84 refactor(docx): split read.rs into read/ 2026-10-26 21:16:49 +00:00
aiongg b8d3ec94b9 refactor(pdf): split exporter.rs into exporter/ 2026-10-26 21:16:49 +00:00
aiongg 7f3912c1df refactor(pdf): split lib.rs into modules 2026-10-26 21:16:49 +00:00
aiongg e1191cbb13 refactor(doc): split props.rs into props/ 2026-10-26 21:16:49 +00:00
aiongg 8348af42bd refactor(doc): split para.rs into para/ 2026-10-26 21:16:49 +00:00
aiongg 440d314f9c refactor(doc): split lib.rs into modules 2026-10-26 21:16:49 +00:00
aiongg 928e32fc12 refactor(doc): split review items out of lib.rs into review.rs 2026-10-26 21:16:49 +00:00
aiongg 3bc47b4cc0 refactor(formats): split sheet.rs into sheet/ 2026-10-26 21:16:49 +00:00
aiongg 2e61c73cb7 refactor(formats): split markdown.rs into markdown/ 2026-10-26 21:16:49 +00:00
aiongg 75bc3eb906 refactor(formats): split model.rs into model/ 2026-10-26 21:16:49 +00:00
aiongg 1bf2735604 refactor(formats): split html.rs into html/ 2026-10-26 21:16:49 +00:00
aiongg 59cec9d8bc refactor(formats): split odt.rs into odt/ 2026-10-26 21:16:49 +00:00
aiongg a209d1b268 refactor(formats): split rtf.rs into rtf/ 2026-10-26 21:16:49 +00:00
aiongg f0c66c81df refactor(formats): split latex.rs into latex/ 2026-10-26 21:16:49 +00:00
aiongg 0ad6f34c49 refactor(formats): move latex tests to latex/tests.rs 2026-10-26 21:16:49 +00:00
aiongg 6c21661c03 refactor(render): split lib.rs media code into media.rs 2026-10-26 21:16:49 +00:00
aiongg a2a0d69193 refactor(fonts): split fontdb.rs into fontdb/ 2026-10-26 21:16:49 +00:00
aiongg 5113be5569 refactor(layout): split math.rs into math/ 2026-10-26 21:16:49 +00:00
aiongg 5a84dd2f4f refactor(layout): split display.rs into display/ 2026-10-26 21:16:49 +00:00
aiongg 4f74183576 refactor(layout): split para.rs into para/ 2026-10-26 21:16:49 +00:00
aiongg 15f50c96df refactor(layout): split lib.rs into modules 2026-10-26 21:16:49 +00:00
aiongg 1344b5204e refactor(ui-egui): split dialogs/dialogs_refs.rs into dialogs_refs/ 2026-10-26 21:16:48 +00:00
aiongg 17f3a4f919 refactor(ui-egui): split dialogs/dialogs_insert.rs into dialogs_insert/ 2026-10-26 21:16:48 +00:00
aiongg ffc631861e refactor(ui-egui): move dialogs_insert tests to dialogs_insert/tests.rs 2026-10-26 21:16:48 +00:00
aiongg 188f379b85 refactor(ui-egui): split dialogs/dialogs_layout.rs into dialogs_layout/ 2026-10-26 21:16:48 +00:00
aiongg afc3b8ad4d refactor(ui-egui): split dialogs.rs into dialogs/ 2026-10-26 21:16:48 +00:00
aiongg 6d26a751f0 refactor(ui-egui): split ribbon.rs into ribbon/ 2026-10-26 21:16:48 +00:00
aiongg 095e1941e3 refactor(ui-egui): split canvas.rs into canvas/ 2026-10-26 21:16:48 +00:00
aiongg 13e8dacda7 refactor(ui-egui): split control.rs into control/ 2026-10-26 21:16:48 +00:00
aiongg 531d474781 refactor(ui-egui): split panes.rs into panes/ 2026-10-26 21:16:48 +00:00
aiongg f01cecf25b refactor(ui-egui): split widgets.rs into widgets/ 2026-10-26 20:54:03 +00:00
aiongg 21cddda18d refactor(ui-egui): split keys.rs into keys/ 2026-10-26 20:54:03 +00:00
aiongg 10060a7b55 refactor(ui-egui): split app.rs into app/ 2026-10-26 20:54:03 +00:00
aiongg da59acabcb refactor(ui-egui): split lib.rs into modules 2026-10-26 20:54:03 +00:00
aiongg 741e6c7986 refactor(ui-egui): move dialogs_templates to dialogs/dialogs_templates.rs 2026-10-26 20:54:03 +00:00
aiongg f2ae6a9c36 refactor(ui-egui): move dialogs_table_style to dialogs/dialogs_table_style.rs 2026-10-26 20:54:03 +00:00
aiongg f5c48c0fa2 refactor(ui-egui): move dialogs_smart_art to dialogs/dialogs_smart_art.rs 2026-10-26 20:54:03 +00:00
aiongg bbfcf81b8d refactor(ui-egui): move dialogs_ribbon to dialogs/dialogs_ribbon.rs 2026-10-26 20:54:03 +00:00
aiongg 695a1b11f4 refactor(ui-egui): move dialogs_review to dialogs/dialogs_review.rs 2026-10-26 20:54:03 +00:00
aiongg 811e5aca3b refactor(ui-egui): move dialogs_refs to dialogs/dialogs_refs.rs 2026-10-26 20:54:03 +00:00
aiongg 19007c8e1a refactor(ui-egui): move dialogs_para to dialogs/dialogs_para.rs 2026-10-26 20:54:03 +00:00
aiongg a2389baed5 refactor(ui-egui): move dialogs_notes to dialogs/dialogs_notes.rs 2026-10-26 20:54:03 +00:00
aiongg b3b7fc6724 refactor(ui-egui): move dialogs_lists to dialogs/dialogs_lists.rs 2026-10-26 20:54:03 +00:00
aiongg 15417121db refactor(ui-egui): move dialogs_layout to dialogs/dialogs_layout.rs 2026-10-26 20:54:03 +00:00
aiongg 81df9013bf refactor(ui-egui): move dialogs_keyboard to dialogs/dialogs_keyboard.rs 2026-10-26 20:54:03 +00:00
aiongg aa05b92df4 refactor(ui-egui): move dialogs_insert to dialogs/dialogs_insert.rs 2026-10-26 20:54:02 +00:00
aiongg d1d17835e6 refactor(ui-egui): move dialogs_controls to dialogs/dialogs_controls.rs 2026-10-26 20:54:02 +00:00
aiongg fc401225b0 refactor(ui-egui): move dialogs_chart to dialogs/dialogs_chart.rs 2026-10-26 20:54:02 +00:00
aiongg 253c21b33c refactor(ui-egui): move control_tests.rs to control/doc_tests.rs 2026-10-26 20:54:02 +00:00
aiongg d3d7152b45 refactor(fonts): move tests_sysfonts.rs to fontdb/tests/sysfonts.rs 2026-10-26 20:54:02 +00:00
aiongg bc8e6772d5 refactor(zotero): move test files to tests/ 2026-10-26 20:54:02 +00:00
aiongg f7c246fb61 refactor(ui-egui): move test files to tests/ 2026-10-26 20:54:02 +00:00
aiongg c507c7650f refactor(render): move tests_color_glyphs.rs to tests/color_glyphs.rs 2026-10-26 20:54:02 +00:00
aiongg fe73d141fe refactor(layout): move tests_*.rs files to tests/ 2026-10-26 20:54:02 +00:00
aiongg fb09da5dc4 refactor(engine): move tests_*.rs files to tests/ 2026-10-26 20:54:02 +00:00
aiongg 0036a9801f refactor(ui-egui): move chrome tests to chrome/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 47983cedf0 refactor(ui-egui): move table_mouse tests to table_mouse/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 809007e530 refactor(wordcraft): move logging tests to logging/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 13f42b0a9d refactor(engine): move cmd/insert tests to cmd/insert/tests.rs 2026-10-26 20:54:02 +00:00
aiongg f7c3f3d564 refactor(engine): move speech tests to speech/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 5b6c461448 refactor(docx): move crypt tests to crypt/tests.rs 2026-10-26 20:54:02 +00:00
aiongg c5b1efb97d refactor(fonts): move fontdb tests to fontdb/tests.rs 2026-10-26 20:54:02 +00:00
aiongg aedf03549f refactor(ui-egui): move dialogs_refs tests to dialogs_refs/tests.rs 2026-10-26 20:54:02 +00:00
aiongg eae2919ea3 refactor(ui-egui): move previews tests to previews/tests.rs 2026-10-26 20:54:02 +00:00
aiongg a88027f1b2 refactor(ui-egui): move table_resize tests to table_resize/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 3b84c637ea refactor(layout): move math tests to math/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 6c22d68ce3 refactor(ui-egui): move panes tests to panes/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 1d06547f06 refactor(engine): move cmd/page tests to cmd/page/tests.rs 2026-10-26 20:54:02 +00:00
aiongg e911006f2d refactor(engine): move cmd/draw tests to cmd/draw/tests.rs 2026-10-26 20:54:02 +00:00
aiongg fbf8340132 refactor(engine): move cmd/table_resize tests to cmd/table_resize/tests.rs 2026-10-26 20:54:02 +00:00
aiongg ffb21794c5 refactor(fonts): move fontdb/user tests to fontdb/user/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 6827876f8e refactor(ui-egui): move caret_glide tests to caret_glide/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 6990384d9d refactor(formats): move html tests to html/tests.rs 2026-10-26 20:54:02 +00:00
aiongg f8cacf71ba refactor(ui-egui): move options tests to options/tests.rs 2026-10-26 20:54:02 +00:00
aiongg f2f25f9363 refactor(engine): move cmd/ink_shape tests to cmd/ink_shape/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 6e63f6cb28 refactor(engine): move cmd/references tests to cmd/references/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 1da89b36a4 refactor(ui-egui): move widgets tests to widgets/tests.rs 2026-10-26 20:54:02 +00:00
aiongg c4fac489c8 refactor(docx): move read/chart tests to read/chart/tests.rs 2026-10-26 20:54:02 +00:00
aiongg a2d1ec38b7 refactor(engine): move cmd/equation tests to cmd/equation/tests.rs 2026-10-26 20:54:02 +00:00
aiongg dde8896fba refactor(ui-egui): move keytips tests to keytips/tests.rs 2026-10-26 20:54:02 +00:00
aiongg e4ee834478 refactor(wordcraft-cli): move main tests to tests.rs 2026-10-26 20:54:02 +00:00
aiongg 1b4c340879 refactor(formats): move markdown tests to markdown/tests.rs 2026-10-26 20:54:02 +00:00
aiongg b9b8440312 refactor(ui-egui): move windows tests to windows/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 7bf497b392 refactor(engine): move recovery/desktop tests to recovery/desktop/tests.rs 2026-10-26 20:54:02 +00:00
aiongg ebce2b4d49 refactor(engine): move cmd/mailings tests to cmd/mailings/tests.rs 2026-10-26 20:54:02 +00:00
aiongg a1d7b85a27 refactor(engine): move cmd/table_mouse tests to cmd/table_mouse/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 8f32774c93 refactor(ui-egui): move mini_toolbar tests to mini_toolbar/tests.rs 2026-10-26 20:54:02 +00:00
aiongg b698872665 refactor(doc): move edit tests to edit/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 9ee1062dd5 refactor(ui-egui): move objects tests to objects/tests.rs 2026-10-26 20:54:02 +00:00
aiongg c94f586da2 refactor(engine): move cmd/citations tests to cmd/citations/tests.rs 2026-10-26 20:54:02 +00:00
aiongg e9c7dd609d refactor(ui-egui): move ribbon tests to ribbon/tests.rs 2026-10-26 20:54:02 +00:00
aiongg fc675615ba refactor(ui-egui): move file_dialogs tests to file_dialogs/tests.rs 2026-10-26 20:54:02 +00:00
aiongg c62e1086d7 refactor(ui-egui): move dialogs_layout tests to dialogs_layout/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 49c1b35fec refactor(engine): move cmd/protect tests to cmd/protect/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 509f161170 refactor(ui-egui): move backstage tests to backstage/tests.rs 2026-10-26 20:54:02 +00:00
aiongg e8692c4aff refactor(engine): move cmd/review tests to cmd/review/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 0cc8533624 refactor(formats): move odt tests to odt/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 3a3d4f60e2 refactor(engine): move cmd/objects tests to cmd/objects/tests.rs 2026-10-26 20:54:02 +00:00
aiongg 3ec3e94546 refactor(render): move lib tests to tests.rs 2026-10-26 20:54:01 +00:00
aiongg 78ff598282 refactor(formats): move rtf tests to rtf/tests.rs 2026-10-26 20:54:01 +00:00
aiongg f9a5dbb670 refactor(ui-egui): move dialogs tests to dialogs/tests.rs 2026-10-26 20:54:01 +00:00
aiongg 2bc90d50bf refactor(ui-egui): move canvas tests to canvas/tests.rs 2026-10-26 20:54:01 +00:00
aiongg d23b025974 refactor(ui-egui): move lib tests to tests.rs 2026-10-26 20:54:01 +00:00
aiongg 4158cbbc1f refactor(ui-egui): move keys tests to keys/tests.rs 2026-10-26 20:54:01 +00:00
aiongg 1547b72a92 refactor(ui-egui): move crates/ui-egui/src/i18n/mod.rs to crates/ui-egui/src/i18n.rs 2026-10-26 20:54:01 +00:00
aiongg 7466008646 refactor(docx): move crates/docx/src/write/mod.rs to crates/docx/src/write.rs 2026-10-26 20:54:01 +00:00
aiongg 07a6cac5bd refactor(docx): move crates/docx/src/read/mod.rs to crates/docx/src/read.rs 2026-10-26 20:54:01 +00:00
aiongg 5dd48bc7a0 refactor(engine): move crates/engine/src/cmd/mod.rs to crates/engine/src/cmd.rs 2026-10-26 20:54:01 +00:00
aiongg 33350b3833 test: scan source directories recursively 2026-10-26 20:54:01 +00:00
Dylan M. Taylor 02120c84f7 Sonar: reduce cognitive complexity in replace parser and reroute tests 2026-10-26 16:52:04 -04:00
skylar a782b534f4 chore: merge current main for nextest update 2026-10-26 14:38:32 -06:00
skylar 05275a5020 ci: require nextest 0.9.150
Use the fixed release for GHSA-vc25-qg58-w57c.
2026-10-26 14:13:06 -06:00
Dylan M. Taylor d859c673c1 Sonar: reduce cognitive complexity in highlight, review and outline commands 2026-10-26 16:01:36 -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
682 changed files with 152903 additions and 116952 deletions
+3
View File
@@ -0,0 +1,3 @@
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
nextest-version = "0.9.150"
+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
+24
View File
@@ -21,3 +21,27 @@ jobs:
lint:
# 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/packaging-lint.yml@main
windows-install-policy:
timeout-minutes: 10
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
dotnet tool install --tool-path $tools wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
$tools >> $env:GITHUB_PATH
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
foreach ($extension in @($policy.extensions)) {
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
}
- name: Build and check non-executable installer fixtures
shell: pwsh
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
+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' }}
+5 -1
View File
@@ -11,7 +11,7 @@ on:
default: ""
permissions:
contents: write
contents: read
concurrency:
group: release-${{ github.ref }}
@@ -19,11 +19,15 @@ concurrency:
jobs:
release:
# Only this job publishes the GitHub release, so only it gets write access.
permissions:
contents: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: wordcraft
app-title: WordCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
+2
View File
@@ -31,3 +31,5 @@ jobs:
with:
app-name: wordcraft
app-dir: WordCraft
install-relative-path: Programs/ArtCraft/WordCraft
install-scope: perUser
+6 -1
View File
@@ -50,7 +50,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
Before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every arc of work that builds, with a task id in the message (`M2.1: line spacing dialog`).
## Typing behaves like Word
Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's observed behaviour in [`docs/typing-parity.md`](docs/typing-parity.md), pinned by `crates/engine/src/tests_typing.rs` (keys go through the same commands as the keyboard). Change behaviour, doc and tests together; a typing fix lands with a test there. Compare headlessly (`ui_shot`); don't drive GUIs with keystrokes on the shared machine.
Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's observed behaviour in [`docs/typing-parity.md`](docs/typing-parity.md), pinned by `crates/engine/src/tests/typing.rs` (keys go through the same commands as the keyboard). Change behaviour, doc and tests together; a typing fix lands with a test there. Compare headlessly (`ui_shot`); don't drive GUIs with keystrokes on the shared machine.
## Running and looking at the app
- `cargo run --release -p wordcraft -- --sample --control 7981` (sample document + control channel).
@@ -82,3 +82,8 @@ Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.co
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
+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) |
+7 -2
View File
@@ -13,7 +13,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
- **Input-derived numbers are hostile** (files, commands, MCP/control params): `get()` instead of `[i]`, slice strings only at char boundaries, checked/saturating arithmetic, clamp sizes, cap allocations, bound recursion.
- **Last-resort guard:** `Session::run` turns an escaped panic into an error and restores the document. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test. `crates/engine/src/tests.rs::hostile_params_never_panic` runs every command with junk params — keep it green.
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it. Tests may `unwrap` (`clippy.toml`).
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it and add `[lints]` with `workspace = true` to their `Cargo.toml`. Tests may `unwrap` (`clippy.toml`).
## Assets: absolutely no Microsoft (or Adobe/Avid/Autodesk) iconography or images
**This rule is non-negotiable.** Breaking it is the worst mistake you can make in this repo.
@@ -49,7 +49,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
Before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every arc of work that builds, with a task id in the message (`M2.1: line spacing dialog`).
## Typing behaves like Word
Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's observed behaviour in [`docs/typing-parity.md`](docs/typing-parity.md), pinned by `crates/engine/src/tests_typing.rs` (keys go through the same commands as the keyboard). Change behaviour, doc and tests together; a typing fix lands with a test there. Compare headlessly (`ui_shot`); don't drive GUIs with keystrokes on the shared machine.
Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's observed behaviour in [`docs/typing-parity.md`](docs/typing-parity.md), pinned by `crates/engine/src/tests/typing.rs` (keys go through the same commands as the keyboard). Change behaviour, doc and tests together; a typing fix lands with a test there. Compare headlessly (`ui_shot`); don't drive GUIs with keystrokes on the shared machine.
## Running and looking at the app
- `cargo run --release -p wordcraft -- --sample --control 7981` (sample document + control channel).
@@ -63,3 +63,8 @@ Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's o
## Roadmap
Progress docs follow [`craftrules/standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md): `ROADMAP.md` (stage, headline numbers, progress log), `docs/target-app-parity.md` (the assessment), `docs/gaps.md` (ranked work list), `docs/roadmap.md` (milestones, Current focus), `docs/architecture.md`, and the parity checklists `docs/file-format-parity.md`, `docs/layout-parity.md`, `docs/typing-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the gap, the parity doc and the progress log, and bump each touched doc's status line and revision history.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
Generated
+507 -337
View File
File diff suppressed because it is too large Load Diff
+39 -11
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.11.0"
version = "0.12.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -52,13 +52,19 @@ 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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", 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,25 +76,32 @@ 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"
[workspace.lints.clippy]
cognitive_complexity = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
too_many_arguments = "deny"
type_complexity = "allow"
# Line tables keep file and line in panics and backtraces; full debug info
@@ -110,4 +123,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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
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.
+5 -1
View File
@@ -227,7 +227,11 @@ closer pagination fidelity, a stability sweep and native printing. Details and e
| 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` |
Installers and executables are code-signed.
The installer is one dual-scope package. By default it installs for your account only,
into `%LOCALAPPDATA%\Programs\ArtCraft\WordCraft`, and needs no administrator rights; choosing
"Everyone on this computer" on the first page installs into `C:\Program Files\ArtCraft\WordCraft` (`Program Files (x86)` for the 32-bit build)
and asks for an administrator. Install only one copy: a copy for another scope is refused, so
uninstall first to change who WordCraft is installed for. 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
+86 -2
View File
File diff suppressed because one or more lines are too long
+26
View File
@@ -0,0 +1,26 @@
# Security policy
## Reporting a vulnerability
Please report security problems privately through GitHub:
**[Report a vulnerability](https://github.com/storytold/wordcraft/security/advisories/new)**
(Security tab › Advisories › Report a vulnerability).
Don't open a public issue or pull request for a vulnerability until a fix is released.
Useful to include:
- the WordCraft version or commit, and the platform (macOS, Windows, Linux, BSD, web);
- what an attacker can do, and what they need (a crafted document, MCP or control-channel access, a local account…);
- steps or a sample file that reproduce it.
We'll acknowledge the report, keep you updated while we work on a fix, and credit you in the advisory unless you'd rather not be named.
## Supported versions
Fixes go into `main` and ship in the next release. Older releases don't get backported fixes.
## Scope
In scope: anything that lets a document, file, command, MCP or control-channel request, or network response read or write files it shouldn't, run code, leak data, or crash WordCraft and lose the user's work.
Crashes from hostile input count, because WordCraft treats every panic as a bug (see [Never crash](AGENTS.md)).
+59 -567
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,29 @@ fn valid_connect_addr(addr: &str) -> bool {
!host.is_empty() && port.parse::<u16>().is_ok()
}
fn load_run_script(rest: &[String], script_path: Option<&Path>) -> Result<Option<script::Script>, Failure> {
match arg_value(rest, "--script") {
Some(path) if !path.starts_with("--") => {
let source = script_path.unwrap_or(Path::new(&path));
Ok(Some(script::load(source)?))
}
_ if rest.iter().any(|arg| arg == "--script") => Err("run: --script requires a value".into()),
_ => Ok(None),
}
}
fn run_source_paths(rest: &[String], script: Option<&script::Script>) -> (Option<String>, Option<String>) {
// A source named on the command line wins over both of the script's.
let (mut from_file, mut from_template) = (arg_value(rest, "--file"), arg_value(rest, "--template"));
if from_file.is_none() && from_template.is_none() {
from_file = script.and_then(|loaded| loaded.file.clone());
from_template = script.and_then(|loaded| loaded.template.clone());
}
(from_file, from_template)
}
/// 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)?;
@@ -468,24 +495,19 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
let page = parse_page_arg("run", &rest)?;
let scale = parse_scale_arg("run", &rest)?;
let password = password_from(&rest)?;
let script = match arg_value(&rest, "--script") {
Some(path) if !path.starts_with("--") => Some(script::load(script_path.as_deref().unwrap_or(Path::new(&path)))?),
_ if rest.iter().any(|a| a == "--script") => return Err("run: --script requires a value".into()),
_ => None,
};
// A source named on the command line wins over both of the script's.
let (mut from_file, mut from_template) = (arg_value(&rest, "--file"), arg_value(&rest, "--template"));
if from_file.is_none() && from_template.is_none() {
from_file = script.as_ref().and_then(|c| c.file.clone());
from_template = script.as_ref().and_then(|c| c.template.clone());
}
let script = load_run_script(&rest, script_path.as_deref())?;
let (from_file, from_template) = run_source_paths(&rest, script.as_ref());
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 +679,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 +709,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",
@@ -725,555 +768,4 @@ fn main() -> ExitCode {
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
fn args(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).into()).collect()
}
#[test]
fn validators_accept_only_the_documented_options() {
assert!(validate_options("info", &args(&["file.docx"]), PASSWORD_OPTIONS).is_ok());
assert!(validate_options("render", &args(&["in.docx", "out.png", "--page", "2", "--scale", "1.5"]), RENDER_OPTIONS).is_ok());
assert!(validate_options("run", &args(&["--cmd", "text.insert={}", "--cmd", "edit.undo", "--print"]), RUN_OPTIONS).is_ok());
assert!(validate_options("commands", &args(&["--json"]), COMMANDS_OPTIONS).is_ok());
assert!(validate_options("parity", &args(&["--markdown"]), PARITY_OPTIONS).is_ok());
assert!(validate_options("mcp", &args(&["--connect", "127.0.0.1:9000"]), MCP_OPTIONS).is_ok());
assert!(validate_options("render", &args(&["in.docx", "out.png", "--page"]), RENDER_OPTIONS).is_ok());
assert!(
validate_options(
"zotero",
&args(&["refresh", "in.docx", "--cmd", "caret.docEnd", "--save", "out.docx", "--trace", "--port", "23119"]),
ZOTERO_OPTIONS
)
.is_ok()
);
}
#[test]
fn an_option_value_is_never_read_as_a_path() {
// `--password`'s value sits between the paths and would be taken for one without
// `positionals`, so `render` would write its picture over a file called after the password.
assert_eq!(positionals(&args(&["in.docx", "--password", "secret", "out.png"]), RENDER_OPTIONS), vec!["in.docx", "out.png"]);
assert_eq!(positionals(&args(&["in.docx", "out.png", "--page", "2"]), RENDER_OPTIONS), vec!["in.docx", "out.png"]);
// A bare flag takes no value, so the next argument stays a path.
assert_eq!(positionals(&args(&["--print", "in.docx"]), RUN_OPTIONS), vec!["in.docx"]);
// `convert` is the one with a value-taking flag between the two paths.
assert_eq!(positionals(&args(&["in.docx", "--format", "flatopc", "out.docx"]), CONVERT_OPTIONS), vec!["in.docx", "out.docx"]);
}
#[test]
fn the_password_comes_from_an_option_a_file_or_the_environment() {
assert_eq!(password_from(&args(&["f.docx"])), Ok(None));
assert_eq!(password_from(&args(&["f.docx", "--password", "s3cret"])), Ok(Some("s3cret".into())));
// `--password` wins over the other two sources.
assert_eq!(password_from(&args(&["--password", "a", "--password-file", "/nonexistent"])), Ok(Some("a".into())));
}
#[test]
fn a_password_file_loses_only_its_trailing_newline() {
let path = std::env::temp_dir().join("wordcraft-cli-password-test.txt");
std::fs::write(&path, "s3cret\r\n").unwrap();
let got = password_from(&args(&["f.docx", "--password-file", &path.to_string_lossy()]));
std::fs::remove_file(&path).ok();
assert_eq!(got, Ok(Some("s3cret".into())));
}
#[test]
fn a_missing_password_file_is_named_without_the_password() {
let err = password_from(&args(&["f.docx", "--password-file", "/nonexistent/secret.txt"])).unwrap_err();
assert!(err.contains("--password-file"), "{err}");
}
#[test]
fn unknown_options_are_rejected_before_file_access() {
assert_eq!(run(&args(&["info", "missing.docx", "--passwrod", "x"])), Err("info: unknown option --passwrod".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--sclae", "2"])), Err("render: unknown option --sclae".into()));
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
}
#[test]
fn help_or_h_after_a_subcommand_asks_for_the_usage() {
for cmd in SUBCOMMANDS {
assert!(wants_help(cmd, &args(&["in.docx", "--help"])), "{cmd}");
assert!(wants_help(cmd, &args(&["-h"])), "{cmd}");
assert!(!wants_help(cmd, &args(&["in.docx", "--hepl"])), "{cmd}");
}
assert!(!wants_help("frobnicate", &args(&["--help"])));
}
#[test]
fn usage_lists_exactly_the_subcommands() {
let listed: Vec<&str> = USAGE
.lines()
.filter_map(|line| line.strip_prefix(" wordcraft-cli ")?.split_whitespace().next())
.filter(|word| !word.starts_with(['-', '[']))
.collect();
assert_eq!(listed, SUBCOMMANDS);
}
#[test]
fn text_keeps_bullet_and_number_labels() {
let doc = wordcraft_engine::io::open_bytes("lists.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap();
assert_eq!(listed_text(&doc).unwrap(), "\u{2022}\talpha\n\u{2022}\tbeta\n1.\tfirst\n2.\tsecond");
}
#[test]
fn author_flag_names_the_headless_session() {
assert!(validate_options("run", &args(&["--author", "Claude (copyedit)", "--cmd", "edit.undo"]), RUN_OPTIONS).is_ok());
assert!(validate_options("mcp", &args(&["--author", "Claude"]), MCP_OPTIONS).is_ok());
let mut s = Session::new(wordcraft_doc::Document::new());
assert!(set_author(&mut s, " Claude (copyedit) ").is_ok());
assert_eq!(s.author, "Claude (copyedit)");
assert!(set_author(&mut s, " ").is_err_and(|e| e.starts_with("--author:")));
assert!(run(&args(&["run", "--author", "Claude", "--cmd", "review.trackChanges={\"value\":true}"])).is_ok());
// The app's user name is the person's own; a bridge flag must not rewrite it.
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.to_string().contains("--author")));
// A malformed address fails at startup, not on every retried call.
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
assert!(run(&args(&["mcp", "--connect", "notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
// The flag goes through the same protection check as the command.
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
let before = s.author.clone();
assert!(set_author(&mut s, "Claude").is_err());
assert_eq!(s.author, before);
assert!(s.run("file.setAuthor", &json!({"name": "Claude"})).is_err());
}
#[test]
fn author_flag_cannot_claim_a_named_exception_after_protection() {
use wordcraft_doc::{CharProps, InlineObject, StoryRef};
let mut s = Session::new(wordcraft_doc::Document::from_text("Named exception."));
let end = s.doc.end_of(StoryRef::Body);
s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap();
let start = s.doc.start_of(StoryRef::Body);
s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: "Alice".into() }, &CharProps::default()).unwrap();
assert_eq!(s.doc.perm_ranges().len(), 1);
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
s.run("select.text", &json!({"text": "Named"})).unwrap();
assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err());
assert!(set_author(&mut s, "Alice").is_err(), "--author changed the identity used for a protected exception");
assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err());
}
#[test]
fn automation_root_flags_are_taken_out_of_the_mcp_arguments() {
let base = std::env::temp_dir().join(format!("wc-cli-roots-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("in")).unwrap();
std::fs::create_dir_all(base.join("out")).unwrap();
let (read, write) = (base.join("in").to_string_lossy().into_owned(), base.join("out").to_string_lossy().into_owned());
let (roots, left) =
automation_roots("mcp", &args(&["--automation-read-root", &read, "--author", "Claude", &format!("--automation-write-root={write}")]))
.unwrap();
let roots = roots.unwrap();
assert_eq!(left, args(&["--author", "Claude"]));
assert!(roots.read_root().is_some() && roots.write_root().is_some());
assert!(validate_options("mcp", &left, MCP_OPTIONS).is_ok());
// A root left out grants none of its access.
let (roots, _) = automation_roots("mcp", &args(&["--automation-read-root", &read])).unwrap();
let roots = roots.unwrap();
assert!(roots.write_root().is_none());
assert!(
roots.check(&format!("{write}/x.docx"), wordcraft_engine::file_access::Access::Write).unwrap_err().contains("write authority is absent")
);
// No flags: nothing is confined and the arguments pass through.
let (roots, left) = automation_roots("mcp", &args(&["--connect", "127.0.0.1:9"])).unwrap();
assert!(roots.is_none());
assert_eq!(left, args(&["--connect", "127.0.0.1:9"]));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn automation_root_flags_reject_bad_operands_and_missing_folders() {
for bad in [
args(&["--automation-read-root"]),
args(&["--automation-read-root", "--author"]),
args(&["--automation-write-root="]),
args(&["--automation-read-root", "a", "--automation-read-root=b"]),
] {
assert!(automation_roots("mcp", &bad).is_err(), "{bad:?}");
}
let e = automation_roots("mcp", &args(&["--automation-read-root", "/definitely/not/a/folder"])).err().unwrap();
assert!(e.starts_with("mcp: cannot open the automation read root"), "{e}");
// Through the command line: an error, not a server that quietly runs unconfined.
assert!(run(&args(&["mcp", "--automation-write-root", "/definitely/not/a/folder"])).is_err());
}
#[cfg(unix)]
#[test]
fn only_the_opened_file_may_be_other_than_unicode() {
use std::os::unix::ffi::OsStringExt;
let latin1 = || OsString::from_vec(b"caf\xe9.docx".to_vec());
let ok = |raw: Vec<OsString>| text_args(&raw).unwrap().1;
assert_eq!(ok(vec!["zotero".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["render".into(), latin1(), "out.png".into(), "--page".into(), "2".into()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["run".into(), "--print".into(), "--file".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["info".into(), "in.docx".into()]), None);
// Every command that opens a file reaches the open with it (no such file here), so
// `OPENS_FILE` and the commands' own arguments agree.
let missing = || OsString::from_vec(b"/definitely/not/caf\xe9.docx".to_vec());
let mut opening: Vec<Vec<OsString>> = OPENS_FILE
.iter()
.map(|(cmd, n)| [vec![(*cmd).into()], vec![OsString::from("refresh"); *n], vec![missing(), "out.png".into()]].concat())
.collect();
opening.push(vec!["run".into(), "--file".into(), missing()]);
for raw in opening {
let e = run(&raw).unwrap_err().to_string();
assert!(e.contains("No such file"), "{raw:?}: {e}");
}
for (raw, n) in [
(vec!["zotero".into(), latin1(), "in.docx".into()], 2),
(vec!["render".into(), "in.docx".into(), latin1()], 3),
(vec!["run".into(), "--save".into(), latin1()], 3),
(vec!["commands".into(), latin1()], 2),
] {
assert_eq!(text_args(&raw).unwrap_err(), format!("argument {n} is not valid Unicode: caf\u{FFFD}.docx"));
}
}
#[test]
fn automation_roots_are_an_error_with_connect() {
let dir = std::env::temp_dir().to_string_lossy().into_owned();
let e = run(&args(&["mcp", "--connect", "127.0.0.1:9", "--automation-read-root", &dir])).unwrap_err().to_string();
assert!(e.contains("--automation-read-root") && e.contains("headless"), "{e}");
}
#[test]
fn render_rejects_missing_or_invalid_page_and_scale() {
// Missing operands
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page"])), Err("render: --page requires a value".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale"])), Err("render: --scale requires a value".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "--scale", "2"])), Err("render: --page requires a value".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale", "--page", "1"])), Err("render: --scale requires a value".into()));
// Non-numeric operands
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--page", "banana"])),
Err("render: invalid --page `banana`: not a valid integer".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "banana"])),
Err("render: invalid --scale `banana`: not a number".into())
);
// Out-of-range values
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--page", "0"])),
Err("render: invalid --page `0`: page numbers start at 1".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--page", "-1"])),
Err("render: invalid --page `-1`: not a valid integer".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "0"])),
Err("render: invalid --scale `0`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "-2"])),
Err("render: invalid --scale `-2`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "NaN"])),
Err("render: invalid --scale `NaN`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "inf"])),
Err("render: invalid --scale `inf`: scale must be a positive number".into())
);
// Duplicate options
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "1", "--page", "2"])), Err("render: duplicate --page option".into()));
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "1", "--scale", "2"])),
Err("render: duplicate --scale option".into())
);
}
#[test]
fn run_rejects_missing_or_invalid_page_and_scale() {
// Missing operands
assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--page"])), Err("run: --page requires a value".into()));
assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale"])), Err("run: --scale requires a value".into()));
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "--scale", "2"])),
Err("run: --page requires a value".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "--page", "1"])),
Err("run: --scale requires a value".into())
);
// Non-numeric operands
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "banana"])),
Err("run: invalid --page `banana`: not a valid integer".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "banana"])),
Err("run: invalid --scale `banana`: not a number".into())
);
// Out-of-range values
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "0"])),
Err("run: invalid --page `0`: page numbers start at 1".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "-1"])),
Err("run: invalid --page `-1`: not a valid integer".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "0"])),
Err("run: invalid --scale `0`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "-1"])),
Err("run: invalid --scale `-1`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "NaN"])),
Err("run: invalid --scale `NaN`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "inf"])),
Err("run: invalid --scale `inf`: scale must be a positive number".into())
);
// Duplicate options
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "1", "--page", "2"])),
Err("run: duplicate --page option".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "1", "--scale", "2"])),
Err("run: duplicate --scale option".into())
);
}
#[test]
fn render_and_run_valid_options_and_defaults_produce_files() {
let dir = std::env::temp_dir().join(format!("wc-cli-render-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let docx_path = dir.join("sample.docx");
// Create sample docx via `run --save sample.docx`
assert!(run(&args(&["run", "--template", "sample", "--save", &docx_path.to_string_lossy()])).is_ok());
assert!(docx_path.exists());
let docx_str = docx_path.to_string_lossy().into_owned();
// 1. render with defaults
let out_default = dir.join("out_default.png");
assert!(run(&args(&["render", &docx_str, &out_default.to_string_lossy()])).is_ok());
assert!(out_default.exists());
// 2. render with valid --page and --scale
let out_valid = dir.join("out_valid.png");
assert!(run(&args(&["render", &docx_str, &out_valid.to_string_lossy(), "--page", "1", "--scale", "2"])).is_ok());
assert!(out_valid.exists());
// 3. render with different flag order
let out_reordered = dir.join("out_reordered.png");
assert!(run(&args(&["render", &docx_str, &out_reordered.to_string_lossy(), "--scale", "1", "--page", "1"])).is_ok());
assert!(out_reordered.exists());
// 4. render failure leaves no output file
let out_invalid = dir.join("out_invalid.png");
assert!(run(&args(&["render", &docx_str, &out_invalid.to_string_lossy(), "--page", "banana"])).is_err());
assert!(!out_invalid.exists());
// 5. run --save out.png with defaults
let run_default = dir.join("run_default.png");
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_default.to_string_lossy()])).is_ok());
assert!(run_default.exists());
// 6. run --save out.png with --page and --scale
let run_valid = dir.join("run_valid.png");
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_valid.to_string_lossy(), "--page", "1", "--scale", "1"])).is_ok());
assert!(run_valid.exists());
// 7. run --save out.png with different flag order
let run_reordered = dir.join("run_reordered.png");
assert!(run(&args(&["run", "--file", &docx_str, "--scale", "1", "--page", "1", "--save", &run_reordered.to_string_lossy()])).is_ok());
assert!(run_reordered.exists());
// 8. run --save out.png failure leaves no output file
let run_invalid = dir.join("run_invalid.png");
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_invalid.to_string_lossy(), "--scale", "banana"])).is_err());
assert!(!run_invalid.exists());
let _ = std::fs::remove_dir_all(dir);
}
/// A scratch folder that removes itself, even when the test fails.
struct ScriptDir(PathBuf);
impl std::ops::Deref for ScriptDir {
type Target = Path;
fn deref(&self) -> &Path {
&self.0
}
}
impl Drop for ScriptDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn script_dir(tag: &str) -> ScriptDir {
let dir = std::env::temp_dir().join(format!("wc-cli-script-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
ScriptDir(dir)
}
fn write_script(dir: &Path, text: &str) -> String {
let p = dir.join("job.json");
std::fs::write(&p, text).unwrap();
p.to_string_lossy().into_owned()
}
#[test]
fn script_builds_a_document_and_saves_docx_and_pdf_relative_to_the_script() {
let dir = script_dir("two");
let script = write_script(
&dir,
r#"{"template":"blank","commands":[{"command":"text.insert","params":{"text":"Hello script"}},{"command":"format.bold"}],"save":["out.docx","out.pdf"]}"#,
);
run(&args(&["run", "--script", &script])).unwrap();
assert!(std::fs::read(dir.join("out.pdf")).unwrap().starts_with(b"%PDF"));
let doc = open(&dir.join("out.docx"), None).unwrap().doc;
let json = serde_json::to_string(&doc).unwrap();
assert!(json.contains("Hello script") && json.contains("\"bold\":true"));
}
#[test]
fn script_with_a_bad_output_writes_no_output() {
let dir = script_dir("badout");
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","b.xyz"]}"#);
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
assert_eq!(e.matches("b.xyz").count(), 1, "{e}");
assert!(!dir.join("a.docx").exists());
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","nope/b.docx"]}"#);
assert!(run(&args(&["run", "--script", &script])).is_err());
assert!(!dir.join("a.docx").exists());
}
#[test]
fn script_with_a_folder_as_output_writes_no_output() {
let dir = script_dir("folderout");
std::fs::create_dir(dir.join("b.pdf")).unwrap();
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","b.pdf"]}"#);
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
assert!(e.contains("b.pdf"), "{e}");
assert!(!dir.join("a.docx").exists());
}
/// APFS refuses file names that aren't valid UTF-8, so the fixture can't be made on macOS
/// (as in `tests/it/non_utf8_args.rs`).
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn script_file_name_need_not_be_unicode() {
use std::os::unix::ffi::OsStringExt;
let dir = script_dir("latin1");
let mut name = dir.as_os_str().to_os_string().into_vec();
name.extend_from_slice(b"/caf\xe9.json");
let path = PathBuf::from(OsString::from_vec(name));
std::fs::write(&path, r#"{"commands":["caret.docEnd"],"save":["o.txt"]}"#).unwrap();
let raw = vec![OsString::from("run"), OsString::from("--script"), path.into_os_string()];
run(&raw).unwrap();
assert!(dir.join("o.txt").exists());
}
#[test]
fn command_line_template_wins_over_the_scripts_file() {
let dir = script_dir("source");
let input = dir.join("in.docx");
run(&args(&["run", "--template", "blank", "--cmd", "text.insert={\"text\":\"FROM FILE\"}", "--save", &input.to_string_lossy()])).unwrap();
let script = write_script(&dir, r#"{"file":"in.docx","commands":["caret.docEnd"]}"#);
let (a, b) = (dir.join("a.txt"), dir.join("b.txt"));
run(&args(&["run", "--script", &script, "--template", "blank", "--save", &a.to_string_lossy()])).unwrap();
assert!(!std::fs::read_to_string(&a).unwrap().contains("FROM FILE"));
run(&args(&["run", "--script", &script, "--save", &b.to_string_lossy()])).unwrap();
assert!(std::fs::read_to_string(&b).unwrap().contains("FROM FILE"));
}
#[test]
fn script_value_missing_is_reported() {
let e = run(&args(&["run", "--script", "--cmd", "caret.docEnd"])).unwrap_err().to_string();
assert!(e.contains("--script requires a value"), "{e}");
}
#[test]
fn script_reads_from_a_stream_and_caps_its_size() {
let s = script::load_from(&br#"{"commands":["caret.docEnd"],"save":["o.docx"]}"#[..], "-", None).unwrap();
assert_eq!((s.commands.len(), s.save), (1, vec!["o.docx".to_string()]));
let big = std::io::repeat(b' ').take(script::MAX_SCRIPT_BYTES + 10);
let e = script::load_from(big, "-", None).unwrap_err();
assert!(e.contains("too large"), "{e}");
assert!(script::load_from(&b"{"[..], "-", None).is_err());
}
#[test]
fn script_commands_run_before_cmd_flags() {
let dir = script_dir("order");
let script = write_script(&dir, r#"{"commands":[{"command":"text.insert","params":{"text":"first"}}]}"#);
let out = dir.join("o.txt");
run(&args(&["run", "--script", &script, "--cmd", "text.insert={\"text\":\" second\"}", "--save", &out.to_string_lossy()])).unwrap();
assert!(std::fs::read_to_string(&out).unwrap().contains("first second"));
}
#[test]
fn script_failure_names_the_command_and_writes_nothing() {
let dir = script_dir("fail");
let script =
write_script(&dir, r#"{"commands":[{"command":"text.insert","params":{"text":"x"}},{"command":"no.such.command"}],"save":["out.docx"]}"#);
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
assert!(e.contains("command 2") && e.contains("no.such.command"), "{e}");
assert!(!dir.join("out.docx").exists());
}
#[test]
fn script_rejects_junk_empty_missing_and_huge_input() {
let dir = script_dir("junk");
for text in [
"not json",
"[]",
"{}",
r#"{"commands":[]}"#,
r#"{"commands":[{"params":{}}]}"#,
r#"{"commands":[1]}"#,
r#"{"commands":[{"command":"a"}],"save":[1]}"#,
] {
let script = write_script(&dir, text);
assert!(run(&args(&["run", "--script", &script])).is_err(), "{text}");
}
assert!(run(&args(&["run", "--script", &dir.join("missing.json").to_string_lossy()])).is_err());
let huge = write_script(&dir, &" ".repeat(5 * 1024 * 1024));
let e = run(&args(&["run", "--script", &huge])).unwrap_err().to_string();
assert!(e.contains("too large"), "{e}");
}
#[test]
fn script_parse_resolves_paths_against_the_base() {
let s = script::parse(r#"{"file":"in.docx","commands":[{"command":"edit.undo"}],"save":["/abs/o.pdf","o.docx"]}"#, Some(Path::new("/base")))
.unwrap();
let joined = |name: &str| Path::new("/base").join(name).to_string_lossy().into_owned();
assert_eq!(s.file, Some(joined("in.docx")));
assert_eq!(s.save, vec!["/abs/o.pdf".to_string(), joined("o.docx")]);
let s = script::parse(r#"{"commands":["caret.docEnd"],"save":["o.docx"]}"#, None).unwrap();
assert_eq!(s.save, vec!["o.docx".to_string()]);
assert_eq!(s.commands.len(), 1);
}
}
mod tests;
+545
View File
@@ -0,0 +1,545 @@
use super::*;
use std::io::Read;
fn args(values: &[&str]) -> Vec<String> {
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());
assert!(validate_options("render", &args(&["in.docx", "out.png", "--page", "2", "--scale", "1.5"]), RENDER_OPTIONS).is_ok());
assert!(validate_options("run", &args(&["--cmd", "text.insert={}", "--cmd", "edit.undo", "--print"]), RUN_OPTIONS).is_ok());
assert!(validate_options("commands", &args(&["--json"]), COMMANDS_OPTIONS).is_ok());
assert!(validate_options("parity", &args(&["--markdown"]), PARITY_OPTIONS).is_ok());
assert!(validate_options("mcp", &args(&["--connect", "127.0.0.1:9000"]), MCP_OPTIONS).is_ok());
assert!(validate_options("render", &args(&["in.docx", "out.png", "--page"]), RENDER_OPTIONS).is_ok());
assert!(
validate_options(
"zotero",
&args(&["refresh", "in.docx", "--cmd", "caret.docEnd", "--save", "out.docx", "--trace", "--port", "23119"]),
ZOTERO_OPTIONS
)
.is_ok()
);
}
#[test]
fn an_option_value_is_never_read_as_a_path() {
// `--password`'s value sits between the paths and would be taken for one without
// `positionals`, so `render` would write its picture over a file called after the password.
assert_eq!(positionals(&args(&["in.docx", "--password", "secret", "out.png"]), RENDER_OPTIONS), vec!["in.docx", "out.png"]);
assert_eq!(positionals(&args(&["in.docx", "out.png", "--page", "2"]), RENDER_OPTIONS), vec!["in.docx", "out.png"]);
// A bare flag takes no value, so the next argument stays a path.
assert_eq!(positionals(&args(&["--print", "in.docx"]), RUN_OPTIONS), vec!["in.docx"]);
// `convert` is the one with a value-taking flag between the two paths.
assert_eq!(positionals(&args(&["in.docx", "--format", "flatopc", "out.docx"]), CONVERT_OPTIONS), vec!["in.docx", "out.docx"]);
}
#[test]
fn the_password_comes_from_an_option_a_file_or_the_environment() {
assert_eq!(password_from(&args(&["f.docx"])), Ok(None));
assert_eq!(password_from(&args(&["f.docx", "--password", "s3cret"])), Ok(Some("s3cret".into())));
// `--password` wins over the other two sources.
assert_eq!(password_from(&args(&["--password", "a", "--password-file", "/nonexistent"])), Ok(Some("a".into())));
}
#[test]
fn a_password_file_loses_only_its_trailing_newline() {
let path = std::env::temp_dir().join("wordcraft-cli-password-test.txt");
std::fs::write(&path, "s3cret\r\n").unwrap();
let got = password_from(&args(&["f.docx", "--password-file", &path.to_string_lossy()]));
std::fs::remove_file(&path).ok();
assert_eq!(got, Ok(Some("s3cret".into())));
}
#[test]
fn a_missing_password_file_is_named_without_the_password() {
let err = password_from(&args(&["f.docx", "--password-file", "/nonexistent/secret.txt"])).unwrap_err();
assert!(err.contains("--password-file"), "{err}");
}
#[test]
fn unknown_options_are_rejected_before_file_access() {
assert_eq!(run(&args(&["info", "missing.docx", "--passwrod", "x"])), Err("info: unknown option --passwrod".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--sclae", "2"])), Err("render: unknown option --sclae".into()));
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
}
#[test]
fn help_or_h_after_a_subcommand_asks_for_the_usage() {
for cmd in SUBCOMMANDS {
assert!(wants_help(cmd, &args(&["in.docx", "--help"])), "{cmd}");
assert!(wants_help(cmd, &args(&["-h"])), "{cmd}");
assert!(!wants_help(cmd, &args(&["in.docx", "--hepl"])), "{cmd}");
}
assert!(!wants_help("frobnicate", &args(&["--help"])));
}
#[test]
fn usage_lists_exactly_the_subcommands() {
let listed: Vec<&str> = USAGE
.lines()
.filter_map(|line| line.strip_prefix(" wordcraft-cli ")?.split_whitespace().next())
.filter(|word| !word.starts_with(['-', '[']))
.collect();
assert_eq!(listed, SUBCOMMANDS);
}
#[test]
fn text_keeps_bullet_and_number_labels() {
let doc = wordcraft_engine::io::open_bytes("lists.md", b"- alpha\n- beta\n\n1. first\n2. second\n").unwrap();
assert_eq!(listed_text(&doc).unwrap(), "\u{2022}\talpha\n\u{2022}\tbeta\n1.\tfirst\n2.\tsecond");
}
#[test]
fn author_flag_names_the_headless_session() {
assert!(validate_options("run", &args(&["--author", "Claude (copyedit)", "--cmd", "edit.undo"]), RUN_OPTIONS).is_ok());
assert!(validate_options("mcp", &args(&["--author", "Claude"]), MCP_OPTIONS).is_ok());
let mut s = Session::new(wordcraft_doc::Document::new());
assert!(set_author(&mut s, " Claude (copyedit) ").is_ok());
assert_eq!(s.author, "Claude (copyedit)");
assert!(set_author(&mut s, " ").is_err_and(|e| e.starts_with("--author:")));
assert!(run(&args(&["run", "--author", "Claude", "--cmd", "review.trackChanges={\"value\":true}"])).is_ok());
// The app's user name is the person's own; a bridge flag must not rewrite it.
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:9", "--author", "Claude"])).is_err_and(|e| e.to_string().contains("--author")));
// A malformed address fails at startup, not on every retried call.
assert!(run(&args(&["mcp", "--connect", "127.0.0.1:notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
assert!(run(&args(&["mcp", "--connect", "notaport"])).is_err_and(|e| e.to_string().contains("numeric port")));
// The flag goes through the same protection check as the command.
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
let before = s.author.clone();
assert!(set_author(&mut s, "Claude").is_err());
assert_eq!(s.author, before);
assert!(s.run("file.setAuthor", &json!({"name": "Claude"})).is_err());
}
#[test]
fn author_flag_cannot_claim_a_named_exception_after_protection() {
use wordcraft_doc::{CharProps, InlineObject, StoryRef};
let mut s = Session::new(wordcraft_doc::Document::from_text("Named exception."));
let end = s.doc.end_of(StoryRef::Body);
s.doc.insert_object(&end, InlineObject::PermEnd { id: 1 }, &CharProps::default()).unwrap();
let start = s.doc.start_of(StoryRef::Body);
s.doc.insert_object(&start, InlineObject::PermStart { id: 1, group: String::new(), editor: "Alice".into() }, &CharProps::default()).unwrap();
assert_eq!(s.doc.perm_ranges().len(), 1);
s.run("review.restrict", &json!({"mode": "readOnly"})).unwrap();
s.run("select.text", &json!({"text": "Named"})).unwrap();
assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err());
assert!(set_author(&mut s, "Alice").is_err(), "--author changed the identity used for a protected exception");
assert!(s.run("text.insert", &json!({"text": "Changed"})).is_err());
}
#[test]
fn automation_root_flags_are_taken_out_of_the_mcp_arguments() {
let base = std::env::temp_dir().join(format!("wc-cli-roots-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("in")).unwrap();
std::fs::create_dir_all(base.join("out")).unwrap();
let (read, write) = (base.join("in").to_string_lossy().into_owned(), base.join("out").to_string_lossy().into_owned());
let (roots, left) =
automation_roots("mcp", &args(&["--automation-read-root", &read, "--author", "Claude", &format!("--automation-write-root={write}")]))
.unwrap();
let roots = roots.unwrap();
assert_eq!(left, args(&["--author", "Claude"]));
assert!(roots.read_root().is_some() && roots.write_root().is_some());
assert!(validate_options("mcp", &left, MCP_OPTIONS).is_ok());
// A root left out grants none of its access.
let (roots, _) = automation_roots("mcp", &args(&["--automation-read-root", &read])).unwrap();
let roots = roots.unwrap();
assert!(roots.write_root().is_none());
assert!(roots.check(&format!("{write}/x.docx"), wordcraft_engine::file_access::Access::Write).unwrap_err().contains("write authority is absent"));
// No flags: nothing is confined and the arguments pass through.
let (roots, left) = automation_roots("mcp", &args(&["--connect", "127.0.0.1:9"])).unwrap();
assert!(roots.is_none());
assert_eq!(left, args(&["--connect", "127.0.0.1:9"]));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn automation_root_flags_reject_bad_operands_and_missing_folders() {
for bad in [
args(&["--automation-read-root"]),
args(&["--automation-read-root", "--author"]),
args(&["--automation-write-root="]),
args(&["--automation-read-root", "a", "--automation-read-root=b"]),
] {
assert!(automation_roots("mcp", &bad).is_err(), "{bad:?}");
}
let e = automation_roots("mcp", &args(&["--automation-read-root", "/definitely/not/a/folder"])).err().unwrap();
assert!(e.starts_with("mcp: cannot open the automation read root"), "{e}");
// Through the command line: an error, not a server that quietly runs unconfined.
assert!(run(&args(&["mcp", "--automation-write-root", "/definitely/not/a/folder"])).is_err());
}
#[cfg(unix)]
#[test]
fn only_the_opened_file_may_be_other_than_unicode() {
use std::os::unix::ffi::OsStringExt;
let latin1 = || OsString::from_vec(b"caf\xe9.docx".to_vec());
let ok = |raw: Vec<OsString>| text_args(&raw).unwrap().1;
assert_eq!(ok(vec!["zotero".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["render".into(), latin1(), "out.png".into(), "--page".into(), "2".into()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["run".into(), "--print".into(), "--file".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["info".into(), "in.docx".into()]), None);
// Every command that opens a file reaches the open with it (no such file here), so
// `OPENS_FILE` and the commands' own arguments agree.
let missing = || OsString::from_vec(b"/definitely/not/caf\xe9.docx".to_vec());
let mut opening: Vec<Vec<OsString>> = OPENS_FILE
.iter()
.map(|(cmd, n)| [vec![(*cmd).into()], vec![OsString::from("refresh"); *n], vec![missing(), "out.png".into()]].concat())
.collect();
opening.push(vec!["run".into(), "--file".into(), missing()]);
for raw in opening {
let e = run(&raw).unwrap_err().to_string();
assert!(e.contains("No such file"), "{raw:?}: {e}");
}
for (raw, n) in [
(vec!["zotero".into(), latin1(), "in.docx".into()], 2),
(vec!["render".into(), "in.docx".into(), latin1()], 3),
(vec!["run".into(), "--save".into(), latin1()], 3),
(vec!["commands".into(), latin1()], 2),
] {
assert_eq!(text_args(&raw).unwrap_err(), format!("argument {n} is not valid Unicode: caf\u{FFFD}.docx"));
}
}
#[test]
fn automation_roots_are_an_error_with_connect() {
let dir = std::env::temp_dir().to_string_lossy().into_owned();
let e = run(&args(&["mcp", "--connect", "127.0.0.1:9", "--automation-read-root", &dir])).unwrap_err().to_string();
assert!(e.contains("--automation-read-root") && e.contains("headless"), "{e}");
}
#[test]
fn render_rejects_missing_or_invalid_page_and_scale() {
// Missing operands
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page"])), Err("render: --page requires a value".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale"])), Err("render: --scale requires a value".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "--scale", "2"])), Err("render: --page requires a value".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale", "--page", "1"])), Err("render: --scale requires a value".into()));
// Non-numeric operands
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--page", "banana"])),
Err("render: invalid --page `banana`: not a valid integer".into())
);
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale", "banana"])), Err("render: invalid --scale `banana`: not a number".into()));
// Out-of-range values
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "0"])), Err("render: invalid --page `0`: page numbers start at 1".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "-1"])), Err("render: invalid --page `-1`: not a valid integer".into()));
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "0"])),
Err("render: invalid --scale `0`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "-2"])),
Err("render: invalid --scale `-2`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "NaN"])),
Err("render: invalid --scale `NaN`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["render", "missing.docx", "out.png", "--scale", "inf"])),
Err("render: invalid --scale `inf`: scale must be a positive number".into())
);
// Duplicate options
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--page", "1", "--page", "2"])), Err("render: duplicate --page option".into()));
assert_eq!(run(&args(&["render", "missing.docx", "out.png", "--scale", "1", "--scale", "2"])), Err("render: duplicate --scale option".into()));
}
#[test]
fn run_rejects_missing_or_invalid_page_and_scale() {
// Missing operands
assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--page"])), Err("run: --page requires a value".into()));
assert_eq!(run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale"])), Err("run: --scale requires a value".into()));
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "--scale", "2"])),
Err("run: --page requires a value".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "--page", "1"])),
Err("run: --scale requires a value".into())
);
// Non-numeric operands
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "banana"])),
Err("run: invalid --page `banana`: not a valid integer".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "banana"])),
Err("run: invalid --scale `banana`: not a number".into())
);
// Out-of-range values
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "0"])),
Err("run: invalid --page `0`: page numbers start at 1".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "-1"])),
Err("run: invalid --page `-1`: not a valid integer".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "0"])),
Err("run: invalid --scale `0`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "-1"])),
Err("run: invalid --scale `-1`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "NaN"])),
Err("run: invalid --scale `NaN`: scale must be a positive number".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "inf"])),
Err("run: invalid --scale `inf`: scale must be a positive number".into())
);
// Duplicate options
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--page", "1", "--page", "2"])),
Err("run: duplicate --page option".into())
);
assert_eq!(
run(&args(&["run", "--template", "sample", "--save", "out.png", "--scale", "1", "--scale", "2"])),
Err("run: duplicate --scale option".into())
);
}
#[test]
fn render_and_run_valid_options_and_defaults_produce_files() {
let dir = std::env::temp_dir().join(format!("wc-cli-render-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let docx_path = dir.join("sample.docx");
// Create sample docx via `run --save sample.docx`
assert!(run(&args(&["run", "--template", "sample", "--save", &docx_path.to_string_lossy()])).is_ok());
assert!(docx_path.exists());
let docx_str = docx_path.to_string_lossy().into_owned();
// 1. render with defaults
let out_default = dir.join("out_default.png");
assert!(run(&args(&["render", &docx_str, &out_default.to_string_lossy()])).is_ok());
assert!(out_default.exists());
// 2. render with valid --page and --scale
let out_valid = dir.join("out_valid.png");
assert!(run(&args(&["render", &docx_str, &out_valid.to_string_lossy(), "--page", "1", "--scale", "2"])).is_ok());
assert!(out_valid.exists());
// 3. render with different flag order
let out_reordered = dir.join("out_reordered.png");
assert!(run(&args(&["render", &docx_str, &out_reordered.to_string_lossy(), "--scale", "1", "--page", "1"])).is_ok());
assert!(out_reordered.exists());
// 4. render failure leaves no output file
let out_invalid = dir.join("out_invalid.png");
assert!(run(&args(&["render", &docx_str, &out_invalid.to_string_lossy(), "--page", "banana"])).is_err());
assert!(!out_invalid.exists());
// 5. run --save out.png with defaults
let run_default = dir.join("run_default.png");
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_default.to_string_lossy()])).is_ok());
assert!(run_default.exists());
// 6. run --save out.png with --page and --scale
let run_valid = dir.join("run_valid.png");
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_valid.to_string_lossy(), "--page", "1", "--scale", "1"])).is_ok());
assert!(run_valid.exists());
// 7. run --save out.png with different flag order
let run_reordered = dir.join("run_reordered.png");
assert!(run(&args(&["run", "--file", &docx_str, "--scale", "1", "--page", "1", "--save", &run_reordered.to_string_lossy()])).is_ok());
assert!(run_reordered.exists());
// 8. run --save out.png failure leaves no output file
let run_invalid = dir.join("run_invalid.png");
assert!(run(&args(&["run", "--file", &docx_str, "--save", &run_invalid.to_string_lossy(), "--scale", "banana"])).is_err());
assert!(!run_invalid.exists());
let _ = std::fs::remove_dir_all(dir);
}
/// A scratch folder that removes itself, even when the test fails.
struct ScriptDir(PathBuf);
impl std::ops::Deref for ScriptDir {
type Target = Path;
fn deref(&self) -> &Path {
&self.0
}
}
impl Drop for ScriptDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn script_dir(tag: &str) -> ScriptDir {
let dir = std::env::temp_dir().join(format!("wc-cli-script-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
ScriptDir(dir)
}
fn write_script(dir: &Path, text: &str) -> String {
let p = dir.join("job.json");
std::fs::write(&p, text).unwrap();
p.to_string_lossy().into_owned()
}
#[test]
fn script_builds_a_document_and_saves_docx_and_pdf_relative_to_the_script() {
let dir = script_dir("two");
let script = write_script(
&dir,
r#"{"template":"blank","commands":[{"command":"text.insert","params":{"text":"Hello script"}},{"command":"format.bold"}],"save":["out.docx","out.pdf"]}"#,
);
run(&args(&["run", "--script", &script])).unwrap();
assert!(std::fs::read(dir.join("out.pdf")).unwrap().starts_with(b"%PDF"));
let doc = open(&dir.join("out.docx"), None).unwrap().doc;
let json = serde_json::to_string(&doc).unwrap();
assert!(json.contains("Hello script") && json.contains("\"bold\":true"));
}
#[test]
fn script_with_a_bad_output_writes_no_output() {
let dir = script_dir("badout");
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","b.xyz"]}"#);
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
assert_eq!(e.matches("b.xyz").count(), 1, "{e}");
assert!(!dir.join("a.docx").exists());
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","nope/b.docx"]}"#);
assert!(run(&args(&["run", "--script", &script])).is_err());
assert!(!dir.join("a.docx").exists());
}
#[test]
fn script_with_a_folder_as_output_writes_no_output() {
let dir = script_dir("folderout");
std::fs::create_dir(dir.join("b.pdf")).unwrap();
let script = write_script(&dir, r#"{"commands":["caret.docEnd"],"save":["a.docx","b.pdf"]}"#);
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
assert!(e.contains("b.pdf"), "{e}");
assert!(!dir.join("a.docx").exists());
}
/// APFS refuses file names that aren't valid UTF-8, so the fixture can't be made on macOS
/// (as in `tests/it/non_utf8_args.rs`).
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn script_file_name_need_not_be_unicode() {
use std::os::unix::ffi::OsStringExt;
let dir = script_dir("latin1");
let mut name = dir.as_os_str().to_os_string().into_vec();
name.extend_from_slice(b"/caf\xe9.json");
let path = PathBuf::from(OsString::from_vec(name));
std::fs::write(&path, r#"{"commands":["caret.docEnd"],"save":["o.txt"]}"#).unwrap();
let raw = vec![OsString::from("run"), OsString::from("--script"), path.into_os_string()];
run(&raw).unwrap();
assert!(dir.join("o.txt").exists());
}
#[test]
fn command_line_template_wins_over_the_scripts_file() {
let dir = script_dir("source");
let input = dir.join("in.docx");
run(&args(&["run", "--template", "blank", "--cmd", "text.insert={\"text\":\"FROM FILE\"}", "--save", &input.to_string_lossy()])).unwrap();
let script = write_script(&dir, r#"{"file":"in.docx","commands":["caret.docEnd"]}"#);
let (a, b) = (dir.join("a.txt"), dir.join("b.txt"));
run(&args(&["run", "--script", &script, "--template", "blank", "--save", &a.to_string_lossy()])).unwrap();
assert!(!std::fs::read_to_string(&a).unwrap().contains("FROM FILE"));
run(&args(&["run", "--script", &script, "--save", &b.to_string_lossy()])).unwrap();
assert!(std::fs::read_to_string(&b).unwrap().contains("FROM FILE"));
}
#[test]
fn script_value_missing_is_reported() {
let e = run(&args(&["run", "--script", "--cmd", "caret.docEnd"])).unwrap_err().to_string();
assert!(e.contains("--script requires a value"), "{e}");
}
#[test]
fn script_reads_from_a_stream_and_caps_its_size() {
let s = script::load_from(&br#"{"commands":["caret.docEnd"],"save":["o.docx"]}"#[..], "-", None).unwrap();
assert_eq!((s.commands.len(), s.save), (1, vec!["o.docx".to_string()]));
let big = std::io::repeat(b' ').take(script::MAX_SCRIPT_BYTES + 10);
let e = script::load_from(big, "-", None).unwrap_err();
assert!(e.contains("too large"), "{e}");
assert!(script::load_from(&b"{"[..], "-", None).is_err());
}
#[test]
fn script_commands_run_before_cmd_flags() {
let dir = script_dir("order");
let script = write_script(&dir, r#"{"commands":[{"command":"text.insert","params":{"text":"first"}}]}"#);
let out = dir.join("o.txt");
run(&args(&["run", "--script", &script, "--cmd", "text.insert={\"text\":\" second\"}", "--save", &out.to_string_lossy()])).unwrap();
assert!(std::fs::read_to_string(&out).unwrap().contains("first second"));
}
#[test]
fn script_failure_names_the_command_and_writes_nothing() {
let dir = script_dir("fail");
let script =
write_script(&dir, r#"{"commands":[{"command":"text.insert","params":{"text":"x"}},{"command":"no.such.command"}],"save":["out.docx"]}"#);
let e = run(&args(&["run", "--script", &script])).unwrap_err().to_string();
assert!(e.contains("command 2") && e.contains("no.such.command"), "{e}");
assert!(!dir.join("out.docx").exists());
}
#[test]
fn script_rejects_junk_empty_missing_and_huge_input() {
let dir = script_dir("junk");
for text in [
"not json",
"[]",
"{}",
r#"{"commands":[]}"#,
r#"{"commands":[{"params":{}}]}"#,
r#"{"commands":[1]}"#,
r#"{"commands":[{"command":"a"}],"save":[1]}"#,
] {
let script = write_script(&dir, text);
assert!(run(&args(&["run", "--script", &script])).is_err(), "{text}");
}
assert!(run(&args(&["run", "--script", &dir.join("missing.json").to_string_lossy()])).is_err());
let huge = write_script(&dir, &" ".repeat(5 * 1024 * 1024));
let e = run(&args(&["run", "--script", &huge])).unwrap_err().to_string();
assert!(e.contains("too large"), "{e}");
}
#[test]
fn script_parse_resolves_paths_against_the_base() {
let s =
script::parse(r#"{"file":"in.docx","commands":[{"command":"edit.undo"}],"save":["/abs/o.pdf","o.docx"]}"#, Some(Path::new("/base"))).unwrap();
let joined = |name: &str| Path::new("/base").join(name).to_string_lossy().into_owned();
assert_eq!(s.file, Some(joined("in.docx")));
assert_eq!(s.save, vec!["/abs/o.pdf".to_string(), joined("o.docx")]);
let s = script::parse(r#"{"commands":["caret.docEnd"],"save":["o.docx"]}"#, None).unwrap();
assert_eq!(s.save, vec!["o.docx".to_string()]);
assert_eq!(s.commands.len(), 1);
}
+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}");
}
+2 -2
View File
@@ -49,9 +49,9 @@ impl PrefsSync {
pub fn changed(&mut self, app: &WordApp) -> Option<String> {
let mut prefs = app.prefs();
if let Some((host, own)) = &self.host_author
&& prefs.author == *host
&& prefs.author.as_deref() == Some(host.as_str())
{
prefs.author = own.clone();
prefs.author = Some(own.clone());
}
let json = match serde_json::to_string(&prefs) {
Ok(j) => j,
+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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
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 }
+13 -8
View File
@@ -35,26 +35,31 @@ 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) => {
log::warn!("{p}: {e}");
let e = e.to_string();
let msg = wordcraft_ui_egui::tl!("Couldn't open {path}: {error}");
app.status(wordcraft_ui_egui::i18n::fmt(msg, &[("path", p), ("error", &e)]));
app.report_open_failure(p, &e);
// Backstage hides the status bar, so leave it to show the error.
app.ui.backstage = false;
}
}
}
}
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());
+1 -336
View File
@@ -346,339 +346,4 @@ pub fn install_panic_hook() {
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("wordcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
#[test]
fn the_default_filter_shows_wordcraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("wordcraft"), LevelFilter::Info);
assert_eq!(f.level_for("wordcraft::control_server"), LevelFilter::Info);
assert_eq!(f.level_for("wordcraft_docx::package"), LevelFilter::Info);
assert_eq!(f.level_for("wordcraft_ui_egui"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,wordcraft_pdf=trace,wordcraft_pdf::fonts=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("wordcraft_pdf::images"), LevelFilter::Trace);
assert_eq!(f.level_for("wordcraft_pdf::fonts"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A trailing `*` is a prefix.
assert_eq!(Filter::parse("wordcraft*=debug").level_for("wordcraft_layout::lines"), LevelFilter::Debug);
assert_eq!(Filter::parse("wordcraft*=debug").level_for("eframe"), LevelFilter::Error);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
// Levels are case-insensitive and surrounding blanks are ignored.
assert_eq!(Filter::parse(" WARN , wordcraft = Debug ").level_for("wordcraft"), LevelFilter::Debug);
assert_eq!(Filter::parse(" WARN , wordcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
let long = "x".repeat(100_000);
let many = "a=info,".repeat(10_000);
for spec in [
"",
",,,",
"=",
"==",
"=debug",
"wordcraft=",
"nonsense=loud",
"🦀=info",
"*",
"*=",
"=*",
"a=b=c",
"warn,,wordcraft=DEBUG",
"\0",
"é::ü=trace",
&long,
&many,
] {
let f = Filter::parse(spec);
for target in ["", "wordcraft", "🦀", "a::b", "*"] {
let _ = f.level_for(target);
}
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,wordcraft=DEBUG").level_for("wordcraft"), LevelFilter::Debug);
// An unknown level is skipped: that target keeps the default.
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
assert_eq!(Filter::parse("wordcraft=").level_for("wordcraft"), LevelFilter::Error);
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day, and the day after it.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps to the epoch instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
// the old formatter gave, rather than falling back to the epoch. FredApps' counterexample:
// UNIX_EPOCH + 10_000_000_000_000 s was 318857-05-20T17:46:40.000Z and must stay so.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "wordcraft_docx::package", "skipping unreadable zip entry 3"),
"2026-10-08T07:59:17.728Z WARN [main] wordcraft_docx::package: skipping unreadable zip entry 3\n"
);
// A multi-line message keeps its lines; the record still ends in one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("wordcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("wordcraft.2.log")), "run 2");
assert!(!dir.join("wordcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exactly_a_full_pending_buffer_drops_nothing() {
let dir = temp_dir("full");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().count(), MAX_PENDING);
assert!(!text.contains("dropped"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn without_a_file_nothing_is_kept_in_memory() {
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early\n");
sink.no_file();
sink.write("late\n");
assert!(sink.pending.is_empty() && sink.dropped == 0);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
use log::Log;
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
let record = |level, target: &'static str, msg: &'static str| {
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
};
record(log::Level::Info, "wordcraft", "before the settings directory");
record(log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(log::Level::Error, "wordcraft_render", "PNG encode: bad size 0×0");
record(log::Level::Debug, "wordcraft", "below info");
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3, "{text}");
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("wordcraft: before the settings directory"), "{text}");
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("wordcraft_render: PNG encode: bad size 0×0"), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("wordcraft_ui_egui").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
// Attaching again rotates: the first log becomes `.1`.
logger.attach_dir(&dir).expect("attach again");
assert_eq!(read(&dir.join("wordcraft.1.log")), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn records_from_many_threads_are_whole_lines() {
use log::Log;
let dir = temp_dir("threads");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let path = logger.attach_dir(&dir).expect("attach");
let workers: Vec<_> = (0..8)
.map(|t| {
let l = std::sync::Arc::clone(&logger);
std::thread::spawn(move || {
for i in 0..100 {
l.log(
&log::Record::builder().level(log::Level::Info).target("wordcraft").args(format_args!("thread {t} record {i}")).build(),
);
}
})
})
.collect();
for w in workers {
w.join().expect("worker");
}
let text = read(&path);
assert_eq!(text.lines().count(), 800);
assert!(text.lines().all(|l| l.contains(" wordcraft: thread ")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
use log::Log;
let dir = temp_dir("unusable");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("early")).build());
assert!(logger.attach_dir(&dir).is_err());
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build());
// Nothing waits in memory for a file that won't come.
let sink = logger.sink.lock().expect("sink");
assert!(sink.pending.is_empty() && sink.file.is_none());
drop(sink);
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_poisoned_sink_still_logs() {
use log::Log;
let dir = temp_dir("poison");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let l = std::sync::Arc::clone(&logger);
let _ = std::thread::spawn(move || {
let _guard = l.sink.lock().expect("lock");
panic!("poison the sink");
})
.join();
assert!(logger.sink.is_poisoned());
let path = logger.attach_dir(&dir).expect("attach");
logger.log(&log::Record::builder().level(log::Level::Warn).target("wordcraft").args(format_args!("after the poison")).build());
assert!(read(&path).contains("wordcraft: after the poison"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn panic_messages_leave_out_quoted_text() {
// What std says when a string is sliced inside a character: the string is the user's text.
let msg = "byte index 5 is not a char boundary; it is inside 'é' (bytes 4..6) of `Dear Ms Smith, my secret`";
assert_eq!(redact_quoted(msg), "byte index 5 is not a char boundary; it is inside 'é' (bytes 4..6) of `…`");
assert_eq!(redact_quoted("byte index 99 is out of range of `my diary`[...]"), "byte index 99 is out of range of `…`[...]");
assert_eq!(redact_quoted("a `b` c `d` e"), "a `…` c `…` e");
// An unpaired backtick hides the rest rather than risk leaking it.
assert_eq!(redact_quoted("of `half a sentence"), "of `…");
assert_eq!(
redact_quoted("no quotes: index out of bounds: the len is 3 but the index is 5"),
"no quotes: index out of bounds: the len is 3 but the index is 5"
);
assert_eq!(redact_quoted(""), "");
assert_eq!(redact_quoted("``"), "`…`");
assert_eq!(redact_quoted("🦀`🦀`🦀"), "🦀`…`🦀");
}
#[test]
fn a_panic_report_names_the_place_and_the_redacted_message() {
assert_eq!(
panic_report(Some("crates/engine/src/cmd/edit.rs:12:5"), Some("of `secret`"), None),
"panic at crates/engine/src/cmd/edit.rs:12:5: of `…`"
);
assert_eq!(panic_report(None, None, None), "panic at an unknown place: (no message)");
assert_eq!(panic_report(Some("a.rs:1:1"), Some("boom"), Some(" 0: main\n")), "panic at a.rs:1:1: boom\n 0: main");
}
}
mod tests;
+332
View File
@@ -0,0 +1,332 @@
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, UNIX_EPOCH};
fn temp_dir(tag: &str) -> PathBuf {
static N: AtomicUsize = AtomicUsize::new(0);
let d = std::env::temp_dir().join(format!("wordcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
d
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).expect("read log")
}
#[test]
fn the_default_filter_shows_wordcraft_info_and_other_crates_warnings() {
let f = Filter::parse(DEFAULT_FILTER);
assert_eq!(f.level_for("wordcraft"), LevelFilter::Info);
assert_eq!(f.level_for("wordcraft::control_server"), LevelFilter::Info);
assert_eq!(f.level_for("wordcraft_docx::package"), LevelFilter::Info);
assert_eq!(f.level_for("wordcraft_ui_egui"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
assert_eq!(f.level_for("naga"), LevelFilter::Warn);
assert_eq!(f.max(), LevelFilter::Info);
}
#[test]
fn directives_follow_env_logger_and_the_most_specific_one_wins() {
let f = Filter::parse("info,wgpu_core=error,wordcraft_pdf=trace,wordcraft_pdf::fonts=off");
assert_eq!(f.level_for("eframe"), LevelFilter::Info);
assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error);
assert_eq!(f.level_for("wordcraft_pdf::images"), LevelFilter::Trace);
assert_eq!(f.level_for("wordcraft_pdf::fonts"), LevelFilter::Off);
assert_eq!(f.max(), LevelFilter::Trace);
// A module name is matched at `::` boundaries, not as a bare prefix.
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
// A trailing `*` is a prefix.
assert_eq!(Filter::parse("wordcraft*=debug").level_for("wordcraft_layout::lines"), LevelFilter::Debug);
assert_eq!(Filter::parse("wordcraft*=debug").level_for("eframe"), LevelFilter::Error);
// A bare target name sets that target to the most verbose level, as env_logger does.
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
// Levels are case-insensitive and surrounding blanks are ignored.
assert_eq!(Filter::parse(" WARN , wordcraft = Debug ").level_for("wordcraft"), LevelFilter::Debug);
assert_eq!(Filter::parse(" WARN , wordcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
}
#[test]
fn hostile_specs_never_panic_and_fall_back_sensibly() {
let long = "x".repeat(100_000);
let many = "a=info,".repeat(10_000);
for spec in [
"",
",,,",
"=",
"==",
"=debug",
"wordcraft=",
"nonsense=loud",
"🦀=info",
"*",
"*=",
"=*",
"a=b=c",
"warn,,wordcraft=DEBUG",
"\0",
"é::ü=trace",
&long,
&many,
] {
let f = Filter::parse(spec);
for target in ["", "wordcraft", "🦀", "a::b", "*"] {
let _ = f.level_for(target);
}
let _ = f.max();
}
assert_eq!(Filter::parse("warn,,wordcraft=DEBUG").level_for("wordcraft"), LevelFilter::Debug);
// An unknown level is skipped: that target keeps the default.
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
assert_eq!(Filter::parse("wordcraft=").level_for("wordcraft"), LevelFilter::Error);
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
}
#[test]
fn timestamps_are_utc_with_milliseconds() {
assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z");
// Leap day, and the day after it.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z");
// 2000 is a leap year (divisible by 400), 2100 is not.
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps to the epoch instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
// the old formatter gave, rather than falling back to the epoch. FredApps' counterexample:
// UNIX_EPOCH + 10_000_000_000_000 s was 318857-05-20T17:46:40.000Z and must stay so.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
fn a_line_carries_time_level_thread_target_and_message() {
assert_eq!(
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "wordcraft_docx::package", "skipping unreadable zip entry 3"),
"2026-10-08T07:59:17.728Z WARN [main] wordcraft_docx::package: skipping unreadable zip entry 3\n"
);
// A multi-line message keeps its lines; the record still ends in one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() {
let dir = temp_dir("rotate");
for run in 1..=4 {
let (path, _file) = rotate(&dir).expect("rotate");
assert_eq!(path, dir.join(LOG_FILE));
assert_eq!(read(&path), "");
std::fs::write(&path, format!("run {run}")).expect("write");
}
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
assert_eq!(read(&dir.join("wordcraft.1.log")), "run 3");
assert_eq!(read(&dir.join("wordcraft.2.log")), "run 2");
assert!(!dir.join("wordcraft.3.log").exists(), "only {KEEP} old logs are kept");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() {
let dir = temp_dir("blocked");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "a file where the directory should be").expect("block");
assert!(rotate(&dir).is_err());
let _ = std::fs::remove_file(&dir);
}
#[test]
fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() {
let dir = temp_dir("pending");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early 1\n");
sink.write("early 2\n");
sink.attach(file);
sink.write("late\n");
assert_eq!(read(&path), "early 1\nearly 2\nlate\n");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() {
let dir = temp_dir("bounded");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING + 5 {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING);
assert!(text.contains("5 earlier log lines were dropped"), "{text}");
assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exactly_a_full_pending_buffer_drops_nothing() {
let dir = temp_dir("full");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(MAX_FILE_BYTES);
for i in 0..MAX_PENDING {
sink.write(&format!("line {i}\n"));
}
sink.attach(file);
let text = read(&path);
assert_eq!(text.lines().count(), MAX_PENDING);
assert!(!text.contains("dropped"), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn without_a_file_nothing_is_kept_in_memory() {
let mut sink = Sink::new(MAX_FILE_BYTES);
sink.write("early\n");
sink.no_file();
sink.write("late\n");
assert!(sink.pending.is_empty() && sink.dropped == 0);
}
#[test]
fn the_file_stops_at_its_size_cap_with_one_note() {
let dir = temp_dir("cap");
let (path, file) = rotate(&dir).expect("rotate");
let mut sink = Sink::new(100);
sink.attach(file);
for i in 0..50 {
sink.write(&format!("line {i:02} with some padding\n"));
}
let text = read(&path);
assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}");
assert_eq!(text.matches("log file reached").count(), 1, "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_logger_filters_records_and_writes_them_to_the_attached_file() {
use log::Log;
let dir = temp_dir("logger");
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
let record = |level, target: &'static str, msg: &'static str| {
logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build());
};
record(log::Level::Info, "wordcraft", "before the settings directory");
record(log::Level::Info, "wgpu_core::device", "too chatty");
let path = logger.attach_dir(&dir).expect("attach");
assert_eq!(path, dir.join(LOG_FILE));
record(log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
record(log::Level::Error, "wordcraft_render", "PNG encode: bad size 0×0");
record(log::Level::Debug, "wordcraft", "below info");
let text = read(&path);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3, "{text}");
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("wordcraft: before the settings directory"), "{text}");
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("wordcraft_render: PNG encode: bad size 0×0"), "{text}");
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("wordcraft_ui_egui").build()));
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
// Attaching again rotates: the first log becomes `.1`.
logger.attach_dir(&dir).expect("attach again");
assert_eq!(read(&dir.join("wordcraft.1.log")), text);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn records_from_many_threads_are_whole_lines() {
use log::Log;
let dir = temp_dir("threads");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let path = logger.attach_dir(&dir).expect("attach");
let workers: Vec<_> = (0..8)
.map(|t| {
let l = std::sync::Arc::clone(&logger);
std::thread::spawn(move || {
for i in 0..100 {
l.log(&log::Record::builder().level(log::Level::Info).target("wordcraft").args(format_args!("thread {t} record {i}")).build());
}
})
})
.collect();
for w in workers {
w.join().expect("worker");
}
let text = read(&path);
assert_eq!(text.lines().count(), 800);
assert!(text.lines().all(|l| l.contains(" wordcraft: thread ")), "{text}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() {
use log::Log;
let dir = temp_dir("unusable");
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
std::fs::write(&dir, "not a directory").expect("block");
let logger = AppLogger::new(Filter::parse("info"), false);
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("early")).build());
assert!(logger.attach_dir(&dir).is_err());
logger.log(&log::Record::builder().level(log::Level::Error).target("x").args(format_args!("still fine")).build());
// Nothing waits in memory for a file that won't come.
let sink = logger.sink.lock().expect("sink");
assert!(sink.pending.is_empty() && sink.file.is_none());
drop(sink);
let _ = std::fs::remove_file(&dir);
}
#[test]
fn a_poisoned_sink_still_logs() {
use log::Log;
let dir = temp_dir("poison");
let logger = std::sync::Arc::new(AppLogger::new(Filter::parse("info"), false));
let l = std::sync::Arc::clone(&logger);
let _ = std::thread::spawn(move || {
let _guard = l.sink.lock().expect("lock");
panic!("poison the sink");
})
.join();
assert!(logger.sink.is_poisoned());
let path = logger.attach_dir(&dir).expect("attach");
logger.log(&log::Record::builder().level(log::Level::Warn).target("wordcraft").args(format_args!("after the poison")).build());
assert!(read(&path).contains("wordcraft: after the poison"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn panic_messages_leave_out_quoted_text() {
// What std says when a string is sliced inside a character: the string is the user's text.
let msg = "byte index 5 is not a char boundary; it is inside 'é' (bytes 4..6) of `Dear Ms Smith, my secret`";
assert_eq!(redact_quoted(msg), "byte index 5 is not a char boundary; it is inside 'é' (bytes 4..6) of `…`");
assert_eq!(redact_quoted("byte index 99 is out of range of `my diary`[...]"), "byte index 99 is out of range of `…`[...]");
assert_eq!(redact_quoted("a `b` c `d` e"), "a `…` c `…` e");
// An unpaired backtick hides the rest rather than risk leaking it.
assert_eq!(redact_quoted("of `half a sentence"), "of `…");
assert_eq!(
redact_quoted("no quotes: index out of bounds: the len is 3 but the index is 5"),
"no quotes: index out of bounds: the len is 3 but the index is 5"
);
assert_eq!(redact_quoted(""), "");
assert_eq!(redact_quoted("``"), "`…`");
assert_eq!(redact_quoted("🦀`🦀`🦀"), "🦀`…`🦀");
}
#[test]
fn a_panic_report_names_the_place_and_the_redacted_message() {
assert_eq!(
panic_report(Some("crates/engine/src/cmd/edit.rs:12:5"), Some("of `secret`"), None),
"panic at crates/engine/src/cmd/edit.rs:12:5: of `…`"
);
assert_eq!(panic_report(None, None, None), "panic at an unknown place: (no message)");
assert_eq!(panic_report(Some("a.rs:1:1"), Some("boom"), Some(" 0: main\n")), "panic at a.rs:1:1: boom\n 0: main");
}
+101 -13
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);
@@ -305,6 +329,26 @@ fn text_args() -> (Vec<String>, Vec<std::path::PathBuf>) {
(args, files)
}
/// The arguments without the ones macOS adds when it launches an app (#1103): `-psn_0_12345`, and
/// the `-NS…` / `-Apple…` options (`-AppleLanguages (en)`, `-NSDocumentRevisionsDebugMode YES`) with
/// the value after each. They are not files, and opening them as files fails.
fn without_launch_args(args: Vec<String>) -> Vec<String> {
let mut kept = Vec::with_capacity(args.len());
let mut args = args.into_iter().peekable();
while let Some(a) = args.next() {
if a.starts_with("-psn_") {
continue;
}
if a.starts_with("-NS") || a.starts_with("-Apple") {
// Its value, unless the next argument is another flag.
args.next_if(|v| !v.starts_with('-'));
continue;
}
kept.push(a);
}
kept
}
/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it
/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB.
@@ -316,11 +360,21 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
// Not when it waits for a password: that open names the document itself.
Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()),
Ok(_) => {}
Err(e) => log::warn!("{name}: {e}"),
Err(e) => {
app.report_open_failure(&name, &e);
}
}
}
/// 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(&wordcraft_ui_egui::UPDATE_APP, &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();
@@ -328,6 +382,8 @@ fn main() -> eframe::Result {
let mut files = Vec::new();
let mut sample = false;
let (args, other_files) = text_args();
// Only macOS (Finder / LaunchServices) adds these; elsewhere `-NS…` could be a real file name.
let args = if cfg!(target_os = "macos") { without_launch_args(args) } else { args };
let mut bad_control: Option<Option<String>> = None;
let mut args = args.into_iter().peekable();
while let Some(a) = args.next() {
@@ -397,6 +453,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 +487,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 +506,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() {
@@ -468,7 +528,7 @@ fn main() -> eframe::Result {
}
for f in files {
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
log::warn!("{f}: {e}");
app.report_open_failure(&f, &e);
}
}
for f in other_files {
@@ -480,6 +540,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),
)))
@@ -553,6 +614,33 @@ mod tests {
}
}
#[cfg(test)]
mod launch_arg_tests {
use super::without_launch_args;
fn strip(args: &[&str]) -> Vec<String> {
without_launch_args(args.iter().map(|a| a.to_string()).collect())
}
#[test]
fn macos_launch_arguments_are_not_files() {
assert_eq!(strip(&["-psn_0_12345", "a.docx"]), ["a.docx"]);
assert_eq!(strip(&["-AppleLanguages", "(en)", "a.docx"]), ["a.docx"]);
assert_eq!(strip(&["a.docx", "-NSDocumentRevisionsDebugMode", "YES", "b.docx"]), ["a.docx", "b.docx"]);
// A flag with no value after it, or followed by another flag, takes nothing with it.
assert_eq!(strip(&["a.docx", "-AppleLocale"]), ["a.docx"]);
assert_eq!(strip(&["-NSFoo", "--sample", "a.docx"]), ["--sample", "a.docx"]);
}
#[test]
fn real_arguments_are_kept() {
assert_eq!(strip(&["--control", "7981", "--sample", "-h"]), ["--control", "7981", "--sample", "-h"]);
// Files whose names merely start like a launch argument keep their place when they are paths.
assert_eq!(strip(&["./-psn_x.docx", "Apple.docx", "-", "-nsfoo"]), ["./-psn_x.docx", "Apple.docx", "-", "-nsfoo"]);
assert!(strip(&[]).is_empty());
}
}
#[cfg(test)]
mod prefs_tests {
use super::write_prefs;
+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
+4
View File
@@ -2,3 +2,7 @@ allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
cognitive-complexity-threshold = 30
+9
View File
@@ -181,3 +181,12 @@ sync_github_name = true
[people.pakiat]
display_name = "OtorO"
[people.TobyWu666]
display_name = "tobywu"
[people.Bra1nZ]
sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
+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 }
+80
View File
@@ -0,0 +1,80 @@
use super::{Arc, Deserialize, Paragraph, Serialize, Table};
/// A block in a story. (Blocks are always behind an `Arc`, so the size difference is fine.)
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Block {
Para(Paragraph),
Table(Table),
}
impl Block {
pub fn as_para(&self) -> Option<&Paragraph> {
match self {
Block::Para(p) => Some(p),
Block::Table(_) => None,
}
}
pub fn as_table(&self) -> Option<&Table> {
match self {
Block::Table(t) => Some(t),
Block::Para(_) => None,
}
}
}
pub type Blocks = Vec<Arc<Block>>;
/// Text boxes inside text boxes count (and show) this many levels deep.
pub const MAX_TEXT_BOX_DEPTH: usize = 4;
/// Bounds the work of expanding text boxes inside text boxes (layout, walks, saving), whatever a
/// document says: a box isn't expanded inside itself, nesting stops at [`MAX_TEXT_BOX_DEPTH`], and
/// an outermost box expands at most [`BoxBudget::MAX_NESTED`] boxes inside it. (A box whose
/// shapes all show that same box, or chains of boxes each showing the next many times, would
/// otherwise grow exponentially.) Outermost boxes aren't capped: their number is the document's
/// (or its pages', for headers), so the work stays linear and no box is left blank for budget.
#[derive(Debug, Default)]
pub struct BoxBudget {
open: Vec<u32>,
nested: usize,
total: usize,
}
impl BoxBudget {
/// Boxes expanded inside one outermost box.
pub const MAX_NESTED: usize = 64;
/// Start expanding text box story `id`; false if it mustn't be (pair a true with `leave`).
pub fn enter(&mut self, id: u32) -> bool {
if self.open.contains(&id) || self.open.len() >= MAX_TEXT_BOX_DEPTH {
return false;
}
if self.open.is_empty() {
self.nested = 0;
} else if self.nested >= Self::MAX_NESTED {
return false;
} else {
self.nested += 1;
}
self.total += 1;
self.open.push(id);
true
}
/// Done expanding the box last entered.
pub fn leave(&mut self) {
self.open.pop();
}
/// Whether a text box is being expanded now.
pub fn inside(&self) -> bool {
!self.open.is_empty()
}
/// Boxes expanded so far.
pub fn total(&self) -> usize {
self.total
}
}
+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.
+38 -5
View File
@@ -64,6 +64,11 @@ pub fn is_cluster_boundary(text: &str, off: usize) -> bool {
/// Byte offsets where clusters start (graphemes, joined per [`joins_cluster`]), in order; the
/// first is 0 for non-empty text.
pub fn cluster_starts(text: &str) -> Vec<usize> {
// ASCII: every character starts a grapheme except LF after CR, and none joins (UAX #29).
if text.is_ascii() {
let b = text.as_bytes();
return (0..b.len()).filter(|&i| !(i > 0 && b.get(i - 1) == Some(&b'\r') && b.get(i) == Some(&b'\n'))).collect();
}
let mut out: Vec<usize> = Vec::new();
let mut prev: Option<char> = None;
for (i, g) in text.grapheme_indices(true) {
@@ -110,12 +115,22 @@ pub fn next_cluster(text: &str, off: usize) -> usize {
}
}
/// Scripts whose Backspace removes one character at a time (a vowel sign, a medial, a stacked
/// consonant) rather than the whole cluster, as on Windows and macOS: the Brahmic scripts of
/// South and South-East Asia.
/// Characters Backspace removes individually rather than with their whole cluster: Arabic
/// combining marks and characters of the Brahmic scripts of South and South-East Asia.
pub fn backspaces_by_char(c: char) -> bool {
matches!(u32::from(c),
0x0900..=0x0DFF // Devanagari … Sinhala
0x0610..=0x061A // Arabic nonspacing marks (Unicode 17.0)
| 0x064B..=0x065F
| 0x0670
| 0x06D6..=0x06DC
| 0x06DF..=0x06E4
| 0x06E7..=0x06E8
| 0x06EA..=0x06ED
| 0x0897..=0x089F
| 0x08CA..=0x08E1
| 0x08E3..=0x08FF
| 0x10EFA..=0x10EFF
| 0x0900..=0x0DFF // Devanagari … Sinhala
| 0x0E00..=0x0EFF // Thai, Lao
| 0x0F00..=0x0FFF // Tibetan
| 0x1000..=0x109F // Myanmar
@@ -188,6 +203,22 @@ mod tests {
assert_eq!(split(kinzi, &cluster_starts(kinzi)), vec!["အ", "င်္ဂ", "လိ", "ပ်"]);
}
/// The ASCII shortcut gives the grapheme rules' answer.
#[test]
fn ascii_cluster_starts_match_the_graphemes() {
let graphemes = |t: &str| -> Vec<usize> { t.grapheme_indices(true).map(|(i, _)| i).collect() };
let all: String = (0u8..128).map(char::from).collect();
for t in ["", "a", "Hello, world.", "\r\n", "a\r\nb\n\rc\r\r\n\n", "\t \u{7f}x\u{0}", all.as_str()] {
assert_eq!(cluster_starts(t), graphemes(t), "{t:?}");
}
for a in 0u8..128 {
for b in 0u8..128 {
let t = format!("{}{}", char::from(a), char::from(b));
assert_eq!(cluster_starts(&t), graphemes(&t), "{t:?}");
}
}
}
#[test]
fn khmer_subscripts_and_indic_conjuncts_are_one_cluster() {
// ខ្មែរ: ខ + coeng + ម + ែ, then រ.
@@ -229,8 +260,10 @@ mod tests {
}
#[test]
fn backspace_by_character_in_brahmic_scripts_only() {
fn backspace_by_character_in_brahmic_scripts_and_arabic_marks() {
assert!(backspaces_by_char('ေ') && backspaces_by_char('ि') && backspaces_by_char('្'));
assert!(backspaces_by_char('\u{064E}') && backspaces_by_char('\u{06E7}') && backspaces_by_char('\u{10EFF}'));
assert!(!backspaces_by_char('\u{301}') && !backspaces_by_char('a') && !backspaces_by_char('語'));
assert!(!backspaces_by_char('ب') && !backspaces_by_char('\u{06E5}') && !backspaces_by_char('\u{08E2}'));
}
}
+96
View File
@@ -0,0 +1,96 @@
use super::block::{Block, Blocks};
use super::parts::Part;
use super::{Arc, BTreeMap, BuildingBlock, Cell, Paragraph, Row, Table};
pub fn para_block(p: Paragraph) -> Arc<Block> {
Arc::new(Block::Para(p))
}
/// The same blocks: the same content. The blocks are compared by address first — the
/// document is copy-on-write, so a block that changed was copied and has another address —
/// and a block that was copied but holds the same content is compared by value.
pub(crate) fn same_blocks(a: &Blocks, b: &Blocks) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| Arc::ptr_eq(x, y) || same_block(x, y))
}
/// The same block by value. A table's cells compare their blocks by address first too: an edit in
/// one cell copies the whole table, which mustn't mean comparing every cell by value.
fn same_block(a: &Block, b: &Block) -> bool {
match (a, b) {
(Block::Table(x), Block::Table(y)) => same_table(x, y),
_ => a == b,
}
}
/// The same table. Every field is listed, so a new one does not build until someone decides
/// whether a change to it counts.
fn same_table(a: &Table, b: &Table) -> bool {
let Table { props, grid, rows, controls } = a;
let Table { props: other_props, grid: other_grid, rows: other_rows, controls: other_controls } = b;
props == other_props
&& grid == other_grid
&& controls == other_controls
&& rows.len() == other_rows.len()
&& rows.iter().zip(other_rows).all(|(x, y)| same_row(x, y))
}
fn same_row(a: &Row, b: &Row) -> bool {
let Row { props, cells, controls } = a;
let Row { props: other_props, cells: other_cells, controls: other_controls } = b;
props == other_props
&& controls == other_controls
&& cells.len() == other_cells.len()
&& cells.iter().zip(other_cells).all(|(x, y)| {
let Cell { props, blocks, controls } = x;
let Cell { props: other_props, blocks: other_blocks, controls: other_controls } = y;
props == other_props && controls == other_controls && same_blocks(blocks, other_blocks)
})
}
/// The same building blocks, their content compared by address first.
pub(crate) fn same_glossary(a: &[BuildingBlock], b: &[BuildingBlock]) -> bool {
a.len() == b.len()
&& a.iter().zip(b).all(|(x, y)| {
let BuildingBlock { name, gallery, category, description, guid, style, types, behaviors, blocks } = x;
let BuildingBlock {
name: other_name,
gallery: other_gallery,
category: other_category,
description: other_description,
guid: other_guid,
style: other_style,
types: other_types,
behaviors: other_behaviors,
blocks: other_blocks,
} = y;
name == other_name
&& gallery == other_gallery
&& category == other_category
&& description == other_description
&& guid == other_guid
&& style == other_style
&& types == other_types
&& behaviors == other_behaviors
&& same_blocks(blocks, other_blocks)
})
}
/// The same parts: the same keys, and each part the same.
pub(crate) fn same_parts(a: &BTreeMap<u32, Part>, b: &BTreeMap<u32, Part>) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|((k1, p1), (k2, p2))| k1 == k2 && same_part(p1, p2))
}
/// The same part: the same kind, text body and blocks. Every field is listed, so a new one
/// does not build until someone decides whether a change to it counts.
fn same_part(a: &Part, b: &Part) -> bool {
let Part { kind, blocks, body } = a;
let Part { kind: other_kind, blocks: other_blocks, body: other_body } = b;
kind == other_kind && body == other_body && same_blocks(blocks, other_blocks)
}
/// The same bytes: the same content. The bytes are compared by address first — a changed
/// picture was copied, so it has another address — and a picture that was copied but holds
/// the same bytes is compared by value.
pub(crate) fn same_bytes(a: &BTreeMap<String, Arc<Vec<u8>>>, b: &BTreeMap<String, Arc<Vec<u8>>>) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|((k1, v1), (k2, v2))| k1 == k2 && (Arc::ptr_eq(v1, v2) || **v1 == **v2))
}
+38 -37
View File
@@ -362,6 +362,43 @@ mod tests {
assert!(near(corner_radius(Some(10_000), -5.0, 100.0), 0.0));
}
/// One rerouting case: the free end settles and the attached end keeps its site.
fn assert_transformed_reroute(kind: ShapeKind, rot: f32, flip_h: bool, flip_v: bool, 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 mut obj = shape(kind, 40.0, 20.0, ShapeExtra { start, end, ..Default::default() });
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
float.rot = rot;
float.flip_h = flip_h;
float.flip_v = flip_v;
// Frame (10, 30, 40, 20), centre (30, 40): mirror, then rotate
// clockwise in y-down group space. Do not use the routing transform
// to compute the expectation.
let sign = if free_start { -1.0 } else { 1.0 };
let dx = sign * if flip_h { -20.0 } else { 20.0 };
let dy = sign * if flip_v { -10.0 } else { 10.0 };
let (sin, cos) = rot.to_radians().sin_cos();
let expected = (30.0 + dx * cos - dy * sin, 40.0 + dx * sin + dy * cos);
let kids = vec![
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }) },
GroupChild { x: 10.0, y: 30.0, obj },
];
let mut group = group(kids);
assert!(reroute_group(&mut group));
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
let (free, attached) = if free_start { (start, end) } else { (end, start) };
assert!(
(free.0 - expected.0).abs() < 0.001 && (free.1 - expected.1).abs() < 0.001,
"{kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {free:?} != {expected:?}"
);
assert_eq!(attached, (140.0, 210.0));
assert_eq!(float.rot, 0.0);
assert!(!reroute_group(&mut group), "rerouting must settle");
}
#[test]
fn rerouting_preserves_transformed_free_and_unresolved_ends() {
for kind in [ShapeKind::StraightConnector, ShapeKind::ElbowConnector, ShapeKind::CurvedConnector] {
@@ -369,43 +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 mut obj = shape(kind, 40.0, 20.0, ShapeExtra { start, end, ..Default::default() });
let InlineObject::Shape { float, .. } = &mut obj else { panic!() };
float.rot = rot;
float.flip_h = flip_h;
float.flip_v = flip_v;
// Frame (10, 30, 40, 20), centre (30, 40): mirror, then rotate
// clockwise in y-down group space. Do not use the routing transform
// to compute the expectation.
let sign = if free_start { -1.0 } else { 1.0 };
let dx = sign * if flip_h { -20.0 } else { 20.0 };
let dy = sign * if flip_v { -10.0 } else { 10.0 };
let (sin, cos) = rot.to_radians().sin_cos();
let expected = (30.0 + dx * cos - dy * sin, 40.0 + dx * sin + dy * cos);
let kids = vec![
GroupChild {
x: 100.0,
y: 200.0,
obj: shape(ShapeKind::Rectangle, 40.0, 20.0, ShapeExtra { id: 7, ..Default::default() }),
},
GroupChild { x: 10.0, y: 30.0, obj },
];
let mut group = group(kids);
assert!(reroute_group(&mut group));
let InlineObject::Group { children, .. } = &group else { panic!() };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!() };
let (start, end) = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
let (free, attached) = if free_start { (start, end) } else { (end, start) };
assert!(
(free.0 - expected.0).abs() < 0.001 && (free.1 - expected.1).abs() < 0.001,
"{kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {free:?} != {expected:?}"
);
assert_eq!(attached, (140.0, 210.0));
assert_eq!(float.rot, 0.0);
assert!(!reroute_group(&mut group), "rerouting must settle");
assert_transformed_reroute(kind, rot, flip_h, flip_v, free, free_start);
}
}
}
+70 -33
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, HashMap};
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) }),
_ => {}
}
}
@@ -420,24 +422,37 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out:
}
/// Positions of the markers without a partner in a block list and the cells inside it.
fn orphans(bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) {
pub(crate) fn orphans(bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out: &mut Vec<(Path, usize)>, tables: &mut Vec<(Path, usize, bool)>) {
if depth > MAX_DEPTH {
return;
}
let events = control_events(bl);
let (_, lone) = pair_events(&events);
for k in lone {
let Some(e) = events.get(k) else { continue };
let mut path = prefix.clone();
path.push(u32::try_from(e.block()).unwrap_or(u32::MAX));
match (e, e.off()) {
(_, Some(off)) => out.push((Path(path), off)),
(ControlEvent::Open { .. }, None) => {
// Which of the table's opens: count those before it on the same table.
let nth = events.iter().take(k).filter(|x| matches!(x, ControlEvent::Open { block, off: None, .. } if *block == e.block())).count();
tables.push((Path(path), nth, true));
if !lone.is_empty() {
let mut table_counts: HashMap<usize, usize> = HashMap::new();
let mut open_nth = Vec::with_capacity(events.len());
for e in &events {
if let ControlEvent::Open { block, off: None, .. } = e {
let count = table_counts.entry(*block).or_insert(0);
open_nth.push(*count);
*count += 1;
} else {
open_nth.push(0);
}
}
for k in lone {
let Some(e) = events.get(k) else { continue };
let mut path = prefix.clone();
path.push(u32::try_from(e.block()).unwrap_or(u32::MAX));
match (e, e.off()) {
(_, Some(off)) => out.push((Path(path), off)),
(ControlEvent::Open { .. }, None) => {
// Which of the table's opens: index among table opens on the same table.
let nth = open_nth.get(k).copied().unwrap_or(0);
tables.push((Path(path), nth, true));
}
(ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)),
}
(ControlEvent::Close { .. }, None) => tables.push((Path(path), 0, false)),
}
}
for (i, b) in bl.iter().enumerate() {
@@ -639,34 +654,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 +707,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 +724,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() };
+866
View File
@@ -0,0 +1,866 @@
use super::block::{Block, Blocks, BoxBudget};
use super::compare::{para_block, same_blocks, same_bytes, same_glossary, same_parts};
use super::error::{DocError, Result};
use super::parts::{MAX_LINKED_BOXES, NoteSeparators, Part, PartKind, PermRange, TextBody, free_id};
use super::position::{Path, Pos, StoryRef};
use super::settings::{CoreProps, CustomProp, Settings};
use super::text_walk::{TextMode, notes_and_boxes, story_word_count_of, text_of, walk};
use std::sync::Arc;
use super::{BTreeMap, CharProps, Document, InlineObject, Numbering, Paragraph, SectionProps, StyleSheet, Table, edit, numbering, para, section};
/// [`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)]
pub(crate) 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 {
fn default() -> Self {
Document::new()
}
}
impl Document {
/// A blank document: one empty Normal paragraph, Letter, built-in styles.
pub fn new() -> Self {
Document {
body: vec![para_block(Paragraph::new())],
last_section: SectionProps::default(),
parts: BTreeMap::new(),
styles: StyleSheet::builtin(),
numbering: Numbering::default(),
comments: BTreeMap::new(),
revisions: Vec::new(),
settings: Settings::default(),
core: CoreProps::default(),
sources: Vec::new(),
custom_props: Vec::new(),
footnote_separators: NoteSeparators::default(),
endnote_separators: NoteSeparators::default(),
media: BTreeMap::new(),
passthrough: BTreeMap::new(),
glossary: Vec::new(),
media_index: MediaIndex::default(),
}
}
/// A document from plain text, one paragraph per line.
pub fn from_text(text: &str) -> Self {
let mut d = Document::new();
d.body = text.split('\n').map(|l| para_block(Paragraph::with_text(l.trim_end_matches('\r'), CharProps::default()))).collect();
d.ensure_nonempty();
d
}
fn fix_cell_blocks(bl: &mut Blocks) {
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
}
fn fix_story_blocks(bl: &mut Blocks) {
if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
for b in bl.iter_mut() {
if let Block::Table(t) = &**b {
let needs = t
.rows
.iter()
.any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_)))));
if needs && let Block::Table(t) = Arc::make_mut(b) {
for r in &mut t.rows {
for c in &mut r.cells {
Self::fix_cell_blocks(&mut c.blocks);
}
}
}
}
}
}
/// Every story must hold at least one paragraph, and every cell too.
pub fn ensure_nonempty(&mut self) {
Self::fix_story_blocks(&mut self.body);
for p in self.parts.values_mut() {
if p.blocks.is_empty() {
p.blocks.push(para_block(Paragraph::new()));
}
}
}
/// Whether the document holds the same content as `other`: the same blocks, the same
/// styles, lists, settings, comments and the rest.
///
/// The blocks are compared by address first: the document is copy-on-write, so a block
/// that changed was copied and has another address. Comparing them by value would walk
/// the whole document, and this is asked after every command.
///
/// Every field is listed, so a new one does not build until someone decides whether a
/// change to it counts (the same rule the session's edit snapshot follows).
pub fn same_content(&self, other: &Document) -> bool {
let Document {
body,
last_section,
parts,
styles,
numbering,
comments,
revisions,
settings,
core,
sources,
custom_props,
footnote_separators,
endnote_separators,
media,
passthrough,
glossary,
media_index: _,
} = self;
let Document {
body: other_body,
last_section: other_section,
parts: other_parts,
styles: other_styles,
numbering: other_numbering,
comments: other_comments,
revisions: other_revisions,
settings: other_settings,
core: other_core,
sources: other_sources,
custom_props: other_props,
footnote_separators: other_footnotes,
endnote_separators: other_endnotes,
media: other_media,
passthrough: other_passthrough,
glossary: other_glossary,
media_index: _,
} = other;
same_blocks(body, other_body)
&& same_parts(parts, other_parts)
&& last_section == other_section
&& styles == other_styles
&& numbering == other_numbering
&& comments == other_comments
&& revisions == other_revisions
&& settings == other_settings
&& core == other_core
&& sources == other_sources
&& custom_props == other_props
&& footnote_separators == other_footnotes
&& endnote_separators == other_endnotes
&& same_bytes(media, other_media)
&& same_bytes(passthrough, other_passthrough)
&& same_glossary(glossary, other_glossary)
}
pub fn story(&self, s: StoryRef) -> Option<&Blocks> {
match s {
StoryRef::Body => Some(&self.body),
StoryRef::Part(id) => self.parts.get(&id).map(|p| &p.blocks),
}
}
pub fn story_mut(&mut self, s: StoryRef) -> Result<&mut Blocks> {
match s {
StoryRef::Body => Ok(&mut self.body),
StoryRef::Part(id) => self.parts.get_mut(&id).map(|p| &mut p.blocks).ok_or(DocError::BadStory(s)),
}
}
/// The block list that contains the block at `path`.
pub fn container(&self, s: StoryRef, path: &Path) -> Option<&Blocks> {
let mut bl = self.story(s)?;
let parent = path.parent();
if !parent.len().is_multiple_of(3) {
return None;
}
for ch in parent.chunks(3) {
let [b, r, c] = ch else { return None };
let Block::Table(t) = &**bl.get(*b as usize)? else { return None };
bl = &t.rows.get(*r as usize)?.cells.get(*c as usize)?.blocks;
}
Some(bl)
}
pub fn container_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Blocks> {
let bad = || DocError::BadPath(path.to_string());
let parent: Vec<u32> = path.parent().to_vec();
if !parent.len().is_multiple_of(3) {
return Err(bad());
}
let mut bl = self.story_mut(s)?;
for ch in parent.chunks(3) {
let [b, r, c] = ch else { return Err(bad()) };
let blk = bl.get_mut(*b as usize).ok_or_else(bad)?;
let Block::Table(t) = Arc::make_mut(blk) else { return Err(bad()) };
bl = &mut t.rows.get_mut(*r as usize).ok_or_else(bad)?.cells.get_mut(*c as usize).ok_or_else(bad)?.blocks;
}
Ok(bl)
}
pub fn block(&self, s: StoryRef, path: &Path) -> Option<&Block> {
self.container(s, path)?.get(path.last()).map(|b| &**b)
}
pub fn para(&self, s: StoryRef, path: &Path) -> Option<&Paragraph> {
self.block(s, path)?.as_para()
}
pub fn para_at(&self, p: &Pos) -> Option<&Paragraph> {
self.para(p.story, &p.path)
}
pub fn para_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Paragraph> {
let i = path.last();
let bl = self.container_mut(s, path)?;
match bl.get_mut(i).map(Arc::make_mut) {
Some(Block::Para(p)) => Ok(p),
_ => Err(DocError::BadPath(path.to_string())),
}
}
pub fn table(&self, s: StoryRef, path: &Path) -> Option<&Table> {
self.block(s, path)?.as_table()
}
pub fn table_mut(&mut self, s: StoryRef, path: &Path) -> Result<&mut Table> {
let i = path.last();
let bl = self.container_mut(s, path)?;
match bl.get_mut(i).map(Arc::make_mut) {
Some(Block::Table(t)) => Ok(t),
_ => Err(DocError::BadPath(path.to_string())),
}
}
/// Paths of every paragraph in a story, in document order (tables descended, depth-limited).
pub fn para_paths(&self, s: StoryRef) -> Vec<Path> {
let mut out = Vec::new();
if let Some(bl) = self.story(s) {
walk(bl, &mut Vec::new(), &mut out, 0);
}
out
}
/// First and last positions of a story.
pub fn start_of(&self, s: StoryRef) -> Pos {
let path = self.para_paths(s).into_iter().next().unwrap_or_else(|| Path::top(0));
Pos { story: s, path, off: 0 }
}
pub fn end_of(&self, s: StoryRef) -> Pos {
let path = self.para_paths(s).pop().unwrap_or_else(|| Path::top(0));
let off = self.para(s, &path).map(|p| p.len()).unwrap_or(0);
Pos { story: s, path, off }
}
/// The position just after the shape that owns text box story `part` (body first, then the
/// other parts), for leaving the text box.
pub fn text_box_anchor(&self, part: u32) -> Option<Pos> {
let stories = std::iter::once(StoryRef::Body).chain(self.parts.keys().filter(|k| **k != part).map(|k| StoryRef::Part(*k)));
for story in stories {
for path in self.para_paths(story) {
let Some(p) = self.para(story, &path) else { continue };
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() });
}
}
}
}
None
}
/// The next / previous paragraph path in document order.
pub fn next_para(&self, s: StoryRef, path: &Path) -> Option<Path> {
let all = self.para_paths(s);
let i = all.binary_search(path).ok()?;
all.get(i + 1).cloned()
}
pub fn prev_para(&self, s: StoryRef, path: &Path) -> Option<Path> {
let all = self.para_paths(s);
let i = all.binary_search(path).ok()?;
i.checked_sub(1).and_then(|j| all.get(j).cloned())
}
/// Clamp a position into the document (valid path, char boundary).
pub fn clamp(&self, p: &Pos) -> Pos {
if let Some(para) = self.para_at(p) {
return Pos { story: p.story, path: p.path.clone(), off: para.clamp(p.off) };
}
let story = if self.story(p.story).is_some() { p.story } else { StoryRef::Body };
let all = self.para_paths(story);
let path = all.iter().rev().find(|q| **q <= p.path).or(all.first()).cloned().unwrap_or_else(|| Path::top(0));
let off = self.para(story, &path).map(|q| q.clamp(p.off)).unwrap_or(0);
Pos { story, path, off }
}
/// The whole text of a story, paragraphs separated by `\n` (tables: cells by `\t`).
pub fn plain_text(&self, s: StoryRef) -> String {
self.story_text(s, TextMode::Plain)
}
/// [`Self::plain_text`] as it reads on screen: symbol-font characters (Wingdings…) as their
/// Unicode equivalents ([`Paragraph::shown_text`]).
pub fn shown_text(&self, s: StoryRef) -> String {
self.story_text(s, TextMode::Shown)
}
/// [`Self::plain_text`] without tracked-deleted runs. A deleted paragraph mark still yields `\n`.
pub fn final_text(&self, s: StoryRef) -> String {
self.story_text(s, TextMode::Final)
}
fn story_text(&self, s: StoryRef, mode: TextMode) -> String {
let mut out = String::new();
if let Some(bl) = self.story(s) {
text_of(bl, &mut out, 0, mode);
}
if out.ends_with('\n') {
out.pop();
}
out
}
/// Section properties in effect for each top-level body block (index → section number), and
/// the list of sections.
pub fn sections(&self) -> Vec<(usize, &SectionProps)> {
let mut v = Vec::new();
for (i, b) in self.body.iter().enumerate() {
if let Block::Para(p) = &**b
&& let Some(s) = &p.section
{
v.push((i, &**s));
}
}
v.push((self.body.len().saturating_sub(1), &self.last_section));
v
}
/// The footnote (`endnote` false) or endnote options in effect in section `sect`: its own,
/// else the document's, else Word's defaults (footnotes at the page bottom in 1, 2, 3;
/// endnotes at the end of the document in i, ii, iii; numbered on through the document).
/// Placements that don't apply to the kind fall back to its default.
pub fn note_options(&self, sect: &SectionProps, endnote: bool) -> section::NoteOptions {
use section::NotePos;
let (own, doc, fmt) = if endnote {
(&sect.endnote_pr, &self.settings.endnote_pr, self.settings.endnote_format)
} else {
(&sect.footnote_pr, &self.settings.footnote_pr, self.settings.footnote_format)
};
let pos = own.pos.or(doc.pos);
let pos = if endnote {
pos.filter(|p| matches!(p, NotePos::SectEnd | NotePos::DocEnd)).unwrap_or(NotePos::DocEnd)
} else {
pos.filter(|p| matches!(p, NotePos::PageBottom | NotePos::BeneathText)).unwrap_or(NotePos::PageBottom)
};
section::NoteOptions {
pos,
num_fmt: own.num_fmt.unwrap_or(fmt),
num_start: own.num_start.or(doc.num_start).unwrap_or(1).clamp(1, section::MAX_NOTE_START),
num_restart: own.num_restart.or(doc.num_restart).unwrap_or_default(),
}
}
/// The section that contains top-level body block `block` (index into `sections()`).
pub fn section_index_of(&self, block: usize) -> usize {
self.sections().iter().position(|(end, _)| block <= *end).unwrap_or(0)
}
/// Mutable section properties for the section containing body block `block`.
pub fn section_mut(&mut self, block: usize) -> &mut SectionProps {
let ends: Vec<usize> = self.sections().iter().map(|(e, _)| *e).collect();
let idx = ends.iter().position(|e| block <= *e).unwrap_or(ends.len().saturating_sub(1));
if idx + 1 < ends.len()
&& let Some(e) = ends.get(idx)
&& let Some(Block::Para(p)) = self.body.get_mut(*e).map(Arc::make_mut)
&& let Some(s) = p.section.as_mut()
{
return s;
}
&mut self.last_section
}
/// Remove the section break on the mark of top-level body paragraph `block`: its section
/// joins the following one ([`SectionProps::absorb`]). False when there is no break there.
pub fn remove_section_break(&mut self, block: usize) -> bool {
if !matches!(self.body.get(block).map(|b| &**b), Some(Block::Para(p)) if p.section.is_some()) {
return false;
}
let Some(Block::Para(p)) = self.body.get_mut(block).map(Arc::make_mut) else { return false };
let Some(before) = p.section.take() else { return false };
self.section_mut(block).absorb(&before);
true
}
/// Allocate a new part id and insert the part. Part ids run out only past `u32::MAX` parts, more
/// than memory holds; then nothing is added and the id is 0, which [`free_id`] never gives a part.
pub fn add_part(&mut self, kind: PartKind, blocks: Blocks) -> u32 {
self.try_add_part(kind, blocks).unwrap_or(0)
}
/// [`Document::add_part`], or an error, adding nothing, when every part id is taken.
pub fn try_add_part(&mut self, kind: PartKind, blocks: Blocks) -> Result<u32> {
let id = free_id(self.parts.keys().next_back().copied(), self.parts.keys().copied(), 1)
.ok_or_else(|| DocError::Invalid("no free part id".into()))?;
let mut part = Part { kind, blocks, body: TextBody::default() };
if part.blocks.is_empty() {
part.blocks.push(para_block(Paragraph::new()));
}
self.parts.insert(id, part);
Ok(id)
}
/// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit,
/// including generated numbered-list labels).
pub fn word_count(&self) -> usize {
self.story_word_count(StoryRef::Body)
}
/// Word's default count: the body plus its text boxes, footnotes and endnotes.
pub fn word_count_including_notes(&self) -> usize {
self.counted_stories().iter().map(|s| self.story_word_count(*s)).sum()
}
/// Word count of one story, including generated numbered-list labels.
pub fn story_word_count(&self, s: StoryRef) -> usize {
let Some(bl) = self.story(s) else { return 0 };
let mut counters = numbering::Counters::default();
story_word_count_of(bl, &self.numbering, &self.styles, &mut counters, 0)
}
/// The body, then the text boxes, footnotes and endnotes it shows (nested ones too), in
/// document order. Headers, footers, comments and stories nothing points at aren't included.
pub fn counted_stories(&self) -> Vec<StoryRef> {
self.reachable(vec![StoryRef::Body], &notes_and_boxes)
}
/// The stories a reader sees, in reading order: the body, then the headers and footers in
/// use, each followed by the notes and text boxes it shows. Hidden stories (a removed header,
/// a first-page or even-page header whose option is off, a linked text box's own text) and
/// comment text aren't included.
pub fn shown_stories(&self) -> Vec<StoryRef> {
let followers: std::collections::BTreeSet<u32> = self.text_box_chains().into_iter().flat_map(|c| c.into_iter().skip(1)).collect();
let follow = |o: &InlineObject| {
let mut v = notes_and_boxes(o);
v.retain(|(id, _)| !followers.contains(id));
v
};
let mut out = self.reachable(vec![StoryRef::Body], &follow);
let mut seen: std::collections::BTreeSet<StoryRef> = out.iter().copied().collect();
let mut roots = Vec::new();
let mut root_seen = std::collections::BTreeSet::new();
let (mut headers, mut footers) = (section::HeaderSet::default(), section::HeaderSet::default());
for (_, sec) in self.sections() {
// Unset versions are inherited from the previous section.
for (set, own) in [(&mut headers, &sec.headers), (&mut footers, &sec.footers)] {
set.default = own.default.or(set.default);
set.first = own.first.or(set.first);
set.even = own.even.or(set.even);
let first = set.first.filter(|_| sec.title_page);
let even = set.even.filter(|_| self.settings.even_odd_headers);
// By the first page each one shows on: page 1, then page 2 (even), then page 3.
let order = if sec.title_page { [first, even, set.default] } else { [set.default, even, None] };
for id in order.into_iter().flatten() {
if self.parts.get(&id).is_some_and(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)) && root_seen.insert(id) {
roots.push(StoryRef::Part(id));
}
}
}
}
for st in self.reachable(roots, &follow) {
if seen.insert(st) {
out.push(st);
}
}
out
}
/// Drop the text box stories no box shows any more (a deleted or cut box's text), nested
/// ones too. Undo snapshots and the clipboard keep their own copies. Free for a document
/// without text boxes; otherwise one pass over its paragraphs. Returns how many went.
pub fn prune_text_boxes(&mut self) -> usize {
if !self.parts.values().any(|p| p.kind == PartKind::TextBox) {
return 0;
}
let roots = std::iter::once(StoryRef::Body)
.chain(self.parts.iter().filter(|(_, p)| p.kind != PartKind::TextBox).map(|(id, _)| StoryRef::Part(*id)))
.collect();
let live = self.reachable(roots, &|o| o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect());
let before = self.parts.len();
self.parts.retain(|id, p| p.kind != PartKind::TextBox || live.contains(&StoryRef::Part(*id)));
let gone = before - self.parts.len();
if gone > 0 {
// A link to a box that went ends the chain there (its id may be reused).
let ids: std::collections::BTreeSet<u32> = self.parts.keys().copied().collect();
for p in self.parts.values_mut() {
if p.body.next.is_some_and(|n| !ids.contains(&n)) {
p.body.next = None;
}
}
}
gone
}
/// The chains of linked text boxes (Create Link), each in order from the box holding the
/// text: every chain has two or more boxes. A link to a part that isn't a text box, a box
/// already linked from another, or links running in a circle are ignored (those boxes show
/// their own text); a chain stops after [`MAX_LINKED_BOXES`].
pub fn text_box_chains(&self) -> Vec<Vec<u32>> {
let is_box = |id: u32| self.parts.get(&id).is_some_and(|p| p.kind == PartKind::TextBox);
// Who links to whom: the first box (by id) to link to a box wins.
let mut prev: BTreeMap<u32, u32> = BTreeMap::new();
for (id, p) in &self.parts {
if p.kind != PartKind::TextBox {
continue;
}
if let Some(n) = p.body.next.filter(|n| *n != *id && is_box(*n)) {
prev.entry(n).or_insert(*id);
}
}
let next = |id: u32| self.parts.get(&id).and_then(|p| p.body.next).filter(|n| prev.get(n) == Some(&id));
let mut out = Vec::new();
for (id, p) in &self.parts {
if p.kind != PartKind::TextBox || prev.contains_key(id) || next(*id).is_none() {
continue;
}
let mut chain = vec![*id];
let mut at = *id;
while let Some(n) = next(at) {
if chain.len() >= MAX_LINKED_BOXES || chain.contains(&n) {
break;
}
chain.push(n);
at = n;
}
out.push(chain);
}
out
}
/// The chain (see [`Document::text_box_chains`]) text box `part` belongs to, if linked.
pub fn text_box_chain(&self, part: u32) -> Option<Vec<u32>> {
self.text_box_chains().into_iter().find(|c| c.contains(&part))
}
/// The size (width, height) of the shapes showing text boxes `ids`, in any story (a group
/// member's as the group stretches it).
pub fn text_box_sizes(&self, ids: &std::collections::BTreeSet<u32>) -> BTreeMap<u32, (f32, f32)> {
let mut out = BTreeMap::new();
let stories = std::iter::once(&self.body).chain(self.parts.values().map(|p| &p.blocks));
for blocks in stories {
for b in blocks {
edit::each_para(b, 0, &mut |p| {
for o in &p.objects {
match o {
InlineObject::Shape { story: Some(id), w, h, .. } if ids.contains(id) => {
out.entry(*id).or_insert((*w, *h));
}
InlineObject::Group { w, h, .. } => {
for (r, c) in o.group_rects(0.0, 0.0, *w, *h) {
if let Some(id) = c.text_box().filter(|id| ids.contains(id)) {
out.entry(id).or_insert((r[2], r[3]));
}
}
}
_ => {}
}
}
});
}
}
out
}
/// Every inline object in reading order: the body's, with each text box's objects where the
/// box is (nested boxes too, within a [`BoxBudget`]).
pub fn objects_in_reading_order(&self, f: &mut dyn FnMut(&InlineObject)) {
self.walk_objects(&self.body, &mut BoxBudget::default(), &mut |p, k| {
if let Some(o) = p.objects.get(k) {
f(o);
}
true
});
}
/// Like [`Document::objects_in_reading_order`], with each object's paragraph and index;
/// `f` returns whether to go into the object's text box (false: the box is left out).
pub fn objects_in_reading_order_at(&self, f: &mut dyn FnMut(&Paragraph, usize) -> bool) {
self.walk_objects(&self.body, &mut BoxBudget::default(), f);
}
/// Like [`Document::objects_in_reading_order_at`], also giving the top-level body block each
/// object is in (for objects in text boxes, the block of the box's anchor).
pub fn objects_in_reading_order_by_block(&self, f: &mut dyn FnMut(usize, &Paragraph, usize) -> bool) {
let mut budget = BoxBudget::default();
for (i, b) in self.body.iter().enumerate() {
self.walk_objects(std::slice::from_ref(b), &mut budget, &mut |p, k| f(i, p, k));
}
}
/// The footnotes and endnotes referenced inside text box story `part` (nested boxes too), in
/// reading order.
pub fn notes_in_text_box(&self, part: u32) -> Vec<u32> {
let mut out = Vec::new();
let mut budget = BoxBudget::default();
if let Some(p) = self.parts.get(&part).filter(|p| p.kind == PartKind::TextBox)
&& budget.enter(part)
{
self.walk_objects(&p.blocks, &mut budget, &mut |p, k| {
if let Some(InlineObject::NoteRef { id, .. }) = p.objects.get(k) {
out.push(*id);
}
true
});
budget.leave();
}
out
}
fn walk_objects(&self, blocks: &[Arc<Block>], budget: &mut BoxBudget, f: &mut dyn FnMut(&Paragraph, usize) -> bool) {
for b in blocks {
edit::each_para(b, 0, &mut |p| {
for (k, o) in p.objects.iter().enumerate() {
if !f(p, k) {
continue;
}
for id in o.text_boxes() {
if let Some(part) = self.parts.get(&id).filter(|p| p.kind == PartKind::TextBox)
&& budget.enter(id)
{
self.walk_objects(&part.blocks, budget, f);
budget.leave();
}
}
}
});
}
}
/// `roots`, then every story their objects lead to (`follow`: an object's story id and the
/// part kinds that count), transitively, in the order found.
fn reachable(&self, roots: Vec<StoryRef>, follow: &dyn Fn(&InlineObject) -> Vec<(u32, &'static [PartKind])>) -> Vec<StoryRef> {
let mut out = roots;
let mut seen: std::collections::BTreeSet<StoryRef> = out.iter().copied().collect();
let mut i = 0;
while let Some(s) = out.get(i).copied() {
i += 1;
let mut found = Vec::new();
for b in self.story(s).into_iter().flatten() {
edit::each_para(b, 0, &mut |p| {
found.extend(p.objects.iter().flat_map(follow));
});
}
for (id, kinds) in found {
if self.parts.get(&id).is_some_and(|p| kinds.contains(&p.kind)) && seen.insert(StoryRef::Part(id)) {
out.push(StoryRef::Part(id));
}
}
}
out
}
/// A drawing id no shape uses yet (for gluing a connector to a shape that has none).
pub fn next_shape_id(&self) -> u32 {
let mut max = 0u32;
let mut see = |o: &InlineObject| match o {
InlineObject::Shape { extra, .. } => max = max.max(extra.id),
InlineObject::Group { children, .. } => {
for c in children {
if let InlineObject::Shape { extra, .. } = &c.obj {
max = max.max(extra.id);
}
}
}
_ => {}
};
for blocks in std::iter::once(&self.body).chain(self.parts.values().map(|p| &p.blocks)) {
for b in blocks {
edit::each_para(b, 0, &mut |p| p.objects.iter().for_each(&mut see));
}
}
max.saturating_add(1).max(1)
}
/// Change every inline object in every story (paragraphs inside tables too), copying only
/// the blocks whose paragraphs `wants` says have objects to change.
pub fn objects_mut(&mut self, wants: &dyn Fn(&InlineObject) -> bool, f: &mut dyn FnMut(&mut InlineObject)) {
let mut stories: Vec<&mut Blocks> = vec![&mut self.body];
stories.extend(self.parts.values_mut().map(|p| &mut p.blocks));
for blocks in stories {
for b in blocks.iter_mut() {
let mut any = false;
edit::each_para(b, 0, &mut |p| any |= p.objects.iter().any(wants));
if any {
edit::each_para_mut(Arc::make_mut(b), 0, &mut |p| {
if p.objects.iter().any(wants) {
p.objects.iter_mut().filter(|o| wants(o)).for_each(&mut *f);
p.touch();
}
});
}
}
}
}
/// Repair every paragraph's runs (see [`Paragraph::normalize`]); for models from untrusted
/// JSON, whose run lengths may overflow, fall short or split a character.
pub fn normalize_runs(&mut self) {
let mut stories: Vec<&mut Blocks> = vec![&mut self.body];
stories.extend(self.parts.values_mut().map(|p| &mut p.blocks));
for blocks in stories {
for b in blocks.iter_mut() {
edit::each_para_mut(Arc::make_mut(b), 0, &mut Paragraph::normalize);
}
}
}
/// Number of paragraphs in the body (all depths).
pub fn paragraph_count(&self) -> usize {
self.para_paths(StoryRef::Body).len()
}
/// Add a media item; returns its key (deduplicated by content).
pub fn add_media(&mut self, bytes: Vec<u8>, ext: &str) -> String {
let ix = &mut self.media_index;
if ix.len != self.media.len() {
ix.rebuild(&self.media);
}
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 = i.saturating_add(1);
key = format!("image{i}.{ext}");
}
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
}
/// The value of a custom property (names compare case-insensitively, as in Word).
pub fn custom_prop(&self, name: &str) -> Option<&str> {
self.custom_props.iter().find(|p| p.name.eq_ignore_ascii_case(name)).map(|p| p.value.as_str())
}
/// Set a custom text property, replacing one of the same name in place.
pub fn set_custom_prop(&mut self, name: &str, value: &str) {
match self.custom_props.iter_mut().find(|p| p.name.eq_ignore_ascii_case(name)) {
Some(p) => {
p.kind = "lpwstr".into();
p.value = value.to_string();
}
None => {
self.custom_props.push(CustomProp { name: name.to_string(), kind: "lpwstr".into(), value: value.to_string(), ..Default::default() })
}
}
}
/// Remove a custom property. Returns whether it existed.
pub fn remove_custom_prop(&mut self, name: &str) -> bool {
let n = self.custom_props.len();
self.custom_props.retain(|p| !p.name.eq_ignore_ascii_case(name));
self.custom_props.len() != n
}
/// Ranges that stay editable while the document is protected (Restrict Editing exceptions),
/// in document order across every story: the [`InlineObject::PermStart`]'s id, group and
/// editor, its start (just after the marker) and its end (at the matching
/// [`InlineObject::PermEnd`], or the end of the story when it has none).
pub fn perm_ranges(&self) -> Vec<PermRange> {
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 {
// 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, 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.entry(*id).or_default().push(out.len());
out.push(PermRange { id: *id, group: group.clone(), editor: editor.clone(), start: start.clone(), end: start });
}
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 };
}
}
_ => {}
}
}
}
// A range left open runs to the end of its story.
let end = self.end_of(story);
for i in open.into_values().flatten() {
if let Some(r) = out.get_mut(i) {
r.end = end.clone();
}
}
}
out
}
/// Bookmark names in the body, in order.
pub fn bookmarks(&self) -> Vec<(String, Pos)> {
let mut out = Vec::new();
for path in self.para_paths(StoryRef::Body) {
if let Some(p) = self.para(StoryRef::Body, &path) {
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 }));
}
}
}
}
out
}
}
+251 -558
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
@@ -329,10 +434,16 @@ impl Document {
at.extend(offs.get(k).copied());
}
}
// Reference offsets and run boundaries are ordered; do not restart at the first
// reference for every run, including runs after the last reference.
let mut at = at.into_iter().peekable();
let mut start = 0usize;
for r in &mut p.runs {
let end = start.saturating_add(r.len);
if at.iter().any(|&o| o >= start && o < end) {
while at.peek().is_some_and(|&o| o < start) {
at.next();
}
if at.peek().is_some_and(|&o| o < end) {
swap(&mut r.props.style);
}
start = end;
@@ -474,7 +585,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 +602,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()));
}
}
{
@@ -673,6 +798,63 @@ impl Document {
Fragment { blocks, parts }
}
/// A copy of the block at `path` (a whole table, say) as a fragment, with its text boxes.
pub fn copy_block(&self, story: crate::StoryRef, path: &Path) -> Option<Fragment> {
Some(self.fragment_of(vec![self.block(story, path)?.clone()]))
}
/// A copy of rows `rows` of the table at `tp`, with the cells of each that reach into grid
/// columns `cols` (see [`crate::Table::slice`]), as a fragment holding that table.
pub fn copy_table_cells(&self, story: crate::StoryRef, tp: &Path, rows: (usize, usize), cols: (usize, usize)) -> Option<Fragment> {
let t = self.table(story, tp)?.slice(rows, cols);
if t.rows.is_empty() {
return None;
}
Some(self.fragment_of(vec![Block::Table(t)]))
}
/// `blocks` as a fragment: half content controls dropped, text boxes copied along.
fn fragment_of(&self, mut blocks: Vec<Block>) -> Fragment {
crate::control::balance_blocks(&mut blocks);
let parts = self.text_box_parts(&blocks);
Fragment { blocks, parts }
}
/// The table whose whole text `a..b` covers when the range runs across its cells: from the
/// start of its first paragraph to the end of its last. Of the tables holding both ends, the
/// outermost one covered whole (a table nested in a cell when the range covers just it).
/// `None` for a range inside one cell, or one that doesn't cover a whole table.
pub fn whole_table(&self, a: &Pos, b: &Pos) -> Option<Path> {
let (a, b) = order(a, b);
if a.story != b.story || a.off != 0 || a.path.parent() == b.path.parent() {
return None;
}
let mut paths: Option<Vec<Path>> = None;
// Table block paths are the prefixes of a cell paragraph's path one block deep, then
// every three steps (block, row, cell) deeper.
let mut n = 1usize;
while n.saturating_add(3) <= a.path.0.len() {
let pre = a.path.0.get(..n)?;
if !b.path.0.starts_with(pre) {
return None;
}
let tp = Path(pre.to_vec());
if self.table(a.story, &tp).is_some() {
let paths = paths.get_or_insert_with(|| self.para_paths(a.story));
let mut inside = paths.iter().filter(|p| p.0.starts_with(pre));
if let Some(first) = inside.next() {
let last = inside.next_back().unwrap_or(first);
let end = self.para(a.story, last).map_or(0, Paragraph::len);
if *first == a.path && *last == b.path && b.off >= end {
return Some(tp);
}
}
}
n = n.saturating_add(3);
}
None
}
/// The whole body as a fragment (with its text boxes), to insert into another document.
pub fn body_fragment(&self) -> Fragment {
let blocks: Vec<Block> = self.body.iter().map(|b| (**b).clone()).collect();
@@ -727,6 +909,13 @@ impl Document {
}
return out;
}
// Across the cells of a whole table: the table itself, not its cells' paragraphs (#1744).
if let Some(tp) = self.whole_table(&a, &b)
&& let Some(blk) = self.block(a.story, &tp)
{
out.push(blk.clone());
return out;
}
for p in self.paths_between(&a, &b) {
if let Some(para) = self.para(a.story, &p) {
let mut q = para.clone();
@@ -744,7 +933,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 +959,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 +1027,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()
@@ -879,542 +1110,4 @@ impl Document {
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{StoryRef, Table};
fn doc(lines: &[&str]) -> Document {
Document::from_text(&lines.join("\n"))
}
/// Paths between two positions are those of all the story's paragraphs between them.
#[test]
fn paths_between_matches_every_path_filtered() {
let mut d = doc(&["a", "b", "c", "d", "e"]);
let mut inner = Table::new(2, 2, 100.0);
inner.rows[1].cells[0].blocks.push(crate::para_block(crate::Paragraph::new()));
let mut t = Table::new(3, 2, 200.0);
t.rows[1].cells[1].blocks.insert(0, Arc::new(Block::Table(inner)));
d.insert_block(StoryRef::Body, &Path::top(2), Block::Table(t)).unwrap();
d.insert_block(StoryRef::Body, &Path::top(4), Block::Table(Table::new(1, 3, 200.0))).unwrap();
let all = d.para_paths(StoryRef::Body);
for a in &all {
for b in &all {
let (pa, pb) = (Pos { story: StoryRef::Body, path: a.clone(), off: 0 }, Pos { story: StoryRef::Body, path: b.clone(), off: 0 });
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
let want: Vec<Path> = all.iter().filter(|p| *p >= lo && *p <= hi).cloned().collect();
assert_eq!(d.paths_between(&pa, &pb), want, "{a:?} {b:?}");
}
}
}
#[test]
fn split_and_join() {
let mut d = doc(&["Hello world"]);
let p = d.split_paragraph(&Pos::body(0, 5)).unwrap();
assert_eq!(p, Pos::body(1, 0));
assert_eq!(d.plain_text(StoryRef::Body), "Hello\n world");
let at = d.delete_range(&Pos::body(0, 5), &Pos::body(1, 0)).unwrap();
assert_eq!(at, Pos::body(0, 5));
assert_eq!(d.plain_text(StoryRef::Body), "Hello world");
}
#[test]
fn delete_range_with_bad_end_leaves_document_unchanged() {
let mut d = doc(&["one", "two", "three"]);
let before = d.plain_text(StoryRef::Body);
// Missing paragraph as the second end.
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(9, 0)).is_err());
assert_eq!(d.plain_text(StoryRef::Body), before);
// Offset past the end of the second paragraph.
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 99)).is_err());
assert_eq!(d.plain_text(StoryRef::Body), before);
// Offset inside a multi-byte character.
let mut d = doc(&["abc", "\u{e9}x"]);
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 1)).is_err());
assert_eq!(d.plain_text(StoryRef::Body), "abc\n\u{e9}x");
// Range entering a table with a bad offset at its start: the cell text stays.
let mut d = doc(&["before", "after"]);
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 1, 200.0))).unwrap();
d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "x", &CharProps::default()).unwrap();
let before = d.plain_text(StoryRef::Body);
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 1, 0, 0]), off: 0 };
assert!(d.delete_range(&Pos::body(0, 99), &cell).is_err());
assert_eq!(d.plain_text(StoryRef::Body), before);
}
#[test]
fn delete_multi() {
let mut d = doc(&["one", "two", "three"]);
d.delete_range(&Pos::body(2, 2), &Pos::body(0, 1)).unwrap();
assert_eq!(d.plain_text(StoryRef::Body), "oree");
assert_eq!(d.body.len(), 1);
}
/// #2127: a range over two section breaks joins three sections into the last; the headers
/// and footers it showed by linking to earlier ones stay, each from the nearest section that
/// named it, as they resolved before the delete.
#[test]
fn delete_over_two_section_breaks_keeps_the_linked_headers() {
let mut d = doc(&["A", "B", "C"]);
let set = |default| crate::section::HeaderSet { default: Some(default), ..Default::default() };
let s1 = crate::section::SectionProps { headers: set(1), footers: set(5), ..Default::default() };
let s2 = crate::section::SectionProps { headers: set(2), ..Default::default() };
let s3 = crate::section::SectionProps { margin_left: 99.0, ..Default::default() };
for (i, s) in [(0, s1), (1, s2), (2, s3.clone())] {
if let Some(Block::Para(p)) = d.body.get_mut(i).map(Arc::make_mut) {
p.section = Some(Box::new(s));
}
}
assert_eq!(d.sections().len(), 4);
d.delete_range(&Pos::body(0, 1), &Pos::body(2, 0)).unwrap();
assert_eq!(d.plain_text(StoryRef::Body), "AC");
let sections = d.sections();
assert_eq!(sections.len(), 2);
assert_eq!(*sections[0].1, crate::section::SectionProps { headers: set(2), footers: set(5), ..s3 });
}
#[test]
fn format_across() {
let mut d = doc(&["abc", "def"]);
d.format_range(&Pos::body(0, 1), &Pos::body(1, 2), &|c| c.bold = Some(true)).unwrap();
let p0 = d.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!(p0.props_of_char(0).bold, None);
assert_eq!(p0.props_of_char(1).bold, Some(true));
let p1 = d.para(StoryRef::Body, &Path::top(1)).unwrap();
assert_eq!(p1.props_of_char(1).bold, Some(true));
assert_eq!(p1.props_of_char(2).bold, None);
}
#[test]
fn copy_paste_round_trip() {
let mut d = doc(&["alpha", "beta", "gamma"]);
let f = d.copy_range(&Pos::body(0, 2), &Pos::body(2, 3));
assert_eq!(f.plain_text(), "pha\nbeta\ngam");
let end = d.insert_fragment(&Pos::body(2, 5), &f).unwrap();
assert_eq!(d.plain_text(StoryRef::Body), "alpha\nbeta\ngammapha\nbeta\ngam");
assert_eq!(end, Pos::body(4, 3));
let one = Fragment::from_text("XY");
let e = d.insert_fragment(&Pos::body(0, 0), &one).unwrap();
assert_eq!(e, Pos::body(0, 2));
assert!(d.plain_text(StoryRef::Body).starts_with("XYalpha"));
}
/// The first paragraph of the deepest cell, following the first table at each level.
fn deepest(d: &Document) -> Path {
let (mut path, mut blocks) = (Vec::new(), &d.body);
while let Some((i, t)) = blocks.iter().enumerate().find_map(|(i, b)| if let Block::Table(t) = &**b { Some((i, t)) } else { None }) {
path.extend([i as u32, 0, 0]);
blocks = &t.rows[0].cells[0].blocks;
}
path.push(0);
Path(path)
}
fn body_height(d: &Document) -> usize {
d.body.iter().filter_map(|b| if let Block::Table(t) = &**b { Some(table_height(t)) } else { None }).max().unwrap_or(0)
}
/// How many times `c` is in `blocks`, the deepest cells too.
fn count(blocks: &[Arc<Block>], c: char) -> usize {
blocks
.iter()
.map(|b| match &**b {
Block::Para(p) => p.text.matches(c).count(),
Block::Table(t) => t.rows.iter().flat_map(|r| &r.cells).map(|cell| count(&cell.blocks, c)).sum(),
})
.sum()
}
#[test]
fn tables_nest_at_most_max_table_depth() {
let mut d = doc(&["x", ""]);
for _ in 0..MAX_TABLE_DEPTH {
let at = deepest(&d);
d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).unwrap();
}
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
// One more in the deepest cell is refused and changes nothing; a paragraph is fine.
let (at, before) = (deepest(&d), d.clone());
assert_eq!(at.depth(), MAX_TABLE_DEPTH);
assert!(d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).is_err());
assert_eq!(d, before);
d.insert_block(StoryRef::Body, &at, Block::Para(Paragraph::new())).unwrap();
// One level up, a table fits but a table holding a table doesn't.
let up = Path(at.0[..at.0.len() - 3].to_vec());
let mut two = Table::new(1, 1, 100.0);
two.rows[0].cells[0].blocks.insert(0, Arc::new(Block::Table(Table::new(1, 1, 100.0))));
assert!(d.insert_block(StoryRef::Body, &up, Block::Table(two)).is_err());
d.insert_block(StoryRef::Body, &up, Block::Table(Table::new(1, 1, 100.0))).unwrap();
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
}
#[test]
fn pasting_a_table_into_its_own_cell_stops_at_max_table_depth() {
// #1596: copy everything and paste it into the deepest cell, again and again: each round
// doubled the nesting, until saving, copying or closing the document overflowed the stack.
let mut d = doc(&["x", ""]);
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 100.0))).unwrap();
d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "a", &CharProps::default()).unwrap();
for _ in 0..8 {
let all = d.copy_range(&Pos::body(0, 0), &Pos::body(d.body.len() - 1, 0));
let at = deepest(&d);
let off = d.para(StoryRef::Body, &at).map_or(0, Paragraph::len);
d.insert_fragment(&Pos { story: StoryRef::Body, path: at, off }, &all).unwrap();
}
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
// Nothing is lost: every copy's text is there, below the limit as paragraphs.
assert_eq!((count(&d.body, 'x'), count(&d.body, 'a')), (256, 256));
}
fn text_box(d: &mut Document, pos: &Pos, text: &str) -> u32 {
let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(text, CharProps::default()))]);
d.insert_object(pos, box_shape(Some(id)), &CharProps::default()).unwrap();
id
}
fn box_shape(story: Option<u32>) -> InlineObject {
InlineObject::Shape {
kind: crate::para::ShapeKind::TextBox,
w: 144.0,
h: 72.0,
fill: None,
stroke: None,
stroke_width: 0.75,
float: Default::default(),
story,
freeform: None,
effects: Default::default(),
extra: Default::default(),
}
}
/// Story ids of the boxes in a body paragraph, in order.
fn boxes_in(d: &Document, path: &Path) -> Vec<Option<u32>> {
let p = d.para(StoryRef::Body, path).unwrap();
p.objects.iter().filter_map(|o| if let InlineObject::Shape { story, .. } = o { Some(*story) } else { None }).collect()
}
#[test]
fn pasted_text_box_gets_its_own_story() {
let mut d = doc(&["ab", "cd"]);
let id = text_box(&mut d, &Pos::body(0, 1), "Inside");
let f = d.copy_range(&Pos::body(0, 0), &Pos::body(0, 5));
assert_eq!(f.parts.keys().copied().collect::<Vec<_>>(), vec![id]);
// Edit the original after copying: the paste keeps the copied text.
d.insert_text(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 6 }, " later", &CharProps::default()).unwrap();
d.insert_fragment(&Pos::body(1, 2), &f).unwrap();
let pasted = boxes_in(&d, &Path::top(1));
let [Some(new)] = pasted[..] else { panic!("{pasted:?}") };
assert_ne!(new, id);
assert_eq!(d.parts.get(&new).map(|p| p.kind), Some(PartKind::TextBox));
assert_eq!(d.plain_text(StoryRef::Part(new)), "Inside");
assert_eq!(d.plain_text(StoryRef::Part(id)), "Inside later");
// Editing the copy leaves the original alone; a second paste is a third story.
d.insert_text(&Pos { story: StoryRef::Part(new), path: Path::top(0), off: 0 }, "Copy: ", &CharProps::default()).unwrap();
assert_eq!(d.plain_text(StoryRef::Part(id)), "Inside later");
d.insert_fragment(&Pos::body(1, 0), &f).unwrap();
let again = boxes_in(&d, &Path::top(1));
assert_eq!(again.len(), 2);
assert!(again.iter().all(|s| s.is_some_and(|s| s != id)) && again[0] != again[1], "{again:?}");
// Multi-paragraph pastes too.
let f2 = d.copy_range(&Pos::body(0, 0), &Pos::body(1, 1));
let before = d.parts.len();
d.insert_fragment(&Pos::body(1, 0), &f2).unwrap();
assert_eq!(d.parts.len(), before + 1);
}
#[test]
fn text_box_pasted_into_another_document_brings_its_text() {
let mut src = doc(&["x"]);
let id = text_box(&mut src, &Pos::body(0, 1), "From the source");
let f = src.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3));
// The target already has a header with the same part id.
let mut dst = doc(&["target"]);
let hdr = dst.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Header text", CharProps::default()))]);
assert_eq!(hdr, id);
dst.insert_fragment(&Pos::body(0, 6), &f).unwrap();
let [Some(new)] = boxes_in(&dst, &Path::top(0))[..] else { panic!() };
assert_ne!(new, hdr);
assert_eq!(dst.plain_text(StoryRef::Part(new)), "From the source");
assert_eq!(dst.plain_text(StoryRef::Part(hdr)), "Header text");
// The whole body of a document, as Insert › Text from File uses it.
let mut dst2 = doc(&["t"]);
dst2.insert_fragment(&Pos::body(0, 1), &src.body_fragment()).unwrap();
let [Some(n2)] = boxes_in(&dst2, &Path::top(0))[..] else { panic!() };
assert_eq!(dst2.plain_text(StoryRef::Part(n2)), "From the source");
}
#[test]
fn text_box_in_a_copied_table_gets_its_own_story() {
let mut d = doc(&["before", "after"]);
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 200.0))).unwrap();
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 };
let id = text_box(&mut d, &cell, "In a cell");
let f = d.copy_range(&Pos::body(0, 0), &Pos::body(2, 5));
assert!(f.parts.contains_key(&id));
d.insert_fragment(&Pos::body(2, 5), &f).unwrap();
let ids: Vec<u32> = d
.para_paths(StoryRef::Body)
.iter()
.filter_map(|p| d.para(StoryRef::Body, p))
.flat_map(|p| p.objects.iter())
.filter_map(|o| if let InlineObject::Shape { story, .. } = o { *story } else { None })
.collect();
assert_eq!(ids.len(), 2, "{ids:?}");
assert_ne!(ids[0], ids[1]);
assert!(ids.iter().all(|i| d.plain_text(StoryRef::Part(*i)) == "In a cell"));
}
#[test]
fn fragment_without_parts_never_shows_an_unrelated_story() {
let mut d = doc(&["x"]);
let hdr = d.add_part(PartKind::Header, Vec::new());
let own = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Mine", CharProps::default()))]);
let mut p = Paragraph::new();
p.insert_object(0, box_shape(Some(hdr)), &CharProps::default()).unwrap();
p.insert_object(0, box_shape(Some(own)), &CharProps::default()).unwrap();
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
let f = Fragment { blocks: vec![Block::Para(p)], ..Default::default() };
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
let got = boxes_in(&d, &Path::top(0));
assert_eq!(got.len(), 3);
assert_eq!(got[0], None, "missing story");
let Some(copy) = got[1] else { panic!("{got:?}") };
assert_ne!(copy, own);
assert_eq!(d.plain_text(StoryRef::Part(copy)), "Mine");
assert_eq!(got[2], None, "a header isn't a text box");
}
#[test]
fn self_nested_text_box_paste_is_bounded() {
let mut d = doc(&["x"]);
let id = text_box(&mut d, &Pos::body(0, 1), "loop");
// The box contains a box showing itself.
d.insert_object(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, box_shape(Some(id)), &CharProps::default()).unwrap();
let f = d.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3));
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}");
}
#[test]
fn tables_in_paths() {
let mut d = doc(&["before", "after"]);
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 2, 200.0))).unwrap();
let paths = d.para_paths(StoryRef::Body);
assert_eq!(paths.len(), 6);
assert_eq!(paths[1], Path(vec![1, 0, 0, 0]));
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 1, 0]), off: 0 };
let e = d.insert_text(&cell, "x", &CharProps::default()).unwrap();
assert_eq!(e.off, 1);
assert_eq!(d.next_para(StoryRef::Body, &Path(vec![1, 0, 1, 0])), Some(Path(vec![1, 1, 0, 0])));
assert!(d.plain_text(StoryRef::Body).contains("\tx"));
// Delete from before the table into a cell: text cleared, table kept (partially covered).
d.delete_range(&Pos::body(0, 2), &cell).unwrap();
assert_eq!(d.para(StoryRef::Body, &Path::top(0)).unwrap().text, "be");
assert!(d.container(StoryRef::Body, &Path(vec![1, 0, 0, 0])).is_some());
assert_eq!(Path(vec![1, 0, 1, 0]).cell(), Some((Path::top(1), 0, 1)));
}
fn para(text: &str) -> Block {
Block::Para(Paragraph::with_text(text, CharProps::default()))
}
#[test]
fn appended_pieces_are_each_followed_by_one_paragraph_mark() {
let mut spike = Fragment::default();
spike.append_piece(Fragment { blocks: vec![para("a")], ..Default::default() });
spike.append_piece(Fragment { blocks: vec![para("b")], ..Default::default() });
assert_eq!(spike.plain_text(), "a\nb\n");
// A piece that already ends with its paragraph mark gets no second one.
let mut spike = Fragment::default();
spike.append_piece(Fragment { blocks: vec![para("a"), para("")], ..Default::default() });
assert_eq!(spike.plain_text(), "a\n");
spike.append_piece(Fragment { blocks: vec![para("b"), para("c")], ..Default::default() });
assert_eq!(spike.plain_text(), "a\nb\nc\n");
}
#[test]
fn appended_pieces_keep_their_own_text_boxes() {
// Two documents give their first text box the same id.
let mut one = doc(&["x"]);
let id1 = text_box(&mut one, &Pos::body(0, 1), "One");
let mut two = doc(&["y"]);
let id2 = text_box(&mut two, &Pos::body(0, 1), "Two");
assert_eq!(id1, id2);
let mut spike = Fragment::default();
spike.append_piece(one.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3)));
spike.append_piece(two.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3)));
let mut dst = doc(&[""]);
dst.insert_fragment(&Pos::body(0, 0), &spike).unwrap();
let texts: Vec<String> =
[0, 1].iter().flat_map(|i| boxes_in(&dst, &Path::top(*i))).map(|b| dst.plain_text(StoryRef::Part(b.unwrap()))).collect();
assert_eq!(texts, ["One", "Two"]);
}
#[test]
fn bad_positions_error() {
let mut d = doc(&["é"]);
assert!(d.insert_text(&Pos::body(0, 1), "x", &CharProps::default()).is_err());
assert!(d.insert_text(&Pos::body(5, 0), "x", &CharProps::default()).is_err());
assert!(d.split_paragraph(&Pos::body(0, 99)).is_err());
let c = d.clamp(&Pos::body(9, 9));
assert_eq!(c, Pos::body(0, 2));
assert!(d.delete_range(&Pos { story: StoryRef::Part(3), path: Path::top(0), off: 0 }, &Pos::body(0, 0)).is_err());
}
// delete_range over whole tables (#1844), in the body, a header and a footnote.
/// A document whose story (the body, or a header or footnote part) holds `lines`.
fn story_doc(kind: Option<PartKind>, lines: &[&str]) -> (Document, StoryRef) {
match kind {
None => (doc(lines), StoryRef::Body),
Some(k) => {
let mut d = doc(&["body"]);
let blocks = lines.iter().map(|l| para_block(Paragraph::with_text(l, CharProps::default()))).collect();
let id = d.add_part(k, blocks);
(d, StoryRef::Part(id))
}
}
}
const STORIES: [Option<PartKind>; 3] = [None, Some(PartKind::Header), Some(PartKind::Footnote)];
fn at(story: StoryRef, path: &[u32], off: usize) -> Pos {
Pos { story, path: Path(path.to_vec()), off }
}
/// Insert an `rows`×`cols` table at `path` whose cells hold `texts`, row by row.
fn put_table(d: &mut Document, story: StoryRef, path: &[u32], rows: usize, cols: usize, texts: &[&str]) {
d.insert_block(story, &Path(path.to_vec()), Block::Table(Table::new(rows, cols, 200.0))).unwrap();
for (k, t) in texts.iter().enumerate() {
let mut p = path.to_vec();
p.extend([(k / cols) as u32, (k % cols) as u32, 0]);
d.insert_text(&at(story, &p, 0), t, &CharProps::default()).unwrap();
}
}
/// The story's top-level blocks: a paragraph's text, or `[table]`.
fn shape(d: &Document, story: StoryRef) -> Vec<String> {
d.story(story).unwrap().iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect()
}
fn cell_text(d: &Document, story: StoryRef, table: &[u32], r: u32, c: u32) -> String {
let mut p = table.to_vec();
p.extend([r, c, 0]);
d.para(story, &Path(p)).map(|p| p.text.clone()).unwrap_or_else(|| "<none>".into())
}
#[test]
fn a_range_ending_at_the_end_of_a_cell_that_isnt_the_last_keeps_the_table() {
for kind in STORIES {
for (r, c) in [(0, 1), (1, 0)] {
let (mut d, st) = story_doc(kind, &["before", "after"]);
put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]);
d.delete_range(&at(st, &[0], 0), &at(st, &[1, r, c, 0], 1)).unwrap();
assert_eq!(shape(&d, st), ["", "[table]", "after"], "{kind:?} ({r},{c})");
assert_eq!(cell_text(&d, st, &[1], 1, 1), "d", "{kind:?} ({r},{c})");
}
}
}
#[test]
fn a_range_from_the_start_of_one_table_into_the_middle_of_the_next_removes_only_the_first() {
for kind in STORIES {
let (mut d, st) = story_doc(kind, &["x", "mid", "y"]);
put_table(&mut d, st, &[2], 2, 2, &["e", "f", "g", "h"]);
put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]);
assert_eq!(shape(&d, st), ["x", "[table]", "mid", "[table]", "y"]);
let to = d.delete_range(&at(st, &[1, 0, 0, 0], 0), &at(st, &[3, 0, 1, 0], 1)).unwrap();
assert_eq!(shape(&d, st), ["x", "", "[table]", "y"], "{kind:?}");
assert_eq!([cell_text(&d, st, &[2], 0, 0), cell_text(&d, st, &[2], 0, 1), cell_text(&d, st, &[2], 1, 0)], ["", "", "g"], "{kind:?}");
assert!(d.para(st, &to.path).is_some(), "{kind:?}: {to:?}");
}
}
#[test]
fn a_range_from_a_cell_paragraph_to_the_end_of_a_table_nested_in_that_cell_removes_only_the_nested_table() {
for kind in STORIES {
let (mut d, st) = story_doc(kind, &["before", "after"]);
put_table(&mut d, st, &[1], 1, 1, &["p"]);
put_table(&mut d, st, &[1, 0, 0, 1], 1, 1, &["n"]);
d.insert_block(st, &Path(vec![1, 0, 0, 2]), Block::Para(Paragraph::with_text("q", CharProps::default()))).unwrap();
let to = d.delete_range(&at(st, &[1, 0, 0, 0], 1), &at(st, &[1, 0, 0, 1, 0, 0, 0], 1)).unwrap();
assert_eq!(shape(&d, st), ["before", "[table]", "after"], "{kind:?}");
let cell = d.container(st, &Path(vec![1, 0, 0, 0])).unwrap();
let cell: Vec<String> = cell.iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect();
assert_eq!(cell, ["pq"], "{kind:?}");
assert_eq!(to, at(st, &[1, 0, 0, 0], 1), "{kind:?}");
}
}
#[test]
fn two_whole_tables_at_the_end_of_a_story_leave_one_empty_paragraph() {
for kind in STORIES {
for trailing in [true, false] {
let (mut d, st) = story_doc(kind, &[""]);
put_table(&mut d, st, &[0], 1, 2, &["a", "b"]);
put_table(&mut d, st, &[1], 2, 1, &["c", "d"]);
if !trailing {
// A story that ends in a table (a hostile file).
d.story_mut(st).unwrap().pop();
}
let to = d.delete_range(&at(st, &[0, 0, 0, 0], 0), &at(st, &[1, 1, 0, 0], 1)).unwrap();
assert_eq!(shape(&d, st), [""], "{kind:?} trailing {trailing}");
assert_eq!(to, at(st, &[0], 0), "{kind:?} trailing {trailing}");
}
}
}
/// Content controls pair their markers within one block list (a story, or a cell), so a
/// marker in a table never has its partner outside it: a control inside a table removed whole
/// goes with it, and one the range only enters keeps both markers (the control is emptied).
#[test]
fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() {
use crate::control::ContentControl;
let start = || InlineObject::ControlStart { control: Box::new(ContentControl::default()) };
for kind in STORIES {
let (mut d, st) = story_doc(kind, &["before", "after"]);
put_table(&mut d, st, &[1], 1, 1, &["c"]);
// One control around "efo" in "before", one around the cell's text.
d.insert_object(&at(st, &[0], 4), InlineObject::ControlEnd, &CharProps::default()).unwrap();
let in_before = d.insert_object(&at(st, &[0], 1), start(), &CharProps::default()).unwrap();
d.insert_object(&at(st, &[1, 0, 0, 0], 1), InlineObject::ControlEnd, &CharProps::default()).unwrap();
d.insert_object(&at(st, &[1, 0, 0, 0], 0), start(), &CharProps::default()).unwrap();
assert_eq!(d.control_ranges(st).len(), 2, "setup");
let end = d.para(st, &Path(vec![1, 0, 0, 0])).unwrap().len();
// From inside the first control to the end of the table.
d.delete_range(&Pos { off: in_before.off + 1, ..in_before }, &at(st, &[1, 0, 0, 0], end)).unwrap();
assert_eq!(shape(&d, st), ["b\u{FFFC}e\u{FFFC}after"], "{kind:?}: the table goes, the paragraphs join");
let ranges = d.control_ranges(st);
assert_eq!(ranges.len(), 1, "{kind:?}: {ranges:?}");
assert!(ranges.iter().all(|r| r.start.path == Path::top(0) && r.end.path == Path::top(0)), "{kind:?}");
let objects = d.para(st, &Path::top(0)).map_or(0, |p| p.objects.len());
assert_eq!(objects, 2, "{kind:?}: no orphan markers left");
}
}
#[test]
fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() {
// 20 tables, each in the first cell of the one around it, ahead of that cell's paragraph:
// the innermost paragraphs come first in the document but the paragraph walk stops short.
let mut t = Table::new(1, 1, 200.0);
if let Some(cell) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
cell.blocks = vec![para_block(Paragraph::with_text("deep", CharProps::default()))];
}
for _ in 0..20 {
let mut outer = Table::new(1, 1, 200.0);
if let Some(cell) = outer.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
cell.blocks.insert(0, Arc::new(Block::Table(t)));
}
t = outer;
}
let mut d = doc(&["before", "after"]);
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
let first = d.para_paths(StoryRef::Body).into_iter().find(|p| p.0.first() == Some(&1)).unwrap();
d.delete_range(&Pos { story: StoryRef::Body, path: first, off: 0 }, &Pos::body(2, 0)).unwrap();
assert!(d.body.get(1).is_some_and(|b| b.as_table().is_some()), "a table with paragraphs the walk never saw was removed");
}
}
mod tests;
+666
View File
@@ -0,0 +1,666 @@
use super::*;
use crate::{StoryRef, Table};
fn doc(lines: &[&str]) -> Document {
Document::from_text(&lines.join("\n"))
}
/// Paths between two positions are those of all the story's paragraphs between them.
#[test]
fn paths_between_matches_every_path_filtered() {
let mut d = doc(&["a", "b", "c", "d", "e"]);
let mut inner = Table::new(2, 2, 100.0);
inner.rows[1].cells[0].blocks.push(crate::para_block(crate::Paragraph::new()));
let mut t = Table::new(3, 2, 200.0);
t.rows[1].cells[1].blocks.insert(0, Arc::new(Block::Table(inner)));
d.insert_block(StoryRef::Body, &Path::top(2), Block::Table(t)).unwrap();
d.insert_block(StoryRef::Body, &Path::top(4), Block::Table(Table::new(1, 3, 200.0))).unwrap();
let all = d.para_paths(StoryRef::Body);
for a in &all {
for b in &all {
let (pa, pb) = (Pos { story: StoryRef::Body, path: a.clone(), off: 0 }, Pos { story: StoryRef::Body, path: b.clone(), off: 0 });
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
let want: Vec<Path> = all.iter().filter(|p| *p >= lo && *p <= hi).cloned().collect();
assert_eq!(d.paths_between(&pa, &pb), want, "{a:?} {b:?}");
}
}
}
#[test]
fn split_and_join() {
let mut d = doc(&["Hello world"]);
let p = d.split_paragraph(&Pos::body(0, 5)).unwrap();
assert_eq!(p, Pos::body(1, 0));
assert_eq!(d.plain_text(StoryRef::Body), "Hello\n world");
let at = d.delete_range(&Pos::body(0, 5), &Pos::body(1, 0)).unwrap();
assert_eq!(at, Pos::body(0, 5));
assert_eq!(d.plain_text(StoryRef::Body), "Hello world");
}
#[test]
fn delete_range_with_bad_end_leaves_document_unchanged() {
let mut d = doc(&["one", "two", "three"]);
let before = d.plain_text(StoryRef::Body);
// Missing paragraph as the second end.
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(9, 0)).is_err());
assert_eq!(d.plain_text(StoryRef::Body), before);
// Offset past the end of the second paragraph.
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 99)).is_err());
assert_eq!(d.plain_text(StoryRef::Body), before);
// Offset inside a multi-byte character.
let mut d = doc(&["abc", "\u{e9}x"]);
assert!(d.delete_range(&Pos::body(0, 1), &Pos::body(1, 1)).is_err());
assert_eq!(d.plain_text(StoryRef::Body), "abc\n\u{e9}x");
// Range entering a table with a bad offset at its start: the cell text stays.
let mut d = doc(&["before", "after"]);
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 1, 200.0))).unwrap();
d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "x", &CharProps::default()).unwrap();
let before = d.plain_text(StoryRef::Body);
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 1, 0, 0]), off: 0 };
assert!(d.delete_range(&Pos::body(0, 99), &cell).is_err());
assert_eq!(d.plain_text(StoryRef::Body), before);
}
#[test]
fn delete_multi() {
let mut d = doc(&["one", "two", "three"]);
d.delete_range(&Pos::body(2, 2), &Pos::body(0, 1)).unwrap();
assert_eq!(d.plain_text(StoryRef::Body), "oree");
assert_eq!(d.body.len(), 1);
}
/// #2127: a range over two section breaks joins three sections into the last; the headers
/// and footers it showed by linking to earlier ones stay, each from the nearest section that
/// named it, as they resolved before the delete.
#[test]
fn delete_over_two_section_breaks_keeps_the_linked_headers() {
let mut d = doc(&["A", "B", "C"]);
let set = |default| crate::section::HeaderSet { default: Some(default), ..Default::default() };
let s1 = crate::section::SectionProps { headers: set(1), footers: set(5), ..Default::default() };
let s2 = crate::section::SectionProps { headers: set(2), ..Default::default() };
let s3 = crate::section::SectionProps { margin_left: 99.0, ..Default::default() };
for (i, s) in [(0, s1), (1, s2), (2, s3.clone())] {
if let Some(Block::Para(p)) = d.body.get_mut(i).map(Arc::make_mut) {
p.section = Some(Box::new(s));
}
}
assert_eq!(d.sections().len(), 4);
d.delete_range(&Pos::body(0, 1), &Pos::body(2, 0)).unwrap();
assert_eq!(d.plain_text(StoryRef::Body), "AC");
let sections = d.sections();
assert_eq!(sections.len(), 2);
assert_eq!(*sections[0].1, crate::section::SectionProps { headers: set(2), footers: set(5), ..s3 });
}
#[test]
fn format_across() {
let mut d = doc(&["abc", "def"]);
d.format_range(&Pos::body(0, 1), &Pos::body(1, 2), &|c| c.bold = Some(true)).unwrap();
let p0 = d.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!(p0.props_of_char(0).bold, None);
assert_eq!(p0.props_of_char(1).bold, Some(true));
let p1 = d.para(StoryRef::Body, &Path::top(1)).unwrap();
assert_eq!(p1.props_of_char(1).bold, Some(true));
assert_eq!(p1.props_of_char(2).bold, None);
}
#[test]
fn copy_paste_round_trip() {
let mut d = doc(&["alpha", "beta", "gamma"]);
let f = d.copy_range(&Pos::body(0, 2), &Pos::body(2, 3));
assert_eq!(f.plain_text(), "pha\nbeta\ngam");
let end = d.insert_fragment(&Pos::body(2, 5), &f).unwrap();
assert_eq!(d.plain_text(StoryRef::Body), "alpha\nbeta\ngammapha\nbeta\ngam");
assert_eq!(end, Pos::body(4, 3));
let one = Fragment::from_text("XY");
let e = d.insert_fragment(&Pos::body(0, 0), &one).unwrap();
assert_eq!(e, Pos::body(0, 2));
assert!(d.plain_text(StoryRef::Body).starts_with("XYalpha"));
}
/// The first paragraph of the deepest cell, following the first table at each level.
fn deepest(d: &Document) -> Path {
let (mut path, mut blocks) = (Vec::new(), &d.body);
while let Some((i, t)) = blocks.iter().enumerate().find_map(|(i, b)| if let Block::Table(t) = &**b { Some((i, t)) } else { None }) {
path.extend([i as u32, 0, 0]);
blocks = &t.rows[0].cells[0].blocks;
}
path.push(0);
Path(path)
}
fn body_height(d: &Document) -> usize {
d.body.iter().filter_map(|b| if let Block::Table(t) = &**b { Some(table_height(t)) } else { None }).max().unwrap_or(0)
}
/// How many times `c` is in `blocks`, the deepest cells too.
fn count(blocks: &[Arc<Block>], c: char) -> usize {
blocks
.iter()
.map(|b| match &**b {
Block::Para(p) => p.text.matches(c).count(),
Block::Table(t) => t.rows.iter().flat_map(|r| &r.cells).map(|cell| count(&cell.blocks, c)).sum(),
})
.sum()
}
#[test]
fn tables_nest_at_most_max_table_depth() {
let mut d = doc(&["x", ""]);
for _ in 0..MAX_TABLE_DEPTH {
let at = deepest(&d);
d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).unwrap();
}
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
// One more in the deepest cell is refused and changes nothing; a paragraph is fine.
let (at, before) = (deepest(&d), d.clone());
assert_eq!(at.depth(), MAX_TABLE_DEPTH);
assert!(d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).is_err());
assert_eq!(d, before);
d.insert_block(StoryRef::Body, &at, Block::Para(Paragraph::new())).unwrap();
// One level up, a table fits but a table holding a table doesn't.
let up = Path(at.0[..at.0.len() - 3].to_vec());
let mut two = Table::new(1, 1, 100.0);
two.rows[0].cells[0].blocks.insert(0, Arc::new(Block::Table(Table::new(1, 1, 100.0))));
assert!(d.insert_block(StoryRef::Body, &up, Block::Table(two)).is_err());
d.insert_block(StoryRef::Body, &up, Block::Table(Table::new(1, 1, 100.0))).unwrap();
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
}
#[test]
fn pasting_a_table_into_its_own_cell_stops_at_max_table_depth() {
// #1596: copy everything and paste it into the deepest cell, again and again: each round
// doubled the nesting, until saving, copying or closing the document overflowed the stack.
let mut d = doc(&["x", ""]);
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 100.0))).unwrap();
d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "a", &CharProps::default()).unwrap();
for _ in 0..8 {
let all = d.copy_range(&Pos::body(0, 0), &Pos::body(d.body.len() - 1, 0));
let at = deepest(&d);
let off = d.para(StoryRef::Body, &at).map_or(0, Paragraph::len);
d.insert_fragment(&Pos { story: StoryRef::Body, path: at, off }, &all).unwrap();
}
assert_eq!(body_height(&d), MAX_TABLE_DEPTH);
// Nothing is lost: every copy's text is there, below the limit as paragraphs.
assert_eq!((count(&d.body, 'x'), count(&d.body, 'a')), (256, 256));
}
fn text_box(d: &mut Document, pos: &Pos, text: &str) -> u32 {
let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(text, CharProps::default()))]);
d.insert_object(pos, box_shape(Some(id)), &CharProps::default()).unwrap();
id
}
fn box_shape(story: Option<u32>) -> InlineObject {
InlineObject::Shape {
kind: crate::para::ShapeKind::TextBox,
w: 144.0,
h: 72.0,
fill: None,
stroke: None,
stroke_width: 0.75,
float: Default::default(),
story,
freeform: None,
effects: Default::default(),
extra: Default::default(),
}
}
/// Story ids of the boxes in a body paragraph, in order.
fn boxes_in(d: &Document, path: &Path) -> Vec<Option<u32>> {
let p = d.para(StoryRef::Body, path).unwrap();
p.objects.iter().filter_map(|o| if let InlineObject::Shape { story, .. } = o { Some(*story) } else { None }).collect()
}
#[test]
fn pasted_text_box_gets_its_own_story() {
let mut d = doc(&["ab", "cd"]);
let id = text_box(&mut d, &Pos::body(0, 1), "Inside");
let f = d.copy_range(&Pos::body(0, 0), &Pos::body(0, 5));
assert_eq!(f.parts.keys().copied().collect::<Vec<_>>(), vec![id]);
// Edit the original after copying: the paste keeps the copied text.
d.insert_text(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 6 }, " later", &CharProps::default()).unwrap();
d.insert_fragment(&Pos::body(1, 2), &f).unwrap();
let pasted = boxes_in(&d, &Path::top(1));
let [Some(new)] = pasted[..] else { panic!("{pasted:?}") };
assert_ne!(new, id);
assert_eq!(d.parts.get(&new).map(|p| p.kind), Some(PartKind::TextBox));
assert_eq!(d.plain_text(StoryRef::Part(new)), "Inside");
assert_eq!(d.plain_text(StoryRef::Part(id)), "Inside later");
// Editing the copy leaves the original alone; a second paste is a third story.
d.insert_text(&Pos { story: StoryRef::Part(new), path: Path::top(0), off: 0 }, "Copy: ", &CharProps::default()).unwrap();
assert_eq!(d.plain_text(StoryRef::Part(id)), "Inside later");
d.insert_fragment(&Pos::body(1, 0), &f).unwrap();
let again = boxes_in(&d, &Path::top(1));
assert_eq!(again.len(), 2);
assert!(again.iter().all(|s| s.is_some_and(|s| s != id)) && again[0] != again[1], "{again:?}");
// Multi-paragraph pastes too.
let f2 = d.copy_range(&Pos::body(0, 0), &Pos::body(1, 1));
let before = d.parts.len();
d.insert_fragment(&Pos::body(1, 0), &f2).unwrap();
assert_eq!(d.parts.len(), before + 1);
}
#[test]
fn text_box_pasted_into_another_document_brings_its_text() {
let mut src = doc(&["x"]);
let id = text_box(&mut src, &Pos::body(0, 1), "From the source");
let f = src.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3));
// The target already has a header with the same part id.
let mut dst = doc(&["target"]);
let hdr = dst.add_part(PartKind::Header, vec![para_block(Paragraph::with_text("Header text", CharProps::default()))]);
assert_eq!(hdr, id);
dst.insert_fragment(&Pos::body(0, 6), &f).unwrap();
let [Some(new)] = boxes_in(&dst, &Path::top(0))[..] else { panic!() };
assert_ne!(new, hdr);
assert_eq!(dst.plain_text(StoryRef::Part(new)), "From the source");
assert_eq!(dst.plain_text(StoryRef::Part(hdr)), "Header text");
// The whole body of a document, as Insert › Text from File uses it.
let mut dst2 = doc(&["t"]);
dst2.insert_fragment(&Pos::body(0, 1), &src.body_fragment()).unwrap();
let [Some(n2)] = boxes_in(&dst2, &Path::top(0))[..] else { panic!() };
assert_eq!(dst2.plain_text(StoryRef::Part(n2)), "From the source");
}
#[test]
fn text_box_in_a_copied_table_gets_its_own_story() {
let mut d = doc(&["before", "after"]);
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 200.0))).unwrap();
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 };
let id = text_box(&mut d, &cell, "In a cell");
let f = d.copy_range(&Pos::body(0, 0), &Pos::body(2, 5));
assert!(f.parts.contains_key(&id));
d.insert_fragment(&Pos::body(2, 5), &f).unwrap();
let ids: Vec<u32> = d
.para_paths(StoryRef::Body)
.iter()
.filter_map(|p| d.para(StoryRef::Body, p))
.flat_map(|p| p.objects.iter())
.filter_map(|o| if let InlineObject::Shape { story, .. } = o { *story } else { None })
.collect();
assert_eq!(ids.len(), 2, "{ids:?}");
assert_ne!(ids[0], ids[1]);
assert!(ids.iter().all(|i| d.plain_text(StoryRef::Part(*i)) == "In a cell"));
}
#[test]
fn fragment_without_parts_never_shows_an_unrelated_story() {
let mut d = doc(&["x"]);
let hdr = d.add_part(PartKind::Header, Vec::new());
let own = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Mine", CharProps::default()))]);
let mut p = Paragraph::new();
p.insert_object(0, box_shape(Some(hdr)), &CharProps::default()).unwrap();
p.insert_object(0, box_shape(Some(own)), &CharProps::default()).unwrap();
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
let f = Fragment { blocks: vec![Block::Para(p)], ..Default::default() };
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
let got = boxes_in(&d, &Path::top(0));
assert_eq!(got.len(), 3);
assert_eq!(got[0], None, "missing story");
let Some(copy) = got[1] else { panic!("{got:?}") };
assert_ne!(copy, own);
assert_eq!(d.plain_text(StoryRef::Part(copy)), "Mine");
assert_eq!(got[2], None, "a header isn't a text box");
}
#[test]
fn self_nested_text_box_paste_is_bounded() {
let mut d = doc(&["x"]);
let id = text_box(&mut d, &Pos::body(0, 1), "loop");
// The box contains a box showing itself.
d.insert_object(&Pos { story: StoryRef::Part(id), path: Path::top(0), off: 0 }, box_shape(Some(id)), &CharProps::default()).unwrap();
let f = d.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3));
let before = d.parts.len();
d.insert_fragment(&Pos::body(0, 0), &f).unwrap();
let added = d.parts.len() - before;
// 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]
fn tables_in_paths() {
let mut d = doc(&["before", "after"]);
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(2, 2, 200.0))).unwrap();
let paths = d.para_paths(StoryRef::Body);
assert_eq!(paths.len(), 6);
assert_eq!(paths[1], Path(vec![1, 0, 0, 0]));
let cell = Pos { story: StoryRef::Body, path: Path(vec![1, 0, 1, 0]), off: 0 };
let e = d.insert_text(&cell, "x", &CharProps::default()).unwrap();
assert_eq!(e.off, 1);
assert_eq!(d.next_para(StoryRef::Body, &Path(vec![1, 0, 1, 0])), Some(Path(vec![1, 1, 0, 0])));
assert!(d.plain_text(StoryRef::Body).contains("\tx"));
// Delete from before the table into a cell: text cleared, table kept (partially covered).
d.delete_range(&Pos::body(0, 2), &cell).unwrap();
assert_eq!(d.para(StoryRef::Body, &Path::top(0)).unwrap().text, "be");
assert!(d.container(StoryRef::Body, &Path(vec![1, 0, 0, 0])).is_some());
assert_eq!(Path(vec![1, 0, 1, 0]).cell(), Some((Path::top(1), 0, 1)));
}
fn para(text: &str) -> Block {
Block::Para(Paragraph::with_text(text, CharProps::default()))
}
#[test]
fn appended_pieces_are_each_followed_by_one_paragraph_mark() {
let mut spike = Fragment::default();
spike.append_piece(Fragment { blocks: vec![para("a")], ..Default::default() });
spike.append_piece(Fragment { blocks: vec![para("b")], ..Default::default() });
assert_eq!(spike.plain_text(), "a\nb\n");
// A piece that already ends with its paragraph mark gets no second one.
let mut spike = Fragment::default();
spike.append_piece(Fragment { blocks: vec![para("a"), para("")], ..Default::default() });
assert_eq!(spike.plain_text(), "a\n");
spike.append_piece(Fragment { blocks: vec![para("b"), para("c")], ..Default::default() });
assert_eq!(spike.plain_text(), "a\nb\nc\n");
}
#[test]
fn appended_pieces_keep_their_own_text_boxes() {
// Two documents give their first text box the same id.
let mut one = doc(&["x"]);
let id1 = text_box(&mut one, &Pos::body(0, 1), "One");
let mut two = doc(&["y"]);
let id2 = text_box(&mut two, &Pos::body(0, 1), "Two");
assert_eq!(id1, id2);
let mut spike = Fragment::default();
spike.append_piece(one.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3)));
spike.append_piece(two.copy_range(&Pos::body(0, 0), &Pos::body(0, 1 + 3)));
let mut dst = doc(&[""]);
dst.insert_fragment(&Pos::body(0, 0), &spike).unwrap();
let texts: Vec<String> = [0, 1].iter().flat_map(|i| boxes_in(&dst, &Path::top(*i))).map(|b| dst.plain_text(StoryRef::Part(b.unwrap()))).collect();
assert_eq!(texts, ["One", "Two"]);
}
#[test]
fn bad_positions_error() {
let mut d = doc(&["é"]);
assert!(d.insert_text(&Pos::body(0, 1), "x", &CharProps::default()).is_err());
assert!(d.insert_text(&Pos::body(5, 0), "x", &CharProps::default()).is_err());
assert!(d.split_paragraph(&Pos::body(0, 99)).is_err());
let c = d.clamp(&Pos::body(9, 9));
assert_eq!(c, Pos::body(0, 2));
assert!(d.delete_range(&Pos { story: StoryRef::Part(3), path: Path::top(0), off: 0 }, &Pos::body(0, 0)).is_err());
}
// delete_range over whole tables (#1844), in the body, a header and a footnote.
/// A document whose story (the body, or a header or footnote part) holds `lines`.
fn story_doc(kind: Option<PartKind>, lines: &[&str]) -> (Document, StoryRef) {
match kind {
None => (doc(lines), StoryRef::Body),
Some(k) => {
let mut d = doc(&["body"]);
let blocks = lines.iter().map(|l| para_block(Paragraph::with_text(l, CharProps::default()))).collect();
let id = d.add_part(k, blocks);
(d, StoryRef::Part(id))
}
}
}
const STORIES: [Option<PartKind>; 3] = [None, Some(PartKind::Header), Some(PartKind::Footnote)];
fn at(story: StoryRef, path: &[u32], off: usize) -> Pos {
Pos { story, path: Path(path.to_vec()), off }
}
/// Insert an `rows`×`cols` table at `path` whose cells hold `texts`, row by row.
fn put_table(d: &mut Document, story: StoryRef, path: &[u32], rows: usize, cols: usize, texts: &[&str]) {
d.insert_block(story, &Path(path.to_vec()), Block::Table(Table::new(rows, cols, 200.0))).unwrap();
for (k, t) in texts.iter().enumerate() {
let mut p = path.to_vec();
p.extend([(k / cols) as u32, (k % cols) as u32, 0]);
d.insert_text(&at(story, &p, 0), t, &CharProps::default()).unwrap();
}
}
/// The story's top-level blocks: a paragraph's text, or `[table]`.
fn shape(d: &Document, story: StoryRef) -> Vec<String> {
d.story(story).unwrap().iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect()
}
fn cell_text(d: &Document, story: StoryRef, table: &[u32], r: u32, c: u32) -> String {
let mut p = table.to_vec();
p.extend([r, c, 0]);
d.para(story, &Path(p)).map(|p| p.text.clone()).unwrap_or_else(|| "<none>".into())
}
#[test]
fn a_range_ending_at_the_end_of_a_cell_that_isnt_the_last_keeps_the_table() {
for kind in STORIES {
for (r, c) in [(0, 1), (1, 0)] {
let (mut d, st) = story_doc(kind, &["before", "after"]);
put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]);
d.delete_range(&at(st, &[0], 0), &at(st, &[1, r, c, 0], 1)).unwrap();
assert_eq!(shape(&d, st), ["", "[table]", "after"], "{kind:?} ({r},{c})");
assert_eq!(cell_text(&d, st, &[1], 1, 1), "d", "{kind:?} ({r},{c})");
}
}
}
#[test]
fn a_range_from_the_start_of_one_table_into_the_middle_of_the_next_removes_only_the_first() {
for kind in STORIES {
let (mut d, st) = story_doc(kind, &["x", "mid", "y"]);
put_table(&mut d, st, &[2], 2, 2, &["e", "f", "g", "h"]);
put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]);
assert_eq!(shape(&d, st), ["x", "[table]", "mid", "[table]", "y"]);
let to = d.delete_range(&at(st, &[1, 0, 0, 0], 0), &at(st, &[3, 0, 1, 0], 1)).unwrap();
assert_eq!(shape(&d, st), ["x", "", "[table]", "y"], "{kind:?}");
assert_eq!([cell_text(&d, st, &[2], 0, 0), cell_text(&d, st, &[2], 0, 1), cell_text(&d, st, &[2], 1, 0)], ["", "", "g"], "{kind:?}");
assert!(d.para(st, &to.path).is_some(), "{kind:?}: {to:?}");
}
}
#[test]
fn a_range_from_a_cell_paragraph_to_the_end_of_a_table_nested_in_that_cell_removes_only_the_nested_table() {
for kind in STORIES {
let (mut d, st) = story_doc(kind, &["before", "after"]);
put_table(&mut d, st, &[1], 1, 1, &["p"]);
put_table(&mut d, st, &[1, 0, 0, 1], 1, 1, &["n"]);
d.insert_block(st, &Path(vec![1, 0, 0, 2]), Block::Para(Paragraph::with_text("q", CharProps::default()))).unwrap();
let to = d.delete_range(&at(st, &[1, 0, 0, 0], 1), &at(st, &[1, 0, 0, 1, 0, 0, 0], 1)).unwrap();
assert_eq!(shape(&d, st), ["before", "[table]", "after"], "{kind:?}");
let cell = d.container(st, &Path(vec![1, 0, 0, 0])).unwrap();
let cell: Vec<String> = cell.iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect();
assert_eq!(cell, ["pq"], "{kind:?}");
assert_eq!(to, at(st, &[1, 0, 0, 0], 1), "{kind:?}");
}
}
#[test]
fn two_whole_tables_at_the_end_of_a_story_leave_one_empty_paragraph() {
for kind in STORIES {
for trailing in [true, false] {
let (mut d, st) = story_doc(kind, &[""]);
put_table(&mut d, st, &[0], 1, 2, &["a", "b"]);
put_table(&mut d, st, &[1], 2, 1, &["c", "d"]);
if !trailing {
// A story that ends in a table (a hostile file).
d.story_mut(st).unwrap().pop();
}
let to = d.delete_range(&at(st, &[0, 0, 0, 0], 0), &at(st, &[1, 1, 0, 0], 1)).unwrap();
assert_eq!(shape(&d, st), [""], "{kind:?} trailing {trailing}");
assert_eq!(to, at(st, &[0], 0), "{kind:?} trailing {trailing}");
}
}
}
/// Content controls pair their markers within one block list (a story, or a cell), so a
/// marker in a table never has its partner outside it: a control inside a table removed whole
/// goes with it, and one the range only enters keeps both markers (the control is emptied).
#[test]
fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() {
use crate::control::ContentControl;
let start = || InlineObject::ControlStart { control: Box::new(ContentControl::default()) };
for kind in STORIES {
let (mut d, st) = story_doc(kind, &["before", "after"]);
put_table(&mut d, st, &[1], 1, 1, &["c"]);
// One control around "efo" in "before", one around the cell's text.
d.insert_object(&at(st, &[0], 4), InlineObject::ControlEnd, &CharProps::default()).unwrap();
let in_before = d.insert_object(&at(st, &[0], 1), start(), &CharProps::default()).unwrap();
d.insert_object(&at(st, &[1, 0, 0, 0], 1), InlineObject::ControlEnd, &CharProps::default()).unwrap();
d.insert_object(&at(st, &[1, 0, 0, 0], 0), start(), &CharProps::default()).unwrap();
assert_eq!(d.control_ranges(st).len(), 2, "setup");
let end = d.para(st, &Path(vec![1, 0, 0, 0])).unwrap().len();
// From inside the first control to the end of the table.
d.delete_range(&Pos { off: in_before.off + 1, ..in_before }, &at(st, &[1, 0, 0, 0], end)).unwrap();
assert_eq!(shape(&d, st), ["b\u{FFFC}e\u{FFFC}after"], "{kind:?}: the table goes, the paragraphs join");
let ranges = d.control_ranges(st);
assert_eq!(ranges.len(), 1, "{kind:?}: {ranges:?}");
assert!(ranges.iter().all(|r| r.start.path == Path::top(0) && r.end.path == Path::top(0)), "{kind:?}");
let objects = d.para(st, &Path::top(0)).map_or(0, |p| p.objects.len());
assert_eq!(objects, 2, "{kind:?}: no orphan markers left");
}
}
#[test]
fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() {
// 20 tables, each in the first cell of the one around it, ahead of that cell's paragraph:
// the innermost paragraphs come first in the document but the paragraph walk stops short.
let mut t = Table::new(1, 1, 200.0);
if let Some(cell) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
cell.blocks = vec![para_block(Paragraph::with_text("deep", CharProps::default()))];
}
for _ in 0..20 {
let mut outer = Table::new(1, 1, 200.0);
if let Some(cell) = outer.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
cell.blocks.insert(0, Arc::new(Block::Table(t)));
}
t = outer;
}
let mut d = doc(&["before", "after"]);
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
let first = d.para_paths(StoryRef::Body).into_iter().find(|p| p.0.first() == Some(&1)).unwrap();
d.delete_range(&Pos { story: StoryRef::Body, path: first, off: 0 }, &Pos::body(2, 0)).unwrap();
assert!(d.body.get(1).is_some_and(|b| b.as_table().is_some()), "a table with paragraphs the walk never saw was removed");
}
+15
View File
@@ -0,0 +1,15 @@
use super::position::StoryRef;
#[derive(Debug, thiserror::Error, Clone, PartialEq)]
pub enum DocError {
#[error("offset {0} is not a character boundary in the paragraph")]
BadOffset(usize),
#[error("no paragraph at {0}")]
BadPath(String),
#[error("no story {0:?}")]
BadStory(StoryRef),
#[error("{0}")]
Invalid(String),
}
pub type Result<T> = std::result::Result<T, DocError>;
+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));
}
}
+30 -1722
View File
File diff suppressed because it is too large Load Diff
+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(_)]));
}
}
+62 -57
View File
@@ -1061,63 +1061,8 @@ pub fn apply_structure(root: &mut Arg, pos: &MathPos, level: usize, action: &str
*kind = ScriptKind::SubSup;
here(if action == "script.addSup" { 2 } else { 1 })
}
(MNode::Matrix { rows, .. }, a) if a.starts_with("matrix.") => {
let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(1).max(1);
// Row and column of the caret's cell.
let mut k = 0;
let mut rc = (0, 0);
for (r, row) in rows.iter().enumerate() {
if c < k + row.len() {
rc = (r, c - k);
break;
}
k += row.len();
}
let (r, col) = rc;
let (mut tr, mut tc) = (r, col);
match a {
"matrix.rowAbove" | "matrix.rowBelow" if rows.len() < 1000 => {
let at = if a == "matrix.rowAbove" { r } else { r + 1 };
rows.insert(at.min(rows.len()), vec![Vec::new(); ncols]);
tr = at;
}
"matrix.colBefore" | "matrix.colAfter" if ncols < 64 => {
let at = if a == "matrix.colBefore" { col } else { col + 1 };
for row in rows.iter_mut() {
row.insert(at.min(row.len()), Vec::new());
}
tc = at;
}
"matrix.deleteRow" if rows.len() > 1 => {
rows.remove(r.min(rows.len() - 1));
tr = r.min(rows.len() - 1);
}
"matrix.deleteCol" if ncols > 1 => {
for row in rows.iter_mut() {
if col < row.len() {
row.remove(col);
}
}
tc = col.min(ncols - 2);
}
_ => {}
}
let flat: usize = rows.iter().take(tr).map(|x| x.len()).sum::<usize>() + tc;
here(flat)
}
(MNode::EqArr { rows }, a) if a.starts_with("eqarr.") => match a {
"eqarr.rowAbove" | "eqarr.rowBelow" if rows.len() < 1000 => {
let at = if a == "eqarr.rowAbove" { c } else { c + 1 }.min(rows.len());
rows.insert(at, Vec::new());
here(at)
}
"eqarr.deleteRow" if rows.len() > 1 => {
let k = c.min(rows.len() - 1);
rows.remove(k);
here(k.min(rows.len() - 1))
}
_ => inner,
},
(MNode::Matrix { rows, .. }, a) if a.starts_with("matrix.") => apply_matrix_action(rows, c, a, &here),
(MNode::EqArr { rows }, a) if a.starts_with("eqarr.") => apply_equation_array_action(rows, c, a, &here, inner),
(MNode::GroupChr { top, chr, .. }, "group.flip") => {
*top = !*top;
*chr = match *chr {
@@ -1141,6 +1086,66 @@ pub fn apply_structure(root: &mut Arg, pos: &MathPos, level: usize, action: &str
Some(clamp(root, &out))
}
fn apply_matrix_action(rows: &mut Vec<Vec<Arg>>, caret: usize, action: &str, here: &impl Fn(usize) -> MathPos) -> MathPos {
let columns = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
let (mut row_index, mut column) = (0, 0);
let mut offset = 0;
for (index, row) in rows.iter().enumerate() {
if caret < offset + row.len() {
row_index = index;
column = caret - offset;
break;
}
offset += row.len();
}
let (mut target_row, mut target_column) = (row_index, column);
match action {
"matrix.rowAbove" | "matrix.rowBelow" if rows.len() < 1000 => {
let at = if action == "matrix.rowAbove" { row_index } else { row_index + 1 };
rows.insert(at.min(rows.len()), vec![Vec::new(); columns]);
target_row = at;
}
"matrix.colBefore" | "matrix.colAfter" if columns < 64 => {
let at = if action == "matrix.colBefore" { column } else { column + 1 };
for row in rows.iter_mut() {
row.insert(at.min(row.len()), Vec::new());
}
target_column = at;
}
"matrix.deleteRow" if rows.len() > 1 => {
rows.remove(row_index.min(rows.len() - 1));
target_row = row_index.min(rows.len() - 1);
}
"matrix.deleteCol" if columns > 1 => {
for row in rows.iter_mut() {
if column < row.len() {
row.remove(column);
}
}
target_column = column.min(columns - 2);
}
_ => {}
}
let flat = rows.iter().take(target_row).map(Vec::len).sum::<usize>() + target_column;
here(flat)
}
fn apply_equation_array_action(rows: &mut Vec<Arg>, caret: usize, action: &str, here: &impl Fn(usize) -> MathPos, inner: MathPos) -> MathPos {
match action {
"eqarr.rowAbove" | "eqarr.rowBelow" if rows.len() < 1000 => {
let at = (if action == "eqarr.rowAbove" { caret } else { caret + 1 }).min(rows.len());
rows.insert(at, Vec::new());
here(at)
}
"eqarr.deleteRow" if rows.len() > 1 => {
let index = caret.min(rows.len() - 1);
rows.remove(index);
here(index.min(rows.len() - 1))
}
_ => inner,
}
}
/// Set the limit placement of n-ary operators (`None`: Word's default by operator).
pub fn set_limits(nodes: &mut Arg, loc: Option<LimLoc>) {
for n in nodes.iter_mut() {
+301 -214
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(),
@@ -823,233 +863,252 @@ fn group_chr_tex(chr: char, top: bool) -> &'static str {
}
}
fn node(n: &MNode, s: &mut String, depth: usize) {
match n {
MNode::Run(r) => {
let (open, close) = if r.nor {
("\\text{", "}")
} else {
match (r.sty, r.scr) {
(_, MScr::DoubleStruck) => ("\\mathbb{", "}"),
(_, MScr::Script) => ("\\mathcal{", "}"),
(_, MScr::Fraktur) => ("\\mathfrak{", "}"),
(_, MScr::SansSerif) => ("\\mathsf{", "}"),
(_, MScr::Monospace) => ("\\mathtt{", "}"),
(Some(MSty::Bold), _) => ("\\mathbf{", "}"),
(Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"),
(Some(MSty::Plain), _) if r.text.chars().any(|c| c.is_alphabetic()) => {
if FUNCTION_NAMES.contains(&r.text.as_str()) {
s.push('\\');
s.push_str(&r.text);
s.push(' ');
return;
}
("\\mathrm{", "}")
}
_ => ("", ""),
}
};
s.push_str(open);
if r.nor {
text_chars(&r.text, s);
} else {
chars(&r.text, s);
}
s.push_str(close);
}
MNode::Frac { kind, num, den } => match kind {
FracKind::NoBar => {
s.push_str("\\genfrac{}{}{0pt}{}");
braced(num, s, depth);
braced(den, s, depth);
}
FracKind::Linear | FracKind::Skewed => {
braced(num, s, depth);
s.push('/');
braced(den, s, depth);
}
FracKind::Bar => {
s.push_str("\\frac");
braced(num, s, depth);
braced(den, s, depth);
}
},
MNode::Script { kind, base, sub, sup } => {
if *kind == ScriptKind::Pre {
s.push_str("{}_");
braced(sub, s, depth);
s.push('^');
braced(sup, s, depth);
braced(base, s, depth);
return;
}
braced(base, s, depth);
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
s.push('_');
braced(sub, s, depth);
}
if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
s.push('^');
braced(sup, s, depth);
}
}
MNode::Rad { deg, deg_hide, e } => {
s.push_str("\\sqrt");
if !deg_hide && !deg.is_empty() {
s.push('[');
arg(deg, s, depth);
s.push(']');
}
braced(e, s, depth);
}
MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } => {
match name_of(*chr) {
Some(n) => {
s.push('\\');
s.push_str(n);
}
None => s.push(*chr),
}
match lim_loc {
Some(crate::math::LimLoc::UndOvr) => s.push_str("\\limits"),
Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => s.push_str("\\nolimits"),
_ => {}
}
if !sub_hide && !sub.is_empty() {
s.push('_');
braced(sub, s, depth);
}
if !sup_hide && !sup.is_empty() {
s.push('^');
braced(sup, s, depth);
}
s.push(' ');
braced(e, s, depth);
}
MNode::Delim { beg, end, sep, elems, .. } => {
s.push_str("\\left");
s.push_str(&delim_tex(*beg));
let mid = match sep {
Some(c) => format!("\\middle{}", delim_tex(Some(*c))),
None => "\\middle|".to_string(),
};
for (i, e) in elems.iter().enumerate() {
if i > 0 {
s.push_str(&mid);
}
arg(e, s, depth);
}
s.push_str("\\right");
s.push_str(&delim_tex(*end));
}
fn node(node: &MNode, output: &mut String, depth: usize) {
match node {
MNode::Run(run) => run_node(run, output),
MNode::Frac { kind, num, den } => fraction_node(*kind, num, den, output, depth),
MNode::Script { kind, base, sub, sup } => script_node(*kind, base, sub, sup, output, depth),
MNode::Rad { deg, deg_hide, e } => radical_node(deg, *deg_hide, e, output, depth),
MNode::Nary { .. } => nary_node(node, output, depth),
MNode::Delim { beg, end, sep, elems, .. } => delimiter_node(*beg, *end, *sep, elems, output, depth),
MNode::Func { name, e } => {
arg(name, s, depth);
braced(e, s, depth);
}
MNode::Lim { upper, e, lim } => {
// `\lim_{…}` keeps its name; other bases stack.
let simple = matches!(e.as_slice(), [MNode::Run(r)] if r.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&r.text.as_str()));
if simple && !upper {
arg(e, s, depth);
s.push('_');
braced(lim, s, depth);
} else if let [MNode::GroupChr { chr, top, e: inner }] = e.as_slice() {
s.push_str(group_chr_tex(*chr, *top));
braced(inner, s, depth);
s.push(if *upper { '^' } else { '_' });
braced(lim, s, depth);
} else {
s.push_str(if *upper { "\\overset" } else { "\\underset" });
braced(lim, s, depth);
braced(e, s, depth);
}
}
MNode::Acc { chr, e } => {
let cmd = match chr {
'\u{302}' => "hat",
'\u{303}' => "tilde",
'\u{307}' => "dot",
'\u{308}' => "ddot",
'\u{20DB}' => "dddot",
'\u{30C}' => "check",
'\u{306}' => "breve",
'\u{301}' => "acute",
'\u{300}' => "grave",
'\u{305}' | '\u{AF}' => "bar",
'\u{20D6}' => "overleftarrow",
'\u{20E1}' => "overleftrightarrow",
_ => "vec",
};
s.push('\\');
s.push_str(cmd);
braced(e, s, depth);
arg(name, output, depth);
braced(e, output, depth);
}
MNode::Lim { upper, e, lim } => limit_node(*upper, e, lim, output, depth),
MNode::Acc { chr, e } => accent_node(*chr, e, output, depth),
MNode::Bar { top, e } => {
s.push_str(if *top { "\\overline" } else { "\\underline" });
braced(e, s, depth);
output.push_str(if *top { "\\overline" } else { "\\underline" });
braced(e, output, depth);
}
MNode::BorderBox { e, .. } => {
s.push_str("\\boxed");
braced(e, s, depth);
output.push_str("\\boxed");
braced(e, output, depth);
}
MNode::Boxed { e } => braced(e, s, depth),
MNode::Boxed { e } => braced(e, output, depth),
MNode::GroupChr { chr, top, e } => {
s.push_str(group_chr_tex(*chr, *top));
braced(e, s, depth);
}
MNode::EqArr { rows } => {
if rows.len() == 1
&& let Some(r) = rows.first()
&& crate::math::has_number_mark(r)
{
arg(r, s, depth);
return;
}
s.push_str("\\begin{aligned}");
for (i, r) in rows.iter().enumerate() {
if i > 0 {
s.push_str("\\\\");
}
for n in r {
match n {
MNode::Run(run) if !run.lit && run.text.contains('&') => {
for (k, part) in run.text.split('&').enumerate() {
if k > 0 {
s.push('&');
}
let mut p = run.clone();
p.text = part.to_string();
node(&MNode::Run(p), s, depth + 1);
}
}
other => node(other, s, depth + 1),
}
}
}
s.push_str("\\end{aligned}");
}
MNode::Matrix { rows, .. } => {
s.push_str("\\begin{matrix}");
for (i, r) in rows.iter().enumerate() {
if i > 0 {
s.push_str("\\\\");
}
for (j, c) in r.iter().enumerate() {
if j > 0 {
s.push('&');
}
arg(c, s, depth);
}
}
s.push_str("\\end{matrix}");
output.push_str(group_chr_tex(*chr, *top));
braced(e, output, depth);
}
MNode::EqArr { rows } => equation_array_node(rows, output, depth),
MNode::Matrix { rows, .. } => matrix_node(rows, output, depth),
MNode::Phant { e, .. } => {
s.push_str("\\phantom");
braced(e, s, depth);
output.push_str("\\phantom");
braced(e, output, depth);
}
}
}
fn run_node(run: &MRun, output: &mut String) {
let (open, close) = if run.nor {
("\\text{", "}")
} else {
match (run.sty, run.scr) {
(_, MScr::DoubleStruck) => ("\\mathbb{", "}"),
(_, MScr::Script) => ("\\mathcal{", "}"),
(_, MScr::Fraktur) => ("\\mathfrak{", "}"),
(_, MScr::SansSerif) => ("\\mathsf{", "}"),
(_, MScr::Monospace) => ("\\mathtt{", "}"),
(Some(MSty::Bold), _) => ("\\mathbf{", "}"),
(Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"),
(Some(MSty::Plain), _) if run.text.chars().any(char::is_alphabetic) => {
if FUNCTION_NAMES.contains(&run.text.as_str()) {
output.push('\\');
output.push_str(&run.text);
output.push(' ');
return;
}
("\\mathrm{", "}")
}
_ => ("", ""),
}
};
output.push_str(open);
if run.nor {
text_chars(&run.text, output)
} else {
chars(&run.text, output)
}
output.push_str(close);
}
fn fraction_node(kind: FracKind, numerator: &[MNode], denominator: &[MNode], output: &mut String, depth: usize) {
match kind {
FracKind::NoBar => {
output.push_str("\\genfrac{}{}{0pt}{}");
braced(numerator, output, depth);
braced(denominator, output, depth);
}
FracKind::Linear | FracKind::Skewed => {
braced(numerator, output, depth);
output.push('/');
braced(denominator, output, depth);
}
FracKind::Bar => {
output.push_str("\\frac");
braced(numerator, output, depth);
braced(denominator, output, depth);
}
}
}
fn script_node(kind: ScriptKind, base: &[MNode], sub: &[MNode], sup: &[MNode], output: &mut String, depth: usize) {
if kind == ScriptKind::Pre {
output.push_str("{}_");
braced(sub, output, depth);
output.push('^');
braced(sup, output, depth);
braced(base, output, depth);
return;
}
braced(base, output, depth);
if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) {
output.push('_');
braced(sub, output, depth);
}
if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) {
output.push('^');
braced(sup, output, depth);
}
}
fn radical_node(degree: &[MNode], degree_hidden: bool, expression: &[MNode], output: &mut String, depth: usize) {
output.push_str("\\sqrt");
if !degree_hidden && !degree.is_empty() {
output.push('[');
arg(degree, output, depth);
output.push(']');
}
braced(expression, output, depth);
}
fn nary_node(node: &MNode, output: &mut String, depth: usize) {
let MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } = node else { return };
match name_of(*chr) {
Some(name) => {
output.push('\\');
output.push_str(name);
}
None => output.push(*chr),
}
match lim_loc {
Some(crate::math::LimLoc::UndOvr) => output.push_str("\\limits"),
Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => output.push_str("\\nolimits"),
_ => {}
}
if !sub_hide && !sub.is_empty() {
output.push('_');
braced(sub, output, depth);
}
if !sup_hide && !sup.is_empty() {
output.push('^');
braced(sup, output, depth);
}
output.push(' ');
braced(e, output, depth);
}
fn delimiter_node(begin: Option<char>, end: Option<char>, separator: Option<char>, elements: &[Arg], output: &mut String, depth: usize) {
output.push_str("\\left");
output.push_str(&delim_tex(begin));
let middle = separator.map(|c| format!("\\middle{}", delim_tex(Some(c)))).unwrap_or_else(|| "\\middle|".to_string());
for (index, element) in elements.iter().enumerate() {
if index > 0 {
output.push_str(&middle);
}
arg(element, output, depth);
}
output.push_str("\\right");
output.push_str(&delim_tex(end));
}
fn limit_node(upper: bool, expression: &[MNode], limit: &[MNode], output: &mut String, depth: usize) {
let simple = matches!(expression, [MNode::Run(run)] if run.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&run.text.as_str()));
if simple && !upper {
arg(expression, output, depth);
output.push('_');
braced(limit, output, depth);
} else if let [MNode::GroupChr { chr, top, e: inner }] = expression {
output.push_str(group_chr_tex(*chr, *top));
braced(inner, output, depth);
output.push(if upper { '^' } else { '_' });
braced(limit, output, depth);
} else {
output.push_str(if upper { "\\overset" } else { "\\underset" });
braced(limit, output, depth);
braced(expression, output, depth);
}
}
fn accent_node(character: char, expression: &[MNode], output: &mut String, depth: usize) {
let command = match character {
'\u{302}' => "hat",
'\u{303}' => "tilde",
'\u{307}' => "dot",
'\u{308}' => "ddot",
'\u{20DB}' => "dddot",
'\u{30C}' => "check",
'\u{306}' => "breve",
'\u{301}' => "acute",
'\u{300}' => "grave",
'\u{305}' | '\u{AF}' => "bar",
'\u{20D6}' => "overleftarrow",
'\u{20E1}' => "overleftrightarrow",
_ => "vec",
};
output.push('\\');
output.push_str(command);
braced(expression, output, depth);
}
fn equation_array_node(rows: &[Arg], output: &mut String, depth: usize) {
if rows.len() == 1
&& let Some(row) = rows.first()
&& crate::math::has_number_mark(row)
{
arg(row, output, depth);
return;
}
output.push_str("\\begin{aligned}");
for (index, row) in rows.iter().enumerate() {
if index > 0 {
output.push_str("\\\\");
}
for child_node in row {
match child_node {
MNode::Run(run) if !run.lit && run.text.contains('&') => {
for (index, part) in run.text.split('&').enumerate() {
if index > 0 {
output.push('&');
}
let mut piece = run.clone();
piece.text = part.to_string();
run_node(&piece, output);
}
}
other => node(other, output, depth + 1),
}
}
}
output.push_str("\\end{aligned}");
}
fn matrix_node(rows: &[Vec<Arg>], output: &mut String, depth: usize) {
output.push_str("\\begin{matrix}");
for (row_index, row) in rows.iter().enumerate() {
if row_index > 0 {
output.push_str("\\\\");
}
for (column, cell) in row.iter().enumerate() {
if column > 0 {
output.push('&');
}
arg(cell, output, depth);
}
}
output.push_str("\\end{matrix}");
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1076,6 +1135,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
+21 -848
View File
@@ -13,852 +13,15 @@ use crate::props::{CharProps, ParaProps};
use crate::section::SectionProps;
use crate::{DocError, Result};
/// Object replacement character: marks an inline object in paragraph text.
pub const OBJ: char = '\u{FFFC}';
/// Manual line break (Shift+Enter).
pub const LINE_BREAK: char = '\n';
/// Page break (Ctrl+Enter).
pub const PAGE_BREAK: char = '\u{000C}';
/// Column break (Ctrl+Shift+Enter).
pub const COLUMN_BREAK: char = '\u{000E}';
/// Optional hyphen (Ctrl+-).
pub const SOFT_HYPHEN: char = '\u{00AD}';
/// Nonbreaking hyphen (Ctrl+Shift+-).
pub const NB_HYPHEN: char = '\u{2011}';
/// Nonbreaking space (Ctrl+Shift+Space).
pub const NBSP: char = '\u{00A0}';
mod float;
mod inline_kinds;
mod inline_object;
mod run;
static REV: AtomicU64 = AtomicU64::new(1);
/// A fresh revision number (process-unique), used by layout caches to detect edits.
pub fn next_rev() -> u64 {
REV.fetch_add(1, Ordering::Relaxed)
}
/// A formatting run: `len` bytes of text sharing `props`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Run {
pub len: usize,
#[serde(default, skip_serializing_if = "CharProps::is_empty")]
pub props: CharProps,
}
/// How a floating object is placed.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum Wrap {
/// In line with text (not floating).
#[default]
Inline,
Square,
Tight,
Through,
TopAndBottom,
BehindText,
InFrontOfText,
}
/// Which sides of a Square, Tight or Through object text may flow on (`@wrapText`, ECMA-376
/// Part 1, §20.4.3.7 `ST_WrapText`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum WrapText {
/// Both sides of the object.
#[default]
BothSides,
/// Only the left side: the band to the object's right stays empty.
Left,
/// Only the right side.
Right,
/// Only whichever side has more room, decided per line.
Largest,
}
impl WrapText {
/// From the OOXML value (`bothSides`, `left`, `right`, `largest`).
pub fn from_ooxml(v: &str) -> Option<WrapText> {
let v = v.trim();
if v.eq_ignore_ascii_case("bothSides") || v.eq_ignore_ascii_case("both") {
Some(WrapText::BothSides)
} else if v.eq_ignore_ascii_case("left") {
Some(WrapText::Left)
} else if v.eq_ignore_ascii_case("right") {
Some(WrapText::Right)
} else if v.eq_ignore_ascii_case("largest") {
Some(WrapText::Largest)
} else {
None
}
}
/// The OOXML value.
pub fn ooxml(self) -> &'static str {
match self {
WrapText::BothSides => "bothSides",
WrapText::Left => "left",
WrapText::Right => "right",
WrapText::Largest => "largest",
}
}
pub fn is_both(&self) -> bool {
*self == WrapText::BothSides
}
}
/// What a floating object's offset is relative to.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum Anchor {
#[default]
Column,
Margin,
Page,
Paragraph,
/// The page's left margin area (from the page's left edge to the text).
LeftMargin,
/// The page's right margin area (from the text to the page's right edge).
RightMargin,
/// The page's top margin area (from the page's top edge to the text).
TopMargin,
/// The page's bottom margin area (from the text to the page's bottom edge).
BottomMargin,
/// The inside margin (left on odd pages, right on even pages when mirrored).
InsideMargin,
/// The outside margin.
OutsideMargin,
/// The anchor character (horizontal only).
Character,
/// The anchor line (vertical only).
Line,
}
/// Alignment of a floating object within its [`Anchor`] area, instead of an offset.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FloatAlign {
/// Left or top.
Start,
Center,
/// Right or bottom.
End,
Inside,
Outside,
}
impl FloatAlign {
/// From an OOXML alignment (`wp:align`, `w:tblpXSpec`, `w:tblpYSpec`).
pub fn from_ooxml(v: &str) -> Option<FloatAlign> {
match v {
"left" | "top" => Some(FloatAlign::Start),
"center" => Some(FloatAlign::Center),
"right" | "bottom" => Some(FloatAlign::End),
"inside" => Some(FloatAlign::Inside),
"outside" => Some(FloatAlign::Outside),
_ => None,
}
}
/// The OOXML name on the horizontal (`left`, `right`) or vertical (`top`, `bottom`) axis.
pub fn ooxml(self, horizontal: bool) -> &'static str {
match (self, horizontal) {
(FloatAlign::Start, true) => "left",
(FloatAlign::Start, false) => "top",
(FloatAlign::Center, _) => "center",
(FloatAlign::End, true) => "right",
(FloatAlign::End, false) => "bottom",
(FloatAlign::Inside, _) => "inside",
(FloatAlign::Outside, _) => "outside",
}
}
}
/// Floating placement (ignored when `wrap` is `Inline`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Float {
pub wrap: Wrap,
/// The sides text wraps on (Square, Tight and Through).
#[serde(skip_serializing_if = "WrapText::is_both")]
pub wrap_text: WrapText,
pub h_rel: Anchor,
pub v_rel: Anchor,
/// Offsets, points (used when the matching alignment is `None`).
pub x: f32,
pub y: f32,
/// Alignments within the anchor area; they take precedence over the offsets.
#[serde(skip_serializing_if = "Option::is_none")]
pub h_align: Option<FloatAlign>,
#[serde(skip_serializing_if = "Option::is_none")]
pub v_align: Option<FloatAlign>,
/// Distance from surrounding text at the left and right (points).
pub dist: f32,
/// Distance from surrounding text above and below (points).
pub dist_top: f32,
pub dist_bottom: f32,
/// Room around the object for effects such as shadows (left, top, right, bottom, points),
/// inline or floating: lines and surrounding text keep clear of it. Rotation's own overhang
/// is not in here: it follows from [`Float::rot`] (see [`Float::spin_pad`]).
pub effect: [f32; 4],
/// Rotation clockwise about the object's centre, degrees (DrawingML `a:xfrm/@rot`). Offsets
/// and the size stay those of the unrotated frame, as in Word; see [`Float::spin`].
#[serde(skip_serializing_if = "is_zero")]
pub rot: f32,
/// Mirrored left to right / top to bottom (`a:xfrm/@flipH`, `@flipV`).
#[serde(skip_serializing_if = "is_false")]
pub flip_h: bool,
#[serde(skip_serializing_if = "is_false")]
pub flip_v: bool,
/// The outline Tight and Through wrapping follow (`wp:wrapPolygon`), in the object's own
/// (unrotated) frame; `None` = derived from the object (see [`crate::wrap`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub wrap_polygon: Option<std::sync::Arc<crate::wrap::WrapPolygon>>,
/// What the drawing is, for screen readers and exports (`wp:docPr` `@descr`, `@title` and the
/// decorative mark). See [`InlineObject::alt_text`].
#[serde(skip_serializing_if = "AltText::is_empty")]
pub alt: AltText,
/// Hidden (`wp:docPr/@hidden`, VML `visibility:hidden`): kept and saved, but neither drawn nor
/// laid out, as Word for Windows does (Word for Mac draws hidden VML shapes).
#[serde(skip_serializing_if = "is_false")]
pub hidden: bool,
/// Anchored in a table cell but placed on the page, not in the cell (`wp:anchor/@layoutInCell`
/// off, any VML shape in a header or footer): a picture watermark in a header that starts with a table.
#[serde(skip_serializing_if = "is_false")]
pub outside_cell: bool,
/// Stacking order among floating objects (`wp:anchor/@relativeHeight`): higher is drawn on
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
#[serde(skip_serializing_if = "is_zero_u32")]
pub z: u32,
}
/// Longest alternative text kept (characters): hostile files and commands are capped.
pub const MAX_ALT_TEXT: usize = 10_000;
/// A drawing's alternative text (Picture/Shape Format › Accessibility › Alt Text).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct AltText {
/// The description of a shape, text box or group. Pictures and charts keep theirs in their
/// own `alt`; read it with [`InlineObject::alt_text`], which knows where it is.
#[serde(skip_serializing_if = "String::is_empty")]
pub text: String,
/// The short title (`wp:docPr/@title`).
#[serde(skip_serializing_if = "String::is_empty")]
pub title: String,
/// Decorative: it adds nothing to the content, so screen readers skip it and the
/// accessibility checker doesn't ask for a description.
#[serde(skip_serializing_if = "is_false")]
pub decorative: bool,
}
impl AltText {
pub fn is_empty(&self) -> bool {
self.text.is_empty() && self.title.is_empty() && !self.decorative
}
}
/// `s` cut to [`MAX_ALT_TEXT`] characters.
pub fn cap_alt(s: &str) -> String {
s.chars().take(MAX_ALT_TEXT).collect()
}
fn is_zero(v: &f32) -> bool {
*v == 0.0
}
fn is_false(v: &bool) -> bool {
!*v
}
fn is_zero_u32(v: &u32) -> bool {
*v == 0
}
impl Float {
/// The rotation and flips, the angle normalised to `0..360` (a NaN angle is no rotation).
pub fn spin(&self) -> wordcraft_geom::Spin {
wordcraft_geom::Spin::new(self.rot, self.flip_h, self.flip_v)
}
/// Set the rotation and flips (the angle normalised).
pub fn set_spin(&mut self, s: wordcraft_geom::Spin) {
self.rot = wordcraft_geom::normalize_degrees(s.deg);
self.flip_h = s.flip_h;
self.flip_v = s.flip_v;
}
/// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a
/// rotated object takes beyond its frame (zero unrotated; negative where the turned object
/// is narrower than its frame, as a wide picture turned 90° is).
pub fn spin_pad(&self, w: f32, h: f32) -> (f32, f32) {
let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0));
let (bw, bh) = self.spin().extent(w, h);
((bw - w) / 2.0, (bh - h) / 2.0)
}
/// The effect extents, finite and clamped (left, top, right, bottom).
pub fn effect_extent(&self) -> [f32; 4] {
self.effect.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum ShapeKind {
#[default]
Rectangle,
RoundedRectangle,
Ellipse,
/// Flowchart OR preset: an ellipse with horizontal and vertical spokes.
FlowchartOr,
Triangle,
/// A right triangle (DrawingML's `rtTriangle`): the right angle at the bottom left.
RightTriangle,
Diamond,
Line,
/// A block arrow pointing right (`rightArrow`; also stands for the double-headed arrows).
Arrow,
/// A block arrow pointing left (`leftArrow`).
ArrowLeft,
/// A block arrow pointing up (`upArrow`).
ArrowUp,
/// A block arrow pointing down (`downArrow`).
ArrowDown,
Star,
Heart,
/// A text box (rectangle with a text story).
TextBox,
/// A shape drawn point by point, such as ink (its geometry is the shape's `freeform`).
Freeform,
/// Connectors: lines whose ends can be glued to other shapes (see [`crate::connector`]).
StraightConnector,
ElbowConnector,
CurvedConnector,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum NoteKind {
#[default]
Footnote,
Endnote,
}
/// Where a positional tab ([`InlineObject::PositionalTab`]) lands on its line, and how the text
/// after it lines up there (ECMA-376 `ST_PTabAlignment`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum PTabAlign {
#[default]
Left,
Center,
Right,
}
impl PTabAlign {
pub const ALL: [PTabAlign; 3] = [PTabAlign::Left, PTabAlign::Center, PTabAlign::Right];
/// The OOXML name (`left`, `center`, `right`).
pub fn ooxml(self) -> &'static str {
match self {
PTabAlign::Left => "left",
PTabAlign::Center => "center",
PTabAlign::Right => "right",
}
}
pub fn from_ooxml(v: &str) -> Option<PTabAlign> {
PTabAlign::ALL.into_iter().find(|a| a.ooxml() == v)
}
}
/// What a positional tab's place is measured against (ECMA-376 `ST_PTabRelativeTo`): the text
/// margins, or the paragraph's indents.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum PTabBase {
#[default]
Margin,
Indent,
}
impl PTabBase {
pub const ALL: [PTabBase; 2] = [PTabBase::Margin, PTabBase::Indent];
/// The OOXML name (`margin`, `indent`).
pub fn ooxml(self) -> &'static str {
match self {
PTabBase::Margin => "margin",
PTabBase::Indent => "indent",
}
}
pub fn from_ooxml(v: &str) -> Option<PTabBase> {
PTabBase::ALL.into_iter().find(|a| a.ooxml() == v)
}
}
/// How ruby (phonetic guide) text lines up over its base text (ECMA-376 `ST_RubyAlign`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum RubyAlign {
#[default]
Center,
/// Letters spread evenly from edge to edge.
DistributeLetter,
/// Letters spread evenly with half a gap at each edge.
DistributeSpace,
Left,
Right,
/// Right-aligned beside vertical text (laid out like `Right` in horizontal text).
RightVertical,
}
impl RubyAlign {
pub const ALL: [RubyAlign; 6] =
[RubyAlign::Center, RubyAlign::DistributeLetter, RubyAlign::DistributeSpace, RubyAlign::Left, RubyAlign::Right, RubyAlign::RightVertical];
/// The OOXML name (`center`, `distributeLetter`…), also used by commands.
pub fn ooxml(self) -> &'static str {
match self {
RubyAlign::Center => "center",
RubyAlign::DistributeLetter => "distributeLetter",
RubyAlign::DistributeSpace => "distributeSpace",
RubyAlign::Left => "left",
RubyAlign::Right => "right",
RubyAlign::RightVertical => "rightVertical",
}
}
pub fn from_ooxml(v: &str) -> Option<RubyAlign> {
RubyAlign::ALL.into_iter().find(|a| a.ooxml() == v)
}
}
/// Phonetic Guide's Offset for a ruby `raise` (baseline to baseline, points): the gap from the top
/// of the base text to the bottom of the ruby text, taking letters as rising 0.8 em above their
/// baseline and dropping 0.2 em below it.
pub fn ruby_offset(raise: f32, base_size: f32, size: f32) -> f32 {
raise - 0.8 * base_size - 0.2 * size
}
/// The ruby raise (baseline to baseline, points) for a Phonetic Guide Offset (see [`ruby_offset`]).
pub fn ruby_raise(offset: f32, base_size: f32, size: f32) -> f32 {
offset + 0.8 * base_size + 0.2 * size
}
/// An inline object anchored at a U+FFFC in the paragraph text.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum InlineObject {
Image {
/// Key in `Document::media`.
media: String,
/// Display size, points.
w: f32,
h: f32,
#[serde(default)]
alt: String,
#[serde(default)]
float: Float,
/// Crop (left, top, right, bottom) fractions 0..1.
#[serde(default)]
crop: [f32; 4],
/// The OLE object (embedded or linked file) or Word signature line this picture shows, as
/// read from a file: saving writes the object back, not only its picture. See
/// [`crate::graphic::Embedded`].
#[serde(default, skip_serializing_if = "Option::is_none")]
ole: Option<std::sync::Arc<crate::graphic::Embedded>>,
},
/// A chart or SmartArt diagram (see [`crate::graphic`]).
Graphic {
w: f32,
h: f32,
#[serde(default)]
alt: String,
#[serde(default)]
float: Float,
graphic: std::sync::Arc<crate::graphic::Graphic>,
},
Shape {
kind: ShapeKind,
w: f32,
h: f32,
fill: Option<crate::fill::Fill>,
stroke: Option<crate::props::Rgb>,
#[serde(default)]
stroke_width: f32,
#[serde(default)]
float: Float,
/// Text box content: `Document::parts` id.
#[serde(default)]
story: Option<u32>,
/// A freeform's geometry (`ShapeKind::Freeform`): its paths, or an ink stroke.
#[serde(default, skip_serializing_if = "Option::is_none")]
freeform: Option<std::sync::Arc<crate::freeform::Freeform>>,
/// Shadow, glow and soft edges.
#[serde(default, skip_serializing_if = "crate::effects::ShapeEffects::is_empty")]
effects: crate::effects::ShapeEffects,
/// Drawing id, text warp, connector ends and arrowheads.
#[serde(default, skip_serializing_if = "crate::connector::ShapeExtra::is_empty")]
extra: crate::connector::ShapeExtra,
},
/// Pictures, shapes and text boxes grouped into one object (Layout › Arrange › Group): it
/// moves, wraps and resizes as one. Its members are laid out in the group's own coordinate
/// space, `ch_w` × `ch_h`, which is stretched over the group's `w` × `h` (DrawingML's
/// `a:chExt` and `a:ext`), so resizing the group scales them.
Group {
w: f32,
h: f32,
#[serde(default)]
float: Float,
/// Size of the members' coordinate space (their offsets and sizes are in it).
ch_w: f32,
ch_h: f32,
children: Vec<GroupChild>,
/// A Drawing Canvas (Insert › Shapes › New Drawing Canvas, DrawingML's `wpc:wpc`) rather
/// than a group: it has its own background and outline, and its frame is independent of
/// its members: resizing it doesn't scale them (`ch_w` × `ch_h` follow `w` × `h`), and
/// what lies outside the frame is clipped.
#[serde(default, skip_serializing_if = "Option::is_none")]
canvas: Option<CanvasStyle>,
},
/// A field: `instr` is the field code (`PAGE`, `NUMPAGES`, `DATE \@ "M/d/yyyy"`, `TOC \o "1-3"`…);
/// `result` the cached display text. A `locked` field keeps its result when fields update.
Field {
instr: String,
#[serde(default)]
result: String,
#[serde(default)]
locked: bool,
/// `w:dirty` (ECMA-376 §17.16.5.35): the cached result is stale — another program that
/// couldn't compute it set the flag so Word refreshes the field on open. Kept through
/// saves; cleared when WordCraft computes a result, like Word's F9.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
dirty: bool,
/// Shows its code (`{ PAGE }`) instead of its result, the other way round from the
/// document-wide View › Field Codes setting (`fields.toggleCode`, Shift+F9). A view
/// toggle: never written to files.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
code: bool,
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`): the
/// source's `w:ffData`, raw OOXML kept for round trip. Empty for other fields.
#[serde(default, skip_serializing_if = "String::is_empty")]
form: String,
},
/// The start of a field whose result is ordinary content — formatted, possibly spanning
/// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Imported TOCs and citation
/// managers' `ADDIN` fields are kept this way; `instr` is the field code. See
/// [`crate::fields`].
FieldStart {
instr: String,
#[serde(default)]
locked: bool,
/// `w:dirty` on the begin marker, as [`InlineObject::Field::dirty`].
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
dirty: bool,
},
/// The end of the innermost open [`InlineObject::FieldStart`].
FieldEnd,
/// The start of a content control whose content is what follows, up to the matching
/// [`InlineObject::ControlEnd`]. See [`crate::control`].
ControlStart {
control: Box<crate::control::ContentControl>,
},
/// The end of the innermost open [`InlineObject::ControlStart`].
ControlEnd,
NoteRef {
kind: NoteKind,
/// `Document::parts` id of the note's story.
id: u32,
/// Custom mark (empty = automatic number).
#[serde(default)]
custom: String,
},
BookmarkStart {
name: String,
},
BookmarkEnd {
name: String,
},
CommentStart {
id: u32,
},
CommentEnd {
id: u32,
},
/// The start of a range that stays editable while the document is protected (Restrict
/// Editing exceptions, `w:permStart`): for everyone (`group` = `everyone`), another editing
/// group, or one editor (`editor`, a user name or address).
PermStart {
id: u32,
#[serde(default, skip_serializing_if = "String::is_empty")]
group: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
editor: String,
},
/// The end of the editable range [`InlineObject::PermStart`] with the same `id` opened.
PermEnd {
id: u32,
},
/// An equation: its linear format (`x=(-b±√(b^2-4ac))/2a`, for plain text) and structure.
/// A display equation sits on a line of its own.
Equation {
linear: String,
#[serde(default)]
display: bool,
/// The structure; empty = parse `linear`.
#[serde(default, skip_serializing_if = "crate::math::Math::is_empty")]
math: crate::math::Math,
},
/// Ruby (Phonetic Guide): small `ruby` text (furigana, pinyin…) set over `base` text. The base
/// text takes the formatting of the object's run; it reads as the paragraph's text.
Ruby {
base: String,
ruby: String,
#[serde(default)]
align: RubyAlign,
/// Ruby text size, points (`w:hps`).
size: f32,
/// From the base text's baseline up to the ruby text's baseline, points (`w:hpsRaise`).
raise: f32,
/// The base text size the ruby was set for, points (`w:hpsBaseText`).
#[serde(default)]
base_size: f32,
/// Language of the ruby (`w:lid`, e.g. `ja-JP`); empty = unspecified.
#[serde(default, skip_serializing_if = "String::is_empty")]
lang: String,
/// Formatting of the ruby text over the run's (font, colour…); its size is `size`.
#[serde(default, skip_serializing_if = "CharProps::is_empty")]
props: Box<CharProps>,
},
/// A positional tab (Word's alignment tab, `w:ptab`): it goes to the left edge, centre or
/// right edge of the margins or of the indents, whatever the paragraph's tab stops, and the
/// text after it lines up there. Text already past that place sends it to the next line.
PositionalTab {
#[serde(default)]
align: PTabAlign,
#[serde(default)]
relative_to: PTabBase,
#[serde(default)]
leader: crate::props::TabLeader,
},
/// Something we don't model, kept for round-trip (raw XML of the source format).
Opaque {
format: String,
xml: String,
#[serde(default)]
text: String,
},
}
/// One member of an [`InlineObject::Group`]: a picture or shape (its own `w`, `h` and story), at
/// `x`, `y` in the group's coordinate space. Its `float` is unused.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GroupChild {
pub x: f32,
pub y: f32,
pub obj: InlineObject,
}
/// Most members a group keeps (hostile files).
pub const MAX_GROUP_CHILDREN: usize = 1000;
/// The background fill and outline of a Drawing Canvas (an [`InlineObject::Group`] with `canvas`).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct CanvasStyle {
pub fill: Option<crate::fill::Fill>,
pub stroke: Option<crate::props::Rgb>,
/// Outline width, points.
pub stroke_width: f32,
}
impl InlineObject {
/// A picture, chart or diagram, shape, text box or group: something drawn with a frame, sized and wrapped.
pub fn is_drawing(&self) -> bool {
matches!(self, InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })
}
/// A drawing's size and placement.
pub fn frame(&self) -> Option<(f32, f32, &Float)> {
match self {
InlineObject::Image { w, h, float, .. }
| InlineObject::Graphic { w, h, float, .. }
| InlineObject::Shape { w, h, float, .. }
| InlineObject::Group { w, h, float, .. } => Some((*w, *h, float)),
_ => None,
}
}
/// A drawing's placement, to change.
pub fn float_mut(&mut self) -> Option<&mut Float> {
match self {
InlineObject::Image { float, .. }
| InlineObject::Graphic { float, .. }
| InlineObject::Shape { float, .. }
| InlineObject::Group { float, .. } => Some(float),
_ => None,
}
}
/// Resize a drawing (a group's members scale with it; a canvas's keep their size and place).
pub fn set_size(&mut self, nw: f32, nh: f32) {
if let InlineObject::Image { w, h, .. }
| InlineObject::Graphic { w, h, .. }
| InlineObject::Shape { w, h, .. }
| InlineObject::Group { w, h, .. } = self
{
*w = nw;
*h = nh;
}
if let InlineObject::Group { ch_w, ch_h, canvas: Some(_), .. } = self {
*ch_w = nw;
*ch_h = nh;
}
}
/// A Drawing Canvas's background and outline (`None` for anything else).
pub fn canvas_style(&self) -> Option<&CanvasStyle> {
match self {
InlineObject::Group { canvas, .. } => canvas.as_ref(),
_ => None,
}
}
/// Whether this is a Drawing Canvas.
pub fn is_canvas(&self) -> bool {
self.canvas_style().is_some()
}
/// The text box story this object shows, if it is a text box.
pub fn text_box(&self) -> Option<u32> {
match self {
InlineObject::Shape { story, .. } => *story,
_ => None,
}
}
/// The text box stories this object shows: a text box's, or its group members'.
pub fn text_boxes(&self) -> Vec<u32> {
match self {
InlineObject::Shape { story: Some(id), .. } => vec![*id],
InlineObject::Group { children, .. } => children.iter().filter_map(|c| c.obj.text_box()).collect(),
_ => Vec::new(),
}
}
/// The text box story slots of this object and its group members, to repoint.
pub fn text_box_slots(&mut self) -> Vec<&mut Option<u32>> {
match self {
InlineObject::Shape { story, .. } => vec![story],
InlineObject::Group { children, .. } => children
.iter_mut()
.filter_map(|c| match &mut c.obj {
InlineObject::Shape { story, .. } => Some(story),
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
/// (x, y, w, h) and the member. Empty for anything else.
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
children
.iter()
.take(MAX_GROUP_CHILDREN)
.filter_map(|c| {
let (cw, chh, _) = c.obj.frame()?;
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
})
.collect()
}
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
/// the `w:ffData` it keeps; `None` for anything else.
pub fn legacy_form(&self) -> Option<crate::legacy_form::LegacyForm> {
match self {
InlineObject::Field { form, .. } if !form.is_empty() => crate::legacy_form::LegacyForm::parse(form),
_ => None,
}
}
/// Zero-width markers don't take part in layout.
pub fn is_marker(&self) -> bool {
matches!(
self,
InlineObject::BookmarkStart { .. }
| InlineObject::BookmarkEnd { .. }
| InlineObject::CommentStart { .. }
| InlineObject::CommentEnd { .. }
| InlineObject::PermStart { .. }
| InlineObject::PermEnd { .. }
| InlineObject::FieldStart { .. }
| InlineObject::FieldEnd
| InlineObject::ControlStart { .. }
| InlineObject::ControlEnd
)
}
/// A drawing's description (alternative text), wherever it is kept.
pub fn alt_text(&self) -> &str {
match self {
InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => alt,
InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => &float.alt.text,
_ => "",
}
}
/// Set a drawing's description (capped at [`MAX_ALT_TEXT`] characters); `false` if this is no drawing.
pub fn set_alt_text(&mut self, text: &str) -> bool {
let text = cap_alt(text);
match self {
InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => *alt = text,
InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => float.alt.text = text,
_ => return false,
}
true
}
/// A drawing marked decorative.
pub fn is_decorative(&self) -> bool {
self.frame().is_some_and(|(_, _, f)| f.alt.decorative)
}
pub fn is_floating(&self) -> bool {
match self {
InlineObject::Image { float, .. }
| InlineObject::Graphic { float, .. }
| InlineObject::Shape { float, .. }
| InlineObject::Group { float, .. } => float.wrap != Wrap::Inline,
_ => false,
}
}
/// The geometry of an ink stroke (a freeform shape drawn with a pen), if this is one.
pub fn ink(&self) -> Option<&crate::freeform::Freeform> {
match self {
InlineObject::Shape { freeform: Some(f), .. } if f.is_ink() => Some(f),
_ => None,
}
}
/// The line a footnote or endnote separator draws (ECMA-376 §17.11: the `w:separator` and
/// `w:continuationSeparator` run elements): a short line, or with `continuation` one across
/// the column. Kept as the run element itself.
pub fn note_separator(continuation: bool) -> InlineObject {
let el = if continuation { "w:continuationSeparator" } else { "w:separator" };
InlineObject::Opaque { format: "docx".into(), xml: format!("<w:r><{el}/></w:r>"), text: String::new() }
}
/// For a [`InlineObject::note_separator`]: whether it is the continuation separator.
pub fn as_note_separator(&self) -> Option<bool> {
match self {
InlineObject::Opaque { format, xml, .. } if format == "docx" => match xml.as_str() {
"<w:r><w:separator/></w:r>" => Some(false),
"<w:r><w:continuationSeparator/></w:r>" => Some(true),
_ => None,
},
_ => None,
}
}
/// The text this object contributes to plain-text extraction.
pub fn plain_text(&self) -> &str {
match self {
InlineObject::Field { result, .. } => result,
InlineObject::Equation { linear, .. } => linear,
InlineObject::Ruby { base, .. } => base,
InlineObject::PositionalTab { .. } => "\t",
InlineObject::Opaque { text, .. } => text,
_ => "",
}
}
}
pub use float::{AltText, Anchor, Float, FloatAlign, MAX_ALT_TEXT, Wrap, WrapText, cap_alt};
pub use inline_kinds::{NoteKind, PTabAlign, PTabBase, RubyAlign, ShapeKind, ruby_offset, ruby_raise};
pub use inline_object::{CanvasStyle, GroupChild, InlineObject, MAX_GROUP_CHILDREN};
pub use run::{COLUMN_BREAK, LINE_BREAK, NB_HYPHEN, NBSP, OBJ, PAGE_BREAK, Run, SOFT_HYPHEN, next_rev};
/// A paragraph.
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -1089,6 +252,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> {
@@ -1332,9 +501,8 @@ impl Paragraph {
pub fn next_boundary(&self, off: usize) -> usize {
crate::cluster::next_cluster(&self.text, self.clamp(off))
}
/// Where Backspace at `off` deletes from: the previous character in the Brahmic scripts
/// (Myanmar, Khmer, Devanagari, Thai…), where it removes one vowel sign or stacked consonant
/// at a time, otherwise the previous caret stop.
/// Where Backspace at `off` deletes from: one Arabic combining mark or one character in the
/// Brahmic scripts (Myanmar, Khmer, Devanagari, Thai…), otherwise the previous caret stop.
pub fn backspace_from(&self, off: usize) -> usize {
let off = self.clamp(off);
match self.text.get(..off).and_then(|t| t.char_indices().next_back()) {
@@ -1559,6 +727,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);
+257
View File
@@ -0,0 +1,257 @@
use super::{Deserialize, Serialize};
/// How a floating object is placed.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum Wrap {
/// In line with text (not floating).
#[default]
Inline,
Square,
Tight,
Through,
TopAndBottom,
BehindText,
InFrontOfText,
}
/// Which sides of a Square, Tight or Through object text may flow on (`@wrapText`, ECMA-376
/// Part 1, §20.4.3.7 `ST_WrapText`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum WrapText {
/// Both sides of the object.
#[default]
BothSides,
/// Only the left side: the band to the object's right stays empty.
Left,
/// Only the right side.
Right,
/// Only whichever side has more room, decided per line.
Largest,
}
impl WrapText {
/// From the OOXML value (`bothSides`, `left`, `right`, `largest`).
pub fn from_ooxml(v: &str) -> Option<WrapText> {
let v = v.trim();
if v.eq_ignore_ascii_case("bothSides") || v.eq_ignore_ascii_case("both") {
Some(WrapText::BothSides)
} else if v.eq_ignore_ascii_case("left") {
Some(WrapText::Left)
} else if v.eq_ignore_ascii_case("right") {
Some(WrapText::Right)
} else if v.eq_ignore_ascii_case("largest") {
Some(WrapText::Largest)
} else {
None
}
}
/// The OOXML value.
pub fn ooxml(self) -> &'static str {
match self {
WrapText::BothSides => "bothSides",
WrapText::Left => "left",
WrapText::Right => "right",
WrapText::Largest => "largest",
}
}
pub fn is_both(&self) -> bool {
*self == WrapText::BothSides
}
}
/// What a floating object's offset is relative to.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum Anchor {
#[default]
Column,
Margin,
Page,
Paragraph,
/// The page's left margin area (from the page's left edge to the text).
LeftMargin,
/// The page's right margin area (from the text to the page's right edge).
RightMargin,
/// The page's top margin area (from the page's top edge to the text).
TopMargin,
/// The page's bottom margin area (from the text to the page's bottom edge).
BottomMargin,
/// The inside margin (left on odd pages, right on even pages when mirrored).
InsideMargin,
/// The outside margin.
OutsideMargin,
/// The anchor character (horizontal only).
Character,
/// The anchor line (vertical only).
Line,
}
/// Alignment of a floating object within its [`Anchor`] area, instead of an offset.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FloatAlign {
/// Left or top.
Start,
Center,
/// Right or bottom.
End,
Inside,
Outside,
}
impl FloatAlign {
/// From an OOXML alignment (`wp:align`, `w:tblpXSpec`, `w:tblpYSpec`).
pub fn from_ooxml(v: &str) -> Option<FloatAlign> {
match v {
"left" | "top" => Some(FloatAlign::Start),
"center" => Some(FloatAlign::Center),
"right" | "bottom" => Some(FloatAlign::End),
"inside" => Some(FloatAlign::Inside),
"outside" => Some(FloatAlign::Outside),
_ => None,
}
}
/// The OOXML name on the horizontal (`left`, `right`) or vertical (`top`, `bottom`) axis.
pub fn ooxml(self, horizontal: bool) -> &'static str {
match (self, horizontal) {
(FloatAlign::Start, true) => "left",
(FloatAlign::Start, false) => "top",
(FloatAlign::Center, _) => "center",
(FloatAlign::End, true) => "right",
(FloatAlign::End, false) => "bottom",
(FloatAlign::Inside, _) => "inside",
(FloatAlign::Outside, _) => "outside",
}
}
}
/// Floating placement (ignored when `wrap` is `Inline`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Float {
pub wrap: Wrap,
/// The sides text wraps on (Square, Tight and Through).
#[serde(skip_serializing_if = "WrapText::is_both")]
pub wrap_text: WrapText,
pub h_rel: Anchor,
pub v_rel: Anchor,
/// Offsets, points (used when the matching alignment is `None`).
pub x: f32,
pub y: f32,
/// Alignments within the anchor area; they take precedence over the offsets.
#[serde(skip_serializing_if = "Option::is_none")]
pub h_align: Option<FloatAlign>,
#[serde(skip_serializing_if = "Option::is_none")]
pub v_align: Option<FloatAlign>,
/// Distance from surrounding text at the left and right (points).
pub dist: f32,
/// Distance from surrounding text above and below (points).
pub dist_top: f32,
pub dist_bottom: f32,
/// Room around the object for effects such as shadows (left, top, right, bottom, points),
/// inline or floating: lines and surrounding text keep clear of it. Rotation's own overhang
/// is not in here: it follows from [`Float::rot`] (see [`Float::spin_pad`]).
pub effect: [f32; 4],
/// Rotation clockwise about the object's centre, degrees (DrawingML `a:xfrm/@rot`). Offsets
/// and the size stay those of the unrotated frame, as in Word; see [`Float::spin`].
#[serde(skip_serializing_if = "is_zero")]
pub rot: f32,
/// Mirrored left to right / top to bottom (`a:xfrm/@flipH`, `@flipV`).
#[serde(skip_serializing_if = "is_false")]
pub flip_h: bool,
#[serde(skip_serializing_if = "is_false")]
pub flip_v: bool,
/// The outline Tight and Through wrapping follow (`wp:wrapPolygon`), in the object's own
/// (unrotated) frame; `None` = derived from the object (see [`crate::wrap`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub wrap_polygon: Option<std::sync::Arc<crate::wrap::WrapPolygon>>,
/// What the drawing is, for screen readers and exports (`wp:docPr` `@descr`, `@title` and the
/// decorative mark). See [`InlineObject::alt_text`].
#[serde(skip_serializing_if = "AltText::is_empty")]
pub alt: AltText,
/// Hidden (`wp:docPr/@hidden`, VML `visibility:hidden`): kept and saved, but neither drawn nor
/// laid out, as Word for Windows does (Word for Mac draws hidden VML shapes).
#[serde(skip_serializing_if = "is_false")]
pub hidden: bool,
/// Anchored in a table cell but placed on the page, not in the cell (`wp:anchor/@layoutInCell`
/// off, any VML shape in a header or footer): a picture watermark in a header that starts with a table.
#[serde(skip_serializing_if = "is_false")]
pub outside_cell: bool,
/// Stacking order among floating objects (`wp:anchor/@relativeHeight`): higher is drawn on
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
#[serde(skip_serializing_if = "is_zero_u32")]
pub z: u32,
}
/// Longest alternative text kept (characters): hostile files and commands are capped.
pub const MAX_ALT_TEXT: usize = 10_000;
/// A drawing's alternative text (Picture/Shape Format › Accessibility › Alt Text).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct AltText {
/// The description of a shape, text box or group. Pictures and charts keep theirs in their
/// own `alt`; read it with [`InlineObject::alt_text`], which knows where it is.
#[serde(skip_serializing_if = "String::is_empty")]
pub text: String,
/// The short title (`wp:docPr/@title`).
#[serde(skip_serializing_if = "String::is_empty")]
pub title: String,
/// Decorative: it adds nothing to the content, so screen readers skip it and the
/// accessibility checker doesn't ask for a description.
#[serde(skip_serializing_if = "is_false")]
pub decorative: bool,
}
impl AltText {
pub fn is_empty(&self) -> bool {
self.text.is_empty() && self.title.is_empty() && !self.decorative
}
}
/// `s` cut to [`MAX_ALT_TEXT`] characters.
pub fn cap_alt(s: &str) -> String {
s.chars().take(MAX_ALT_TEXT).collect()
}
fn is_zero(v: &f32) -> bool {
*v == 0.0
}
fn is_false(v: &bool) -> bool {
!*v
}
fn is_zero_u32(v: &u32) -> bool {
*v == 0
}
impl Float {
/// The rotation and flips, the angle normalised to `0..360` (a NaN angle is no rotation).
pub fn spin(&self) -> wordcraft_geom::Spin {
wordcraft_geom::Spin::new(self.rot, self.flip_h, self.flip_v)
}
/// Set the rotation and flips (the angle normalised).
pub fn set_spin(&mut self, s: wordcraft_geom::Spin) {
self.rot = wordcraft_geom::normalize_degrees(s.deg);
self.flip_h = s.flip_h;
self.flip_v = s.flip_v;
}
/// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a
/// rotated object takes beyond its frame (zero unrotated; negative where the turned object
/// is narrower than its frame, as a wide picture turned 90° is).
pub fn spin_pad(&self, w: f32, h: f32) -> (f32, f32) {
let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0));
let (bw, bh) = self.spin().extent(w, h);
((bw - w) / 2.0, (bh - h) / 2.0)
}
/// The effect extents, finite and clamped (left, top, right, bottom).
pub fn effect_extent(&self) -> [f32; 4] {
self.effect.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
}
}
+146
View File
@@ -0,0 +1,146 @@
use super::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum ShapeKind {
#[default]
Rectangle,
RoundedRectangle,
Ellipse,
/// Flowchart OR preset: an ellipse with horizontal and vertical spokes.
FlowchartOr,
Triangle,
/// A right triangle (DrawingML's `rtTriangle`): the right angle at the bottom left.
RightTriangle,
Diamond,
Line,
/// A block arrow pointing right (`rightArrow`; also stands for the double-headed arrows).
Arrow,
/// A block arrow pointing left (`leftArrow`).
ArrowLeft,
/// A block arrow pointing up (`upArrow`).
ArrowUp,
/// A block arrow pointing down (`downArrow`).
ArrowDown,
Star,
Heart,
/// A text box (rectangle with a text story).
TextBox,
/// A shape drawn point by point, such as ink (its geometry is the shape's `freeform`).
Freeform,
/// Connectors: lines whose ends can be glued to other shapes (see [`crate::connector`]).
StraightConnector,
ElbowConnector,
CurvedConnector,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum NoteKind {
#[default]
Footnote,
Endnote,
}
/// Where a positional tab ([`InlineObject::PositionalTab`]) lands on its line, and how the text
/// after it lines up there (ECMA-376 `ST_PTabAlignment`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum PTabAlign {
#[default]
Left,
Center,
Right,
}
impl PTabAlign {
pub const ALL: [PTabAlign; 3] = [PTabAlign::Left, PTabAlign::Center, PTabAlign::Right];
/// The OOXML name (`left`, `center`, `right`).
pub fn ooxml(self) -> &'static str {
match self {
PTabAlign::Left => "left",
PTabAlign::Center => "center",
PTabAlign::Right => "right",
}
}
pub fn from_ooxml(v: &str) -> Option<PTabAlign> {
PTabAlign::ALL.into_iter().find(|a| a.ooxml() == v)
}
}
/// What a positional tab's place is measured against (ECMA-376 `ST_PTabRelativeTo`): the text
/// margins, or the paragraph's indents.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum PTabBase {
#[default]
Margin,
Indent,
}
impl PTabBase {
pub const ALL: [PTabBase; 2] = [PTabBase::Margin, PTabBase::Indent];
/// The OOXML name (`margin`, `indent`).
pub fn ooxml(self) -> &'static str {
match self {
PTabBase::Margin => "margin",
PTabBase::Indent => "indent",
}
}
pub fn from_ooxml(v: &str) -> Option<PTabBase> {
PTabBase::ALL.into_iter().find(|a| a.ooxml() == v)
}
}
/// How ruby (phonetic guide) text lines up over its base text (ECMA-376 `ST_RubyAlign`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum RubyAlign {
#[default]
Center,
/// Letters spread evenly from edge to edge.
DistributeLetter,
/// Letters spread evenly with half a gap at each edge.
DistributeSpace,
Left,
Right,
/// Right-aligned beside vertical text (laid out like `Right` in horizontal text).
RightVertical,
}
impl RubyAlign {
pub const ALL: [RubyAlign; 6] =
[RubyAlign::Center, RubyAlign::DistributeLetter, RubyAlign::DistributeSpace, RubyAlign::Left, RubyAlign::Right, RubyAlign::RightVertical];
/// The OOXML name (`center`, `distributeLetter`…), also used by commands.
pub fn ooxml(self) -> &'static str {
match self {
RubyAlign::Center => "center",
RubyAlign::DistributeLetter => "distributeLetter",
RubyAlign::DistributeSpace => "distributeSpace",
RubyAlign::Left => "left",
RubyAlign::Right => "right",
RubyAlign::RightVertical => "rightVertical",
}
}
pub fn from_ooxml(v: &str) -> Option<RubyAlign> {
RubyAlign::ALL.into_iter().find(|a| a.ooxml() == v)
}
}
/// Phonetic Guide's Offset for a ruby `raise` (baseline to baseline, points): the gap from the top
/// of the base text to the bottom of the ruby text, taking letters as rising 0.8 em above their
/// baseline and dropping 0.2 em below it.
pub fn ruby_offset(raise: f32, base_size: f32, size: f32) -> f32 {
raise - 0.8 * base_size - 0.2 * size
}
/// The ruby raise (baseline to baseline, points) for a Phonetic Guide Offset (see [`ruby_offset`]).
pub fn ruby_raise(offset: f32, base_size: f32, size: f32) -> f32 {
offset + 0.8 * base_size + 0.2 * size
}
+421
View File
@@ -0,0 +1,421 @@
use super::float::{Float, Wrap, cap_alt};
use super::inline_kinds::{NoteKind, PTabAlign, PTabBase, RubyAlign, ShapeKind};
use super::{CharProps, Deserialize, Serialize};
/// An inline object anchored at a U+FFFC in the paragraph text.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum InlineObject {
Image {
/// Key in `Document::media`.
media: String,
/// Display size, points.
w: f32,
h: f32,
#[serde(default)]
alt: String,
#[serde(default)]
float: Float,
/// Crop (left, top, right, bottom) fractions 0..1.
#[serde(default)]
crop: [f32; 4],
/// The OLE object (embedded or linked file) or Word signature line this picture shows, as
/// read from a file: saving writes the object back, not only its picture. See
/// [`crate::graphic::Embedded`].
#[serde(default, skip_serializing_if = "Option::is_none")]
ole: Option<std::sync::Arc<crate::graphic::Embedded>>,
},
/// A chart or SmartArt diagram (see [`crate::graphic`]).
Graphic {
w: f32,
h: f32,
#[serde(default)]
alt: String,
#[serde(default)]
float: Float,
graphic: std::sync::Arc<crate::graphic::Graphic>,
},
Shape {
kind: ShapeKind,
w: f32,
h: f32,
fill: Option<crate::fill::Fill>,
stroke: Option<crate::props::Rgb>,
#[serde(default)]
stroke_width: f32,
#[serde(default)]
float: Float,
/// Text box content: `Document::parts` id.
#[serde(default)]
story: Option<u32>,
/// A freeform's geometry (`ShapeKind::Freeform`): its paths, or an ink stroke.
#[serde(default, skip_serializing_if = "Option::is_none")]
freeform: Option<std::sync::Arc<crate::freeform::Freeform>>,
/// Shadow, glow and soft edges.
#[serde(default, skip_serializing_if = "crate::effects::ShapeEffects::is_empty")]
effects: crate::effects::ShapeEffects,
/// Drawing id, text warp, connector ends and arrowheads.
#[serde(default, skip_serializing_if = "crate::connector::ShapeExtra::is_empty")]
extra: crate::connector::ShapeExtra,
},
/// Pictures, shapes and text boxes grouped into one object (Layout › Arrange › Group): it
/// moves, wraps and resizes as one. Its members are laid out in the group's own coordinate
/// space, `ch_w` × `ch_h`, which is stretched over the group's `w` × `h` (DrawingML's
/// `a:chExt` and `a:ext`), so resizing the group scales them.
Group {
w: f32,
h: f32,
#[serde(default)]
float: Float,
/// Size of the members' coordinate space (their offsets and sizes are in it).
ch_w: f32,
ch_h: f32,
children: Vec<GroupChild>,
/// A Drawing Canvas (Insert › Shapes › New Drawing Canvas, DrawingML's `wpc:wpc`) rather
/// than a group: it has its own background and outline, and its frame is independent of
/// its members: resizing it doesn't scale them (`ch_w` × `ch_h` follow `w` × `h`), and
/// what lies outside the frame is clipped.
#[serde(default, skip_serializing_if = "Option::is_none")]
canvas: Option<CanvasStyle>,
},
/// A field: `instr` is the field code (`PAGE`, `NUMPAGES`, `DATE \@ "M/d/yyyy"`, `TOC \o "1-3"`…);
/// `result` the cached display text. A `locked` field keeps its result when fields update.
Field {
instr: String,
#[serde(default)]
result: String,
#[serde(default)]
locked: bool,
/// `w:dirty` (ECMA-376 §17.16.5.35): the cached result is stale — another program that
/// couldn't compute it set the flag so Word refreshes the field on open. Kept through
/// saves; cleared when WordCraft computes a result, like Word's F9.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
dirty: bool,
/// Shows its code (`{ PAGE }`) instead of its result, the other way round from the
/// document-wide View › Field Codes setting (`fields.toggleCode`, Shift+F9). A view
/// toggle: never written to files.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
code: bool,
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`): the
/// source's `w:ffData`, raw OOXML kept for round trip. Empty for other fields.
#[serde(default, skip_serializing_if = "String::is_empty")]
form: String,
},
/// The start of a field whose result is ordinary content — formatted, possibly spanning
/// paragraphs — up to the matching [`InlineObject::FieldEnd`]. Imported TOCs and citation
/// managers' `ADDIN` fields are kept this way; `instr` is the field code. See
/// [`crate::fields`].
FieldStart {
instr: String,
#[serde(default)]
locked: bool,
/// `w:dirty` on the begin marker, as [`InlineObject::Field::dirty`].
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
dirty: bool,
},
/// The end of the innermost open [`InlineObject::FieldStart`].
FieldEnd,
/// The start of a content control whose content is what follows, up to the matching
/// [`InlineObject::ControlEnd`]. See [`crate::control`].
ControlStart {
control: Box<crate::control::ContentControl>,
},
/// The end of the innermost open [`InlineObject::ControlStart`].
ControlEnd,
NoteRef {
kind: NoteKind,
/// `Document::parts` id of the note's story.
id: u32,
/// Custom mark (empty = automatic number).
#[serde(default)]
custom: String,
},
BookmarkStart {
name: String,
},
BookmarkEnd {
name: String,
},
CommentStart {
id: u32,
},
CommentEnd {
id: u32,
},
/// The start of a range that stays editable while the document is protected (Restrict
/// Editing exceptions, `w:permStart`): for everyone (`group` = `everyone`), another editing
/// group, or one editor (`editor`, a user name or address).
PermStart {
id: u32,
#[serde(default, skip_serializing_if = "String::is_empty")]
group: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
editor: String,
},
/// The end of the editable range [`InlineObject::PermStart`] with the same `id` opened.
PermEnd {
id: u32,
},
/// An equation: its linear format (`x=(-b±√(b^2-4ac))/2a`, for plain text) and structure.
/// A display equation sits on a line of its own.
Equation {
linear: String,
#[serde(default)]
display: bool,
/// The structure; empty = parse `linear`.
#[serde(default, skip_serializing_if = "crate::math::Math::is_empty")]
math: crate::math::Math,
},
/// Ruby (Phonetic Guide): small `ruby` text (furigana, pinyin…) set over `base` text. The base
/// text takes the formatting of the object's run; it reads as the paragraph's text.
Ruby {
base: String,
ruby: String,
#[serde(default)]
align: RubyAlign,
/// Ruby text size, points (`w:hps`).
size: f32,
/// From the base text's baseline up to the ruby text's baseline, points (`w:hpsRaise`).
raise: f32,
/// The base text size the ruby was set for, points (`w:hpsBaseText`).
#[serde(default)]
base_size: f32,
/// Language of the ruby (`w:lid`, e.g. `ja-JP`); empty = unspecified.
#[serde(default, skip_serializing_if = "String::is_empty")]
lang: String,
/// Formatting of the ruby text over the run's (font, colour…); its size is `size`.
#[serde(default, skip_serializing_if = "CharProps::is_empty")]
props: Box<CharProps>,
},
/// A positional tab (Word's alignment tab, `w:ptab`): it goes to the left edge, centre or
/// right edge of the margins or of the indents, whatever the paragraph's tab stops, and the
/// text after it lines up there. Text already past that place sends it to the next line.
PositionalTab {
#[serde(default)]
align: PTabAlign,
#[serde(default)]
relative_to: PTabBase,
#[serde(default)]
leader: crate::props::TabLeader,
},
/// Something we don't model, kept for round-trip (raw XML of the source format).
Opaque {
format: String,
xml: String,
#[serde(default)]
text: String,
},
}
/// One member of an [`InlineObject::Group`]: a picture or shape (its own `w`, `h` and story), at
/// `x`, `y` in the group's coordinate space. Its `float` is unused.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GroupChild {
pub x: f32,
pub y: f32,
pub obj: InlineObject,
}
/// Most members a group keeps (hostile files).
pub const MAX_GROUP_CHILDREN: usize = 1000;
/// The background fill and outline of a Drawing Canvas (an [`InlineObject::Group`] with `canvas`).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct CanvasStyle {
pub fill: Option<crate::fill::Fill>,
pub stroke: Option<crate::props::Rgb>,
/// Outline width, points.
pub stroke_width: f32,
}
impl InlineObject {
/// A picture, chart or diagram, shape, text box or group: something drawn with a frame, sized and wrapped.
pub fn is_drawing(&self) -> bool {
matches!(self, InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. } | InlineObject::Group { .. })
}
/// A drawing's size and placement.
pub fn frame(&self) -> Option<(f32, f32, &Float)> {
match self {
InlineObject::Image { w, h, float, .. }
| InlineObject::Graphic { w, h, float, .. }
| InlineObject::Shape { w, h, float, .. }
| InlineObject::Group { w, h, float, .. } => Some((*w, *h, float)),
_ => None,
}
}
/// A drawing's placement, to change.
pub fn float_mut(&mut self) -> Option<&mut Float> {
match self {
InlineObject::Image { float, .. }
| InlineObject::Graphic { float, .. }
| InlineObject::Shape { float, .. }
| InlineObject::Group { float, .. } => Some(float),
_ => None,
}
}
/// Resize a drawing (a group's members scale with it; a canvas's keep their size and place).
pub fn set_size(&mut self, nw: f32, nh: f32) {
if let InlineObject::Image { w, h, .. }
| InlineObject::Graphic { w, h, .. }
| InlineObject::Shape { w, h, .. }
| InlineObject::Group { w, h, .. } = self
{
*w = nw;
*h = nh;
}
if let InlineObject::Group { ch_w, ch_h, canvas: Some(_), .. } = self {
*ch_w = nw;
*ch_h = nh;
}
}
/// A Drawing Canvas's background and outline (`None` for anything else).
pub fn canvas_style(&self) -> Option<&CanvasStyle> {
match self {
InlineObject::Group { canvas, .. } => canvas.as_ref(),
_ => None,
}
}
/// Whether this is a Drawing Canvas.
pub fn is_canvas(&self) -> bool {
self.canvas_style().is_some()
}
/// The text box story this object shows, if it is a text box.
pub fn text_box(&self) -> Option<u32> {
match self {
InlineObject::Shape { story, .. } => *story,
_ => None,
}
}
/// The text box stories this object shows: a text box's, or its group members'.
pub fn text_boxes(&self) -> Vec<u32> {
match self {
InlineObject::Shape { story: Some(id), .. } => vec![*id],
InlineObject::Group { children, .. } => children.iter().filter_map(|c| c.obj.text_box()).collect(),
_ => Vec::new(),
}
}
/// The text box story slots of this object and its group members, to repoint.
pub fn text_box_slots(&mut self) -> Vec<&mut Option<u32>> {
match self {
InlineObject::Shape { story, .. } => vec![story],
InlineObject::Group { children, .. } => children
.iter_mut()
.filter_map(|c| match &mut c.obj {
InlineObject::Shape { story, .. } => Some(story),
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
/// (x, y, w, h) and the member. Empty for anything else.
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
children
.iter()
.take(MAX_GROUP_CHILDREN)
.filter_map(|c| {
let (cw, chh, _) = c.obj.frame()?;
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
})
.collect()
}
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
/// the `w:ffData` it keeps; `None` for anything else.
pub fn legacy_form(&self) -> Option<crate::legacy_form::LegacyForm> {
match self {
InlineObject::Field { form, .. } if !form.is_empty() => crate::legacy_form::LegacyForm::parse(form),
_ => None,
}
}
/// Zero-width markers don't take part in layout.
pub fn is_marker(&self) -> bool {
matches!(
self,
InlineObject::BookmarkStart { .. }
| InlineObject::BookmarkEnd { .. }
| InlineObject::CommentStart { .. }
| InlineObject::CommentEnd { .. }
| InlineObject::PermStart { .. }
| InlineObject::PermEnd { .. }
| InlineObject::FieldStart { .. }
| InlineObject::FieldEnd
| InlineObject::ControlStart { .. }
| InlineObject::ControlEnd
)
}
/// A drawing's description (alternative text), wherever it is kept.
pub fn alt_text(&self) -> &str {
match self {
InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => alt,
InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => &float.alt.text,
_ => "",
}
}
/// Set a drawing's description (capped at [`MAX_ALT_TEXT`] characters); `false` if this is no drawing.
pub fn set_alt_text(&mut self, text: &str) -> bool {
let text = cap_alt(text);
match self {
InlineObject::Image { alt, .. } | InlineObject::Graphic { alt, .. } => *alt = text,
InlineObject::Shape { float, .. } | InlineObject::Group { float, .. } => float.alt.text = text,
_ => return false,
}
true
}
/// A drawing marked decorative.
pub fn is_decorative(&self) -> bool {
self.frame().is_some_and(|(_, _, f)| f.alt.decorative)
}
pub fn is_floating(&self) -> bool {
match self {
InlineObject::Image { float, .. }
| InlineObject::Graphic { float, .. }
| InlineObject::Shape { float, .. }
| InlineObject::Group { float, .. } => float.wrap != Wrap::Inline,
_ => false,
}
}
/// The geometry of an ink stroke (a freeform shape drawn with a pen), if this is one.
pub fn ink(&self) -> Option<&crate::freeform::Freeform> {
match self {
InlineObject::Shape { freeform: Some(f), .. } if f.is_ink() => Some(f),
_ => None,
}
}
/// The line a footnote or endnote separator draws (ECMA-376 §17.11: the `w:separator` and
/// `w:continuationSeparator` run elements): a short line, or with `continuation` one across
/// the column. Kept as the run element itself.
pub fn note_separator(continuation: bool) -> InlineObject {
let el = if continuation { "w:continuationSeparator" } else { "w:separator" };
InlineObject::Opaque { format: "docx".into(), xml: format!("<w:r><{el}/></w:r>"), text: String::new() }
}
/// For a [`InlineObject::note_separator`]: whether it is the continuation separator.
pub fn as_note_separator(&self) -> Option<bool> {
match self {
InlineObject::Opaque { format, xml, .. } if format == "docx" => match xml.as_str() {
"<w:r><w:separator/></w:r>" => Some(false),
"<w:r><w:continuationSeparator/></w:r>" => Some(true),
_ => None,
},
_ => None,
}
}
/// The text this object contributes to plain-text extraction.
pub fn plain_text(&self) -> &str {
match self {
InlineObject::Field { result, .. } => result,
InlineObject::Equation { linear, .. } => linear,
InlineObject::Ruby { base, .. } => base,
InlineObject::PositionalTab { .. } => "\t",
InlineObject::Opaque { text, .. } => text,
_ => "",
}
}
}
+37
View File
@@ -0,0 +1,37 @@
use super::{AtomicU64, CharProps, Deserialize, Ordering, Serialize};
/// Object replacement character: marks an inline object in paragraph text.
pub const OBJ: char = '\u{FFFC}';
/// Manual line break (Shift+Enter).
pub const LINE_BREAK: char = '\n';
/// Page break (Ctrl+Enter).
pub const PAGE_BREAK: char = '\u{000C}';
/// Column break (Ctrl+Shift+Enter).
pub const COLUMN_BREAK: char = '\u{000E}';
/// Optional hyphen (Ctrl+-).
pub const SOFT_HYPHEN: char = '\u{00AD}';
/// Nonbreaking hyphen (Ctrl+Shift+-).
pub const NB_HYPHEN: char = '\u{2011}';
/// Nonbreaking space (Ctrl+Shift+Space).
pub const NBSP: char = '\u{00A0}';
static REV: AtomicU64 = AtomicU64::new(1);
/// A fresh revision number (process-unique), used by layout caches to detect edits.
pub fn next_rev() -> u64 {
REV.fetch_add(1, Ordering::Relaxed)
}
/// A formatting run: `len` bytes of text sharing `props`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Run {
pub len: usize,
#[serde(default, skip_serializing_if = "CharProps::is_empty")]
pub props: CharProps,
}
+133
View File
@@ -0,0 +1,133 @@
use super::block::Blocks;
use super::position::Pos;
use super::{Deserialize, Serialize, props};
/// A free id, `min` or more, for something new (a part, a list, a comment): one past `largest`, the
/// largest id in use, so ids keep growing; once that would pass `u32::MAX` (a file can use any id),
/// the lowest one `used` (every id in use) doesn't hold. `None` when every id from `min` up is taken.
pub fn free_id(largest: Option<u32>, used: impl Iterator<Item = u32>, min: u32) -> Option<u32> {
let Some(largest) = largest else { return Some(min) };
if let Some(next) = largest.checked_add(1) {
return Some(next.max(min));
}
let mut ids: Vec<u32> = used.filter(|&i| i >= min).collect();
ids.sort_unstable();
ids.dedup();
let mut want = min;
for i in ids {
if i > want {
break;
}
want = want.checked_add(1)?;
}
Some(want)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum PartKind {
#[default]
Header,
Footer,
Footnote,
Endnote,
Comment,
TextBox,
}
/// A document's own separators and continuation notice for its footnotes or its endnotes
/// (ECMA-376 §17.11: the `w:footnote`/`w:endnote` entries of type `separator`,
/// `continuationSeparator` and `continuationNotice`). `None` = the default: a short line above
/// the notes, a line across the column above notes continued from the page before, no notice.
/// The lines themselves are [`InlineObject::note_separator`] objects in the paragraphs.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct NoteSeparators {
/// Above the notes on a page.
pub separator: Option<Blocks>,
/// Above the rest of a note continued from the page before.
pub continuation_separator: Option<Blocks>,
/// Below the part of a note that continues on the next page.
pub continuation_notice: Option<Blocks>,
}
impl NoteSeparators {
/// No separator of the document's own: the defaults apply.
pub fn is_empty(&self) -> bool {
self.separator.is_none() && self.continuation_separator.is_none() && self.continuation_notice.is_none()
}
}
/// A secondary story.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
pub struct Part {
pub kind: PartKind,
pub blocks: Blocks,
/// A text box's text direction, alignment and link (unused by other parts).
#[serde(default, skip_serializing_if = "TextBody::is_default")]
pub body: TextBody,
}
/// How a text box shows its text (Shape Format › Text): which way it runs, where it sits between
/// the box's top and bottom, and the box its overflow continues in.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct TextBody {
pub vert: props::TextVert,
pub anchor: props::VAlign,
/// Create Link: the text box (another `TextBox` part, shown by its own shape) the text
/// continues in when it doesn't fit this one. The linked box shows the rest of this box's
/// story; its own blocks are unused. See [`Document::text_box_chains`].
pub next: Option<u32>,
/// Internal margins (left, top, right, bottom), EMU, when the file gives its own
/// (`wps:bodyPr` `lIns`… ECMA-376 Part 1 §21.1.2.1.1, or VML `v:textbox` `inset`);
/// `None` is Word's defaults.
pub inset: Option<[u32; 4]>,
/// Resize shape to fit text (`a:spAutoFit`, VML `mso-fit-shape-to-text`): Word grows the
/// box to its text, so none of the text is cut.
pub auto_fit: bool,
/// The colour the shape's style gives its text (`wps:style/a:fontRef`): what automatic text
/// colour means inside this box, instead of black. Text whose colour comes from its runs,
/// styles or the document defaults keeps it (Word, observed with synthetic documents).
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_color: Option<props::Rgb>,
}
/// Word's default text box margins (left, top, right, bottom), EMU: 0.1" and 0.05".
pub const TEXT_BOX_INSET: [u32; 4] = [91_440, 45_720, 91_440, 45_720];
/// Widest text box margin kept, EMU (22", the largest page side).
pub const MAX_TEXT_BOX_INSET: u32 = 20_116_800;
impl TextBody {
pub fn is_default(&self) -> bool {
*self == TextBody::default()
}
/// The internal margins (left, top, right, bottom) in points.
pub fn insets_pt(&self) -> [f32; 4] {
self.inset.unwrap_or(TEXT_BOX_INSET).map(|v| v.min(MAX_TEXT_BOX_INSET) as f32 / 12_700.0)
}
}
/// Most boxes one chain of linked text boxes runs through (hostile files).
pub const MAX_LINKED_BOXES: usize = 256;
/// A range that stays editable while the document is protected; see [`Document::perm_ranges`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PermRange {
pub id: u32,
/// Editing group (`everyone`, `editors`…); empty when the range names an editor.
pub group: String,
/// The one editor the range is for; empty for a group.
pub editor: String,
pub start: Pos,
pub end: Pos,
}
impl PermRange {
/// Can everyone edit it (rather than one person or group)?
pub fn everyone(&self) -> bool {
self.group.eq_ignore_ascii_case("everyone")
}
}
+73
View File
@@ -0,0 +1,73 @@
use super::{Deserialize, Serialize};
/// Which story a position is in.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum StoryRef {
#[default]
Body,
Part(u32),
}
/// Path to a paragraph: `[block]` in a story, `[block, row, cell, block]` inside a table cell,
/// three more indexes per nesting level. Lexicographic order = document order.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
pub struct Path(pub Vec<u32>);
impl Path {
pub fn top(i: usize) -> Path {
Path(vec![i as u32])
}
/// The block index within its container.
pub fn last(&self) -> usize {
self.0.last().copied().unwrap_or(0) as usize
}
pub fn with_last(&self, i: usize) -> Path {
let mut p = self.clone();
if let Some(l) = p.0.last_mut() {
*l = i as u32;
}
p
}
/// The container prefix (everything but the last index).
pub fn parent(&self) -> &[u32] {
self.0.split_last().map(|(_, r)| r).unwrap_or(&[])
}
/// Nesting depth (0 = top-level story block).
pub fn depth(&self) -> usize {
self.0.len().saturating_sub(1) / 3
}
/// The enclosing table cell (table block path, row, cell) if any.
pub fn cell(&self) -> Option<(Path, usize, usize)> {
if self.0.len() < 4 {
return None;
}
let n = self.0.len();
let t = Path(self.0.get(..n - 3)?.to_vec());
Some((t, *self.0.get(n - 3)? as usize, *self.0.get(n - 2)? as usize))
}
}
impl std::fmt::Display for Path {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s: Vec<String> = self.0.iter().map(ToString::to_string).collect();
write!(f, "{}", s.join("/"))
}
}
/// A position in a document.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
pub struct Pos {
pub story: StoryRef,
pub path: Path,
pub off: usize,
}
impl Pos {
pub fn new(story: StoryRef, path: Path, off: usize) -> Pos {
Pos { story, path, off }
}
pub fn body(block: usize, off: usize) -> Pos {
Pos { story: StoryRef::Body, path: Path::top(block), off }
}
}
+12 -1364
View File
File diff suppressed because it is too large Load Diff
+545
View File
@@ -0,0 +1,545 @@
use super::color::{Rgb, TextColor};
use super::para_props::Border;
use super::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum Underline {
#[default]
None,
Single,
Words,
Double,
Thick,
Dotted,
Dash,
DotDash,
DotDotDash,
Wave,
DoubleWave,
}
impl Underline {
pub const ALL: [Underline; 11] = [
Underline::None,
Underline::Single,
Underline::Words,
Underline::Double,
Underline::Thick,
Underline::Dotted,
Underline::Dash,
Underline::DotDash,
Underline::DotDotDash,
Underline::Wave,
Underline::DoubleWave,
];
/// The OOXML `w:u/@w:val` name.
pub fn ooxml(self) -> &'static str {
match self {
Underline::None => "none",
Underline::Single => "single",
Underline::Words => "words",
Underline::Double => "double",
Underline::Thick => "thick",
Underline::Dotted => "dotted",
Underline::Dash => "dash",
Underline::DotDash => "dotDash",
Underline::DotDotDash => "dotDotDash",
Underline::Wave => "wave",
Underline::DoubleWave => "wavyDouble",
}
}
pub fn from_ooxml(s: &str) -> Underline {
Underline::ALL.iter().copied().find(|u| u.ooxml() == s).unwrap_or(match s {
"thickDash" | "dashLong" | "dashedHeavy" | "dashLongHeavy" => Underline::Dash,
"dottedHeavy" => Underline::Dotted,
"wavyHeavy" => Underline::Wave,
"dashDotHeavy" => Underline::DotDash,
"dashDotDotHeavy" => Underline::DotDotDash,
_ => Underline::Single,
})
}
}
/// The OpenType ligatures a run asks for ([MS-DOCX] `w14:ligatures`): standard (`liga`),
/// contextual (`clig`), historical (`hlig`) and discretional (`dlig`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Ligatures {
pub standard: bool,
pub contextual: bool,
pub historical: bool,
pub discretional: bool,
}
impl Ligatures {
/// Every combination of the four flags.
pub fn all() -> impl Iterator<Item = Ligatures> {
(0u8..16).map(|b| Ligatures { standard: b & 1 != 0, contextual: b & 2 != 0, historical: b & 4 != 0, discretional: b & 8 != 0 })
}
/// The `w14:ligatures/@w14:val` name: `none`, `all`, or the set flags joined in camelCase
/// (`standardContextual`).
pub fn ooxml(self) -> String {
let on: Vec<&str> =
[(self.standard, "standard"), (self.contextual, "contextual"), (self.historical, "historical"), (self.discretional, "discretional")]
.into_iter()
.filter_map(|(on, n)| on.then_some(n))
.collect();
match on.len() {
0 => "none".into(),
4 => "all".into(),
_ => on
.iter()
.enumerate()
.flat_map(|(i, n)| n.chars().enumerate().map(move |(j, c)| if i > 0 && j == 0 { c.to_ascii_uppercase() } else { c }))
.collect(),
}
}
/// `None` for a name [MS-DOCX] doesn't define.
pub fn from_ooxml(s: &str) -> Option<Ligatures> {
Ligatures::all().find(|l| l.ooxml() == s)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum VertAlign {
#[default]
Baseline,
Superscript,
Subscript,
}
/// Word's 16 highlighter colours (plus none).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum Highlight {
#[default]
None,
Yellow,
BrightGreen,
Turquoise,
Pink,
Blue,
Red,
DarkBlue,
Teal,
Green,
Violet,
DarkRed,
DarkYellow,
Gray50,
Gray25,
Black,
White,
}
impl Highlight {
pub const ALL: [Highlight; 17] = [
Highlight::None,
Highlight::Yellow,
Highlight::BrightGreen,
Highlight::Turquoise,
Highlight::Pink,
Highlight::Blue,
Highlight::Red,
Highlight::DarkBlue,
Highlight::Teal,
Highlight::Green,
Highlight::Violet,
Highlight::DarkRed,
Highlight::DarkYellow,
Highlight::Gray50,
Highlight::Gray25,
Highlight::Black,
Highlight::White,
];
pub fn rgb(self) -> Option<Rgb> {
Some(match self {
Highlight::None => return None,
Highlight::Yellow => Rgb(255, 255, 0),
Highlight::BrightGreen => Rgb(0, 255, 0),
Highlight::Turquoise => Rgb(0, 255, 255),
Highlight::Pink => Rgb(255, 0, 255),
Highlight::Blue => Rgb(0, 0, 255),
Highlight::Red => Rgb(255, 0, 0),
Highlight::DarkBlue => Rgb(0, 0, 128),
Highlight::Teal => Rgb(0, 128, 128),
Highlight::Green => Rgb(0, 128, 0),
Highlight::Violet => Rgb(128, 0, 128),
Highlight::DarkRed => Rgb(128, 0, 0),
Highlight::DarkYellow => Rgb(128, 128, 0),
Highlight::Gray50 => Rgb(128, 128, 128),
Highlight::Gray25 => Rgb(192, 192, 192),
Highlight::Black => Rgb(0, 0, 0),
Highlight::White => Rgb(255, 255, 255),
})
}
/// OOXML `w:highlight/@w:val`.
pub fn ooxml(self) -> &'static str {
match self {
Highlight::None => "none",
Highlight::Yellow => "yellow",
Highlight::BrightGreen => "green",
Highlight::Turquoise => "cyan",
Highlight::Pink => "magenta",
Highlight::Blue => "blue",
Highlight::Red => "red",
Highlight::DarkBlue => "darkBlue",
Highlight::Teal => "darkCyan",
Highlight::Green => "darkGreen",
Highlight::Violet => "darkMagenta",
Highlight::DarkRed => "darkRed",
Highlight::DarkYellow => "darkYellow",
Highlight::Gray50 => "darkGray",
Highlight::Gray25 => "lightGray",
Highlight::Black => "black",
Highlight::White => "white",
}
}
pub fn from_ooxml(s: &str) -> Highlight {
Highlight::ALL.iter().copied().find(|h| h.ooxml() == s).unwrap_or(Highlight::None)
}
pub fn name(self) -> &'static str {
match self {
Highlight::None => "No Color",
Highlight::Yellow => "Yellow",
Highlight::BrightGreen => "Bright Green",
Highlight::Turquoise => "Turquoise",
Highlight::Pink => "Pink",
Highlight::Blue => "Blue",
Highlight::Red => "Red",
Highlight::DarkBlue => "Dark Blue",
Highlight::Teal => "Teal",
Highlight::Green => "Green",
Highlight::Violet => "Violet",
Highlight::DarkRed => "Dark Red",
Highlight::DarkYellow => "Dark Yellow",
Highlight::Gray50 => "Gray-50%",
Highlight::Gray25 => "Gray-25%",
Highlight::Black => "Black",
Highlight::White => "White",
}
}
}
/// The text size, in points, of a run whose size no style or default sets.
pub const DEFAULT_FONT_SIZE: f32 = 11.0;
/// Character formatting (direct or in a style). `None` = inherit.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct CharProps {
/// Character style id.
#[serde(skip_serializing_if = "Option::is_none")]
pub style: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub font: Option<String>,
/// The symbol's own font (OOXML `w:sym/@w:font`, ECMA-376 §17.3.3.30), set on a symbol
/// character from a `w:sym` or Insert › Symbol: it is shown in this font whatever `font` is,
/// so changing the text's font keeps the symbol. `None` = the run's font.
#[serde(skip_serializing_if = "Option::is_none")]
pub sym_font: Option<String>,
/// Points.
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bold: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub italic: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub underline: Option<Underline>,
#[serde(skip_serializing_if = "Option::is_none")]
pub underline_color: Option<Rgb>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strike: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub double_strike: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<TextColor>,
#[serde(skip_serializing_if = "Option::is_none")]
pub highlight: Option<Highlight>,
/// Character shading (background fill).
#[serde(skip_serializing_if = "Option::is_none")]
pub shading: Option<Rgb>,
/// Character border (`w:bdr`); `style: None` = explicitly no border.
#[serde(skip_serializing_if = "Option::is_none")]
pub border: Option<Border>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vert_align: Option<VertAlign>,
#[serde(skip_serializing_if = "Option::is_none")]
pub caps: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub small_caps: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hidden: Option<bool>,
/// Hidden in Web Layout (`w:webHidden`); distinct from `w:vanish` (`hidden`).
#[serde(skip_serializing_if = "Option::is_none")]
pub web_hidden: Option<bool>,
/// Snap characters to the document grid (`w:snapToGrid`). Unset inherits; Word's default is on.
#[serde(skip_serializing_if = "Option::is_none")]
pub snap_to_grid: Option<bool>,
/// Character spacing (expanded/condensed), points.
#[serde(skip_serializing_if = "Option::is_none")]
pub spacing: Option<f32>,
/// Horizontal scale, percent (100 = normal).
#[serde(skip_serializing_if = "Option::is_none")]
pub scale: Option<f32>,
/// Raised (+) / lowered (−) position, points.
#[serde(skip_serializing_if = "Option::is_none")]
pub position: Option<f32>,
/// Kerning for fonts at or above this size (points); 0 = off.
#[serde(skip_serializing_if = "Option::is_none")]
pub kern: Option<f32>,
/// OpenType ligatures (`w14:ligatures`). `None` leaves WordCraft's default: standard and
/// contextual ligatures.
#[serde(skip_serializing_if = "Option::is_none")]
pub ligatures: Option<Ligatures>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outline: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shadow: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub emboss: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub engrave: Option<bool>,
/// WordArt text effects: fill, outline, shadow, glow, reflection ([MS-DOCX] `w14:` run
/// properties). Overlaid effect by effect.
#[serde(skip_serializing_if = "Option::is_none")]
pub text_effects: Option<Box<crate::wordart::TextEffects>>,
/// BCP 47 language tag (proofing).
#[serde(skip_serializing_if = "Option::is_none")]
pub lang: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub no_proof: Option<bool>,
/// Right-to-left run (OOXML `w:rtl`): its characters are complex script and read right to left.
#[serde(skip_serializing_if = "Option::is_none")]
pub rtl: Option<bool>,
/// Format the whole run with the complex-script properties below (OOXML `w:cs`).
#[serde(skip_serializing_if = "Option::is_none")]
pub cs: Option<bool>,
/// Complex-script font (Arabic, Persian, Hebrew…; OOXML `w:rFonts/@w:cs`). `None` = `font`.
#[serde(skip_serializing_if = "Option::is_none")]
pub font_cs: Option<String>,
/// East Asian font (Chinese, Japanese and Korean text; OOXML `w:rFonts/@w:eastAsia`). `None` =
/// inherit, then `font`.
#[serde(skip_serializing_if = "Option::is_none")]
pub font_ea: Option<String>,
/// Complex-script size, points (`w:szCs`). `None` leaves the inherited value unchanged.
#[serde(skip_serializing_if = "Option::is_none")]
pub size_cs: Option<f32>,
/// Complex-script bold / italic (`w:bCs`, `w:iCs`). `None` leaves the inherited value unchanged.
#[serde(skip_serializing_if = "Option::is_none")]
pub bold_cs: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub italic_cs: Option<bool>,
/// BCP 47 language of complex-script text (`w:lang/@w:bidi`, e.g. `fa-IR`).
#[serde(skip_serializing_if = "Option::is_none")]
pub lang_bidi: Option<String>,
/// BCP 47 language of East Asian text (`w:lang/@w:eastAsia`, e.g. `zh-CN`, `ja-JP`, `ko-KR`).
#[serde(skip_serializing_if = "Option::is_none")]
pub lang_ea: Option<String>,
/// East Asian typography: two lines in one and horizontal in vertical (`w:eastAsianLayout`).
/// Kept for round trip; layout doesn't apply it yet.
#[serde(skip_serializing_if = "Option::is_none")]
pub east_asian_layout: Option<EastAsianLayout>,
/// Hyperlink target: a URL, or `#bookmark` for an internal link.
#[serde(skip_serializing_if = "Option::is_none")]
pub link: Option<String>,
/// Tracked insertion: index into `Document::revisions`.
#[serde(skip_serializing_if = "Option::is_none")]
pub ins: Option<u32>,
/// Tracked deletion: index into `Document::revisions`.
#[serde(skip_serializing_if = "Option::is_none")]
pub del: Option<u32>,
/// Tracked formatting change (`w:rPrChange`): the formatting before it.
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<CharProps>>>,
}
/// East Asian typography settings of a run (ISO/IEC 29500-1 §17.3.2.10, `w:eastAsianLayout`):
/// *two lines in one* sets the run's text on two sub-lines inside one line (warichu), *horizontal
/// in vertical* turns it 90° to read across a vertical line (tate-chu-yoko). Each attribute is
/// `None` when the file doesn't say.
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct EastAsianLayout {
/// East Asian typography run id (`w:id`).
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<i32>,
/// Two lines in one (`w:combine`).
#[serde(skip_serializing_if = "Option::is_none")]
pub combine: Option<bool>,
/// Brackets around two lines in one (`w:combineBrackets`).
#[serde(skip_serializing_if = "Option::is_none")]
pub combine_brackets: Option<CombineBrackets>,
/// Horizontal in vertical (`w:vert`).
#[serde(skip_serializing_if = "Option::is_none")]
pub vert: Option<bool>,
/// Compress horizontal-in-vertical text to the line height (`w:vertCompress`).
#[serde(skip_serializing_if = "Option::is_none")]
pub vert_compress: Option<bool>,
}
/// Brackets around two lines in one (`ST_CombineBrackets`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CombineBrackets {
None,
Round,
Square,
Angle,
Curly,
}
impl CombineBrackets {
pub const ALL: [CombineBrackets; 5] =
[CombineBrackets::None, CombineBrackets::Round, CombineBrackets::Square, CombineBrackets::Angle, CombineBrackets::Curly];
/// OOXML `w:combineBrackets` value.
pub fn ooxml(self) -> &'static str {
match self {
CombineBrackets::None => "none",
CombineBrackets::Round => "round",
CombineBrackets::Square => "square",
CombineBrackets::Angle => "angle",
CombineBrackets::Curly => "curly",
}
}
/// The value for `s`, `None` for one the schema doesn't have.
pub fn from_ooxml(s: &str) -> Option<CombineBrackets> {
CombineBrackets::ALL.into_iter().find(|b| b.ooxml() == s)
}
}
/// A tracked formatting change (ECMA-376 §17.13.5: `w:rPrChange`, `w:pPrChange`,
/// `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:sectPrChange`): the properties as they
/// were before the change, and the revision (author, date) that made it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PropChange<T> {
/// Index into `Document::revisions`.
pub rev: u32,
/// The properties before the change (formatting only: no revision or link marks).
pub old: T,
}
impl<T> PropChange<T> {
pub fn boxed(rev: u32, old: T) -> Option<Box<PropChange<T>>> {
Some(Box::new(PropChange { rev, old }))
}
}
/// A tracked change of a paragraph's list numbering (`w:numberingChange`, kept for round trip):
/// `original` is the number as it was shown before the change.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct NumChange {
/// Index into `Document::revisions`.
pub rev: u32,
pub original: String,
}
impl CharProps {
/// The same formatting outside a hyperlink: no link target, and no "Hyperlink" character
/// style that came with it. What a paragraph mark or text typed next to a link gets.
pub fn unlinked(&self) -> CharProps {
let mut c = self.clone();
if c.link.take().is_some() && c.style.as_deref() == Some("Hyperlink") {
c.style = None;
}
c
}
/// The fields of `self` that differ from `base` (equal ones unset): what `self` adds when
/// overlaid on `base`.
pub fn minus(mut self, base: &CharProps) -> CharProps {
self.minus_text_style(base);
self.minus_run_visibility(base);
self.minus_script_style(base);
self.minus_document_marks(base);
self
}
fn minus_text_style(&mut self, base: &CharProps) {
drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl);
}
fn minus_run_visibility(&mut self, base: &CharProps) {
drop_same_fields!(self, base; web_hidden, snap_to_grid);
}
fn minus_script_style(&mut self, base: &CharProps) {
drop_same_fields!(self, base; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout);
}
fn minus_document_marks(&mut self, base: &CharProps) {
drop_same_fields!(self, base; link, ins, del, ligatures);
}
/// Apply every `Some` field of `patch` on top of `self`.
///
/// Plain and complex-script properties inherit independently. Commands that intend to
/// format both scripts must set both values explicitly.
pub fn overlay(&mut self, patch: &CharProps) {
self.overlay_text_style(patch);
self.overlay_run_visibility(patch);
self.overlay_script_style(patch);
self.overlay_document_marks(patch);
if let Some(p) = &patch.text_effects {
self.text_effects.get_or_insert_with(Default::default).overlay(p);
}
}
fn overlay_text_style(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl);
}
fn overlay_run_visibility(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; web_hidden, snap_to_grid);
}
fn overlay_script_style(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout);
}
fn overlay_document_marks(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; link, ins, del, fmt_change, ligatures);
}
pub fn overlaid(mut self, patch: &CharProps) -> CharProps {
self.overlay(patch);
self
}
pub fn is_empty(&self) -> bool {
*self == CharProps::default()
}
/// The font a character of this run is shown in: the symbol's own font when it has one
/// ([`Self::sym_font`]), else the run's font.
pub fn shown_font(&self) -> Option<&str> {
self.sym_font.as_deref().filter(|f| !f.trim().is_empty()).or(self.font.as_deref())
}
/// The same formatting for text typed or painted next to a symbol: without the symbol's own
/// font.
pub fn without_symbol(mut self) -> CharProps {
self.sym_font = None;
self
}
/// Clear direct formatting but keep what isn't "formatting" (links, revisions, character style
/// is cleared too, as Word's Clear Formatting does).
pub fn cleared(&self) -> CharProps {
CharProps {
link: self.link.clone(),
ins: self.ins,
del: self.del,
fmt_change: self.fmt_change.clone(),
// A symbol stays the symbol it is.
sym_font: self.sym_font.clone(),
..Default::default()
}
}
/// Just the formatting: without the link, revision marks and tracked formatting change.
pub fn formatting(&self) -> CharProps {
CharProps { link: None, ins: None, del: None, fmt_change: None, ..self.clone() }
}
}
+171
View File
@@ -0,0 +1,171 @@
use super::{Deserialize, Serialize};
/// An sRGB colour.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
pub struct Rgb(pub u8, pub u8, pub u8);
impl<'de> Deserialize<'de> for Rgb {
/// Reads what it writes (`[r, g, b]`) and also a string [`Rgb::parse`] reads, so the colours in
/// `CharProps` / `ParaProps` take the `RRGGBB` that `para.borders` and `table.shading` take (#865).
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::{Error, SeqAccess, Unexpected, Visitor};
struct V;
impl<'de> Visitor<'de> for V {
type Value = Rgb;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("`[r, g, b]`, `RRGGBB` or `#RRGGBB`")
}
fn visit_str<E: Error>(self, s: &str) -> Result<Rgb, E> {
Rgb::parse(s).ok_or_else(|| E::invalid_value(Unexpected::Str(s), &self))
}
fn visit_seq<A: SeqAccess<'de>>(self, mut a: A) -> Result<Rgb, A::Error> {
let mut c = [0u8; 3];
for (i, x) in c.iter_mut().enumerate() {
*x = a.next_element()?.ok_or_else(|| A::Error::invalid_length(i, &self))?;
}
if a.next_element::<serde::de::IgnoredAny>()?.is_some() {
return Err(A::Error::invalid_length(4, &self));
}
Ok(Rgb(c[0], c[1], c[2]))
}
}
d.deserialize_any(V)
}
}
impl Rgb {
pub const BLACK: Rgb = Rgb(0, 0, 0);
pub const WHITE: Rgb = Rgb(255, 255, 255);
/// `RRGGBB` (no `#`).
pub fn hex(self) -> String {
format!("{:02X}{:02X}{:02X}", self.0, self.1, self.2)
}
/// Parse `RRGGBB` or `#RRGGBB`.
pub fn parse(s: &str) -> Option<Rgb> {
let s = s.trim();
let s = s.strip_prefix('#').unwrap_or(s);
if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let p = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
Some(Rgb(p(0)?, p(2)?, p(4)?))
}
/// This colour with the DrawingML colour modifier `name` (`lumMod`, `lumOff`, `tint`,
/// `shade`, `satMod`, ECMA-376 §20.1.2.3) at `v` (1.0 = 100%); other names leave it
/// unchanged.
pub fn modified(self, name: &str, v: f64) -> Rgb {
match name {
"lumMod" => adjust_lum(self, |l| l * v),
"lumOff" => adjust_lum(self, |l| l + v),
"tint" => mix(self, 255.0, v),
"shade" => mix(self, 0.0, v),
"satMod" => {
let (h, sat, l) = to_hsl(self);
from_hsl(h, (sat * v).clamp(0.0, 1.0), l)
}
_ => self,
}
}
/// The colour with hue `h` (0..1 of a turn), saturation `s` and lightness `l` (0..1).
pub fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
from_hsl(h, s, l)
}
/// This colour with its HSL luminance changed by `f`.
pub fn with_lum(self, f: impl Fn(f64) -> f64) -> Rgb {
adjust_lum(self, f)
}
pub fn luma(self) -> f32 {
0.299 * self.0 as f32 + 0.587 * self.1 as f32 + 0.114 * self.2 as f32
}
}
/// A text colour: automatic (black on light backgrounds, white on dark) or a fixed colour.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
pub enum TextColor {
#[default]
Auto,
Rgb(Rgb),
}
impl TextColor {
/// `auto` or `automatic` (any case), or a colour [`Rgb::parse`] reads.
pub fn parse(s: &str) -> Option<TextColor> {
if s.eq_ignore_ascii_case("auto") || s.eq_ignore_ascii_case("automatic") { Some(TextColor::Auto) } else { Rgb::parse(s).map(TextColor::Rgb) }
}
}
impl<'de> Deserialize<'de> for TextColor {
/// Reads what it writes (`"Auto"`, `{"Rgb": [r, g, b]}`) and also a string [`TextColor::parse`]
/// reads, so `styles.modify` and `format.set` take the colours `format.color` takes.
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::{Error, Unexpected};
#[derive(Deserialize)]
enum Written {
Auto,
Rgb(Rgb),
}
match serde_json::Value::deserialize(d)? {
serde_json::Value::String(s) => {
TextColor::parse(&s).ok_or_else(|| D::Error::invalid_value(Unexpected::Str(&s), &"`auto`, `RRGGBB` or `#RRGGBB`"))
}
v => Ok(match serde_json::from_value(v).map_err(D::Error::custom)? {
Written::Auto => TextColor::Auto,
Written::Rgb(c) => TextColor::Rgb(c),
}),
}
}
}
/// `tint` (towards `to` = 255) and `shade` (towards 0): `v` is the share of the original colour kept.
fn mix(c: Rgb, to: f64, v: f64) -> Rgb {
let f = |x: u8| (x as f64 * v + to * (1.0 - v)).round().clamp(0.0, 255.0) as u8;
Rgb(f(c.0), f(c.1), f(c.2))
}
/// `c` with its HSL luminance changed by `f`.
fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb {
let (h, s, l) = to_hsl(c);
from_hsl(h, s, f(l).clamp(0.0, 1.0))
}
fn to_hsl(c: Rgb) -> (f64, f64, f64) {
let (r, g, b) = (c.0 as f64 / 255.0, c.1 as f64 / 255.0, c.2 as f64 / 255.0);
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
let l = (max + min) / 2.0;
if max == min {
return (0.0, 0.0, l);
}
let d = max - min;
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
let h = if max == r {
(g - b) / d + if g < b { 6.0 } else { 0.0 }
} else if max == g {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
};
(h / 6.0, s, l)
}
fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
let byte = |v: f64| (v * 255.0).round().clamp(0.0, 255.0) as u8;
if s == 0.0 {
return Rgb(byte(l), byte(l), byte(l));
}
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
let p = 2.0 * l - q;
let channel = |t: f64| {
let t = t.rem_euclid(1.0);
let v = if t < 1.0 / 6.0 {
p + (q - p) * 6.0 * t
} else if t < 0.5 {
q
} else if t < 2.0 / 3.0 {
p + (q - p) * (2.0 / 3.0 - t) * 6.0
} else {
p
};
byte(v)
};
Rgb(channel(h + 1.0 / 3.0), channel(h), channel(h - 1.0 / 3.0))
}
+408
View File
@@ -0,0 +1,408 @@
use super::char_props::{NumChange, PropChange};
use super::color::Rgb;
use super::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum Align {
#[default]
Left,
Center,
Right,
Justify,
Distribute,
}
impl Align {
/// Alignment is logical (`Left` is the start edge, ISO 29500 `start`): the alignment as seen
/// on the page, or the logical one for a visual choice, in a paragraph that reads right to
/// left when `rtl` (Left and Right swap; the swap is its own inverse).
pub fn visual(self, rtl: bool) -> Align {
match self {
Align::Left if rtl => Align::Right,
Align::Right if rtl => Align::Left,
a => a,
}
}
}
/// Line spacing rule.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "rule", content = "value")]
pub enum LineSpacing {
/// Multiple of single spacing (1.0 = single, 1.15, 1.5, 2.0 = double).
Multiple(f32),
/// At least this many points.
AtLeast(f32),
/// Exactly this many points.
Exactly(f32),
}
impl Default for LineSpacing {
fn default() -> Self {
LineSpacing::Multiple(1.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum TabAlign {
#[default]
Left,
Center,
Right,
Decimal,
Bar,
/// Clears an inherited stop at this position.
Clear,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum TabLeader {
#[default]
None,
Dot,
Hyphen,
Underscore,
MiddleDot,
}
impl TabLeader {
pub fn char(self) -> Option<char> {
match self {
TabLeader::None => None,
TabLeader::Dot => Some('.'),
TabLeader::Hyphen => Some('-'),
TabLeader::Underscore => Some('_'),
TabLeader::MiddleDot => Some('·'),
}
}
}
/// Two tab positions closer than this (points) are the same stop: `para.tabs` replaces at this
/// tolerance, and the Tabs dialog and the ruler merge and detect no-ops with it.
pub const TAB_POS_EPS: f32 = 0.5;
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct TabStop {
/// Points from the paragraph's left indent origin (the text column's left edge).
pub pos: f32,
#[serde(default)]
pub align: TabAlign,
#[serde(default)]
pub leader: TabLeader,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum BorderStyle {
#[default]
None,
Single,
Double,
Dotted,
Dashed,
Thick,
Triple,
DotDash,
Wave,
}
impl BorderStyle {
pub fn ooxml(self) -> &'static str {
match self {
BorderStyle::None => "nil",
BorderStyle::Single => "single",
BorderStyle::Double => "double",
BorderStyle::Dotted => "dotted",
BorderStyle::Dashed => "dashed",
BorderStyle::Thick => "thick",
BorderStyle::Triple => "triple",
BorderStyle::DotDash => "dotDash",
BorderStyle::Wave => "wave",
}
}
pub fn from_ooxml(s: &str) -> BorderStyle {
match s {
"nil" | "none" => BorderStyle::None,
"double" => BorderStyle::Double,
"dotted" => BorderStyle::Dotted,
"dashed" | "dashSmallGap" => BorderStyle::Dashed,
"thick" => BorderStyle::Thick,
"triple" => BorderStyle::Triple,
"dotDash" | "dotDotDash" => BorderStyle::DotDash,
"wave" | "doubleWave" => BorderStyle::Wave,
_ => BorderStyle::Single,
}
}
}
/// One border line.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Border {
pub style: BorderStyle,
/// Line width, points.
pub width: f32,
pub color: Option<Rgb>,
/// Distance from the text, points.
pub space: f32,
}
impl Border {
pub fn single(width: f32) -> Border {
Border { style: BorderStyle::Single, width, color: None, space: 0.0 }
}
pub fn is_visible(&self) -> bool {
self.style != BorderStyle::None && self.width > 0.0
}
}
/// Borders on the four sides (plus inner borders for tables and runs of bordered paragraphs).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Borders {
pub top: Option<Border>,
pub left: Option<Border>,
pub bottom: Option<Border>,
pub right: Option<Border>,
/// Between paragraphs / inside horizontal (tables).
pub between: Option<Border>,
/// Inside vertical (tables).
pub inside_v: Option<Border>,
}
impl Borders {
pub fn all(b: Border) -> Borders {
Borders { top: Some(b), left: Some(b), bottom: Some(b), right: Some(b), between: Some(b), inside_v: Some(b) }
}
pub fn box_(b: Border) -> Borders {
Borders { top: Some(b), left: Some(b), bottom: Some(b), right: Some(b), between: None, inside_v: None }
}
pub fn any_visible(&self) -> bool {
[self.top, self.left, self.bottom, self.right, self.between, self.inside_v].iter().flatten().any(Border::is_visible)
}
/// Apply every `Some` side of `patch` on top of `self`.
pub fn overlay(&mut self, patch: &Borders) {
if patch.top.is_some() {
self.top = patch.top;
}
if patch.left.is_some() {
self.left = patch.left;
}
if patch.bottom.is_some() {
self.bottom = patch.bottom;
}
if patch.right.is_some() {
self.right = patch.right;
}
if patch.between.is_some() {
self.between = patch.between;
}
if patch.inside_v.is_some() {
self.inside_v = patch.inside_v;
}
}
}
/// A reference to a numbering definition and level.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct NumRef {
/// `Numbering::nums` id; 0 removes numbering inherited from a style.
pub num: u32,
pub level: u8,
}
/// A text frame's placement (`w:framePr`, ECMA-376 §17.3.1.11), in points. Offsets apply when
/// the matching alignment is unset; anchors and the wrap keep OOXML's names.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TextFrame {
/// Width; `None` fits the text.
pub w: Option<f32>,
/// Height, with `h_exact` exact rather than at least; `None` fits the text.
pub h: Option<f32>,
pub h_exact: bool,
pub x: f32,
pub y: f32,
/// `left`, `center`, `right`, `inside`, `outside`.
pub x_align: Option<String>,
/// `top`, `center`, `bottom`, `inside`, `outside` (`inline` is no alignment).
pub y_align: Option<String>,
/// `text` (the column), `margin` or `page`.
pub h_anchor: String,
/// `text` (the paragraph), `margin` or `page`.
pub v_anchor: String,
/// `around`, `auto`, `notBeside`, `none`, `tight` or `through`.
pub wrap: String,
/// Distance from the text left and right, and above and below.
pub h_space: f32,
pub v_space: f32,
}
/// Paragraph formatting. `None` = inherit.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ParaProps {
/// Paragraph style id.
#[serde(skip_serializing_if = "Option::is_none")]
pub style: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub align: Option<Align>,
/// Left indent, points.
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_left: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_right: Option<f32>,
/// First-line indent, points; negative = hanging.
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_first: Option<f32>,
/// Left (start) indent in characters (`w:leftChars` / `w:startChars`, stored by Word in
/// hundredths). A non-zero value wins over `indent_left`; one character is the size of the
/// paragraph mark's font. `Some(0.0)` cancels an inherited value. Common in East Asian documents.
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_left_chars: Option<f32>,
/// Right (end) indent in characters (`w:rightChars` / `w:endChars`), like `indent_left_chars`.
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_right_chars: Option<f32>,
/// First-line indent in characters (`w:firstLineChars`; negative = `w:hangingChars`), like
/// `indent_left_chars`.
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_first_chars: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub space_before: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub space_after: Option<f32>,
/// HTML-style automatic space before (`w:beforeAutospacing`): 14 pt, none at the top of the
/// document or of a table cell, and collapsing with the space after the paragraph above.
#[serde(skip_serializing_if = "Option::is_none")]
pub space_before_auto: Option<bool>,
/// HTML-style automatic space after (`w:afterAutospacing`): 14 pt, none at the end of a cell.
#[serde(skip_serializing_if = "Option::is_none")]
pub space_after_auto: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub line_spacing: Option<LineSpacing>,
/// Ignore space before/after between paragraphs of the same style.
#[serde(skip_serializing_if = "Option::is_none")]
pub contextual_spacing: Option<bool>,
/// `w:mirrorIndents` (ECMA-376 §17.3.1.18): the left and right indents are the inside and
/// outside ones, so they swap sides on even (left-hand) pages.
#[serde(skip_serializing_if = "Option::is_none")]
pub mirror_indents: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keep_next: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keep_lines: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page_break_before: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub widow_control: Option<bool>,
/// 0..=8 = Level 1..9; 9 = body text.
#[serde(skip_serializing_if = "Option::is_none")]
pub outline_level: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
pub numbering: Option<NumRef>,
/// Tab stops (replaces the inherited list; `TabAlign::Clear` stops remove inherited ones).
#[serde(skip_serializing_if = "Option::is_none")]
pub tabs: Option<Vec<TabStop>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shading: Option<Rgb>,
#[serde(skip_serializing_if = "Option::is_none")]
pub borders: Option<Borders>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suppress_hyphens: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suppress_line_numbers: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bidi: Option<bool>,
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap: Option<u8>,
/// Drop cap "In margin" (`w:dropCap="margin"`): the letter hangs in the left margin and the
/// text keeps its indent. Unset = dropped into the text.
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap_margin: Option<bool>,
/// The drop cap's distance from the text, in points (`w:hSpace`). Unset = 0.
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap_distance: Option<f32>,
/// The drop cap letter's font family (the cap run's `w:rFonts`). Unset = the text's font.
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap_font: Option<String>,
/// A text frame (`w:framePr` other than a drop cap): the paragraph is placed apart from the
/// text, which may wrap around it — a footer's page number in a frame, for instance.
#[serde(skip_serializing_if = "Option::is_none")]
pub frame: Option<TextFrame>,
/// Asian typography (`w:kinsoku`): East Asian line-breaking rules — no line starts with
/// closing punctuation such as 、。」 or ends with opening punctuation such as 「(.
/// Unset = on.
#[serde(skip_serializing_if = "Option::is_none")]
pub kinsoku: Option<bool>,
/// `w:wordWrap`: Latin words wrap whole; `false` lets them break at any character (Word's
/// "Allow Latin text to wrap in the middle of a word"). Unset = on.
#[serde(skip_serializing_if = "Option::is_none")]
pub word_wrap: Option<bool>,
/// `w:overflowPunct`: punctuation may hang past the line end. Unset = on.
#[serde(skip_serializing_if = "Option::is_none")]
pub overflow_punct: Option<bool>,
/// `w:topLinePunct`: compress punctuation at the start of a line. Unset = off.
#[serde(skip_serializing_if = "Option::is_none")]
pub top_line_punct: Option<bool>,
/// `w:autoSpaceDE`: automatic space between Asian and Latin text. Unset = on.
#[serde(rename = "autoSpaceDE", skip_serializing_if = "Option::is_none")]
pub auto_space_de: Option<bool>,
/// `w:autoSpaceDN`: automatic space between Asian text and numbers. Unset = on.
#[serde(rename = "autoSpaceDN", skip_serializing_if = "Option::is_none")]
pub auto_space_dn: Option<bool>,
/// Tracked change of the list numbering (`w:numberingChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub num_change: Option<Box<NumChange>>,
/// Tracked formatting change (`w:pPrChange`): the paragraph properties before it.
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<ParaProps>>>,
/// `w:snapToGrid`: lines snap to the section's document grid. Unset = on.
#[serde(skip_serializing_if = "Option::is_none")]
pub snap_to_grid: Option<bool>,
}
impl ParaProps {
pub fn overlay(&mut self, patch: &ParaProps) {
self.overlay_layout(patch);
self.overlay_pagination(patch);
self.overlay_script_layout(patch);
}
fn overlay_layout(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars,
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders);
}
fn overlay_pagination(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, suppress_hyphens, suppress_line_numbers,
drop_cap, drop_cap_margin, drop_cap_distance, drop_cap_font, frame, snap_to_grid);
}
fn overlay_script_layout(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
}
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
self.overlay(patch);
self
}
pub fn is_empty(&self) -> bool {
*self == ParaProps::default()
}
/// After setting indents in points: keep the indents in characters (left, right, first line)
/// an inherited style may set from winning over them, as Word does (`w:firstLineChars="0"`).
pub fn cancel_char_indents(&mut self, left: bool, right: bool, first: bool) {
for (on, chars) in [(left, &mut self.indent_left_chars), (right, &mut self.indent_right_chars), (first, &mut self.indent_first_chars)] {
if on {
*chars = Some(0.0);
}
}
}
/// Just the formatting: without the tracked changes.
pub fn formatting(&self) -> ParaProps {
ParaProps { num_change: None, fmt_change: None, ..self.clone() }
}
}
+308
View File
@@ -0,0 +1,308 @@
use super::char_props::PropChange;
use super::color::Rgb;
use super::para_props::{Align, Borders};
use super::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum VAlign {
#[default]
Top,
Center,
Bottom,
}
/// Which way a table cell's text runs (`w:textDirection`, ECMA-376 §17.4.72).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum TextDirection {
/// Left to right, lines stacking downwards.
#[default]
Horizontal,
/// Turned 90° clockwise: lines read top to bottom and stack from right to left (`tbRl`).
Down,
/// Turned 90° counter-clockwise: lines read bottom to top and stack from left to right (`btLr`).
Up,
}
impl TextDirection {
/// The direction an OOXML `ST_TextDirection` value names (transitional and strict names).
/// The East Asian vertical layouts turn like `Down`; unknown values are horizontal.
pub fn from_ooxml(v: &str) -> TextDirection {
match v {
"tbRl" | "tbRlV" | "tbLrV" | "rl" | "rlV" | "lrV" => TextDirection::Down,
"btLr" | "lr" => TextDirection::Up,
_ => TextDirection::Horizontal,
}
}
/// The transitional OOXML name.
pub fn ooxml(self) -> &'static str {
match self {
TextDirection::Horizontal => "lrTb",
TextDirection::Down => "tbRl",
TextDirection::Up => "btLr",
}
}
/// Word's Text Direction button: horizontal → down → up → horizontal.
pub fn next(self) -> TextDirection {
match self {
TextDirection::Horizontal => TextDirection::Down,
TextDirection::Down => TextDirection::Up,
TextDirection::Up => TextDirection::Horizontal,
}
}
pub fn is_turned(self) -> bool {
self != TextDirection::Horizontal
}
}
/// Which way a text box's text runs (Shape Format › Text › Text Direction; DrawingML
/// `a:bodyPr/@vert`, ECMA-376 §21.1.10.83 `ST_TextVerticalType`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum TextVert {
/// Upright, lines stacking downwards (`horz`).
#[default]
Horz,
/// Turned 90° clockwise (`vert`).
Vert,
/// Turned 270° (`vert270`).
Vert270,
/// Upright letters stacked one above the other (`wordArtVert`).
Stacked,
}
impl TextVert {
/// The direction an `ST_TextVerticalType` value names; the East Asian and Mongolian vertical
/// layouts turn like `vert`, unknown values are horizontal.
pub fn from_ooxml(v: &str) -> TextVert {
match v {
"vert" | "eaVert" | "mongolianVert" => TextVert::Vert,
"vert270" => TextVert::Vert270,
"wordArtVert" | "wordArtVertRtl" => TextVert::Stacked,
_ => TextVert::Horz,
}
}
pub fn ooxml(self) -> &'static str {
match self {
TextVert::Horz => "horz",
TextVert::Vert => "vert",
TextVert::Vert270 => "vert270",
TextVert::Stacked => "wordArtVert",
}
}
/// How its lines turn on the page (stacked text stays upright).
pub fn turn(self) -> TextDirection {
match self {
TextVert::Vert => TextDirection::Down,
TextVert::Vert270 => TextDirection::Up,
TextVert::Horz | TextVert::Stacked => TextDirection::Horizontal,
}
}
}
/// Where a text box's text sits between its top and bottom (Align Text; `a:bodyPr/@anchor`,
/// `ST_TextAnchoringType`): reuses [`VAlign`]. Justified and distributed anchoring read as top
/// and middle.
pub fn valign_from_anchor(v: &str) -> VAlign {
match v {
"ctr" | "dist" => VAlign::Center,
"b" => VAlign::Bottom,
_ => VAlign::Top,
}
}
/// The `ST_TextAnchoringType` value for `a`.
pub fn anchor_ooxml(a: VAlign) -> &'static str {
match a {
VAlign::Top => "t",
VAlign::Center => "ctr",
VAlign::Bottom => "b",
}
}
/// Table-wide properties.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TableProps {
pub style: Option<String>,
/// Preferred width: points (`Some`) or automatic (`None`).
pub width: Option<f32>,
/// Preferred width as a percentage of the text column (overrides `width`).
pub width_pct: Option<f32>,
pub align: Option<Align>,
/// Indent from the left margin, points.
pub indent: Option<f32>,
pub borders: Option<Borders>,
/// Default cell margins (top, left, bottom, right), points.
pub cell_margins: Option<[f32; 4]>,
/// Fixed column widths (no auto-fit).
pub fixed: bool,
/// Table style options ("look"): header row, total row, banded rows, first column, last
/// column, banded columns.
pub look: TableLook,
pub shading: Option<Rgb>,
/// Alternative text: the title (`w:tblCaption`).
pub caption: Option<String>,
/// Alternative text: the description (`w:tblDescription`).
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Floating placement (`w:tblpPr`); `None` for a table in the text flow.
#[serde(skip_serializing_if = "Option::is_none")]
pub float: Option<TableFloat>,
/// Tracked formatting change (`w:tblPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<TableProps>>>,
/// Right-to-left table (`w:bidiVisual`): the first logical column stands at the right edge,
/// cell margins and left/right borders mirror, and the alignment and indent are measured from
/// the right margin.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub bidi_visual: bool,
}
/// Where a floating table sits; the text after it wraps around it.
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TableFloat {
/// What `x` is measured from: the column (`Column`), the margin or the page.
pub h_rel: crate::para::Anchor,
/// What `y` is measured from: the text where the table stands (`Paragraph`), the margin or
/// the page.
pub v_rel: crate::para::Anchor,
/// Offsets, points (used when the matching alignment is `None`).
pub x: f32,
pub y: f32,
pub h_align: Option<crate::para::FloatAlign>,
pub v_align: Option<crate::para::FloatAlign>,
/// Distance from surrounding text: left, top, right, bottom (points).
pub dist: [f32; 4],
/// Whether it may overlap other floating tables (`w:tblOverlap`); Word's default is yes.
pub overlap: bool,
}
impl TableFloat {
/// The distances from text, finite and clamped (left, top, right, bottom).
pub fn dist_from_text(&self) -> [f32; 4] {
self.dist.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct TableLook {
pub header_row: bool,
pub total_row: bool,
pub banded_rows: bool,
pub first_column: bool,
pub last_column: bool,
pub banded_columns: bool,
}
impl Default for TableLook {
fn default() -> Self {
TableLook { header_row: true, total_row: false, banded_rows: true, first_column: true, last_column: false, banded_columns: false }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum HeightRule {
#[default]
Auto,
AtLeast,
Exact,
}
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct RowProps {
/// Points (meaning depends on `height_rule`).
pub height: Option<f32>,
pub height_rule: HeightRule,
/// Repeat as a header row at the top of each page.
pub header: bool,
/// Don't let the row break across pages.
pub cant_split: bool,
/// Table properties this row overrides (`w:tblPrEx`, ECMA-376 §17.4.61): width, alignment,
/// indent, borders, shading, layout and cell margins. `Some` when the file has the element.
#[serde(skip_serializing_if = "Option::is_none")]
pub exceptions: Option<Box<TableProps>>,
/// Grid columns skipped before the row's first cell (`w:gridBefore`), and the preferred
/// width of that gap in points (`w:wBefore`).
#[serde(skip_serializing_if = "is_zero")]
pub grid_before: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub w_before: Option<f32>,
/// Grid columns left after the row's last cell (`w:gridAfter`), and their width in points
/// (`w:wAfter`).
#[serde(skip_serializing_if = "is_zero")]
pub grid_after: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub w_after: Option<f32>,
/// Tracked formatting change (`w:trPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<RowProps>>>,
}
fn is_zero(v: &u32) -> bool {
*v == 0
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum VMerge {
#[default]
None,
/// First cell of a vertically merged range.
Restart,
/// Continues the merge from the cell above.
Continue,
}
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct CellProps {
/// Preferred width, points.
pub width: Option<f32>,
/// Preferred width as a percentage of the table's (`w:tcW w:type="pct"`).
#[serde(skip_serializing_if = "Option::is_none")]
pub width_pct: Option<f32>,
/// Number of grid columns spanned (1 = no horizontal merge).
pub span: u32,
pub vmerge: VMerge,
pub shading: Option<Rgb>,
pub borders: Option<Borders>,
/// Vertical alignment (`w:vAlign`); `None` follows the table style, else top. An explicit
/// `Top` beats the style.
#[serde(skip_serializing_if = "Option::is_none")]
pub valign: Option<VAlign>,
/// Cell margins override (top, left, bottom, right), points.
pub margins: Option<[f32; 4]>,
/// Per-side overrides when `margins` is absent; omitted sides inherit table defaults.
#[serde(skip_serializing_if = "Option::is_none")]
pub partial_margins: Option<[Option<f32>; 4]>,
/// Which way the cell's text runs.
pub text_direction: TextDirection,
/// The file states the horizontal direction (`lrTb`) outright; saving writes it back.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub text_direction_set: bool,
pub no_wrap: bool,
/// The cell's last paragraph takes no room when it is empty (`w:hideMark`): a row of such
/// empty cells shrinks to its borders.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub hide_mark: bool,
/// Tracked formatting change (`w:tcPrChange`).
#[serde(skip_serializing_if = "Option::is_none")]
pub fmt_change: Option<Box<PropChange<CellProps>>>,
}
impl CellProps {
/// Resolve top, left, bottom and right margins against the current table defaults.
/// A complete override takes precedence over per-side overrides.
pub fn resolved_margins(&self, defaults: [f32; 4]) -> [f32; 4] {
if let Some(margins) = self.margins {
return margins;
}
self.partial_margins.map_or(defaults, |sides| std::array::from_fn(|i| sides[i].unwrap_or(defaults[i])))
}
}
+6 -1
View File
@@ -270,7 +270,12 @@ impl ResolvedPara {
drop_cap_margin: p.drop_cap_margin.unwrap_or(false),
drop_cap_distance: p.drop_cap_distance.filter(|d| d.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_DROP_CAP_DISTANCE),
drop_cap_font: p.drop_cap_font.clone().filter(|f| !f.trim().is_empty()),
frame: p.frame.clone().filter(|_| p.drop_cap.unwrap_or(0) == 0),
// Explicit zero dimensions cancel a frame; omitted dimensions still fit its text.
frame: p.frame.clone().filter(|f| p.drop_cap.unwrap_or(0) == 0 && (f.w != Some(0.0) || f.h != Some(0.0))).map(|mut f| {
f.w = f.w.filter(|v| *v != 0.0);
f.h = f.h.filter(|v| *v != 0.0);
f
}),
suppress_hyphens: p.suppress_hyphens.unwrap_or(false),
suppress_line_numbers: p.suppress_line_numbers.unwrap_or(false),
kinsoku: p.kinsoku.unwrap_or(true),
+93
View File
@@ -0,0 +1,93 @@
use super::{Blocks, Deserialize, Document, Serialize};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Comment {
pub author: String,
pub initials: String,
/// ISO 8601.
pub date: String,
/// Reply to this comment id.
pub parent: Option<u32>,
pub resolved: bool,
/// `Document::parts` id of the comment text.
pub part: u32,
/// Word's durable id for the comment (`w16cid:durableId`, hex), kept across saves so Word
/// keeps tracking the same comment. `None` for comments made here: the writer assigns one.
#[serde(skip_serializing_if = "Option::is_none")]
pub durable_id: Option<String>,
/// The comment's UTC timestamp: from the file (`w16cex:dateUtc`, where `date` is local time),
/// or the time WordCraft stamped the comment with.
#[serde(skip_serializing_if = "Option::is_none")]
pub date_utc: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum RevisionKind {
#[default]
Insert,
Delete,
Format,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct Revision {
pub kind: RevisionKind,
pub author: String,
pub date: String,
/// A move (ECMA-376 §17.13.5.21–28): the name linking the source (an `Delete` revision,
/// `w:moveFrom`) with the destination (an `Insert` revision, `w:moveTo`). `None` = a plain
/// insertion or deletion.
#[serde(skip_serializing_if = "Option::is_none")]
pub move_name: Option<String>,
}
impl Revision {
pub fn new(kind: RevisionKind, author: impl Into<String>, date: impl Into<String>) -> Revision {
Revision { kind, author: author.into(), date: date.into(), move_name: None }
}
}
impl Document {
/// The move name of revision `idx`, when it is one end of a move.
pub fn move_name(&self, idx: Option<u32>) -> Option<&str> {
idx.and_then(|i| self.revisions.get(i as usize)).and_then(|r| r.move_name.as_deref())
}
}
/// A building block (Insert › Quick Parts, AutoText…) kept in the document's glossary
/// (ECMA-376 §17.12: `w:docPart`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct BuildingBlock {
/// The entry's name (`w:docPartPr/w:name`).
pub name: String,
/// Its gallery (`w:category/w:gallery`: `docParts` for Quick Parts, `autoTxt` for AutoText…).
pub gallery: String,
/// Its category within the gallery (`w:category/w:name`, e.g. "General").
pub category: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub description: String,
/// The entry's identity (`w:guid`), kept as read.
#[serde(skip_serializing_if = "String::is_empty")]
pub guid: String,
/// The paragraph style it's associated with (`w:style`), kept as read.
#[serde(skip_serializing_if = "String::is_empty")]
pub style: String,
/// Its types (`w:types/w:type`: `normal`, `bbPlcHdr` for a content control's placeholder…).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub types: Vec<String>,
/// How it's inserted (`w:behaviors/w:behavior`: `content`, `p`, `pg`).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub behaviors: Vec<String>,
/// The content (`w:docPartBody`).
pub blocks: Blocks,
}
/// The gallery Quick Parts saves to (`w:gallery w:val="docParts"`).
pub const QUICK_PARTS_GALLERY: &str = "docParts";
/// The AutoText gallery (`w:gallery w:val="autoTxt"`).
pub const AUTO_TEXT_GALLERY: &str = "autoTxt";
+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() };
+312
View File
@@ -0,0 +1,312 @@
use super::{Deserialize, Rgb, Serialize, math, section, styles};
/// A bibliography source.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct Source {
/// Unique tag cited by CITATION fields.
pub tag: String,
/// book, bookSection, article (journal), periodical, conference, report, website, film, other
pub kind: String,
/// "Last, First; Last, First"
pub author: String,
/// A corporate author ("World Health Organization"): written as it is, never split into
/// names, and in place of `author` when set (Create Source › Corporate Author, `b:Corporate`,
/// #4848).
pub corporate: String,
pub title: String,
pub year: String,
pub publisher: String,
pub city: String,
pub journal: String,
pub volume: String,
pub pages: String,
pub url: String,
/// More bibliography fields (Create Source › Show All Bibliography Fields).
pub editor: String,
pub edition: String,
pub issue: String,
pub month: String,
pub day: String,
/// Date accessed (websites).
pub accessed: String,
pub doi: String,
}
/// A custom document property (File › Info › Properties › Custom). Citation managers keep
/// their per-document preferences here (Zotero: `ZOTERO_PREF_1`, `ZOTERO_PREF_2`…).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct CustomProp {
pub name: String,
/// OOXML variant type: `lpwstr`, `i4`, `r8`, `bool`, `filetime`…, or `raw` when `value` is
/// the property's XML content kept verbatim (vectors, blobs).
pub kind: String,
pub value: String,
/// Bookmark associated with this property (`linkTarget`), retained without evaluating it.
#[serde(skip_serializing_if = "Option::is_none")]
pub link_target: Option<String>,
}
/// Document properties (File › Info).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default, rename_all = "camelCase")]
pub struct CoreProps {
pub title: String,
pub subject: String,
pub creator: String,
pub keywords: String,
pub description: String,
pub category: String,
pub last_modified_by: String,
pub created: String,
pub modified: String,
pub revision: u32,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Settings {
pub track_changes: bool,
/// Record a tracked cut and paste of a sentence or paragraph as a move (Track moves); off
/// when the file has `w:doNotTrackMoves` (ECMA-376 §17.15.1.29), and then it is an
/// ordinary deletion and insertion.
pub track_moves: bool,
/// Default tab stop interval, points.
pub default_tab: f32,
pub even_odd_headers: bool,
pub mirror_margins: bool,
pub auto_hyphenation: bool,
/// Hyphenation zone (`w:hyphenationZone`), points: a line is hyphenated only when breaking
/// it at a word boundary would leave more than this empty at its end.
pub hyphenation_zone: f32,
/// Words in capitals may be hyphenated (`w:doNotHyphenateCaps` turns it off).
pub hyphenate_caps: bool,
/// Most consecutive lines that may end with a hyphen (`w:consecutiveHyphenLimit`); 0 = no limit.
pub consecutive_hyphen_limit: u32,
pub page_color: Option<Rgb>,
/// Picture or texture page background (Design › Page Color › Fill Effects), drawn over the colour.
pub page_picture: Option<PagePicture>,
pub watermark: Option<Watermark>,
/// Theme fonts (major = headings, minor = body).
pub major_font: String,
pub minor_font: String,
/// Theme colours: dk1 lt1 dk2 lt2 accent1..6 hlink folHlink.
pub theme_colors: Vec<Rgb>,
pub theme_name: String,
pub footnote_format: section::NumFormat,
pub endnote_format: section::NumFormat,
/// The document's footnote and endnote placement, start and restart (`w:footnotePr` /
/// `w:endnotePr` in the settings); sections may override them.
#[serde(skip_serializing_if = "section::NoteProps::is_empty")]
pub footnote_pr: section::NoteProps,
#[serde(skip_serializing_if = "section::NoteProps::is_empty")]
pub endnote_pr: section::NoteProps,
/// Restrict Editing, when enforced: `readOnly`, `comments`, `trackedChanges`, `forms`, or
/// `none` when only formatting is restricted (`w:documentProtection w:edit`).
pub protection: Option<String>,
/// Restrict Editing limits formatting to the styles that aren't locked, and forbids direct
/// formatting (`w:formatting="1"`).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub protect_formatting: bool,
/// The protection password's hash, as the file's `w:documentProtection` attributes
/// (`w:algorithmName`, `w:hashValue`, `w:saltValue`, `w:spinCount`, or the older
/// `w:cryptAlgorithmSid`, `w:hash`, `w:salt`…). Empty: no password.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub protect_hash: Vec<(String, String)>,
/// Word compatibility mode the document is laid out in (`compatibilityMode`): 15 for Word
/// 2013 and later, which places a table's border at the margin rather than its text and lets
/// justified lines shrink their spaces to fit more text.
pub compat_mode: u32,
/// Fonts the document's font table classes as roman (serif, `w:family w:val="roman"`): Word
/// draws one that isn't installed in a serif face.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub roman_fonts: Vec<String>,
/// `w:doNotUseHTMLParagraphAutoSpacing`: the space between two paragraphs is the first's space
/// after plus the second's space before, not the larger of the two.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub sum_paragraph_spacing: bool,
/// `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,
/// Drawing grid spacing in points, across and down (View › Gridlines); ECMA-376's default is 1/8 inch.
pub grid_h: f32,
pub grid_v: f32,
/// Equation options (`m:mathPr`), when the document has them.
#[serde(skip_serializing_if = "Option::is_none")]
pub math: Option<math::MathProps>,
/// Citation and bibliography style (References › Style) by name, when the document
/// records one (OOXML's `b:Sources/@StyleName`).
#[serde(skip_serializing_if = "Option::is_none")]
pub bib_style: Option<String>,
/// The template the document is attached to (Tools › Templates and Add-ins): the target of
/// `w:attachedTemplate`'s relationship, a path or `file:` URL as the file gives it.
#[serde(skip_serializing_if = "Option::is_none")]
pub attached_template: Option<String>,
/// Update the document's styles from the attached template when it is opened (`w:linkStyles`).
pub link_styles: bool,
/// The zoom the document was saved at (`w:zoom`), restored when it is opened; `None` for 100%
/// without a fit mode.
#[serde(skip_serializing_if = "Option::is_none")]
pub zoom: Option<Zoom>,
/// Remove personal information when saving (`w:removePersonalInformation`, ECMA-376
/// §17.15.1.70): the saved file names comment and revision authors "Author" and leaves out
/// the document's creator and last-modified-by. The open document keeps its real values.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub remove_personal_info: bool,
/// Remove date and time from annotations when saving (`w:removeDateAndTime`, §17.15.1.69):
/// comments and tracked changes are saved without `w:date`.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub remove_date_time: bool,
}
/// A saved zoom: a percentage, or a fit mode (the percentage it last came to).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Zoom {
/// 10–500.
pub percent: u32,
pub fit: Option<ZoomFit>,
}
/// View › Zoom's fit modes a document can be saved in.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ZoomFit {
OnePage,
PageWidth,
TextWidth,
}
/// Word's compatibility mode for documents that don't state one.
pub const LEGACY_COMPAT_MODE: u32 = 12;
/// The compatibility mode of documents created by Word 2013 and later, and by WordCraft.
pub const COMPAT_MODE_CURRENT: u32 = 15;
/// Word's hyphenation zone when a document doesn't set one (0.25"), points.
pub const DEFAULT_HYPHENATION_ZONE: f32 = 18.0;
/// ECMA-376 §17.15.1.44/45: 180 twentieths of a point when a document doesn't say.
pub const DEFAULT_GRID: f32 = 9.0;
/// A picture fill for the page background (`v:background/v:fill` with an image).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PagePicture {
/// Key into [`Document::media`].
pub media: String,
/// `v:fill/@type` (`tile`, `frame`, `tile`...); tiled when `tile`, stretched to the page otherwise.
pub fill_type: String,
/// `o:title` of the fill (texture name), kept for round trip.
pub title: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Watermark {
pub text: String,
pub font: String,
pub color: Rgb,
pub diagonal: bool,
pub semitransparent: bool,
/// The shape's box (its `style` width and height, points), which the text is stretched to
/// fill (`v:textpath fitshape`); `None`: the box Word's Design › Watermark gives
/// ([`Watermark::frame`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<[f32; 2]>,
}
impl Default for Watermark {
fn default() -> Self {
Watermark {
text: "DRAFT".into(),
font: styles::BODY_FONT.into(),
color: Rgb(0xC0, 0xC0, 0xC0),
diagonal: true,
semitransparent: true,
size: None,
}
}
}
impl Watermark {
/// The box the text fills on a page `page_w` × `page_h` (points): its own size, else half the
/// page diagonal across (half the page width when not diagonal), as high as the text's length
/// makes it. Centred on the margins and, when diagonal, turned 45° counterclockwise (Word's
/// `rotation:315`).
pub fn frame(&self, page_w: f32, page_h: f32) -> [f32; 2] {
if let Some([w, h]) = self.size.filter(|[w, h]| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0) {
return [w.min(4000.0), h.min(4000.0)];
}
let (pw, ph) = (wordcraft_geom::finite(page_w).clamp(72.0, 4000.0), wordcraft_geom::finite(page_h).clamp(72.0, 4000.0));
let across = if self.diagonal { (pw * pw + ph * ph).sqrt() } else { pw };
let width = (across * 0.5).clamp(36.0, 2000.0);
let chars = self.text.chars().take(200).count().max(1) as f32;
[width, (width / (0.55 * chars)).clamp(12.0, 400.0)]
}
}
/// Our default theme colours.
pub const THEME_COLORS: [Rgb; 12] = [
Rgb(0x00, 0x00, 0x00),
Rgb(0xFF, 0xFF, 0xFF),
Rgb(0x0E, 0x28, 0x41),
Rgb(0xE8, 0xE8, 0xE8),
Rgb(0x15, 0x60, 0x82),
Rgb(0xE9, 0x71, 0x32),
Rgb(0x19, 0x6B, 0x24),
Rgb(0x0F, 0x9E, 0xD5),
Rgb(0xA0, 0x2B, 0x93),
Rgb(0x4E, 0xA7, 0x2E),
Rgb(0x46, 0x78, 0x86),
Rgb(0x96, 0x60, 0x7D),
];
impl Default for Settings {
fn default() -> Self {
Settings {
track_changes: false,
track_moves: true,
default_tab: 36.0,
even_odd_headers: false,
mirror_margins: false,
auto_hyphenation: false,
hyphenation_zone: DEFAULT_HYPHENATION_ZONE,
hyphenate_caps: true,
consecutive_hyphen_limit: 0,
page_color: None,
page_picture: None,
watermark: None,
major_font: styles::HEADING_FONT.into(),
minor_font: styles::BODY_FONT.into(),
theme_colors: THEME_COLORS.to_vec(),
theme_name: "Craft".into(),
footnote_format: section::NumFormat::Decimal,
endnote_format: section::NumFormat::LowerRoman,
footnote_pr: section::NoteProps::default(),
endnote_pr: section::NoteProps::default(),
protection: None,
protect_formatting: false,
protect_hash: Vec::new(),
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,
math: None,
bib_style: None,
attached_template: None,
link_styles: false,
zoom: None,
remove_personal_info: false,
remove_date_time: false,
}
}
}
+145 -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)]
@@ -71,6 +77,80 @@ pub const MAX_COLS: usize = 63;
pub const MAX_TABLE_DEPTH: usize = 24;
impl Table {
/// Grid columns `lo..hi` that [`Table::slice`] copies for `rows`/`cols` (widened over merged cells),
/// and whether any cell touches them (Cut clears the same cells it copies).
pub fn slice_bounds(&self, rows: (usize, usize), cols: (usize, usize)) -> (usize, usize, bool) {
let ((r0, r1), (g0, g1)) = (rows, cols);
let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0);
// Grid columns `lo..hi` the cells touching them cover: a cell merged across that is partly
// in them widens them, for every row, so the copy isn't ragged. Each round only widens
// (at most once per grid column), so a bound of the grid's width plus one round is enough.
let (mut lo, mut hi) = (g0, g1.saturating_add(1));
let mut any = false;
for _ in 0..=MAX_COLS.saturating_add(1) {
let (mut nlo, mut nhi, mut found) = (lo, hi, false);
for row in kept() {
let mut g = 0usize;
for cell in &row.cells {
let end = g.saturating_add(cell.span());
if g < hi && end > lo {
(nlo, nhi, found) = (nlo.min(g), nhi.max(end), true);
}
g = end;
}
}
any = found;
if (nlo, nhi) == (lo, hi) {
break;
}
(lo, hi) = (nlo, nhi);
}
(lo, hi, any)
}
/// A copy of rows `rows` of this table holding, of each row, the cells that reach into grid
/// columns `cols` (both inclusive): what Copy takes from selected rows or cells. Content
/// controls around rows and the table aren't copied (they'd lose their other end); a cell
/// that continues a vertical merge from a row left out starts the merge instead.
pub fn slice(&self, rows: (usize, usize), cols: (usize, usize)) -> Table {
let (r0, r1) = rows;
let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0);
let (lo, hi, any) = self.slice_bounds(rows, cols);
let mut out_rows = Vec::new();
for row in kept() {
let mut g = 0usize;
let mut cells = Vec::new();
for cell in &row.cells {
let end = g.saturating_add(cell.span());
if any && g < hi && end > lo {
cells.push(cell.clone());
}
g = end;
}
if !cells.is_empty() {
out_rows.push(Row { props: row.props.clone(), cells, controls: ControlWrap::default() });
}
}
if let Some(first) = out_rows.first_mut() {
for c in &mut first.cells {
if c.props.vmerge == VMerge::Continue {
c.props.vmerge = VMerge::Restart;
}
}
}
let whole = lo == 0 && hi >= self.grid.len();
let grid = if whole { self.grid.clone() } else { self.grid.get(lo..hi.min(self.grid.len())).map(<[f32]>::to_vec).unwrap_or_default() };
let mut props = self.props.clone();
if !whole {
// Part of the columns: as wide as they are, not the whole table's width.
if props.width.is_some() || props.width_pct.is_some() {
props.width = Some(grid.iter().sum());
}
props.width_pct = None;
}
Table { props, grid, rows: out_rows, controls: ControlWrap::default() }
}
/// A `rows × cols` table spanning `width` points, "Table Grid" style.
pub fn new(rows: usize, cols: usize, width: f32) -> Table {
let rows = rows.clamp(1, MAX_ROWS);
@@ -97,7 +177,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))
}
@@ -559,6 +640,69 @@ impl Table {
mod tests {
use super::*;
/// A 3×3 table, 100 pt columns: (0,0)–(0,1) merged across, (1,2)–(2,2) merged down.
fn merged() -> Table {
let mut t = Table::new(3, 3, 300.0);
t.merge(0, 0, 0, 1);
t.merge(1, 2, 2, 2);
assert_eq!(t.rows[0].cells.iter().map(Cell::span).collect::<Vec<_>>(), [2, 1]);
assert_eq!((t.rows[1].cells[2].props.vmerge, t.rows[2].cells[2].props.vmerge), (VMerge::Restart, VMerge::Continue));
t
}
fn spans(t: &Table) -> Vec<Vec<usize>> {
t.rows.iter().map(|r| r.cells.iter().map(Cell::span).collect()).collect()
}
/// #1744 review: a cell merged across that is partly selected takes its whole width, and the
/// other rows widen with it, so the copy isn't ragged.
#[test]
fn slice_widens_to_a_merged_cell_in_every_row() {
let t = merged().slice((0, 1), (1, 2));
assert_eq!(t.grid, [100.0, 100.0, 100.0]);
assert_eq!(spans(&t), [vec![2, 1], vec![1, 1, 1]]);
// Rows without the merged cell: just the selected columns.
let t = merged().slice((1, 2), (1, 2));
assert_eq!(t.grid, [100.0, 100.0]);
assert_eq!(spans(&t), [vec![1, 1], vec![1, 1]]);
}
/// #1744 review: a cell continuing a vertical merge from a row left out starts it instead.
#[test]
fn slice_starts_a_vertical_merge_whose_first_row_is_left_out() {
let t = merged().slice((2, 2), (0, 2));
assert_eq!(t.rows.len(), 1);
assert_eq!(t.rows[0].cells[2].props.vmerge, VMerge::Restart);
// Both rows kept: the merge is kept as it is.
let t = merged().slice((1, 2), (2, 2));
assert_eq!((t.rows[0].cells[0].props.vmerge, t.rows[1].cells[0].props.vmerge), (VMerge::Restart, VMerge::Continue));
}
/// #1744 review: part of the columns is as wide as they are; content controls around rows
/// and the table aren't copied.
#[test]
fn slice_width_and_controls() {
let mut t = Table::new(3, 3, 300.0);
(t.props.width, t.props.width_pct) = (Some(300.0), Some(50.0));
let wrap = ControlWrap { open: vec![crate::control::ContentControl::default()], close: 1 };
t.controls = wrap.clone();
t.rows[1].controls = wrap;
let part = t.slice((0, 2), (2, 2));
assert_eq!((part.grid.len(), part.props.width, part.props.width_pct), (1, Some(100.0), None));
let whole = t.slice((0, 2), (0, 2));
assert_eq!((whole.props.width, whole.props.width_pct), (Some(300.0), Some(50.0)), "all columns keep the table's width");
let row = t.slice((1, 1), (0, 2));
assert!(row.controls.is_empty() && row.rows[0].controls.is_empty());
// A table sized only by a percentage keeps a width for the part.
let mut pct = Table::new(3, 3, 300.0);
(pct.props.width, pct.props.width_pct) = (None, Some(50.0));
let part = pct.slice((0, 2), (2, 2));
assert_eq!((part.props.width, part.props.width_pct), (Some(100.0), None));
// Nothing in range: no rows.
assert!(t.slice((5, 6), (0, 2)).rows.is_empty());
assert!(t.slice((0, 2), (9, 9)).rows.is_empty());
}
#[test]
fn grid_before_shifts_the_row() {
let mut t = Table::new(2, 3, 300.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())))
}
+193
View File
@@ -1,5 +1,7 @@
use super::*;
mod note_conversion;
#[test]
fn new_document_is_valid() {
let d = Document::new();
@@ -90,6 +92,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 +564,155 @@ 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:?}");
}
}
#[test]
fn lone_table_open_markers_linear_time() {
let mut d = Document::new();
let mut t = Table::new(1, 1, 100.0);
const COUNT: usize = 2_000;
t.controls.open = (0..COUNT).map(|i| ContentControl { tag: format!("ctrl_{i}"), ..Default::default() }).collect();
d.body.insert(0, Arc::new(Block::Table(t)));
let mut paras = Vec::new();
let mut tables = Vec::new();
let timer = std::time::Instant::now();
crate::control::orphans(&d.body, &mut Vec::new(), 0, &mut paras, &mut tables);
assert_eq!(paras.len(), 0);
assert_eq!(tables.len(), COUNT);
for (i, (path, nth, is_open)) in tables.iter().enumerate() {
assert_eq!(*path, Path::top(0));
assert_eq!(*nth, i);
assert!(*is_open);
}
assert!(d.has_unbalanced_controls());
let removed = d.balance_controls();
assert_eq!(removed, COUNT);
assert!(!d.has_unbalanced_controls());
assert!(timer.elapsed().as_secs_f32() < 10.0, "{:?}", timer.elapsed());
let Block::Table(tbl) = &*d.body[0] else { panic!("expected table") };
assert!(tbl.controls.open.is_empty());
}
#[test]
fn lone_table_open_markers_mixed_tables_and_paired() {
let mut d = Document::new();
let mut t0 = Table::new(1, 1, 100.0);
t0.controls.open = (0..3).map(|i| ContentControl { tag: format!("t0_{i}"), ..Default::default() }).collect();
t0.controls.close = 1;
let mut t1 = Table::new(1, 1, 100.0);
t1.controls.open = (0..2).map(|i| ContentControl { tag: format!("t1_{i}"), ..Default::default() }).collect();
d.body = vec![Arc::new(Block::Table(t0)), Arc::new(Block::Table(t1))];
let mut paras = Vec::new();
let mut tables = Vec::new();
crate::control::orphans(&d.body, &mut Vec::new(), 0, &mut paras, &mut tables);
assert_eq!(paras.len(), 0);
assert_eq!(tables, vec![(Path::top(0), 0, true), (Path::top(0), 1, true), (Path::top(1), 0, true), (Path::top(1), 1, true),]);
let removed = d.balance_controls();
assert_eq!(removed, 4);
assert!(!d.has_unbalanced_controls());
let Block::Table(res0) = &*d.body[0] else { panic!("expected table") };
assert_eq!(res0.controls.open.len(), 1);
assert_eq!(res0.controls.open[0].tag, "t0_2");
assert_eq!(res0.controls.close, 1);
let Block::Table(res1) = &*d.body[1] else { panic!("expected table") };
assert!(res1.controls.open.is_empty());
assert_eq!(res1.controls.close, 0);
}
+145
View File
@@ -0,0 +1,145 @@
use super::*;
use crate::para::{InlineObject, NoteKind, OBJ};
fn reference(id: u32, kind: NoteKind) -> InlineObject {
InlineObject::NoteRef { kind, id, custom: "*".into() }
}
fn styled(len: usize, style: &str) -> Run {
Run { len, props: CharProps { style: Some(style.into()), italic: Some(true), ..Default::default() } }
}
#[test]
fn conversion_preserves_reference_run_boundaries_and_unmatched_storage() {
for (foot, end, expected) in [
(true, false, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "EndnoteReference", "FootnoteReference"]),
(false, true, ["FootnoteReference", "FootnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]),
(true, true, ["FootnoteReference", "EndnoteReference", "FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]),
] {
let mut d = Document::new();
let note = |style: &str| {
let mut p = Paragraph::with_text("Original note 漢", CharProps::default());
p.props.style = Some(style.into());
p.mark.style = Some(style.into());
vec![para_block(p)]
};
let f = d.add_part(PartKind::Footnote, note("FootnoteText"));
let e = d.add_part(PartKind::Endnote, note("EndnoteText"));
let p = Paragraph {
text: format!("é{OBJ}{OBJ}漢{OBJ}{OBJ}"),
objects: vec![
reference(f, NoteKind::Footnote),
InlineObject::BookmarkStart { name: "kept".into() },
reference(e, NoteKind::Endnote),
reference(999, NoteKind::Footnote),
reference(f, NoteKind::Footnote),
],
runs: vec![
styled(2, "FootnoteReference"),
styled(3, "FootnoteReference"),
styled(3, "FootnoteReference"),
styled(3, "EndnoteReference"),
styled(3, "EndnoteReference"),
styled(3, "FootnoteReference"),
],
..Paragraph::new()
};
let mut table = Table::new(1, 1, 200.0);
table.rows[0].cells[0].blocks = vec![para_block(p.clone())];
d.body = vec![para_block(p.clone()), Arc::new(Block::Table(table))];
let h = d.add_part(PartKind::Header, vec![para_block(p.clone())]);
let t = d.add_part(PartKind::TextBox, vec![para_block(p.clone())]);
let before = d.clone();
assert_eq!(d.convert_notes(foot, end), usize::from(foot) + usize::from(end));
for (story, path) in [
(StoryRef::Body, Path::top(0)),
(StoryRef::Body, Path(vec![1, 0, 0, 0])),
(StoryRef::Part(h), Path::top(0)),
(StoryRef::Part(t), Path::top(0)),
] {
let got = d.para(story, &path).unwrap();
assert_eq!(got.text, p.text);
assert_eq!(got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::<Vec<_>>(), expected);
assert!(got.runs.iter().all(|r| r.props.italic == Some(true)));
assert_eq!(got.objects[1], p.objects[1]);
assert_eq!(got.objects[3], p.objects[3], "a missing note stays unchanged");
assert_eq!(got.objects[0], reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote }));
assert_eq!(got.objects[2], reference(e, if end { NoteKind::Footnote } else { NoteKind::Endnote }));
assert_eq!(got.objects[4], got.objects[0], "surplus object storage retains the existing conversion");
}
for (id, flipped, from, to) in [(f, foot, "FootnoteText", "EndnoteText"), (e, end, "EndnoteText", "FootnoteText")] {
let got = d.para(StoryRef::Part(id), &Path::top(0)).unwrap();
assert_eq!(got.text, before.plain_text(StoryRef::Part(id)));
assert_eq!(got.props.style.as_deref(), Some(if flipped { to } else { from }));
assert_eq!(got.mark.style, got.props.style);
}
let mut missing = Paragraph {
text: format!("{OBJ}{OBJ}"),
objects: vec![reference(f, if foot { NoteKind::Endnote } else { NoteKind::Footnote })],
runs: vec![styled(0, "FootnoteReference"), styled(3, "FootnoteReference"), styled(3, "FootnoteReference")],
..Paragraph::new()
};
missing.runs.push(styled(usize::MAX, "FootnoteReference"));
d.body = vec![para_block(missing)];
d.convert_notes(true, true);
let got = d.para(StoryRef::Body, &Path::top(0)).unwrap();
assert_eq!(
got.runs.iter().map(|r| r.props.style.as_deref().unwrap()).collect::<Vec<_>>(),
["FootnoteReference", "EndnoteReference", "FootnoteReference", "FootnoteReference"]
);
}
}
fn dense(n: usize) -> Document {
let mut d = Document::new();
let id = d.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("Shared original note", CharProps::default()))]);
d.body = vec![para_block(Paragraph {
text: format!("{}{}", OBJ.to_string().repeat(n), "x".repeat(n)),
objects: (0..n).map(|_| reference(id, NoteKind::Footnote)).collect(),
runs: (0..n).map(|_| styled(OBJ.len_utf8(), "FootnoteReference")).chain((0..n).map(|_| styled(1, "FootnoteReference"))).collect(),
..Paragraph::new()
})];
d
}
// Thread CPU excludes scheduler delays. Missing or nonadvancing counters provide no timing
// evidence; the semantic assertions still run on those platforms.
fn cpu_cost(f: impl FnOnce()) -> Option<f64> {
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
let before = ran();
f();
match (before, ran()) {
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
_ => None,
}
}
#[test]
fn conversion_work_scales_with_reference_and_formatting_runs() {
let (small, big) = (dense(2_000), dense(16_000));
let convert = |input: &Document| {
let mut d = input.clone();
assert_eq!(d.convert_notes(true, false), 1);
let p = d.para(StoryRef::Body, &Path::top(0)).unwrap();
let n = p.objects.len();
assert!(p.runs[..n].iter().all(|r| r.props.style.as_deref() == Some("EndnoteReference")));
assert!(p.runs[n..].iter().all(|r| r.props.style.as_deref() == Some("FootnoteReference")));
std::hint::black_box(d);
};
convert(&small);
convert(&big);
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
for _ in 0..3 {
let a = cpu_cost(|| (0..8).for_each(|_| convert(&small)));
let b = cpu_cost(|| convert(&big));
if let (Some(a), Some(b)) = (a, b) {
t1 = t1.min(a / 8.0);
t8 = t8.min(b);
}
}
if t1.is_finite() && t8.is_finite() {
let ratio = t8 / t1;
eprintln!("note conversion CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
assert!(ratio < 24.0, "8x the references and runs cost {ratio:.2}x CPU");
}
}
+163
View File
@@ -0,0 +1,163 @@
use super::block::{Block, Blocks};
use super::parts::PartKind;
use super::position::Path;
use super::{InlineObject, Numbering, numbering, para, section, styles};
/// Words in `s` (tokens separated by whitespace that contain at least one alphanumeric char).
pub fn count_words(s: &str) -> usize {
s.split(|c: char| c.is_whitespace() || c == para::OBJ).filter(|w| w.chars().any(char::is_alphanumeric)).count()
}
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
pub(crate) const PARA_WALK_DEPTH: usize = 16;
/// The note and text box stories an object shows, for `Document::reachable`.
pub(crate) fn notes_and_boxes(o: &InlineObject) -> Vec<(u32, &'static [PartKind])> {
match o {
InlineObject::NoteRef { id, .. } => vec![(*id, &[PartKind::Footnote, PartKind::Endnote][..])],
o => o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect(),
}
}
pub(crate) fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
if depth > PARA_WALK_DEPTH {
return;
}
for (i, b) in bl.iter().enumerate() {
prefix.push(i as u32);
match &**b {
Block::Para(_) => out.push(Path(prefix.clone())),
Block::Table(t) => {
for (r, row) in t.rows.iter().enumerate() {
for (c, cell) in row.cells.iter().enumerate() {
prefix.push(r as u32);
prefix.push(c as u32);
walk(&cell.blocks, prefix, out, depth + 1);
prefix.pop();
prefix.pop();
}
}
}
}
prefix.pop();
}
}
/// [`walk`] for the paragraphs from path `a` to `b` inclusive, skipping the blocks outside.
pub(crate) fn walk_between(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize, (a, b): (&[u32], &[u32])) {
use std::cmp::Ordering::{Greater, Less};
if depth > PARA_WALK_DEPTH {
return;
}
// Where the paths starting with `q` lie against `x`: all before, all after, or either.
let side = |q: &[u32], x: &[u32]| {
let m = q.len().min(x.len());
q[..m].cmp(&x[..m])
};
for (i, blk) in bl.iter().enumerate() {
prefix.push(i as u32);
if side(prefix, b) == Greater {
prefix.pop();
break;
}
if side(prefix, a) != Less {
match &**blk {
Block::Para(_) => {
if prefix.as_slice() >= a && prefix.as_slice() <= b {
out.push(Path(prefix.clone()));
}
}
Block::Table(t) => {
for (r, row) in t.rows.iter().enumerate() {
for (c, cell) in row.cells.iter().enumerate() {
prefix.push(r as u32);
prefix.push(c as u32);
if side(prefix, a) != Less && side(prefix, b) != Greater {
walk_between(&cell.blocks, prefix, out, depth + 1, (a, b));
}
prefix.pop();
prefix.pop();
}
}
}
}
}
prefix.pop();
}
}
/// Which text of a paragraph [`text_of`] collects.
#[derive(Clone, Copy)]
pub(crate) enum TextMode {
/// Every run, tracked deletions included ([`Paragraph::plain_text`]).
Plain,
/// As shown on screen ([`Paragraph::shown_text`]).
Shown,
/// Without tracked deletions ([`Paragraph::final_text`]).
Final,
}
pub(crate) fn text_of(bl: &Blocks, out: &mut String, depth: usize, mode: TextMode) {
if depth > 16 {
return;
}
for b in bl {
match &**b {
Block::Para(p) => {
out.push_str(&match mode {
TextMode::Plain => p.plain_text(),
TextMode::Shown => p.shown_text(),
TextMode::Final => p.final_text(),
});
out.push('\n');
}
Block::Table(t) => {
for row in &t.rows {
let mut cells = Vec::new();
for c in &row.cells {
let mut s = String::new();
text_of(&c.blocks, &mut s, depth + 1, mode);
cells.push(s.trim_end_matches('\n').replace('\n', " "));
}
out.push_str(&cells.join("\t"));
out.push('\n');
}
}
}
}
}
pub(crate) fn story_word_count_of(
bl: &Blocks,
numbering: &Numbering,
styles: &styles::StyleSheet,
counters: &mut numbering::Counters,
depth: usize,
) -> usize {
if depth > 16 {
return 0;
}
let mut total = 0usize;
for b in bl {
match &**b {
Block::Para(p) => {
let n = p.props.numbering.or_else(|| styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0);
if let Some(n) = n
&& let Some((label, def)) = counters.next_label(numbering, n.num, n.level)
&& !matches!(def.format, section::NumFormat::Bullet | section::NumFormat::None)
{
total = total.saturating_add(count_words(&label));
}
total = total.saturating_add(count_words(&p.plain_text()));
}
Block::Table(t) => {
for row in &t.rows {
for c in &row.cells {
total = total.saturating_add(story_word_count_of(&c.blocks, numbering, styles, counters, depth + 1));
}
}
}
}
}
total
}
+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 }
+30 -6
View File
@@ -90,13 +90,16 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
let tag = child_text(src_el, "Tag");
let raw_kind = child_text(src_el, "SourceType");
let kind = crate::write::from_source_type(&raw_kind);
let (author, editor) = match src_el.els().find(|e| e.local() == "Author") {
let (author, corporate, editor) = match src_el.els().find(|e| e.local() == "Author") {
Some(a) => {
let auth = a.els().find(|e| e.local() == "Author").map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a));
let role = a.els().find(|e| e.local() == "Author");
let auth = role.map(parse_persons).filter(|s| !s.is_empty()).unwrap_or_else(|| parse_persons(a));
// Word's Corporate Author: one name, kept whole (`b:Corporate`, #4848).
let corporate = role.map(|r| child_text(r, "Corporate").trim().to_string()).unwrap_or_default();
let ed = a.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default();
(auth, ed)
(auth, corporate, ed)
}
None => (String::new(), String::new()),
None => (String::new(), String::new(), String::new()),
};
let editor = if editor.is_empty() { src_el.els().find(|e| e.local() == "Editor").map(parse_persons).unwrap_or_default() } else { editor };
let title = child_text(src_el, "Title");
@@ -132,11 +135,12 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
};
// Keep sources with some content, once each (by tag, or title and author untagged).
if tag.is_empty() && title.is_empty() && author.is_empty() && editor.is_empty() {
if tag.is_empty() && title.is_empty() && author.is_empty() && corporate.is_empty() && editor.is_empty() {
continue;
}
let fresh = if tag.is_empty() {
seen.untagged.insert((title.clone(), if author.is_empty() { editor.clone() } else { author.clone() }))
let who = [&author, &corporate, &editor].into_iter().find(|w| !w.is_empty()).cloned().unwrap_or_default();
seen.untagged.insert((title.clone(), who))
} else {
seen.tags.insert(tag.clone())
};
@@ -147,6 +151,7 @@ pub(crate) fn parse(root: &El, seen: &mut Seen, room: usize) -> Sources {
tag,
kind,
author,
corporate,
title,
year,
city,
@@ -265,6 +270,25 @@ mod tests {
assert!(read_sources(br#"<o:Sources xmlns:o="urn:example:other"><o:Source/></o:Sources>"#).is_err(), "an unrelated namespace");
}
/// #4848: a corporate author (Word's Corporate Author box, `b:Corporate`) is read whole, and
/// an untagged source with only a corporate author is still kept, once.
#[test]
fn a_corporate_author_is_read_whole() {
let xml = br#"<b:Sources xmlns:b="http://schemas.openxmlformats.org/officeDocument/2006/bibliography">
<b:Source><b:Tag>Wor20</b:Tag><b:SourceType>Report</b:SourceType>
<b:Author><b:Author><b:Corporate> World Health Organization </b:Corporate></b:Author></b:Author>
<b:Title>World report on hearing</b:Title><b:Year>2020</b:Year></b:Source>
<b:Source><b:SourceType>Book</b:SourceType>
<b:Author><b:Author><b:Corporate>Harbor Guild, Inc.</b:Corporate></b:Author></b:Author></b:Source>
<b:Source><b:SourceType>Book</b:SourceType>
<b:Author><b:Author><b:Corporate>Harbor Guild, Inc.</b:Corporate></b:Author></b:Author></b:Source>
</b:Sources>"#;
let f = read_sources(xml).unwrap();
assert_eq!(f.sources.len(), 2, "{:?}", f.sources);
assert_eq!((f.sources[0].corporate.as_str(), f.sources[0].author.as_str()), ("World Health Organization", ""));
assert_eq!(f.sources[1].corporate, "Harbor Guild, Inc.", "a comma doesn't split a corporate name");
}
/// Word's own 2004/10 bibliography namespace (`Sources.xml` written by scripts and older
/// builds, the VBA `Source.XML` format) reads as well as OOXML's.
#[test]
+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 {
+190 -198
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}"))),
}
@@ -797,199 +984,4 @@ fn primary_transform() -> Vec<u8> {
}
#[cfg(test)]
mod tests {
#[test]
fn protection_password_round_trip() {
use super::PasswordCheck::*;
let attrs = super::protection_hash("s3cret", 1000).unwrap();
assert_eq!(super::check_protection_password(&attrs, "s3cret"), Correct);
assert_eq!(super::check_protection_password(&attrs, "S3cret"), Incorrect);
// Damaged attributes are refused, never accepted.
let mut bad = attrs.clone();
bad.retain(|(k, _)| k != "w:hashValue");
bad.push(("w:hashValue".into(), "not base64!".into()));
assert_eq!(super::check_protection_password(&bad, "s3cret"), Unsupported);
let mut odd = attrs.clone();
odd.retain(|(k, _)| k != "w:algorithmName");
odd.push(("w:algorithmName".into(), "WHIRLPOOL".into()));
assert_eq!(super::check_protection_password(&odd, "s3cret"), Unsupported);
assert_eq!(super::check_protection_password(&[("w:cryptProviderType".into(), "rsaAES".into())], "x"), NoPassword);
}
/// The worked example of ECMA-376 Part 4 §14.8.1: "Example" gives the key 0x64CEED7E, hashed
/// as "7EEDCE64".
#[test]
fn legacy_key_matches_the_specification_example() {
assert_eq!(super::legacy_key("Example"), 0x64CE_ED7E);
assert_eq!(super::legacy_key_hex("Example").as_str(), "7EEDCE64");
assert_eq!(super::legacy_key(""), 0);
// A leading BOM is dropped; only the first 15 characters count.
assert_eq!(super::legacy_key("\u{FEFF}Example"), 0x64CE_ED7E);
assert_eq!(super::legacy_key("abcdefghijklmnopq"), super::legacy_key("abcdefghijklmno"));
assert_ne!(super::legacy_key("abcdefghijklmno"), super::legacy_key("abcdefghijklmn"));
}
/// The legacy form (`w:cryptAlgorithmSid`, `w:hash`, `w:salt`, `w:cryptSpinCount`), as Word
/// writes it. The expected hash was computed independently from the specification's stages.
#[test]
fn legacy_protection_hash_is_verified() {
use super::PasswordCheck::*;
let legacy = |sid: &str, hash: &str, spin: &str| -> Vec<(String, String)> {
vec![
("w:cryptProviderType".into(), "rsaFull".into()),
("w:cryptAlgorithmClass".into(), "hash".into()),
("w:cryptAlgorithmType".into(), "typeAny".into()),
("w:cryptAlgorithmSid".into(), sid.into()),
("w:cryptSpinCount".into(), spin.into()),
("w:hash".into(), hash.into()),
("w:salt".into(), "AAECAwQFBgcICQoLDA0ODw==".into()),
]
};
let attrs = legacy("4", KNOWN_LEGACY_SHA1, "100000");
assert_eq!(super::check_protection_password(&attrs, "Example"), Correct);
assert_eq!(super::check_protection_password(&attrs, "example"), Incorrect);
assert_eq!(super::check_protection_password(&attrs, ""), Incorrect);
// MD5 (and the other algorithms WordCraft lacks), hostile spin counts: refused.
assert_eq!(super::check_protection_password(&legacy("3", KNOWN_LEGACY_SHA1, "100000"), "Example"), Unsupported);
assert_eq!(super::check_protection_password(&legacy("4", KNOWN_LEGACY_SHA1, "4294967295"), "Example"), Unsupported);
assert_eq!(super::check_protection_password(&legacy("4", KNOWN_LEGACY_SHA1, "-1"), "Example"), Unsupported);
let mut ext = attrs.clone();
ext.push(("w:algIdExt".into(), "0000000A".into()));
assert_eq!(super::check_protection_password(&ext, "Example"), Unsupported);
// No algorithm named: the very old bare-hash form isn't defined, so it's refused.
let bare = vec![("w:hash".to_string(), KNOWN_LEGACY_SHA1.to_string())];
assert_eq!(super::check_protection_password(&bare, "Example"), Unsupported);
}
/// SHA-1 of the salt 00 01 … 0F and "7EEDCE64" as UTF-16LE, iterated 100,000 times.
const KNOWN_LEGACY_SHA1: &str = "pEWelKoH6SVpvMmyPAJ8bHA/g/s=";
use super::*;
fn zip_package() -> Vec<u8> {
let mut doc = wordcraft_doc::Document::from_text("Top secret plans\nSecond paragraph");
doc.core.title = "Secret".into();
crate::write(&doc).unwrap()
}
#[test]
fn round_trip_with_a_password() {
let pkg = zip_package();
let enc = encrypt_with_spin_count(&pkg, "pässwörd 🔑", 16).unwrap();
assert!(is_encrypted(&enc));
assert!(!is_encrypted(&pkg));
// The compound file carries the data spaces Word expects.
let comp = cfb::CompoundFile::open(Cursor::new(&enc)).unwrap();
assert!(comp.is_stream("/\u{6}DataSpaces/TransformInfo/StrongEncryptionTransform/\u{6}Primary"));
assert_eq!(decrypt(&enc, Some("pässwörd 🔑")).unwrap(), pkg);
let doc = crate::read_with_password(&enc, Some("pässwörd 🔑")).unwrap();
assert!(doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Top secret plans"));
assert_eq!(doc.core.title, "Secret");
// Without a password, or with the wrong one, it says so instead of opening blank.
assert_eq!(crate::read(&enc), Err(DocxError::PasswordRequired));
assert_eq!(crate::read_with_password(&enc, None), Err(DocxError::PasswordRequired));
assert_eq!(decrypt(&enc, Some("Pässwörd 🔑")), Err(DocxError::WrongPassword));
// A package that is a whole number of segments, and an empty one.
for n in [0usize, SEGMENT, SEGMENT * 2 + 1] {
let data: Vec<u8> = (0..n).map(|i| (i * 7) as u8).collect();
let enc = encrypt_with_spin_count(&data, "x", 1).unwrap();
assert_eq!(decrypt(&enc, Some("x")).unwrap(), data);
}
assert!(encrypt(&pkg, "").is_err());
assert!(encrypt(&pkg, &"x".repeat(MAX_PASSWORD_CHARS + 1)).is_err());
}
#[test]
fn a_file_without_data_integrity_is_refused() {
let enc = encrypt_with_spin_count(&zip_package(), "pw", 4).unwrap();
let mut comp = cfb::CompoundFile::open(Cursor::new(enc)).unwrap();
let mut info = Vec::new();
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
let xml = String::from_utf8(info[8..].to_vec()).unwrap();
let mut stripped = info[..8].to_vec();
stripped.extend_from_slice(xml.replacen("<dataIntegrity", "<nothing", 1).as_bytes());
comp.create_stream("/EncryptionInfo").unwrap().write_all(&stripped).unwrap();
comp.flush().unwrap();
let enc = comp.into_inner().into_inner();
assert!(matches!(decrypt(&enc, Some("pw")), Err(DocxError::Encryption(m)) if m.contains("dataIntegrity")));
assert!(crate::read_with_password(&enc, Some("pw")).is_err());
}
#[test]
fn tampered_package_fails_the_integrity_check() {
let pkg = zip_package();
let enc = encrypt_with_spin_count(&pkg, "pw", 4).unwrap();
let mut comp = cfb::CompoundFile::open(Cursor::new(enc)).unwrap();
let mut data = Vec::new();
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut data).unwrap();
let last = data.len() - 1;
data[last] ^= 1;
comp.create_stream("/EncryptedPackage").unwrap().write_all(&data).unwrap();
comp.flush().unwrap();
let enc = comp.into_inner().into_inner();
assert!(matches!(decrypt(&enc, Some("pw")), Err(DocxError::Encryption(m)) if m.contains("integrity")));
}
/// A compound file with the given EncryptionInfo and EncryptedPackage streams.
fn cfb_with(info: &[u8], package: &[u8]) -> Vec<u8> {
let mut comp = cfb::CompoundFile::create(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()
}
#[test]
fn hostile_encryption_info_errors_without_panicking() {
let good = encrypt_with_spin_count(&zip_package(), "pw", 2).unwrap();
let mut comp = cfb::CompoundFile::open(Cursor::new(&good)).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();
let xml = String::from_utf8(info[8..].to_vec()).unwrap();
// (EncryptionInfo, EncryptedPackage, must fail). Truncations only must not panic: the
// XML reader is lenient, so cutting the closing tags still reads.
let mut cases: Vec<(Vec<u8>, Vec<u8>, bool)> = Vec::new();
for n in (0..info.len()).step_by(7) {
cases.push((info[..n].to_vec(), package.clone(), n < info.len() / 2));
}
for n in [0, 4, 8, 9, 100, package.len() - 1] {
cases.push((info.clone(), package[..n].to_vec(), true));
}
// Standard and unknown versions, absurd numbers, bad base64, a huge StreamSize.
let with = |from: &str, to: &str| {
let mut v = info[..8].to_vec();
v.extend_from_slice(xml.replacen(from, to, 2).as_bytes());
v
};
cases.push(([3u8, 0, 2, 0, 0x24, 0, 0, 0].to_vec(), package.clone(), true));
cases.push(([9u8, 0, 9, 0].to_vec(), package.clone(), true));
cases.push((with("spinCount=\"2\"", "spinCount=\"4294967295\""), package.clone(), true));
cases.push((with("keyBits=\"256\"", "keyBits=\"8\""), package.clone(), true));
cases.push((with("blockSize=\"16\"", "blockSize=\"4096\""), package.clone(), true));
cases.push((with("hashSize=\"64\"", "hashSize=\"0\""), package.clone(), true));
cases.push((with("saltSize=\"16\"", "saltSize=\"99999999999999999999\""), package.clone(), true));
cases.push((with("SHA512", "MD5"), package.clone(), true));
cases.push((with("ChainingModeCBC", "ChainingModeCFB"), package.clone(), true));
cases.push((with("saltValue=\"", "saltValue=\"!!"), package.clone(), true));
cases.push((with("encryptedKeyValue=\"", "encryptedKeyValue=\"AAAA"), package.clone(), true));
// Data integrity is mandatory: a file without it is refused, never opened unchecked.
cases.push((with("<dataIntegrity", "<nothing"), package.clone(), true));
let mut huge = package.clone();
huge[..8].copy_from_slice(&u64::MAX.to_le_bytes());
cases.push((with("<dataIntegrity", "<nothing"), huge, true));
for (i, p, must_fail) in cases {
let file = cfb_with(&i, &p);
let r = decrypt(&file, Some("pw"));
assert!(!must_fail || r.is_err());
let _ = crate::read_with_password(&file, Some("pw"));
assert!(crate::read(&file).is_err());
}
// A file that only looks like a compound file.
let mut fake = CFB_MAGIC.to_vec();
fake.extend_from_slice(&[0u8; 600]);
assert!(!is_encrypted(&fake));
assert!(decrypt(&fake, Some("pw")).is_err());
}
}
mod tests;
+393
View File
@@ -0,0 +1,393 @@
#[test]
fn protection_password_round_trip() {
use super::PasswordCheck::*;
let attrs = super::protection_hash("s3cret", 1000).unwrap();
assert_eq!(super::check_protection_password(&attrs, "s3cret"), Correct);
assert_eq!(super::check_protection_password(&attrs, "S3cret"), Incorrect);
// Damaged attributes are refused, never accepted.
let mut bad = attrs.clone();
bad.retain(|(k, _)| k != "w:hashValue");
bad.push(("w:hashValue".into(), "not base64!".into()));
assert_eq!(super::check_protection_password(&bad, "s3cret"), Unsupported);
let mut odd = attrs.clone();
odd.retain(|(k, _)| k != "w:algorithmName");
odd.push(("w:algorithmName".into(), "WHIRLPOOL".into()));
assert_eq!(super::check_protection_password(&odd, "s3cret"), Unsupported);
assert_eq!(super::check_protection_password(&[("w:cryptProviderType".into(), "rsaAES".into())], "x"), NoPassword);
}
/// The worked example of ECMA-376 Part 4 §14.8.1: "Example" gives the key 0x64CEED7E, hashed
/// as "7EEDCE64".
#[test]
fn legacy_key_matches_the_specification_example() {
assert_eq!(super::legacy_key("Example"), 0x64CE_ED7E);
assert_eq!(super::legacy_key_hex("Example").as_str(), "7EEDCE64");
assert_eq!(super::legacy_key(""), 0);
// A leading BOM is dropped; only the first 15 characters count.
assert_eq!(super::legacy_key("\u{FEFF}Example"), 0x64CE_ED7E);
assert_eq!(super::legacy_key("abcdefghijklmnopq"), super::legacy_key("abcdefghijklmno"));
assert_ne!(super::legacy_key("abcdefghijklmno"), super::legacy_key("abcdefghijklmn"));
}
/// The legacy form (`w:cryptAlgorithmSid`, `w:hash`, `w:salt`, `w:cryptSpinCount`), as Word
/// writes it. The expected hash was computed independently from the specification's stages.
#[test]
fn legacy_protection_hash_is_verified() {
use super::PasswordCheck::*;
let legacy = |sid: &str, hash: &str, spin: &str| -> Vec<(String, String)> {
vec![
("w:cryptProviderType".into(), "rsaFull".into()),
("w:cryptAlgorithmClass".into(), "hash".into()),
("w:cryptAlgorithmType".into(), "typeAny".into()),
("w:cryptAlgorithmSid".into(), sid.into()),
("w:cryptSpinCount".into(), spin.into()),
("w:hash".into(), hash.into()),
("w:salt".into(), "AAECAwQFBgcICQoLDA0ODw==".into()),
]
};
let attrs = legacy("4", KNOWN_LEGACY_SHA1, "100000");
assert_eq!(super::check_protection_password(&attrs, "Example"), Correct);
assert_eq!(super::check_protection_password(&attrs, "example"), Incorrect);
assert_eq!(super::check_protection_password(&attrs, ""), Incorrect);
// MD5 (and the other algorithms WordCraft lacks), hostile spin counts: refused.
assert_eq!(super::check_protection_password(&legacy("3", KNOWN_LEGACY_SHA1, "100000"), "Example"), Unsupported);
assert_eq!(super::check_protection_password(&legacy("4", KNOWN_LEGACY_SHA1, "4294967295"), "Example"), Unsupported);
assert_eq!(super::check_protection_password(&legacy("4", KNOWN_LEGACY_SHA1, "-1"), "Example"), Unsupported);
let mut ext = attrs.clone();
ext.push(("w:algIdExt".into(), "0000000A".into()));
assert_eq!(super::check_protection_password(&ext, "Example"), Unsupported);
// No algorithm named: the very old bare-hash form isn't defined, so it's refused.
let bare = vec![("w:hash".to_string(), KNOWN_LEGACY_SHA1.to_string())];
assert_eq!(super::check_protection_password(&bare, "Example"), Unsupported);
}
/// SHA-1 of the salt 00 01 … 0F and "7EEDCE64" as UTF-16LE, iterated 100,000 times.
const KNOWN_LEGACY_SHA1: &str = "pEWelKoH6SVpvMmyPAJ8bHA/g/s=";
use super::*;
fn zip_package() -> Vec<u8> {
let mut doc = wordcraft_doc::Document::from_text("Top secret plans\nSecond paragraph");
doc.core.title = "Secret".into();
crate::write(&doc).unwrap()
}
#[test]
fn round_trip_with_a_password() {
let pkg = zip_package();
let enc = encrypt_with_spin_count(&pkg, "pässwörd 🔑", 16).unwrap();
assert!(is_encrypted(&enc));
assert!(!is_encrypted(&pkg));
// The compound file carries the data spaces Word expects.
let comp = cfb::CompoundFile::open(Cursor::new(&enc)).unwrap();
assert!(comp.is_stream("/\u{6}DataSpaces/TransformInfo/StrongEncryptionTransform/\u{6}Primary"));
assert_eq!(decrypt(&enc, Some("pässwörd 🔑")).unwrap(), pkg);
let doc = crate::read_with_password(&enc, Some("pässwörd 🔑")).unwrap();
assert!(doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Top secret plans"));
assert_eq!(doc.core.title, "Secret");
// Without a password, or with the wrong one, it says so instead of opening blank.
assert_eq!(crate::read(&enc), Err(DocxError::PasswordRequired));
assert_eq!(crate::read_with_password(&enc, None), Err(DocxError::PasswordRequired));
assert_eq!(decrypt(&enc, Some("Pässwörd 🔑")), Err(DocxError::WrongPassword));
// A package that is a whole number of segments, and an empty one.
for n in [0usize, SEGMENT, SEGMENT * 2 + 1] {
let data: Vec<u8> = (0..n).map(|i| (i * 7) as u8).collect();
let enc = encrypt_with_spin_count(&data, "x", 1).unwrap();
assert_eq!(decrypt(&enc, Some("x")).unwrap(), data);
}
assert!(encrypt(&pkg, "").is_err());
assert!(encrypt(&pkg, &"x".repeat(MAX_PASSWORD_CHARS + 1)).is_err());
}
#[test]
fn a_file_without_data_integrity_is_refused() {
let enc = encrypt_with_spin_count(&zip_package(), "pw", 4).unwrap();
let mut comp = cfb::CompoundFile::open(Cursor::new(enc)).unwrap();
let mut info = Vec::new();
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
let xml = String::from_utf8(info[8..].to_vec()).unwrap();
let mut stripped = info[..8].to_vec();
stripped.extend_from_slice(xml.replacen("<dataIntegrity", "<nothing", 1).as_bytes());
comp.create_stream("/EncryptionInfo").unwrap().write_all(&stripped).unwrap();
comp.flush().unwrap();
let enc = comp.into_inner().into_inner();
assert!(matches!(decrypt(&enc, Some("pw")), Err(DocxError::Encryption(m)) if m.contains("dataIntegrity")));
assert!(crate::read_with_password(&enc, Some("pw")).is_err());
}
#[test]
fn tampered_package_fails_the_integrity_check() {
let pkg = zip_package();
let enc = encrypt_with_spin_count(&pkg, "pw", 4).unwrap();
let mut comp = cfb::CompoundFile::open(Cursor::new(enc)).unwrap();
let mut data = Vec::new();
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut data).unwrap();
let last = data.len() - 1;
data[last] ^= 1;
comp.create_stream("/EncryptedPackage").unwrap().write_all(&data).unwrap();
comp.flush().unwrap();
let enc = comp.into_inner().into_inner();
assert!(matches!(decrypt(&enc, Some("pw")), Err(DocxError::Encryption(m)) if m.contains("integrity")));
}
/// A compound file with the given EncryptionInfo and EncryptedPackage streams.
fn cfb_with(info: &[u8], package: &[u8]) -> Vec<u8> {
let mut comp = cfb::CompoundFile::create(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()
}
#[test]
fn hostile_encryption_info_errors_without_panicking() {
let good = encrypt_with_spin_count(&zip_package(), "pw", 2).unwrap();
let mut comp = cfb::CompoundFile::open(Cursor::new(&good)).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();
let xml = String::from_utf8(info[8..].to_vec()).unwrap();
// (EncryptionInfo, EncryptedPackage, must fail). Truncations only must not panic: the
// XML reader is lenient, so cutting the closing tags still reads.
let mut cases: Vec<(Vec<u8>, Vec<u8>, bool)> = Vec::new();
for n in (0..info.len()).step_by(7) {
cases.push((info[..n].to_vec(), package.clone(), n < info.len() / 2));
}
for n in [0, 4, 8, 9, 100, package.len() - 1] {
cases.push((info.clone(), package[..n].to_vec(), true));
}
// Standard and unknown versions, absurd numbers, bad base64, a huge StreamSize.
let with = |from: &str, to: &str| {
let mut v = info[..8].to_vec();
v.extend_from_slice(xml.replacen(from, to, 2).as_bytes());
v
};
cases.push(([3u8, 0, 2, 0, 0x24, 0, 0, 0].to_vec(), package.clone(), true));
cases.push(([9u8, 0, 9, 0].to_vec(), package.clone(), true));
cases.push((with("spinCount=\"2\"", "spinCount=\"4294967295\""), package.clone(), true));
cases.push((with("keyBits=\"256\"", "keyBits=\"8\""), package.clone(), true));
cases.push((with("blockSize=\"16\"", "blockSize=\"4096\""), package.clone(), true));
cases.push((with("hashSize=\"64\"", "hashSize=\"0\""), package.clone(), true));
cases.push((with("saltSize=\"16\"", "saltSize=\"99999999999999999999\""), package.clone(), true));
cases.push((with("SHA512", "MD5"), package.clone(), true));
cases.push((with("ChainingModeCBC", "ChainingModeCFB"), package.clone(), true));
cases.push((with("saltValue=\"", "saltValue=\"!!"), package.clone(), true));
cases.push((with("encryptedKeyValue=\"", "encryptedKeyValue=\"AAAA"), package.clone(), true));
// Data integrity is mandatory: a file without it is refused, never opened unchecked.
cases.push((with("<dataIntegrity", "<nothing"), package.clone(), true));
let mut huge = package.clone();
huge[..8].copy_from_slice(&u64::MAX.to_le_bytes());
cases.push((with("<dataIntegrity", "<nothing"), huge, true));
for (i, p, must_fail) in cases {
let file = cfb_with(&i, &p);
let r = decrypt(&file, Some("pw"));
assert!(!must_fail || r.is_err());
let _ = crate::read_with_password(&file, Some("pw"));
assert!(crate::read(&file).is_err());
}
// A file that only looks like a compound file.
let mut fake = CFB_MAGIC.to_vec();
fake.extend_from_slice(&[0u8; 600]);
assert!(!is_encrypted(&fake));
assert!(decrypt(&fake, Some("pw")).is_err());
}
/// The key a real-world Standard-encrypted package derives: Apache POI's test file
/// `protected_passtika.xlsx` (Apache-2.0 test data), password "tika", written by Excel
/// itself. The value was computed from [MS-OFFCRYPTO] §2.3.4.7 independently of this code —
/// the file decrypts to a valid package under it and nothing else opens that file — so real
/// Word 2007 documents stay pinned, not just spec-derived ones.
#[test]
fn standard_key_matches_a_real_world_package() {
let salt: [u8; 16] = [0x19, 0xd0, 0xc7, 0xf3, 0x95, 0x28, 0x78, 0x43, 0x6a, 0x49, 0x49, 0xd7, 0xf0, 0xa2, 0xcf, 0x1e];
const KEY: &str = "8d4d00143a5f2e4918af7e6e7410d46c";
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
assert_eq!(hex(&standard_key(&salt, "tika", 16)), KEY);
// A different password or salt gives a different key; the key is exactly the length
// asked for, for each AES width.
assert_ne!(hex(&standard_key(&salt, "tika2", 16)), KEY);
assert_ne!(hex(&standard_key(&[0; 16], "tika", 16)), KEY);
assert_eq!(standard_key(&salt, "tika", 24).len(), 24);
assert_eq!(standard_key(&salt, "tika", 32).len(), 32);
}
/// 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);
}
+8 -3
View File
@@ -25,11 +25,11 @@ pub fn read(root: &El) -> Vec<CustomProp> {
let Some(v) = p.els().next() else { continue };
let kind = v.local();
let prop = if SIMPLE.contains(&kind) {
CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text() }
CustomProp { name: name.to_string(), kind: kind.to_string(), value: v.text(), link_target: p.attr("linkTarget").map(str::to_string) }
} else {
let mut xml = String::new();
el_xml(v, &mut xml, 0);
CustomProp { name: name.to_string(), kind: "raw".into(), value: xml }
CustomProp { name: name.to_string(), kind: "raw".into(), value: xml, link_target: p.attr("linkTarget").map(str::to_string) }
};
out.push(prop);
}
@@ -51,7 +51,12 @@ pub fn write(props: &[CustomProp]) -> Vec<u8> {
if p.kind == "raw" && !raw_is_wellformed(&p.value) {
continue;
}
w.open("property", &[("fmtid", FMTID), ("pid", &pid.to_string()), ("name", &p.name)]);
let pid = pid.to_string();
let mut attrs = vec![("fmtid", FMTID), ("pid", &pid), ("name", &p.name)];
if let Some(target) = &p.link_target {
attrs.push(("linkTarget", target));
}
w.open("property", &attrs);
if p.kind == "raw" {
w.raw(&p.value);
} else {
+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);
+88 -4
View File
@@ -4,13 +4,20 @@
//! 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};
use crate::package::{EMBEDDED_PARTS, EmbeddedManifest};
/// 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,14 +35,27 @@ 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> {
let Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else {
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 manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default();
// The table is kept under the spelling the package stores it as (#3872).
let Some(key) = crate::package::font_table_key(&doc.passthrough) else {
return Vec::new();
};
let manifest = doc.passthrough.get(EMBEDDED_PARTS).map(|m| EmbeddedManifest::parse(m)).unwrap_or_default();
let Some(part) = manifest.parts.get(FONT_TABLE_PART) else {
let Some(table) = doc.passthrough.get(key).and_then(|b| crate::xml::parse(b).ok()) else {
return Vec::new();
};
let Some(part) = manifest.parts.get(key) else {
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 +81,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 +116,54 @@ fn font_key(guid: &str) -> Option<[u8; 16]> {
}
Some(key)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::package::FONT_TABLE_PART;
/// 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};
+137 -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;
@@ -95,6 +96,15 @@ pub const MAX_VBA_RELATED: usize = 64;
pub const EMBEDDED_PARTS: &str = "wordcraft:embedded.parts";
/// The font table kept from a file (read by `Reader::keep_part_tree`), with the fonts it embeds.
pub const FONT_TABLE_PART: &str = "word/fontTable.xml";
/// The spelling the font table is stored under in `parts` (part names are case-insensitive, so a
/// package may spell it `WORD/FONTTABLE.XML`), preferring the exact [`FONT_TABLE_PART`].
pub fn font_table_key<V>(parts: &BTreeMap<String, V>) -> Option<&str> {
if let Some((k, _)) = parts.get_key_value(FONT_TABLE_PART) {
return Some(k.as_str());
}
parts.keys().find(|k| k.eq_ignore_ascii_case(FONT_TABLE_PART)).map(String::as_str)
}
/// Custom XML roots kept by the package (P) or main document (D): `owner\ttype\tpart`.
/// Their opaque bytes and related parts use the same bounded manifest as embedded objects.
pub const CUSTOM_XML_ROOTS: &str = "wordcraft:customXml.roots";
@@ -191,6 +201,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 +236,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 +300,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 +334,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 +416,25 @@ 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 lower = name.to_ascii_lowercase();
let Some(at) = lower.rfind("_rels/").filter(|_| lower.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 +491,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 +533,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())]);
+444
View File
@@ -0,0 +1,444 @@
//! DOCX → [`Document`].
pub(crate) mod chart;
pub(crate) mod diagram;
mod drawing_color;
mod embed;
mod freeform;
mod glossary;
pub(crate) mod math;
mod props;
mod sdt;
mod shape_style;
mod story;
mod wordart;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
use wordcraft_doc::control::{ControlEvent, ControlKind, control_events, pair_events};
use wordcraft_doc::graphic::{Graphic, GraphicKind};
use wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, Num};
use wordcraft_doc::para::NoteKind;
use wordcraft_doc::para::OBJ;
use wordcraft_doc::props::Rgb;
use wordcraft_doc::props::VAlign;
use wordcraft_doc::section::NumFormat;
use wordcraft_doc::styles::{PartFormat, Style, StyleKind, StyleSheet, TablePart, TableStyleParts};
use wordcraft_doc::{
Block, Blocks, Comment, Document, InlineObject, PagePicture, Paragraph, PartKind, Revision, RevisionKind, Zoom, ZoomFit, para_block,
};
use crate::DocxError;
use crate::bibliography::{MAX_SOURCES, Seen};
use crate::package::{ContentTypes, EMBEDDED_PARTS, EmbeddedManifest, MAX_VBA_RELATED, Package, Rels, VBA_PROJECT_PART, VBA_RELATED, rel_is, rt};
use crate::units::{flag, int, on_off, tw, u32_of};
use crate::xml::El;
pub(crate) use props::PropCtx;
use story::StoryCtx;
mod meta;
mod parts;
mod styles;
mod toc;
pub(crate) use meta::PROTECTION_HASH_ATTRS;
pub(crate) use styles::LOWER_NAMES;
use toc::{is_toc, toc_control_to_toc, toc_field_to_heading, unstyle_toc_links};
/// Most fonts read from a font table, and the longest name (hostile files).
const MAX_TABLE_FONTS: usize = 2000;
const MAX_FONT_NAME: usize = 256;
/// Most parts (headers, notes, comments, text boxes) we create from one file.
const MAX_PARTS: usize = 50_000;
/// Deepest chain of headers/footers whose own sections reference further headers/footers.
const MAX_HF_DEPTH: usize = 4;
/// Most graphic work (items plus path segments, see `story::graphic_work`) built for one file's
/// charts and diagrams; later ones are left empty.
const MAX_GRAPHIC_WORK: usize = 2_000_000;
/// Custom XML parts looked at for bibliography sources.
const MAX_BIBLIOGRAPHY_PARTS: usize = 64;
/// Longest attached-template path we keep (bytes); a longer one is not a real path.
const MAX_TEMPLATE_PATH: usize = 32 * 1024;
pub(crate) struct Reader<'p> {
pkg: &'p Package,
pub doc: Document,
pub pc: PropCtx,
revs: HashMap<(u8, String, String, Option<String>), u32>,
/// Move ranges (`w:moveFromRangeStart` …) seen so far.
pub moves: MoveCtx,
media_by_path: HashMap<String, String>,
hf_by_path: HashMap<String, u32>,
/// Header/footer parts being read right now (see [`MAX_HF_DEPTH`]).
hf_depth: usize,
pub footnotes: HashMap<i64, u32>,
pub endnotes: HashMap<i64, u32>,
/// The note being read (kind, part id): its `w:footnoteRef` / `w:endnoteRef` mark is a
/// reference to itself.
current_note: Option<(NoteKind, u32)>,
/// Reading a footnote or endnote separator or continuation notice (where the `w:separator`
/// and `w:continuationSeparator` run elements belong).
in_note_sep: bool,
pub comment_map: HashMap<String, u32>,
pub comments_ended: HashSet<u32>,
pub bookmarks: HashMap<String, String>,
/// Restrict Editing exception ranges (`w:permStart`): the file's id → ours.
pub perms: HashMap<String, u32>,
/// Chart and diagram parts by path, parsed once.
graphic_parts: HashMap<String, Option<Arc<El>>>,
/// Charts and diagrams by (kind, part path, width bits, height bits), built once.
graphics: HashMap<(GraphicKind, String, u32, u32), Arc<Graphic>>,
/// Graphic work left before charts and diagrams are left empty (see [`MAX_GRAPHIC_WORK`]).
graphic_budget: usize,
/// Drawing ids connectors are glued to (`a:stCxn`, `a:endCxn`), and whether any shape's id
/// was kept while reading (those not glued to are dropped at the end).
pub glued: HashSet<u32>,
pub shape_ids: bool,
/// The main document part's name.
main: String,
/// The package's content types, read when first needed.
content_types: Option<ContentTypes>,
/// Parts kept for charts, diagrams and OLE objects (see `embed`).
embedded: EmbeddedManifest,
/// Linked text boxes as read: (`wps:txbx/@id` or `wps:linkedTxbx/@id`, `seq` (0 for the box
/// holding the text), text box part). See [`link_text_boxes`].
pub txbx_links: Vec<(u32, u32, u32)>,
/// The theme's fill styles, which shapes' style references point to.
pub theme_fills: shape_style::ThemeFills,
/// The fill of the group being read (what its members' `a:grpFill` stands for).
pub group_fill: Option<wordcraft_doc::fill::Fill>,
}
/// The package path that internal relationship `id` points at, if it has type `kind` (an `rt`
/// constant): a crafted id can't make a chart of some other, possibly huge, part.
fn part_of(rels: &Rels, id: &str, kind: &str) -> Option<String> {
rels.by_id(id).filter(|r| !r.external && rel_is(&r.kind, kind)).map(|r| r.target.clone())
}
/// Read a `.docx` package.
pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
// A password-protected document is a compound file, not a zip: say so rather than failing
// as a broken zip (`read_with_password` opens it).
if crate::is_encrypted(bytes) {
return crate::read_with_password(bytes, None);
}
read_package(bytes)
}
/// [`read`] for bytes known not to be an encrypted package.
pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
read_opened(&Package::open(bytes)?)
}
/// Read a package whose parts are already unpacked (from a zip, a Flat OPC file or a converted
/// Word 2003 XML document).
pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
let root_rels = pkg.rels("");
let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string());
let Some(root) = pkg.xml_strict(&main)? else {
return Err(DocxError::MissingPart(main));
};
if root.name != "w:document" {
return Err(DocxError::NotWord(format!("root element is {}", root.name)));
}
let rels = pkg.rels(&main);
let mut doc = Document::new();
doc.body.clear();
let mut r = Reader {
pkg,
doc,
pc: PropCtx::default(),
revs: HashMap::new(),
moves: MoveCtx::default(),
media_by_path: HashMap::new(),
hf_by_path: HashMap::new(),
hf_depth: 0,
footnotes: HashMap::new(),
endnotes: HashMap::new(),
current_note: None,
in_note_sep: false,
comment_map: HashMap::new(),
comments_ended: HashSet::new(),
bookmarks: HashMap::new(),
perms: HashMap::new(),
graphic_parts: HashMap::new(),
graphics: HashMap::new(),
graphic_budget: MAX_GRAPHIC_WORK,
glued: HashSet::new(),
shape_ids: false,
main: main.clone(),
content_types: None,
embedded: EmbeddedManifest::default(),
txbx_links: Vec::new(),
theme_fills: Default::default(),
group_fill: None,
};
r.pc.major_font = r.doc.settings.major_font.clone();
r.pc.minor_font = r.doc.settings.minor_font.clone();
let part = |t: &str| rels.by_type(t).filter(|x| !x.external).map(|x| x.target.clone());
// The font table and the fonts embedded through it ride along as opaque parts.
if let Some(p) = part(rt::FONT_TABLE) {
r.keep_part_tree(&p);
r.read_font_families(&p);
}
if let Some(p) = part(rt::THEME) {
r.read_theme(&p);
}
// Secondary parts are optional: a broken one is skipped (logged), not fatal.
let lenient = |what: &str, res: Result<(), DocxError>| {
if let Err(e) = res {
log::warn!("docx: ignoring unreadable {what}: {e}");
}
};
// Styles come before settings, but their theme colours already follow its colour mapping, and
// their `…Bidi` theme fonts its theme font language.
if let Some(p) = part(rt::SETTINGS) {
r.read_color_mapping(&p);
}
if let Some(p) = part(rt::STYLES) {
lenient("styles", r.read_styles(&p));
}
if let Some(p) = part(rt::NUMBERING) {
lenient("numbering", r.read_numbering(&p));
}
// Without a stated mode (or settings at all) Word lays a document out as Word 2007 did.
r.doc.settings.compat_mode = wordcraft_doc::LEGACY_COMPAT_MODE;
if let Some(p) = part(rt::SETTINGS) {
lenient("settings", r.read_settings(&p));
}
// Comments before notes: a comment range inside a footnote or endnote resolves its id against
// the comments already read, and was dropped when the notes came first.
if let Some(p) = part(rt::COMMENTS) {
let ex = part(rt::COMMENTS_EX);
let ids = part(rt::COMMENTS_IDS);
let cex = part(rt::COMMENTS_EXTENSIBLE);
lenient("comments", r.read_comments(&p, ex.as_deref(), ids.as_deref(), cex.as_deref()));
}
if let Some(p) = part(rt::FOOTNOTES) {
lenient("footnotes", r.read_notes(&p, true));
}
if let Some(p) = part(rt::ENDNOTES) {
lenient("endnotes", r.read_notes(&p, false));
}
// Bibliography sources live in a custom XML data store (`b:Sources`), related from the
// package root or the main part; Word's own files sometimes leave the relationship out, so
// the conventional `customXml/itemN.xml` names are tried too. Each part is read at most once.
let mut bib_parts: Vec<String> = Vec::new();
for rel in root_rels.list.iter().chain(rels.list.iter()) {
if rel_is(&rel.kind, rt::CUSTOM_XML) && !rel.external && !bib_parts.contains(&rel.target) {
bib_parts.push(rel.target.clone());
if bib_parts.len() >= MAX_BIBLIOGRAPHY_PARTS {
break;
}
}
}
for i in 1..=16 {
let path = format!("customXml/item{i}.xml");
if bib_parts.len() < MAX_BIBLIOGRAPHY_PARTS && pkg.get(&path).is_some() && !bib_parts.contains(&path) {
bib_parts.push(path);
}
}
let mut seen_sources = Seen::default();
for path in &bib_parts {
if r.doc.sources.len() >= MAX_SOURCES {
break;
}
lenient("customXml bibliography", r.read_bibliography(path, &mut seen_sources));
}
if let Some(bg) = root.child("w:background").and_then(|b| b.attr("w:color")).and_then(Rgb::parse) {
r.doc.settings.page_color = Some(bg);
}
// A picture or texture fill: `w:background/v:background/v:fill` with a relationship id.
if let Some(fill) = root.child("w:background").and_then(|b| b.child("v:background")).and_then(|b| b.child("v:fill")) {
let rid = fill.attr("r:id").or_else(|| fill.attr("r:embed")).or_else(|| fill.attr("o:relid"));
if let Some(media) = rid.and_then(|id| r.media_for(&rels, id)) {
r.doc.settings.page_picture = Some(PagePicture {
media,
fill_type: fill.attr("type").unwrap_or("").chars().take(32).collect(),
title: fill.attr("o:title").unwrap_or("").chars().take(255).collect(),
});
}
}
if let Some(body) = root.child("w:body") {
let mut sc = StoryCtx::default();
let mut blocks = Blocks::new();
r.read_blocks(&mut sc, body, &rels, &mut blocks, 0);
r.flush_pending(&mut sc, &mut blocks);
r.doc.body = blocks;
unstyle_toc_links(&mut r.doc);
toc_control_to_toc(&mut r.doc.body);
toc_field_to_heading(&mut r.doc.body);
if let Some(s) = body.child("w:sectPr") {
r.doc.last_section = r.read_section(s, &rels);
}
}
// A watermark in the box Word's Design › Watermark gives it for the page keeps no box of its
// own: it follows the page, as when it was made.
let (pw, ph) = (r.doc.last_section.page_w, r.doc.last_section.page_h);
if let Some(wm) = r.doc.settings.watermark.as_mut()
&& let Some([w, h]) = wm.size
{
let [dw, dh] = wordcraft_doc::Watermark { size: None, ..wm.clone() }.frame(pw, ph);
if (w - dw).abs() < 0.06 && (h - dh).abs() < 0.06 {
wm.size = None;
}
}
let core = root_rels.by_type(rt::CORE).map(|x| x.target.clone()).unwrap_or_else(|| "docProps/core.xml".into());
r.read_core(&core);
if let Some(app) = root_rels.by_type(rt::APP).map(|x| x.target.clone()) {
r.read_app(&app);
}
if let Some(c) = root_rels.by_type(rt::CUSTOM)
&& let Ok(Some(x)) = r.xml(&c.target)
{
r.doc.custom_props = crate::custom::read(&x);
}
// A macro project and every part it relates to (VBA data, signatures…) ride along as opaque
// bytes, never parsed or run, so a .docm/.dotm saved again carries exactly what the file had.
// Only the relationships and content types are read.
if let Some(v) = part(rt::VBA_PROJECT)
&& let Some(b) = pkg.get_shared(&v).filter(|b| !b.is_empty())
{
r.doc.passthrough.insert(VBA_PROJECT_PART.into(), b.clone());
r.read_vba_related(&v);
}
r.read_glossary(&rels);
r.keep_custom_xml(&root_rels, &rels);
r.keep_unmodelled(&root, &root_rels, &rels);
if !r.embedded.parts.is_empty() {
let manifest = r.embedded.to_bytes();
r.doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest));
}
r.finish_moves();
let links = std::mem::take(&mut r.txbx_links);
link_text_boxes(&mut r.doc, links);
if r.shape_ids {
keep_glued_ids(&mut r.doc, &r.glued);
}
let mut doc = r.doc;
doc.ensure_nonempty();
Ok(doc)
}
/// Most move ranges open at once, and most move names tracked, per file.
const MAX_OPEN_MOVES: usize = 64;
const MAX_MOVES: usize = 100_000;
/// The move ranges of a file (ECMA-376 §17.13.5.22–27): a `w:moveFrom` / `w:moveTo` is one end
/// of a move when it sits inside a range of that kind; the range's name links both ends.
#[derive(Default)]
pub(crate) struct MoveCtx {
/// Open source and destination ranges: (`w:id`, `w:name`).
open: [Vec<(String, String)>; 2],
/// Per name: a source range started / ended, a destination range started / ended (bits 0–3).
seen: HashMap<String, u8>,
/// The range of each kind that ended last in the paragraph being read (for its mark).
pub closed_here: [Option<String>; 2],
}
impl Reader<'_> {
/// A `w:moveFromRangeStart/End` or `w:moveToRangeStart/End`.
pub fn move_range(&mut self, e: &El) {
let (side, start) = match e.name.as_str() {
"w:moveFromRangeStart" => (0, true),
"w:moveFromRangeEnd" => (0, false),
"w:moveToRangeStart" => (1, true),
"w:moveToRangeEnd" => (1, false),
_ => return,
};
let id = e.attr("w:id").unwrap_or("").chars().take(64).collect::<String>();
let m = &mut self.moves;
let Some(open) = m.open.get_mut(side) else { return };
if start {
let name = e.attr("w:name").unwrap_or("").chars().take(256).collect::<String>();
if name.is_empty() || open.len() >= MAX_OPEN_MOVES || (m.seen.len() >= MAX_MOVES && !m.seen.contains_key(&name)) {
return;
}
*m.seen.entry(name.clone()).or_default() |= 1 << (side * 2);
open.push((id, name));
} else if let Some(i) = open.iter().rposition(|(x, _)| *x == id) {
let (_, name) = open.remove(i);
*m.seen.entry(name.clone()).or_default() |= 2 << (side * 2);
if let Some(c) = m.closed_here.get_mut(side) {
*c = Some(name);
}
}
}
/// The revision of a `w:moveFrom` (`to` false) or `w:moveTo`: one end of the innermost open
/// range of its kind, else (no range) a plain deletion or insertion.
pub fn move_revision(&mut self, to: bool, e: &El, mark: bool) -> u32 {
let side = usize::from(to);
let open = self.moves.open.get(side).and_then(|o| o.last()).map(|(_, n)| n.clone());
// A paragraph mark comes after its paragraph's text: a range that ended in it counts.
let name = if mark { open.or_else(|| self.moves.closed_here.get(side).cloned().flatten()) } else { open };
let kind = if to { RevisionKind::Insert } else { RevisionKind::Delete };
self.revision_named(kind, e, name)
}
/// Moves missing an end (a source or destination range, or its text) are plain deletions
/// and insertions.
fn finish_moves(&mut self) {
let mut ends: HashMap<&str, u8> = HashMap::new();
for r in &self.doc.revisions {
if let Some(n) = r.move_name.as_deref() {
*ends.entry(n).or_default() |= if r.kind == RevisionKind::Delete { 1 } else { 2 };
}
}
let ok: HashSet<String> =
ends.into_iter().filter(|(n, e)| *e == 3 && self.moves.seen.get(*n) == Some(&0b1111)).map(|(n, _)| n.to_string()).collect();
for r in &mut self.doc.revisions {
if r.move_name.as_ref().is_some_and(|n| !ok.contains(n)) {
r.move_name = None;
}
}
}
}
/// Link the text boxes read as (id, seq, part) into chains (see [`Reader::txbx_links`]): each
/// id's boxes in `seq` order after the box holding the text. A repeated seq, a chain without its
/// first box or more than [`wordcraft_doc::MAX_LINKED_BOXES`] boxes: the extra boxes stay apart.
fn link_text_boxes(doc: &mut Document, mut links: Vec<(u32, u32, u32)>) {
links.sort_by_key(|l| (l.0, l.1));
links.dedup_by_key(|l| (l.0, l.1));
for group in links.chunk_by(|a, b| a.0 == b.0) {
if group.first().is_none_or(|l| l.1 != 0) {
continue;
}
let chain: Vec<u32> = group.iter().take(wordcraft_doc::MAX_LINKED_BOXES).map(|l| l.2).collect();
for pair in chain.windows(2) {
if let [a, b] = pair
&& let Some(p) = doc.parts.get_mut(a)
{
p.body.next = Some(*b);
}
}
}
}
/// Drop the drawing ids read with shapes, except those connectors are glued to (so a shape
/// without connectors reads back as it was written).
fn keep_glued_ids(doc: &mut Document, glued: &HashSet<u32>) {
let has_id = |o: &InlineObject| match o {
InlineObject::Shape { extra, .. } => extra.id != 0,
InlineObject::Group { children, .. } => children.iter().any(|c| matches!(&c.obj, InlineObject::Shape { extra, .. } if extra.id != 0)),
_ => false,
};
let clear = |o: &mut InlineObject| {
if let InlineObject::Shape { extra, .. } = o
&& !glued.contains(&extra.id)
{
extra.id = 0;
}
};
doc.objects_mut(&has_id, &mut |o| match o {
InlineObject::Group { children, .. } => children.iter_mut().for_each(|c| clear(&mut c.obj)),
o => clear(o),
});
}
File diff suppressed because it is too large Load Diff
+210
View File
@@ -0,0 +1,210 @@
use super::model::Pos;
use super::{CHAR_W, GAP, GraphicItem, LEGEND_SHARE, LINE, MAX_LEGEND_ROWS, PAD, PathSeg, Rgb, ShapeKind, TEXT, TextAlign};
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Rect {
pub(super) x: f32,
pub(super) y: f32,
pub(super) w: f32,
pub(super) h: f32,
}
impl Rect {
pub(super) fn right(self) -> f32 {
self.x + self.w
}
pub(super) fn bottom(self) -> f32 {
self.y + self.h
}
pub(super) fn arr(self) -> [f32; 4] {
[self.x, self.y, self.w, self.h]
}
}
/// Estimated text width, points: text is placed before it's shaped, at [`CHAR_W`] a character.
pub(in super::super) fn text_w(s: &str, size: f32) -> f32 {
s.chars().count() as f32 * size * CHAR_W
}
/// `s`, cut short with an ellipsis to fit `width` points by the [`text_w`] estimate.
pub(super) fn clip_text(s: &str, width: f32, size: f32) -> String {
let fit = (width / (size * CHAR_W)).floor().max(0.0) as usize;
if s.chars().count() <= fit {
return s.to_string();
}
let mut out: String = s.chars().take(fit.saturating_sub(1)).collect();
out.push('…');
out
}
pub(super) fn widest(items: &[String], size: f32) -> f32 {
items.iter().map(|s| text_w(s, size)).fold(0.0, f32::max)
}
pub(super) fn text(out: &mut Vec<GraphicItem>, rect: Rect, s: &str, size: f32, align: TextAlign, bold: bool) {
out.push(GraphicItem::Text { rect: rect.arr(), text: s.to_string(), size, color: TEXT, bold, align, font: None });
}
/// Text centred on `cx` with its top at `y`.
pub(super) fn text_at(out: &mut Vec<GraphicItem>, cx: f32, y: f32, s: &str, size: f32) {
let w = text_w(s, size) + 4.0;
text(out, Rect { x: cx - w / 2.0, y, w, h: size * LINE }, s, size, TextAlign::Center, false);
}
pub(super) fn fill(out: &mut Vec<GraphicItem>, segs: Vec<PathSeg>, color: Rgb) {
if !segs.is_empty() {
out.push(GraphicItem::Path { segs, fill: Some(color), stroke: None, stroke_width: 0.0 });
}
}
pub(super) fn stroke(out: &mut Vec<GraphicItem>, segs: Vec<PathSeg>, color: Rgb, width: f32) {
if !segs.is_empty() {
out.push(GraphicItem::Path { segs, fill: None, stroke: Some(color), stroke_width: width });
}
}
fn swatch(out: &mut Vec<GraphicItem>, x: f32, y: f32, color: Rgb) {
out.push(GraphicItem::Shape { rect: [x, y, SWATCH, SWATCH], kind: ShapeKind::Rectangle, fill: Some(color), stroke: None, stroke_width: 0.0 });
}
pub(super) fn seg(x0: f32, y0: f32, x1: f32, y1: f32) -> Vec<PathSeg> {
vec![PathSeg::Move(x0, y0), PathSeg::Line(x1, y1)]
}
pub(super) fn rect_segs(x0: f32, y0: f32, x1: f32, y1: f32) -> Vec<PathSeg> {
vec![PathSeg::Move(x0, y0), PathSeg::Line(x1, y0), PathSeg::Line(x1, y1), PathSeg::Line(x0, y1), PathSeg::Close]
}
/// Polyline pieces: a gap (`None`) starts a new sub-path.
pub(super) fn runs(pts: impl Iterator<Item = Option<(f32, f32)>>) -> Vec<PathSeg> {
let mut segs = Vec::new();
let mut open = false;
for p in pts {
match p {
Some((x, y)) => {
segs.push(if open { PathSeg::Line(x, y) } else { PathSeg::Move(x, y) });
open = true;
}
None => open = false,
}
}
segs
}
/// Words of `s` in lines of at most `width` points (a word wider than that keeps its own line).
pub(super) fn wrap(s: &str, width: f32, size: f32) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
let mut cur = String::new();
for word in s.split_whitespace() {
let joined = if cur.is_empty() { word.to_string() } else { format!("{cur} {word}") };
if cur.is_empty() || text_w(&joined, size) <= width {
cur = joined;
} else {
lines.push(std::mem::take(&mut cur));
cur = word.to_string();
}
}
if !cur.is_empty() {
lines.push(cur);
}
if lines.is_empty() {
lines.push(String::new());
}
lines.truncate(3);
lines
}
pub(super) const SWATCH: f32 = 7.0;
/// Space between legend entries in a row, points.
const SEP: f32 = 10.0;
/// Legend in the given side of `area`; returns the area left for the plot.
pub(super) fn legend(out: &mut Vec<GraphicItem>, entries: &[(String, Rgb)], pos: Pos, area: Rect, fs: f32) -> Rect {
if entries.is_empty() {
return area;
}
// Entry texts are cut to a share of the chart, so a long name can't push the legend out.
let entries: Vec<(String, Rgb)> = entries.iter().map(|(s, c)| (clip_text(s, area.w * LEGEND_SHARE, fs), *c)).collect();
match pos {
Pos::Right | Pos::Left => side_legend(out, &entries, pos == Pos::Left, area, fs),
Pos::Top | Pos::Bottom => row_legend(out, &entries, pos == Pos::Top, area, fs),
}
}
/// A legend in a column at the left or right of `area`, keeping only the entries that fit above
/// its bottom; returns the area left for the plot.
fn side_legend(out: &mut Vec<GraphicItem>, entries: &[(String, Rgb)], left: bool, area: Rect, fs: f32) -> Rect {
let lh = fs * LINE;
let entries = entries.get(..((area.h / lh).floor().max(0.0) as usize).min(entries.len())).unwrap_or(&[]);
if entries.is_empty() {
return area;
}
let tw = entries.iter().map(|(s, _)| text_w(s, fs)).fold(0.0, f32::max);
let block_w = SWATCH + GAP + tw;
let block_h = lh * entries.len() as f32;
let x = if left { area.x } else { area.right() - block_w };
let y0 = area.y + ((area.h - block_h) / 2.0).max(0.0);
for (k, (s, c)) in entries.iter().enumerate() {
let y = y0 + lh * k as f32;
swatch(out, x, y + (lh - SWATCH) / 2.0, *c);
text(out, Rect { x: x + SWATCH + GAP, y, w: tw + 2.0, h: lh }, s, fs, TextAlign::Left, false);
}
let used = block_w + PAD;
let mut area = area;
area.w = (area.w - used).max(1.0);
if left {
area.x += used;
}
area
}
/// A legend in centred rows the width of `area` above or below it (at most
/// [`MAX_LEGEND_ROWS`], and half its height); returns the area left for the plot.
fn row_legend(out: &mut Vec<GraphicItem>, entries: &[(String, Rgb)], top: bool, area: Rect, fs: f32) -> Rect {
let lh = fs * LINE;
let max_rows = MAX_LEGEND_ROWS.min((area.h / 2.0 / lh).floor().max(1.0) as usize);
let rows = legend_rows(entries, area.w, fs, max_rows);
let y0 = if top { area.y } else { area.bottom() - lh * rows.len() as f32 };
for (r, row) in rows.iter().enumerate() {
let total = row.iter().map(|(_, _, w)| SWATCH + GAP + w).sum::<f32>() + SEP * row.len().saturating_sub(1) as f32;
let mut x = area.x + ((area.w - total) / 2.0).max(0.0);
let y = y0 + lh * r as f32;
for (s, c, w) in row {
swatch(out, x, y + (lh - SWATCH) / 2.0, *c);
text(out, Rect { x: x + SWATCH + GAP, y, w: w + 2.0, h: lh }, s, fs, TextAlign::Left, false);
x += SWATCH + GAP + w + SEP;
}
}
let used = lh * rows.len() as f32 + PAD;
let mut area = area;
area.h = (area.h - used).max(1.0);
if top {
area.y += used;
}
area
}
/// Legend entries (text, colour, text width) packed into at most `max_rows` rows of `width`
/// points; the entries past the last row are left out.
fn legend_rows(entries: &[(String, Rgb)], width: f32, fs: f32, max_rows: usize) -> Vec<Vec<(String, Rgb, f32)>> {
let mut rows: Vec<Vec<(String, Rgb, f32)>> = Vec::new();
let mut row_w = 0.0;
for (s, c) in entries {
let w = text_w(s, fs);
let entry_w = SWATCH + GAP + w;
let fits = !rows.is_empty() && row_w + SEP + entry_w <= width;
if fits {
row_w += SEP + entry_w;
} else if rows.len() < max_rows {
rows.push(Vec::new());
row_w = entry_w;
} else {
break;
}
if let Some(row) = rows.last_mut() {
row.push((s.clone(), *c, w));
}
}
rows
}
+370
View File
@@ -0,0 +1,370 @@
/// A piece of a format section: literal text, a number character (`#`, `0`, `,`, `.`), or the
/// exponent of a scientific format (`E+` when true, `E-` when false).
enum Tok {
Lit(String),
Num(char),
Exp(bool),
}
/// The sections of a format code (positive; negative; zero; text), split outside quotes.
fn sections(code: &str) -> Vec<&str> {
let mut out = Vec::new();
let (mut start, mut quoted, mut escaped) = (0, false, false);
for (i, c) in code.char_indices() {
match c {
_ if escaped => escaped = false,
'\\' if !quoted => escaped = true,
'"' => quoted = !quoted,
';' if !quoted => {
out.push(code.get(start..i).unwrap_or(""));
start = i + 1;
}
_ => {}
}
}
out.push(code.get(start..).unwrap_or(""));
out
}
/// The first section of a format code.
fn first_section(code: &str) -> &str {
sections(code).first().copied().unwrap_or("")
}
/// The pieces of one format section. Quoted text, escapes and `[$€-407]` currency symbols are
/// literal; other brackets (`[Red]`) are dropped; `_x` (room for `x`) is a space; `*x` is dropped.
fn format_toks(section: &str) -> Vec<Tok> {
let mut toks = Vec::new();
let mut chars = section.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' => toks.push(Tok::Lit(chars.by_ref().take_while(|q| *q != '"').collect())),
'[' => {
let inner: String = chars.by_ref().take_while(|q| *q != ']').collect();
if let Some(sym) = currency(&inner) {
toks.push(Tok::Lit(sym.to_string()));
}
}
'\\' => toks.extend(chars.next().map(|n| Tok::Lit(n.to_string()))),
'_' => {
chars.next();
toks.push(Tok::Lit(" ".to_string()));
}
'*' => {
chars.next();
}
'E' | 'e' if matches!(chars.peek(), Some('+' | '-')) => toks.push(Tok::Exp(chars.next() == Some('+'))),
'#' | '0' | ',' | '.' => toks.push(Tok::Num(c)),
c => toks.push(Tok::Lit(c.to_string())),
}
}
toks
}
/// The symbol of a `$€-407` bracket content (`[$€-407]`), if it is one.
fn currency(inner: &str) -> Option<&str> {
inner.strip_prefix('$').map(|rest| rest.split('-').next().unwrap_or(""))
}
/// Tick text for `v` in a number format code (`#,##0`, `0.0`, `0%`, `0.00E+00`, and literal text
/// such as `"€"0.00` or `#,##0.00 [$€-407]`). A negative number takes the second section when
/// there is one, which shows it without a minus (`#,##0;(#,##0)`). `General` shows as many
/// decimals as the step needs.
pub(super) fn format_value(v: f64, code: &str, step: f64) -> String {
let v = if v.abs() < step * 1e-9 { 0.0 } else { v };
let secs = sections(code);
match secs.get(1) {
Some(neg) if v < 0.0 => format_section(-v, neg, step),
_ => format_section(v, secs.first().copied().unwrap_or(""), step),
}
}
/// `v` in one section of a format code.
fn format_section(v: f64, section: &str, step: f64) -> String {
let toks = format_toks(section);
let is_num = |t: &Tok| matches!(t, Tok::Num(_) | Tok::Exp(_));
let lit = |r: &[Tok]| -> String {
r.iter()
.filter_map(|t| match t {
Tok::Lit(l) => Some(l.as_str()),
_ => None,
})
.collect()
};
let (Some(s), Some(e)) = (toks.iter().position(is_num), toks.iter().rposition(is_num)) else { return general(v, step) };
let body = toks.get(s..=e).unwrap_or(&[]);
let (pre, suf) = (lit(toks.get(..s).unwrap_or(&[])), lit(toks.get(e + 1..).unwrap_or(&[])));
let digits = |r: &[Tok]| -> String { r.iter().filter_map(|t| if let Tok::Num(c) = t { Some(*c) } else { None }).collect() };
if let Some(k) = body.iter().position(|t| matches!(t, Tok::Exp(_))) {
let plus = matches!(body.get(k), Some(Tok::Exp(true)));
let mantissa = digits(body.get(..k).unwrap_or(&[]));
let exp_digits = digits(body.get(k + 1..).unwrap_or(&[])).len();
return format!("{pre}{}{suf}", scientific(v, decimals(&mantissa), exp_digits, plus));
}
let body = digits(body);
let pct = pre.contains('%') || suf.contains('%');
let v = if pct { v * 100.0 } else { v };
let digits = format!("{:.*}", decimals(&body), v.abs());
let (ip, fp) = match digits.split_once('.') {
Some((a, b)) => (a.to_string(), format!(".{b}")),
None => (digits.clone(), String::new()),
};
let mut grouped = String::new();
for (k, ch) in ip.chars().enumerate() {
if body.contains(',') && k > 0 && (ip.len() - k) % 3 == 0 {
grouped.push(',');
}
grouped.push(ch);
}
let sign = if v < 0.0 && digits.chars().any(|c| c != '0' && c != '.') { "-" } else { "" };
format!("{sign}{pre}{grouped}{fp}{suf}")
}
/// Decimal places of a number body (`#,##0.00` has 2).
fn decimals(body: &str) -> usize {
body.split_once('.').map_or(0, |(_, d)| d.chars().filter(|c| matches!(c, '0' | '#')).count()).min(12)
}
/// `v` with no number format (`General`): as many decimals as the tick step needs.
fn general(v: f64, step: f64) -> String {
let mut d = 0;
while d < 6 && (step * 10f64.powi(d)).fract().abs() > 1e-9 {
d += 1;
}
let s = format!("{:.*}", d as usize, v);
if s.trim_start_matches(['-', '0', '.']).is_empty() { s.trim_start_matches('-').to_string() } else { s }
}
/// `v` in scientific notation: `decimals` mantissa decimals, at least `exp_digits` exponent
/// digits, and a `+` on positive exponents when `plus` (`0.00E+00`: 1.23E+04).
fn scientific(v: f64, decimals: usize, exp_digits: usize, plus: bool) -> String {
let s = format!("{:.*e}", decimals, v.abs());
let (mantissa, exp) = s.split_once('e').unwrap_or((s.as_str(), "0"));
let exp: i32 = if v == 0.0 { 0 } else { exp.parse().unwrap_or(0) };
let sign = if exp < 0 {
"-"
} else if plus {
"+"
} else {
""
};
let minus = if v < 0.0 { "-" } else { "" };
format!("{minus}{mantissa}E{sign}{:0w$}", exp.unsigned_abs(), w = exp_digits.clamp(1, 4))
}
/// A moment as Excel shows a serial day number.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Stamp {
pub(super) y: i64,
pub(super) m: u32,
pub(super) d: u32,
/// Day of the week, 0 = Sunday.
weekday: u32,
/// Seconds into the day.
secs: u32,
}
/// The moment of Excel serial day `serial` as Excel shows it, from 0 ("January 0, 1900") to
/// 31 December 9999. In the 1900 system serial 60 is Excel's phantom 29 February 1900, and
/// serials before it count from 31 December 1899; in the 1904 system (`date1904`) serial 0 is
/// 1 January 1904. `None` outside that range.
pub(super) fn excel_stamp(serial: f64, date1904: bool) -> Option<Stamp> {
let serial = if date1904 { serial + 1462.0 } else { serial };
if !(0.0..2_958_466.0).contains(&serial) {
return None;
}
let whole = serial.floor();
let secs = (((serial - whole) * 86_400.0).round() as u32).min(86_399);
let day = whole as i64;
let (y, m, d) = match day {
0 => (1900, 1, 0),
60 => (1900, 2, 29),
1..=59 => civil(day + 1),
_ => civil(day),
};
// Excel's weekdays count serial 1 as a Sunday.
Some(Stamp { y, m, d, weekday: (day - 1).rem_euclid(7) as u32, secs })
}
/// Calendar date (year, month, day) of day `n` counted from 30 December 1899 (an Excel serial
/// day number). Serial `n` is `n - 25_569` days from 1970-01-01.
///
/// The caller's serial guard keeps `n` inside chrono's range, but the Gregorian calendar repeats
/// every 400 years (146_097 days), so this stays exact for any `n` without hand-rolled calendar
/// math: chrono converts the day within its cycle and whole cycles go back into the year (an
/// i128 so the extremes can't overflow). 1970-01-01 is `num_days_from_ce` 719_163, so serial `n`
/// is CE day `n - 25_569 + 719_163`.
pub(super) fn civil(n: i64) -> (i64, u32, u32) {
use chrono::Datelike;
// CE day of serial `n`, in i128 so no `n` overflows.
let from_ce = i128::from(n) - 25_569 + 719_163;
let (cycles, rest) = (from_ce.div_euclid(146_097), from_ce.rem_euclid(146_097));
match i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(date) => {
let y = i128::from(date.year()) + cycles * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day())
}
// Unreachable: every `rest` in 0..146_097 is a valid CE day.
None => (1900, 1, 1),
}
}
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
/// Whether a format code (outside its quoted and bracketed parts) has date or time fields.
pub(super) fn is_date_code(code: &str) -> bool {
format_toks_raw(code).any(|c| matches!(c.to_ascii_lowercase(), 'y' | 'd' | 'm' | 'h' | 's'))
}
/// The characters of a format code's first section outside quotes and brackets.
fn format_toks_raw(code: &str) -> impl Iterator<Item = char> + '_ {
let mut quoted = false;
let mut bracket = false;
first_section(code).chars().filter(move |c| match c {
'"' => {
quoted = !quoted;
false
}
'[' if !quoted => {
bracket = true;
false
}
']' if bracket => {
bracket = false;
false
}
_ => !quoted && !bracket,
})
}
/// A piece of a date format: literal text, a field (`y`, `m`, `d`, `h`, `s`, or `n` for minutes)
/// with its length, or AM/PM (`AM/PM` when true, `A/P` when false).
enum DTok {
Lit(String),
Field(char, usize),
AmPm(bool),
}
/// The pieces of a date format's first section. `m` or `mm` right after an hour or right before
/// a seconds field is minutes.
fn date_toks(code: &str) -> Vec<DTok> {
let section: Vec<char> = first_section(code).chars().collect();
let mut toks = Vec::new();
let mut i = 0;
while let Some(&c) = section.get(i) {
let at = i;
i += 1;
match c {
'"' => {
let lit: String = section.get(i..).unwrap_or(&[]).iter().take_while(|q| **q != '"').collect();
i += lit.chars().count() + 1;
toks.push(DTok::Lit(lit));
}
'[' => {
let inner: String = section.get(i..).unwrap_or(&[]).iter().take_while(|q| **q != ']').collect();
i += inner.chars().count() + 1;
toks.extend(currency(&inner).map(|s| DTok::Lit(s.to_string())));
}
'\\' | '_' | '*' => {
let next = section.get(i).copied();
i += 1;
match (c, next) {
('\\', Some(n)) => toks.push(DTok::Lit(n.to_string())),
('_', _) => toks.push(DTok::Lit(" ".to_string())),
_ => {}
}
}
'a' | 'A' if starts_ci(&section, at, "AM/PM") => {
i += 4;
toks.push(DTok::AmPm(true));
}
'a' | 'A' if starts_ci(&section, at, "A/P") => {
i += 2;
toks.push(DTok::AmPm(false));
}
_ if matches!(c.to_ascii_lowercase(), 'y' | 'm' | 'd' | 'h' | 's') => {
let mut n = 1;
while section.get(i).is_some_and(|q| q.eq_ignore_ascii_case(&c)) {
i += 1;
n += 1;
}
toks.push(DTok::Field(c.to_ascii_lowercase(), n));
}
c => toks.push(DTok::Lit(c.to_string())),
}
}
mark_minutes(&mut toks);
toks
}
/// Whether `chars` from `at` on start with `pat`, ignoring ASCII case.
fn starts_ci(chars: &[char], at: usize, pat: &str) -> bool {
pat.chars().enumerate().all(|(k, p)| chars.get(at + k).is_some_and(|c| c.eq_ignore_ascii_case(&p)))
}
/// Turns the `m`/`mm` fields that mean minutes (after an hour, or before seconds) into `n`.
fn mark_minutes(toks: &mut [DTok]) {
let fields: Vec<usize> = toks.iter().enumerate().filter(|(_, t)| matches!(t, DTok::Field(..))).map(|(k, _)| k).collect();
let kind = |k: Option<&usize>, toks: &[DTok]| match k.and_then(|k| toks.get(*k)) {
Some(DTok::Field(c, _)) => Some(*c),
_ => None,
};
for (j, k) in fields.iter().enumerate() {
let after_hour = j.checked_sub(1).and_then(|p| kind(fields.get(p), toks)) == Some('h');
let before_secs = kind(fields.get(j + 1), toks) == Some('s');
if let Some(DTok::Field(c @ 'm', n)) = toks.get_mut(*k)
&& *n <= 2
&& (after_hour || before_secs)
{
*c = 'n';
}
}
}
/// A moment in a date format: day, month, year, weekday (`ddd`, `dddd`), hour, minute and
/// second fields replaced (12-hour with AM/PM); quoted text and currency kept, other brackets
/// dropped.
pub(super) fn date_text(t: Stamp, code: &str) -> String {
let toks = date_toks(code);
let twelve = toks.iter().any(|k| matches!(k, DTok::AmPm(_)));
let hour = t.secs / 3600;
let mut out = String::new();
for tok in &toks {
match tok {
DTok::Lit(s) => out.push_str(s),
DTok::AmPm(long) => out.push_str(match (hour < 12, long) {
(true, true) => "AM",
(false, true) => "PM",
(true, false) => "A",
(false, false) => "P",
}),
DTok::Field(c, n) => out.push_str(&field_text(t, *c, *n, twelve)),
}
}
out
}
/// One date or time field (`c` as in [`DTok::Field`], `n` letters long).
fn field_text(t: Stamp, c: char, n: usize, twelve: bool) -> String {
let month = MONTHS.get((t.m as usize).clamp(1, 12) - 1).copied().unwrap_or("");
let weekday = WEEKDAYS.get(t.weekday as usize).copied().unwrap_or("");
let pad = |v: u32| if n >= 2 { format!("{v:02}") } else { v.to_string() };
let hour = t.secs / 3600;
match c {
'y' if n <= 2 => format!("{:02}", t.y.rem_euclid(100)),
'y' => format!("{:04}", t.y),
'd' if n <= 2 => pad(t.d),
'd' if n == 3 => weekday.get(..3).unwrap_or(weekday).to_string(),
'd' => weekday.to_string(),
'm' if n <= 2 => pad(t.m),
'm' if n == 3 => month.get(..3).unwrap_or(month).to_string(),
'm' if n == 4 => month.to_string(),
'm' => month.get(..1).unwrap_or(month).to_string(),
'h' if twelve => pad(if hour.is_multiple_of(12) { 12 } else { hour % 12 }),
'h' => pad(hour),
'n' => pad(t.secs / 60 % 60),
_ => pad(t.secs % 60),
}
}
+86
View File
@@ -0,0 +1,86 @@
use super::Rgb;
/// A data label to draw once the chart is: the value at (`x`, `y`), centred there (`inside`, in
/// white on the fill) or just beside it (above, or right of a horizontal bar's end).
pub(super) struct DataLabel {
pub(super) x: f32,
pub(super) y: f32,
pub(super) text: String,
pub(super) inside: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Kind {
Bar,
Line,
Area,
Pie,
Doughnut,
Scatter,
Radar,
}
impl Kind {
pub(super) fn of(name: &str) -> Option<Kind> {
Some(match name {
"c:barChart" => Kind::Bar,
"c:lineChart" => Kind::Line,
"c:areaChart" => Kind::Area,
"c:pieChart" => Kind::Pie,
"c:doughnutChart" => Kind::Doughnut,
"c:scatterChart" => Kind::Scatter,
"c:radarChart" => Kind::Radar,
_ => return None,
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Grouping {
Clustered,
Stacked,
Percent,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Pos {
Right,
Left,
Top,
Bottom,
}
/// One value axis (or category axis) as the file states it.
#[derive(Default)]
pub(super) struct Axis {
pub(super) deleted: bool,
pub(super) grid: bool,
pub(super) line: bool,
/// `c:crossBetween val="midCat"`: categories sit on the tick marks, not between them.
pub(super) mid: bool,
pub(super) min: Option<f64>,
pub(super) max: Option<f64>,
/// Number format of the tick labels, when not linked to the source data.
pub(super) fmt: Option<String>,
}
pub(super) struct Series {
pub(super) name: String,
pub(super) color: Option<Rgb>,
pub(super) line_w: f32,
/// Whether the series is drawn as a line (scatter series without one get markers).
pub(super) line: bool,
pub(super) values: Vec<Option<f64>>,
/// Scatter x values (index + 1 when the file has none).
pub(super) xs: Vec<Option<f64>>,
/// Per-point colours from `c:dPt`.
pub(super) points: Vec<Option<Rgb>>,
pub(super) fmt: Option<String>,
/// Format code of the scatter x values' cache.
pub(super) x_fmt: Option<String>,
/// Data labels on or off for this series (`c:dLbls` of its own), when it says.
pub(super) labels: Option<bool>,
/// The values as the file has them, for data labels (`values` become shares in a 100%
/// stacked chart); empty when the series shows no labels.
pub(super) raw: Vec<Option<f64>>,
}
+336
View File
@@ -0,0 +1,336 @@
use super::format::{date_text, excel_stamp, format_value, is_date_code};
use super::model::{Axis, Grouping, Kind, Pos, Series};
use super::plot::label;
use super::{Chart, El, FONT, LINE_W, MAX_CELLS, MAX_POINTS, MAX_RADAR_SERIES, MAX_SERIES, MAX_SPOKES, Rgb, fill_colour, int, measure, slot};
impl Chart {
pub(super) fn parse(space: &El, theme: &[Rgb]) -> Option<Chart> {
let chart = space.child("c:chart")?;
let plot = chart.child("c:plotArea")?;
let ty = plot.els().find(|e| Kind::of(&e.name).is_some())?;
let kind = Kind::of(&ty.name)?;
let scatter = kind == Kind::Scatter;
let date1904 = space.child("c:date1904").is_some_and(|d| d.attr("val").is_none_or(truthy));
let mut series = read_series(ty, scatter, theme);
if series.is_empty() {
return None;
}
let cats = ty.children("c:ser").find_map(|s| s.child("c:cat")).map(|c| labels(Some(c), MAX_POINTS, date1904)).unwrap_or_default();
let n = if kind == Kind::Radar {
series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len()).min(MAX_SPOKES)
} else if matches!(kind, Kind::Pie | Kind::Doughnut) {
series.first().map_or(0, |s| s.values.len()).max(cats.len())
} else {
series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len())
};
// Every series is padded to `n` points, so keep only as many as the cell budget holds.
if let Some(keep) = MAX_CELLS.checked_div(n) {
series.truncate(keep.max(1));
}
if kind == Kind::Radar {
series.truncate(MAX_RADAR_SERIES);
}
let grouping = match ty.child("c:grouping").and_then(|g| g.attr("val")) {
Some("stacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Stacked,
Some("percentStacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Percent,
_ => Grouping::Clustered,
};
let mut cats = cats;
cats.resize(n, String::new());
let radar_style = ty.child("c:radarStyle").and_then(|r| r.attr("val"));
let type_labels = show_values(ty.child("c:dLbls"));
for s in &mut series {
s.values.resize(n, None);
s.xs.resize(n, None);
s.labels = Some(s.labels.or(type_labels).unwrap_or(false));
if s.labels == Some(true) {
s.raw = s.values.clone();
}
}
if grouping == Grouping::Percent {
to_percent(&mut series, n);
}
let (cat, val) = chart_axes(plot, scatter);
let val_fmt = if grouping == Grouping::Percent {
"0%".to_string()
} else {
val.fmt.clone().or_else(|| series.iter().find_map(|s| s.fmt.clone())).unwrap_or_else(|| "General".to_string())
};
let num = |n: &str| ty.child(n).and_then(|g| g.attr("val")).and_then(int);
Some(Chart {
kind,
horizontal: kind == Kind::Bar && ty.child("c:barDir").and_then(|d| d.attr("val")) == Some("bar"),
grouping,
gap: num("c:gapWidth").unwrap_or(150).clamp(0, 500) as f32 / 100.0,
overlap: num("c:overlap").unwrap_or(0).clamp(-100, 100) as f32 / 100.0,
hole: num("c:holeSize").unwrap_or(50).clamp(10, 90) as f32 / 100.0,
first_angle: num("c:firstSliceAng").unwrap_or(0).clamp(0, 360) as f64,
radar_fill: radar_style == Some("filled"),
radar_marks: radar_style == Some("marker"),
vary: matches!(kind, Kind::Pie | Kind::Doughnut) && ty.child("c:varyColors").and_then(|v| v.attr("val")).is_none_or(truthy),
n,
cats,
title: title_of(chart, &series),
series,
accents: (4..10).map(|i| slot(theme, i)).collect(),
legend: legend_pos(chart),
cat,
val,
text_size: text_size(space),
val_fmt,
date1904,
plot_border: plot.child("c:spPr").and_then(|p| p.child("a:ln")).and_then(|l| fill_colour(l, theme)),
})
}
pub(super) fn stacked(&self) -> bool {
self.grouping != Grouping::Clustered
}
fn accent(&self, i: usize) -> Rgb {
self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK)
}
pub(super) fn series_colour(&self, j: usize) -> Rgb {
self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j))
}
/// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or
/// the series colour.
pub(super) fn point_colour(&self, j: usize, i: usize) -> Rgb {
if let Some(Some(c)) = self.series.get(j).and_then(|s| s.points.get(i)) {
return *c;
}
if self.vary { self.accent(i) } else { self.series_colour(j) }
}
pub(super) fn legend_entries(&self) -> Vec<(String, Rgb)> {
if matches!(self.kind, Kind::Pie | Kind::Doughnut) {
return self
.cats
.iter()
.enumerate()
.map(|(i, c)| (if c.is_empty() { (i + 1).to_string() } else { c.clone() }, self.point_colour(0, i)))
.collect();
}
self.series
.iter()
.enumerate()
.map(|(j, s)| (if s.name.is_empty() { format!("Series {}", j + 1) } else { s.name.clone() }, self.series_colour(j)))
.collect()
}
}
/// The series of a chart type element, within the chart's cell budget.
pub(super) fn read_series(ty: &El, scatter: bool, theme: &[Rgb]) -> Vec<Series> {
let mut budget = MAX_CELLS;
let mut series = Vec::new();
for ser in ty.children("c:ser").take(MAX_SERIES) {
let s = Series::parse(ser, scatter, theme, budget.min(MAX_POINTS));
budget = budget.saturating_sub(s.values.len().max(s.xs.len()));
series.push(s);
}
series
}
/// Values as their share of each category's total (100% stacked charts).
fn to_percent(series: &mut [Series], n: usize) {
for i in 0..n {
let total: f64 = series.iter().map(|s| s.values.get(i).copied().flatten().map_or(0.0, f64::abs)).sum();
for s in series.iter_mut() {
if let Some(Some(v)) = s.values.get_mut(i) {
*v = if total > 0.0 { *v / total } else { 0.0 };
}
}
}
}
/// The category axis (scatter: the horizontal value axis) and the value axis of a plot area.
fn chart_axes(plot: &El, scatter: bool) -> (Axis, Axis) {
let vals_ax: Vec<&El> = plot.children("c:valAx").collect();
let horiz_pos = |a: &&El| matches!(a.child("c:axPos").and_then(|p| p.attr("val")), Some("b" | "t"));
let (val_el, x_el) = if scatter {
let x = vals_ax.iter().copied().find(horiz_pos).or_else(|| vals_ax.first().copied());
let y = vals_ax.iter().copied().find(|a| !horiz_pos(a)).or_else(|| vals_ax.iter().copied().find(|a| Some(*a) != x));
(y, x)
} else {
(vals_ax.first().copied(), None)
};
let cat_el = plot.els().find(|e| matches!(e.name.as_str(), "c:catAx" | "c:dateAx"));
(axis(if scatter { x_el } else { cat_el }), axis(val_el))
}
/// The chart's title. An empty `c:title` shows the series name (Word's automatic title of a
/// one-series chart) unless the automatic title is deleted.
pub(super) fn title_of(chart: &El, series: &[Series]) -> Option<String> {
let auto_deleted = chart.child("c:autoTitleDeleted").and_then(|a| a.attr("val")).is_some_and(truthy);
let auto = if !auto_deleted && series.len() == 1 { series.first().map(|s| s.name.clone()).filter(|s| !s.is_empty()) } else { None };
chart.child("c:title").and_then(|t| title_text(t).or(auto))
}
/// Whether a `c:dLbls` shows the values (`None` when there's none, or it doesn't say).
fn show_values(d: Option<&El>) -> Option<bool> {
let d = d?;
if d.child("c:delete").and_then(|e| e.attr("val")).is_some_and(truthy) {
return Some(false);
}
d.child("c:showVal").map(|v| v.attr("val").is_none_or(truthy))
}
fn legend_pos(chart: &El) -> Option<Pos> {
chart.child("c:legend").map(|l| match l.child("c:legendPos").and_then(|p| p.attr("val")).unwrap_or("r") {
"l" => Pos::Left,
"t" | "tr" => Pos::Top,
"b" => Pos::Bottom,
_ => Pos::Right,
})
}
/// The chart's text size (its `c:txPr` default run size), points.
pub(super) fn text_size(space: &El) -> f32 {
space
.child("c:txPr")
.and_then(|t| t.find("a:defRPr"))
.and_then(|d| d.attr("sz"))
.and_then(int)
.map_or(FONT, |v| (v as f32 / 100.0).clamp(6.0, 24.0))
}
impl Series {
fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series {
let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default();
let sp = ser.child("c:spPr");
let ln = sp.and_then(|p| p.child("a:ln"));
let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme));
let color = sp.and_then(|p| fill_colour(p, theme)).or_else(|| ln.and_then(|l| fill_colour(l, theme))).or(marker);
let line_w = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).map_or(LINE_W, |w| w.clamp(0.0, 20.0));
let line = ln.is_none_or(|l| l.child("a:noFill").is_none());
let (values, fmt) = numbers(ser.child(if scatter { "c:yVal" } else { "c:val" }), cap);
let (mut xs, x_fmt) = if scatter { numbers(ser.child("c:xVal"), cap) } else { (Vec::new(), None) };
if scatter && xs.is_empty() {
xs = (1..=values.len()).map(|i| Some(i as f64)).collect();
}
let mut points = vec![None; values.len()];
for dpt in ser.children("c:dPt") {
let i = dpt.child("c:idx").and_then(|e| e.attr("val")).and_then(int).and_then(|i| usize::try_from(i).ok());
let c = dpt.child("c:spPr").and_then(|p| fill_colour(p, theme));
if let Some(slot) = i.and_then(|i| points.get_mut(i)) {
*slot = c;
}
}
let labels = show_values(ser.child("c:dLbls"));
Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
}
}
/// Longest number-format code honoured (the limit spreadsheet apps use); longer ones are ignored.
const MAX_FORMAT_CODE: usize = 255;
/// Category labels of a `c:cat` (string or number cache), capped. Numbers show in the cache's
/// format code (dates included); text and `General` numbers as they are stored.
pub(super) fn labels(el: Option<&El>, cap: usize, date1904: bool) -> Vec<String> {
let Some(el) = el else { return Vec::new() };
let Some(cache) = el.find("c:strCache").or_else(|| el.find("c:numCache")).or_else(|| el.find("c:strLit")).or_else(|| el.find("c:numLit")) else {
return Vec::new();
};
let code =
cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && f.len() <= MAX_FORMAT_CODE && !f.eq_ignore_ascii_case("General"));
pts(cache, cap)
.into_iter()
.map(|p| {
let text = p.unwrap_or_default();
match (code.as_deref(), text.trim().parse::<f64>()) {
(Some(code), Ok(v)) if v.is_finite() => label(&category_text(v, code, 1.0, date1904)),
_ => label(&text),
}
})
.collect()
}
/// A number in its format code: a date or time for date codes (serial days, from 1904 when
/// `date1904`), else as [`format_value`] with tick step `step`.
pub(super) fn category_text(v: f64, code: &str, step: f64, date1904: bool) -> String {
match excel_stamp(v, date1904) {
Some(t) if is_date_code(code) => date_text(t, code),
_ => format_value(v, code, step),
}
}
/// Values of a `c:val`-style element (number cache or literal), capped; with its format code.
pub(super) fn numbers(el: Option<&El>, cap: usize) -> (Vec<Option<f64>>, Option<String>) {
let Some(cache) = el.and_then(|e| e.find("c:numCache").or_else(|| e.find("c:numLit"))) else {
return (Vec::new(), None);
};
let fmt = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && f.len() <= MAX_FORMAT_CODE);
let vals = pts(cache, cap).into_iter().map(|p| p.and_then(|t| t.trim().parse::<f64>().ok()).filter(|v| v.is_finite())).collect();
(vals, fmt)
}
/// The `c:pt` texts of a cache by index. Indexes and counts from the file are capped.
fn pts(cache: &El, cap: usize) -> Vec<Option<String>> {
let declared = cache.child("c:ptCount").and_then(|c| c.attr("val")).and_then(int).unwrap_or(0).clamp(0, cap as i64) as usize;
let mut out: Vec<Option<String>> = vec![None; declared];
for p in cache.children("c:pt") {
let Some(i) = p.attr("idx").and_then(int).and_then(|i| usize::try_from(i).ok()) else { continue };
if i >= cap {
continue;
}
if out.len() <= i {
out.resize(i + 1, None);
}
if let Some(slot) = out.get_mut(i) {
*slot = p.child("c:v").map(|v| v.text());
}
}
out
}
pub(super) fn axis(a: Option<&El>) -> Axis {
let Some(a) = a else { return Axis { deleted: true, ..Default::default() } };
let scaling = a.child("c:scaling");
let num =
|n: &str| scaling.and_then(|s| s.child(n)).and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite());
Axis {
deleted: a.child("c:delete").and_then(|d| d.attr("val")).is_some_and(truthy),
grid: a.child("c:majorGridlines").is_some(),
line: a.child("c:spPr").and_then(|p| p.child("a:ln")).and_then(|l| l.child("a:noFill")).is_none(),
mid: a.child("c:crossBetween").and_then(|c| c.attr("val")) == Some("midCat"),
min: num("c:min"),
max: num("c:max"),
fmt: a
.child("c:numFmt")
.filter(|f| f.attr("sourceLinked") != Some("1"))
.and_then(|f| f.attr("formatCode"))
.filter(|f| f.len() <= MAX_FORMAT_CODE)
.map(str::to_string),
}
}
fn title_text(t: &El) -> Option<String> {
let tx = t.child("c:tx")?;
let s = match tx.child("c:rich") {
Some(rich) => {
let mut s = String::new();
rich_into(rich, &mut s);
s
}
None => tx.find("c:v").map(|v| v.text()).unwrap_or_default(),
};
let s = label(s.trim());
(!s.is_empty()).then_some(s)
}
/// The `a:t` runs under an element, in order.
fn rich_into(e: &El, s: &mut String) {
for c in e.els() {
if c.name == "a:t" {
s.push_str(&c.text());
} else {
rich_into(c, s);
}
}
}
fn truthy(v: &str) -> bool {
matches!(v.trim(), "1" | "true")
}
+463
View File
@@ -0,0 +1,463 @@
use super::canvas::{Rect, fill, rect_segs, runs, seg, stroke, text, text_at, text_w, widest, wrap};
use super::format::format_value;
use super::model::{DataLabel, Grouping, Kind, Series};
use super::parse::category_text;
use super::polar::{draw_data_labels, label_of};
use super::{AXIS, Chart, El, GAP, GRID, GraphicItem, LINE, MAX_LABEL, MAX_TICKS, PathSeg, Rgb, ShapeKind, TextAlign, fill_colour, measure};
pub(super) fn label(s: &str) -> String {
s.chars().take(MAX_LABEL).collect()
}
/// Chart area: the chart space's fill and border (white and borderless by default).
pub(super) fn backdrop(out: &mut Vec<GraphicItem>, space: &El, theme: &[Rgb], w: f32, h: f32) {
let sp = space.child("c:spPr");
let fill = match sp {
Some(p) if p.child("a:noFill").is_some() => None,
Some(p) => fill_colour(p, theme).or(Some(Rgb::WHITE)),
None => Some(Rgb::WHITE),
};
let ln = sp.and_then(|p| p.child("a:ln"));
let stroke = ln.and_then(|l| fill_colour(l, theme));
let stroke_width = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.75).clamp(0.0, 20.0);
if fill.is_some() || stroke.is_some() {
out.push(GraphicItem::Shape { rect: [0.0, 0.0, w, h], kind: ShapeKind::Rectangle, fill, stroke, stroke_width });
}
}
/// An axis scale: `lo` to `hi` in steps of `step`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Scale {
pub(super) lo: f64,
pub(super) hi: f64,
pub(super) step: f64,
}
impl Scale {
pub(super) fn ticks(&self) -> Vec<f64> {
let n = (((self.hi - self.lo) / self.step + 1e-9).floor().max(0.0) as usize).min(MAX_TICKS);
(0..=n).map(|k| self.lo + k as f64 * self.step).filter(|t| *t <= self.hi + 1e-9).collect()
}
}
/// A scale for data `dlo..dhi` (zero included when `zero`), as Excel and Word pick it: an end
/// away from zero sits 5% of the data span beyond the data (an end at zero stays there), rounded
/// out to a step of 1, 2, 2.5, 5 or 10 times a power of ten (about seven steps). `min`/`max`
/// from the file replace the ends.
pub(super) fn nice_scale(dlo: f64, dhi: f64, zero: bool, min: Option<f64>, max: Option<f64>) -> Scale {
let (dlo, dhi) = if dhi > dlo { (dlo, dhi) } else { (dlo, dlo + 1.0) };
let span = dhi - dlo;
// With zero, the ends reach it: all-negative data still starts its bars at zero.
let (ends_lo, ends_hi) = if zero { (dlo.min(0.0), dhi.max(0.0)) } else { (dlo, dhi) };
let lo = min.unwrap_or(if ends_lo < 0.0 { ends_lo - 0.05 * span } else { ends_lo });
let hi = max.unwrap_or(if ends_hi > 0.0 { ends_hi + 0.05 * span } else { ends_hi });
if !(hi > lo && hi.is_finite() && lo.is_finite()) {
return Scale { lo: 0.0, hi: 1.0, step: 0.2 };
}
let raw = (hi - lo) / 7.5;
let mag = 10f64.powf(raw.log10().floor());
let step = [1.0, 2.0, 2.5, 5.0, 10.0].iter().map(|m| m * mag).find(|s| *s >= raw).unwrap_or(10.0 * mag);
let lo_r = if min.is_some() { lo } else { ((lo / step) + 1e-9).floor() * step };
let hi_r = if max.is_some() { hi } else { ((hi / step) - 1e-9).ceil() * step };
if !(step.is_finite() && step > 0.0 && lo_r.is_finite() && hi_r.is_finite() && hi_r > lo_r) {
return Scale { lo: 0.0, hi: 1.0, step: 0.2 };
}
Scale { lo: lo_r, hi: hi_r, step }
}
/// The plot rectangle and the scales that map values and categories onto it.
struct Frame {
plot: Rect,
horiz: bool,
n: usize,
vs: Scale,
xs: Option<Scale>,
mid: bool,
}
impl Frame {
/// Width of one category band.
fn band(&self) -> f32 {
let len = if self.horiz { self.plot.h } else { self.plot.w };
len / self.n.max(1) as f32
}
/// Start of category `i`'s band: its left edge, or for bars its top edge (category 0 at the
/// bottom).
fn band_start(&self, i: usize) -> f32 {
let k = i as f32;
if self.horiz { self.plot.bottom() - (k + 1.0) * self.band() } else { self.plot.x + k * self.band() }
}
/// A value's position along the value direction: y for columns, x for bars.
fn val(&self, v: f64) -> f32 {
let t = ((v - self.vs.lo) / (self.vs.hi - self.vs.lo)).clamp(0.0, 1.0) as f32;
if self.horiz { self.plot.x + t * self.plot.w } else { self.plot.bottom() - t * self.plot.h }
}
/// x of category `i` for line and area points.
fn cat_x(&self, i: usize) -> f32 {
if !self.mid {
return self.plot.x + (i as f32 + 0.5) * self.band();
}
if self.n <= 1 { self.plot.x + self.plot.w / 2.0 } else { self.plot.x + self.plot.w * i as f32 / (self.n - 1) as f32 }
}
/// x of a scatter point.
fn scat_x(&self, x: f64) -> f32 {
match self.xs {
Some(s) => self.plot.x + ((x - s.lo) / (s.hi - s.lo)).clamp(0.0, 1.0) as f32 * self.plot.w,
None => self.plot.x,
}
}
}
/// The value scale and, for scatter charts, the x scale, with their ticks and tick texts.
pub(super) struct Axes {
pub(super) vs: Scale,
vticks: Vec<(f64, String)>,
xs: Option<Scale>,
xticks: Vec<(f64, String)>,
}
impl Axes {
pub(super) fn of(c: &Chart) -> Axes {
let (dlo, dhi) = extent(c);
let (vmin, vmax) = if c.grouping == Grouping::Percent { (Some(0.0), Some(1.0)) } else { (c.val.min, c.val.max) };
// Lines and scatters don't need zero for a narrow range (1000..1050 stays on its own scale).
let zero = !matches!(c.kind, Kind::Line | Kind::Scatter) || !narrow(dlo, dhi);
let vs = nice_scale(dlo, dhi, zero, vmin, vmax);
let vticks = vs.ticks().into_iter().map(|t| (t, format_value(t, &c.val_fmt, vs.step))).collect();
let xs = (c.kind == Kind::Scatter).then(|| {
let (xl, xh) = x_extent(c);
nice_scale(xl, xh, false, c.cat.min, c.cat.max)
});
// Scatter x values show in the axis's own format, else their cache's (dates included).
let xfmt = c.cat.fmt.clone().or_else(|| c.series.iter().find_map(|s| s.x_fmt.clone())).unwrap_or_else(|| "General".to_string());
let xticks = xs.map(|s| s.ticks().into_iter().map(|t| (t, category_text(t, &xfmt, s.step, c.date1904))).collect()).unwrap_or_default();
Axes { vs, vticks, xs, xticks }
}
}
/// The room around the plot for axis labels, and the category labels as they'll be drawn.
struct Margins {
left: f32,
top: f32,
bottom: f32,
/// Category labels in lines (none for scatter charts or a deleted category axis).
wrapped: Vec<Vec<String>>,
/// Every `skip`-th category label is shown under a column chart.
skip: usize,
}
/// The margins the axis labels need around the plot in `area`. Category labels under a column
/// chart wrap to the band; labels that still don't fit show every k-th category.
fn axis_margins(c: &Chart, ax: &Axes, area: Rect) -> Margins {
let fs = c.text_size;
let lh = fs * LINE;
let scatter = c.kind == Kind::Scatter;
let (cat_on, val_on) = (!c.cat.deleted, !c.val.deleted);
let left = if c.horizontal {
if cat_on { widest(&c.cats, fs).min(area.w * 0.4) + GAP } else { 0.0 }
} else if val_on {
ax.vticks.iter().map(|(_, s)| text_w(s, fs)).fold(0.0, f32::max) + GAP
} else {
0.0
};
let band_w = (area.w - left).max(1.0) / c.n as f32;
let wrapped: Vec<Vec<String>> = if cat_on && !scatter {
let width = if c.horizontal { left - GAP } else { band_w };
c.cats.iter().map(|s| wrap(s, width, fs)).collect()
} else {
Vec::new()
};
let widest_line = wrapped.iter().flatten().map(|s| text_w(s, fs)).fold(0.0, f32::max);
let skip = ((widest_line / band_w).ceil() as usize).max(1);
let label_lines = wrapped.iter().map(Vec::len).max().unwrap_or(1) as f32;
let bottom = if cat_on && !c.horizontal {
if scatter { lh + GAP } else { label_lines * lh + GAP }
} else if c.horizontal && val_on {
lh + GAP
} else {
0.0
};
let top = if !c.horizontal && val_on { lh / 2.0 } else { 0.0 };
Margins { left, top, bottom, wrapped, skip }
}
/// A bar, column, line, area or scatter chart in `area`: gridlines, series, axes, then labels.
pub(super) fn cartesian(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
if c.n == 0 {
return;
}
let ax = Axes::of(c);
let m = axis_margins(c, &ax, area);
let plot = Rect { x: area.x + m.left, y: area.y + m.top, w: area.w - m.left, h: area.h - m.top - m.bottom };
if plot.w < 1.0 || plot.h < 1.0 {
return;
}
let g = Frame { plot, horiz: c.horizontal, n: c.n, vs: ax.vs, xs: ax.xs, mid: c.val.mid };
if c.val.grid {
draw_grid(out, &g, &ax.vticks);
}
let mut labels = Vec::new();
match c.kind {
Kind::Bar => bars(out, c, &g, &mut labels),
Kind::Line => lines(out, c, &g, &mut labels),
Kind::Area => areas(out, c, &g, &mut labels),
Kind::Scatter => draw_scatter(out, c, &g, &mut labels),
Kind::Pie | Kind::Doughnut | Kind::Radar => {}
}
draw_axes(out, c, &g);
draw_data_labels(out, &labels, c.text_size, g.horiz && c.kind == Kind::Bar);
if !c.val.deleted {
draw_value_labels(out, c, &g, &ax.vticks, Rect { w: m.left, ..area });
}
if !c.cat.deleted {
draw_category_labels(out, c, &g, &ax.xticks, &m, area);
}
}
/// Major gridlines at the value ticks.
fn draw_grid(out: &mut Vec<GraphicItem>, g: &Frame, ticks: &[(f64, String)]) {
let plot = g.plot;
for (t, _) in ticks {
let p = g.val(*t);
let segs = if g.horiz { seg(p, plot.y, p, plot.bottom()) } else { seg(plot.x, p, plot.right(), p) };
stroke(out, segs, GRID, 0.75);
}
}
/// The category axis line, the plot area's outline and the value axis line, as the file shows them.
fn draw_axes(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame) {
let plot = g.plot;
if !c.cat.deleted && c.cat.line {
let segs = if g.horiz { seg(plot.x, plot.y, plot.x, plot.bottom()) } else { seg(plot.x, plot.bottom(), plot.right(), plot.bottom()) };
stroke(out, segs, AXIS, 0.75);
}
if let Some(color) = c.plot_border {
stroke(out, rect_segs(plot.x, plot.y, plot.right(), plot.bottom()), color, 0.75);
}
if !c.val.deleted && c.val.line {
let segs = if g.horiz { seg(plot.x, plot.bottom(), plot.right(), plot.bottom()) } else { seg(plot.x, plot.y, plot.x, plot.bottom()) };
stroke(out, segs, AXIS, 0.75);
}
}
/// Value tick labels: under the plot for bars, else right-aligned in `margin` (the area's left
/// margin).
fn draw_value_labels(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, ticks: &[(f64, String)], margin: Rect) {
let (fs, lh) = (c.text_size, c.text_size * LINE);
for (t, s) in ticks {
let p = g.val(*t);
if g.horiz {
text_at(out, p, g.plot.bottom() + GAP / 2.0, s, fs);
} else {
text(out, Rect { x: margin.x, y: p - lh / 2.0, w: margin.w - GAP, h: lh }, s, fs, TextAlign::Right, false);
}
}
}
/// Category labels: scatter x ticks under the plot, bar labels in the left margin, column
/// labels under their bands (every `m.skip`-th).
fn draw_category_labels(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, xticks: &[(f64, String)], m: &Margins, area: Rect) {
let (fs, lh) = (c.text_size, c.text_size * LINE);
let plot = g.plot;
if c.kind == Kind::Scatter {
for (t, s) in xticks {
text_at(out, g.scat_x(*t), plot.bottom() + GAP / 2.0, s, fs);
}
} else if g.horiz {
for (i, lines) in m.wrapped.iter().enumerate() {
let top = g.band_start(i) + g.band() / 2.0 - lines.len() as f32 * lh / 2.0;
for (k, s) in lines.iter().enumerate() {
text(out, Rect { x: area.x, y: top + k as f32 * lh, w: m.left - GAP, h: lh }, s, fs, TextAlign::Right, false);
}
}
} else {
let band = g.band();
for (i, lines) in m.wrapped.iter().enumerate().filter(|(i, _)| i % m.skip == 0) {
for (k, s) in lines.iter().enumerate() {
let y = plot.bottom() + GAP / 2.0 + k as f32 * lh;
text(out, Rect { x: g.cat_x(i) - band / 2.0, y, w: band, h: lh }, s, fs, TextAlign::Center, false);
}
}
}
}
/// Whether data `lo..hi` is a narrow band away from zero: its span is under a sixth of the end
/// farther from zero (the mirror image for negative data).
pub(super) fn narrow(lo: f64, hi: f64) -> bool {
if lo > 0.0 {
(hi - lo) / hi < 1.0 / 6.0
} else if hi < 0.0 {
(hi - lo) / lo.abs() < 1.0 / 6.0
} else {
false
}
}
/// Lowest and highest value of the plot; stacked series add up per category.
fn extent(c: &Chart) -> (f64, f64) {
let mut pts: Vec<f64> = Vec::new();
if c.stacked() {
for i in 0..c.n {
let (mut p, mut q) = (0.0, 0.0);
for s in &c.series {
match s.values.get(i).copied().flatten() {
Some(v) if v >= 0.0 => p += v,
Some(v) => q += v,
None => {}
}
}
pts.push(p);
pts.push(q);
}
} else {
pts.extend(c.series.iter().flat_map(|s| s.values.iter().flatten().copied()));
}
let lo = pts.iter().copied().fold(f64::INFINITY, f64::min);
let hi = pts.iter().copied().fold(f64::NEG_INFINITY, f64::max);
if lo.is_finite() && hi.is_finite() { (lo, hi) } else { (0.0, 0.0) }
}
/// Range of scatter x values (index + 1 where missing).
fn x_extent(c: &Chart) -> (f64, f64) {
let xs: Vec<f64> = c.series.iter().flat_map(|s| s.xs.iter().flatten().copied()).collect();
let lo = xs.iter().copied().fold(f64::INFINITY, f64::min);
let hi = xs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
if lo.is_finite() && hi.is_finite() { (lo, hi) } else { (0.0, 1.0) }
}
fn bars(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
let stacked = c.stacked();
let slots = if stacked { 1 } else { c.series.len().max(1) };
let ov = if stacked { 0.0 } else { c.overlap };
let ns = slots as f32;
let band = g.band();
let thick = band / (ns - (ns - 1.0) * ov + c.gap);
let group = thick * (ns - (ns - 1.0) * ov);
let mut pos = vec![0.0f64; g.n];
let mut neg = vec![0.0f64; g.n];
for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) {
let slot = if stacked { 0 } else { j };
let slot = (if g.horiz { slots.saturating_sub(1 + slot) } else { slot }) as f32;
for i in 0..g.n {
let Some(v) = s.values.get(i).copied().flatten() else { continue };
let (v0, v1) = if !stacked {
(0.0, v)
} else if v >= 0.0 {
let Some(p) = pos.get_mut(i) else { continue };
let b = *p;
*p += v;
(b, *p)
} else {
let Some(q) = neg.get_mut(i) else { continue };
let b = *q;
*q += v;
(b, *q)
};
let a = g.band_start(i) + (band - group) / 2.0 + slot * thick * (1.0 - ov);
let (p0, p1) = (g.val(v0), g.val(v1));
let (p0, p1) = (p0.min(p1), p0.max(p1));
let segs = if g.horiz { rect_segs(p0, a, p1, a + thick) } else { rect_segs(a, p0, a + thick, p1) };
fill(out, segs, c.point_colour(j, i));
if let Some(text) = label_of(s, i) {
let mid = a + thick / 2.0;
let (x, y) = match (g.horiz, stacked) {
(true, true) => ((p0 + p1) / 2.0, mid),
(false, true) => (mid, (p0 + p1) / 2.0),
// Beyond the bar's end: right of a positive bar, above a positive column.
(true, false) => (if v >= 0.0 { p1 } else { p0 }, mid),
(false, false) => (mid, if v >= 0.0 { p0 } else { p1 }),
};
labels.push(DataLabel { x, y, text, inside: stacked });
}
}
}
}
/// Whether a series has any point to draw.
pub(super) fn has_values(s: &Series) -> bool {
s.values.iter().any(Option::is_some)
}
/// Line series; stacked ones are drawn at the running total of the series up to them.
fn lines(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
let mut total = vec![0.0f64; g.n];
for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) {
let pts: Vec<Option<(f32, f32)>> = (0..g.n)
.map(|i| {
let v = s.values.get(i).copied().flatten()?;
let y = match total.get_mut(i) {
Some(t) if c.stacked() => {
*t += v;
*t
}
_ => v,
};
Some((g.cat_x(i), g.val(y)))
})
.collect();
for (i, p) in pts.iter().enumerate() {
if let (Some((x, y)), Some(text)) = (p, label_of(s, i)) {
labels.push(DataLabel { x: *x, y: *y, text, inside: false });
}
}
stroke(out, runs(pts.into_iter()), c.series_colour(j), s.line_w);
}
}
fn areas(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
let mut base = vec![0.0f64; g.n];
for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) {
let vals = (0..g.n).map(|i| s.values.get(i).copied().flatten().unwrap_or(0.0));
let top: Vec<f64> = base.iter().zip(vals).map(|(b, v)| b + v).collect();
let mut segs = Vec::with_capacity(2 * g.n + 1);
for (i, t) in top.iter().enumerate() {
let (x, y) = (g.cat_x(i), g.val(*t));
segs.push(if i == 0 { PathSeg::Move(x, y) } else { PathSeg::Line(x, y) });
}
for (i, b) in base.iter().enumerate().rev() {
segs.push(PathSeg::Line(g.cat_x(i), g.val(*b)));
}
segs.push(PathSeg::Close);
fill(out, segs, c.series_colour(j));
for (i, (t, b)) in top.iter().zip(&base).enumerate() {
if let Some(text) = label_of(s, i) {
labels.push(DataLabel { x: g.cat_x(i), y: g.val((t + b) / 2.0), text, inside: true });
}
}
if c.stacked() {
base = top;
}
}
}
/// Scatter series as lines, or as small markers when the series has no line.
fn draw_scatter(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
const MARKER: f32 = 6.0;
for (j, s) in c.series.iter().enumerate() {
let pts: Vec<Option<(f32, f32)>> =
s.xs.iter()
.zip(&s.values)
.map(|(x, y)| match (x, y) {
(Some(x), Some(y)) => Some((g.scat_x(*x), g.val(*y))),
_ => None,
})
.collect();
for (i, p) in pts.iter().enumerate() {
if let (Some((x, y)), Some(text)) = (p, label_of(s, i)) {
labels.push(DataLabel { x: *x, y: *y - MARKER / 2.0, text, inside: false });
}
}
if s.line {
stroke(out, runs(pts.iter().copied()), c.series_colour(j), s.line_w);
continue;
}
for (x, y) in pts.iter().flatten() {
let rect = [x - MARKER / 2.0, y - MARKER / 2.0, MARKER, MARKER];
out.push(GraphicItem::Shape { rect, kind: ShapeKind::Ellipse, fill: Some(c.series_colour(j)), stroke: None, stroke_width: 0.0 });
}
}
}

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