Compare commits

...
507 Commits
Author SHA1 Message Date
Dylan M. TaylorandBrandon Thomas ae5c65b9dd Paste: charge copied text box stories for the formatting they clone (#5379)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 09:05:14 -05:00
1f8f9e8e2c DOC import: keep one override per table cell instead of a copy per repeated sprm (#5374)
* fix(docbin): keep one override per table cell instead of a copy per repeated sprm

* DOC import: vertical alignment reads only the cells a row can hold, like the other cell-range sprms

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-11-03 09:04:13 -05:00
Dylan M. TaylorandBrandon Thomas cf23ca73c9 DOC import: attach sections to their paragraphs through an index of paragraph ends (#5373)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-03 09:03:10 -05:00
pakiatandClaude Opus 5.5 b2b988cdc8 Tests: harnesses that render through wgpu take turns (#5354)
Two wgpu devices drawing at once crash Windows' software rasterizer (WARP, d3d10warp.dll: 0xc0000005, sometimes heap corruption 0xc0000374), which is the adapter a machine without a GPU or a remote session gets. The ui-egui tests that render (a dragged shape, a rotation drag, and the ignored spelling review preview) now hold one lock while their harness lives. On Windows 11 over SSH the two rendering tests crashed 8 of 20 runs together (0 of 20 alone), and the whole ui-egui lib run 2 of 6; with the lock 0 of 20 and 0 of 10.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:01:59 -05:00
1ff68d64e3 i18n: Arabic interface with mirrored RTL shell (+ parity docs) (#5370)
* Arabic interface: mirrored right-to-left window and Arabic UI fonts

Adds the Arabic (`ar`) interface language's code: its catalogs are craft-i18n's
`locales/ar`, so this changes no catalog.

- `ar` is registered in `i18n::LANGUAGES` and `Lang::rtl()` says it is written
  right to left; its translations are used as written (egui's text layout
  orders each line's bidi text itself). A ribbon location reads from the right
  with a leftwards mark.
- `rtl.rs`: the window is mirrored. Containers open in `rtl::root`; columns,
  grids, checkboxes, radio buttons, menus, rows, panels, the dialog tab strip
  and the ribbon's scrolling row follow the language's direction. Title bar,
  status bar, ribbon tabs and groups, File pages (including Print, with the
  settings on the right and a solid scrollbar gutter), dialogs, menus and the
  task panes (their docking edge swaps) are laid out from the right. egui's
  `Grid` panics in a right-aligned layout, so grids go through `rtl::grid`.
- Arabic UI font fallback: the craft-fonts Arabic face, else an installed
  Arabic font, loaded when the interface is Arabic.
- The "Auto (200%)" size label keeps its percent sign inside the isolated
  number.
- Tests: Arabic locale, saved preference and lookup order on fixture catalogs;
  an interface audit (skipped without the craft-i18n Arabic catalog) that lays
  the app out right to left at several widths, checks tab, group, pane, Print
  and Go To positions and that nothing panics.

The Arabic work builds on Abdelhaq El Amraoui's mirrored-interface PR (#737).

Co-Authored-By: Abdelhaq El Amraoui <abdelhaqelamraoui@gmail.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Arabic interface: pin craft-i18n at a commit with the Arabic catalog; review fixes

- craft-i18n.rev and the Flatpak manifest pin 19358491 (craft-i18n main), the
  first commit line that has locales/ar/wordcraft.tsv. The old pin had no
  Arabic catalog, so `ar` was registered but not embedded and two i18n tests
  (the_language_setting_is_a_ui_command_and_persists,
  the_languages_offered_are_the_embedded_ones_of_the_list) failed in CI.
- The Arabic font fallback is requested for any right-to-left interface
  (`Lang::rtl()`), not only after the language picker sets the flag, so an
  automatic or saved Arabic gets the installed Arabic face.
- The file browser, the update window and Reading Mode's toolbar run their
  bodies in `rtl::root`, so their far-side buttons and rows are mirrored.
- A group in a right-to-left ribbon row is measured from the right edge: the
  cursor's left edge is negative infinity there, so its width was NaN and the
  ribbon planner dropped every group, never compacting a narrow Arabic window.
  A narrow window now folds groups into drop-downs.
- The mirrored checkbox and radio marks are labelled by their text, so screen
  readers name them.
- Tests: group width in a right-to-left row; the Arabic interface audit checks
  Reading Mode's toolbar.

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

* Arabic interface: ask for the installed Arabic font separately from the CJK one

A previous commit made every right-to-left interface request the installed CJK
fallback font (`system_cjk`) so that the installed Arabic font would load. That
also made every Arabic launch read a large system CJK font file when no
craft-fonts input is embedded (the Flatpak build has none).

`install_ui_fonts` now passes a separate `system_arabic` flag (`Lang::rtl()`):
the installed Arabic font is read for it, and `system_cjk` is again set only
by a CJK interface or by CJK text on screen. `WordApp::fonts_system_arabic`
remembers what was installed, so a language change reinstalls the fonts.

Tests: the_installed_arabic_font_is_asked_for_by_arabic_alone (the Arabic flag
adds the installed font only when none is embedded, and the CJK request alone
adds no Arabic font). Checked a headless screenshot of the Arabic interface
without craft-fonts: the installed Arabic font draws the labels.

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

* Fix the AutoSave switch knob: on is at the right, mirrored in Arabic

The title bar's AutoSave knob was placed with `lead_x(rtl == on, ..)`, which
reverses main's mapping in both directions: left to right it drew the knob at
the left when the switch was on, and right to left at the right. main draws it
at the right when on and at the left when off, so the mirrored interface must
draw it at the left when on: `rtl != on`.

The position is now `autosave_knob_x(rtl, on, track)`, pinned by a test for all
four cases. A headless screenshot of the off switch shows the knob at the left
in English and at the right in Arabic.

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

* Arabic interface: file names and paths in right-to-left lines; kerning row order

Tester feedback on the Arabic interface:

- File > Open's Recent list, the built-in file browser and the title bar drew
  file names and paths raw, from the left edge. In an Arabic line the marks of
  a name ("(نهائي) 2.docx") and the folders of a path with Arabic parts were
  scrambled. Rows now start at the side reading starts from (`rtl::lead_x`),
  names go through `i18n::isolate` (a first-strong isolate, with a left-to-right
  mark before the extension so `2.docx` stays `2.docx`) and paths through
  `i18n::isolate_path` (a left-to-right isolate with a mark after every
  separator, so neighbouring Arabic folders do not swap). Other languages get
  the text untouched; saved and spoken text never carries the marks.
- Font dialog, Kerning row: the dialog's grid is drawn left to right inside a
  right-to-left window, so "Points and above" came after its number; in an
  Arabic interface it now comes first, so the line reads "14 نقطة فأكثر" from the
  right. (The grid's columns themselves still keep their left-to-right order.)

Tests: names_and_paths_are_isolated_in_a_right_to_left_interface.

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

* Arabic interface: mirror through craft_ui::direction and Style::right_to_left

The suite's craft-ui now has the direction helpers (`craft_ui::direction`) and
its egui fork a `Style::right_to_left` that mirrors egui's own chrome. Use them
instead of our parallel wrappers:

- `rtl::sync(ctx)` applies the interface direction every frame the language's
  changes (`direction::apply`): it sets the shared direction state and
  `Style::right_to_left` on every theme, which draws check boxes and radio
  buttons with the mark at the reading start, mirrors submenus, scroll bars,
  title bars and the combo boxes' arrows (the Print page's choice lists, the
  font list) and opens combo menus from the right.
- `rtl::checkbox`, `radio_value`, `radio` and `indeterminate_checkbox` are
  gone: about 200 call sites use plain `ui.checkbox`, `ui.radio_value`, ... and
  egui does the mirroring (and names the control by its text, as a plain widget).
- `lead_x`, `lead_center`, `lead_top`, `start_panel` and `end_panel` delegate to
  `craft_ui::direction`. `root`, `flows`, `island`, `grid`, `menu`, `vertical`,
  `far_side` and the other layout helpers stay: the shared crate has no
  equivalent that fits them.

Visual changes: the combo boxes' drop-down arrows and lists are mirrored in
Arabic; nothing changes in left-to-right languages (checked headless
screenshots of the English main window, Font dialog and Print page).

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

* Recent files and the file browser: full path on hover, long names keep their start

Review feedback:

- Each row of File > Open's Recent list shows the document's full path as its
  tooltip (the path line under the name can be longer than the row).
- A long name in the built-in file browser is laid out in one row, with an
  ellipsis at its end, from the side reading starts from, instead of being
  clipped at the row's edge: in a right-to-left interface the old text anchored
  at the right edge cut the end of an Arabic name and the start of a Latin one.
  The whole name is the row's tooltip when it was shortened.

Checked headless screenshots of the file browser with a long English and a long
Arabic name.

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

* Font combos in a right-to-left row: the arrow joins its field on the left

The ribbon's font name and size boxes are drawn from a field and a separate
arrow box that share one outline: the field is square and the arrow's box is
rounded on the side where the arrow goes, painted a border width under the
field. That assumed the arrow is right of the field. In a right-to-left row
(the Arabic ribbon) the arrow comes after the field on its left, so the field
was square on the wrong side and the arrow box rounded on the side facing the
field: two boxes with a gap-like seam, the arrow looking outside the frame.

`arrow_box_shape(rtl, ..)` mirrors the radii and the underlap, and the field's
square corners are chosen by the row's direction (the nw/sw pair in right to
left). Left-to-right rows are drawn exactly as before (English before/after
crops are identical).

Test: the_arrow_box_is_rounded_away_from_its_field.

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

* Pin craft-i18n 4612850 (merged Arabic WordCraft rows, craft-i18n#76)

The pin and the Flatpak manifest commit move from 1935849 to 4612850, which is on
craft-i18n main and adds the 70 Arabic rows this branch relies on.

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

* Pin craft-i18n cac2cf0 (Arabic number agreement and status-bar rows)

Moves the pin and the Flatpak manifest commit from 4612850 to the current craft-i18n
main, which includes craft-i18n#95 (48bed03) and #81 (ef58ff3).

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

* Arabic: isolate paths shown beside labels; mirror the Print page zoom

File > Info shows Location as its own left-to-right path label (cut short with the full path on hover, not wrapped), and the open-failure, Exported and dictionary-file messages isolate their paths. The Print preview zoom puts minus at the slider's left end and plus at its right in Arabic, as the status bar does.

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

* Arabic: mirror the label/field rows of the Font, Info, Paragraph, Page Setup, Insert Table and Options forms

rtl::Table lays a form out as an egui Grid in a left-to-right interface (same id, columns and spacing, so the output is unchanged) and, where the interface is mirrored, with each row's cells running from the right, every column as wide as its widest cell. File > Info puts Properties at the right and Statistics after it, and the Font dialog's kerning row reads 14 points and above.

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

* docs: record Arabic mirrored interface landing (#1195)

Update the language counts, Arabic parity row, gap #11, and UI/ROADMAP
status so they match the craft-i18n catalog and mirrored shell already
on this branch.

* Fix Organizer Copy arrow in RTL and refresh roadmap status.

Point the Copy glyph at the Document column when the shell is mirrored,
and bump docs/roadmap.md status/revision history for Arabic UI (#1195).

* fix(ui): keep pane dock left/right physical in RTL

ui.paneDock and view.stylesPaneDock now store and report physical window sides; only unset defaults still open on the reading-start edge. Also wrap the language-packs grid for RTL, document the contract, and load a system Arabic face for Arabic names in LTR UIs.

* docs: clarify mirrored default pane edges in RTL

Start-default panes open on the physical right in Arabic; end-default
panes open on the physical left — not every never-docked pane on the right.

* refactor(ui): one PaneState for a saved pane place

The four saved-dock arms built the same PaneState; only the place and
whether it was set explicitly differ.

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

* style(ui): rustfmt the panes re-exports

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

---------

Co-authored-by: The Legend Of MTS Dev <tlo.mts.tsh@gmail.com>
Co-authored-by: Abdelhaq El Amraoui <abdelhaqelamraoui@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: email <email@email.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-03 15:00:45 +01:00
Dylan M. Taylor 051fd875ef Proofing: look up unchecked runs by binary search, not a scan per issue (#5344)
paragraph_issues dropped issues touching a run left unchecked by testing
every run of the paragraph against every issue. A paragraph can have many
runs and many issues within the proofing size limit, so the filter was
quadratic in the paragraph's size. The unchecked ranges are now merged and
sorted once, each issue is looked up by binary search, and paragraphs
without unchecked runs skip the filter.
2026-11-03 08:55:09 -05:00
Brandon ThomasandClaude Opus 5.5 ffb155a032 ci: the font-store sanitising test waits out an install another test's window moved onto the engine's thread
Another ui-egui test's frame runs language_packs::tick, which puts installs on the engine's
thread while this test's backend is set; start_install then returned Ok and the test failed
in the full suite (it passed alone). Red on main since the language packs merge (#5224).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:09:38 -05:00
dd2a651093 References: Remove and Replace Table of Contents treat a title above a section break as Word does (#5152)
Word's TOC over entries in columns keeps its TOC Heading title in a one-column
section above a blank paragraph that ends it; Update Table moves the field into
that blank paragraph. Replace (references.toc with settings) put the new title
under the break, inside the two-column section, and Remove kept the blank
paragraph and its break.

Observed black-box in Word for Mac 16.114.3 on a synthetic document: Insert
Table of Contents keeps the title, the blank paragraph and the break and only
changes the field; Remove Table of Contents takes the title, the blank
paragraph and its break with the entries.

Replace now keeps the title above the break (renamed when a title is given)
and rebuilds the field and entries in place; Remove takes the title and the
blank section-ending paragraph with the table. A paragraph of another style
above the break stays.

Co-authored-by: Harrison Smith <harrison@hsmith.dev>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 22:47:03 -05:00
ljkx bcbc8619e9 #5290: Page Up/Down move by a page in Outline, Draft and Web Layout (#5300)
* #5290: Page Up/Down move by a page in Outline, Draft and Web Layout

These views lay the document out as one long page, so caret.pageUp/pageDown looked for a previous or next page, found none and left the caret where it was. Move by the text area height of the caret's section instead, the same distance Print Layout moves.

* #5290: Page Up/Down pick the line past the caret, and page from a hidden outline selection end

Choose the destination among the lines past the caret by height first (DocLayout::line_toward), so narrow right-indented paragraphs can't keep the caret on its own line when the step is short. The above/below test is shared with vertical().

In Outline view, a selection that ends in text a collapsed heading hides pages from where the outline shows it (outline::shown_pos, pulled out of after_command), keeping the anchor. Avoid f32::clamp, which panics on a NaN page height.
2026-11-02 22:43:22 -05:00
Michael MuandClaude Sonnet 5.5 ab5ee42049 A command line file's Password box survives the startup document's replacement (#5334)
When a region is chosen in File › Options, start-up replaces the first blank document with one
on that region's paper through `session.set_document`. That changes the session's active
document, but the window still showed the old one, so on the first frame `sync_document` took
the change for a switch of document and cleared the dialog. A password-protected file named on
the command line (a double-click in the file manager) had just asked for its password with
`file.open`, so the Password box was dropped before it was drawn and a blank Document1 stayed.

`WordApp::replace_startup_document` swaps the document and lets the window follow at once; the
start-up code uses it. Before the file is opened, so nothing is open to lose.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:43:19 -05:00
Dylan M. Taylor e00ff04d4a Fonts: refuse WOFF2 collections whose rebuilt directories are huge or list a table twice (#5332)
The WOFF2 decoder rebuilds every collection font's table directory (16 bytes
per listed table) before checking its output size, while the file spends as
little as one byte per listed table. A small collection listing the same
tables over and over made it allocate hundreds of megabytes twice.

Preflight the collection directory before decoding: the rebuilt directories
must fit 16 MiB and no font may list a table twice.
2026-11-02 22:24:55 -05:00
Dylan M. Taylor 220994decf Fonts: cut names read from a font's name table to 128 characters (#5333)
A name record can hold 64K characters, and every face copies its family into
each localized name and every variable-font named instance (with their
localized names again), so a small font could claim hundreds of megabytes of
name strings before any later check.

name() now keeps at most MAX_NAME_CHARS characters, the limit localized names
already used, so every copy stays small.
2026-11-02 22:24:52 -05:00
Dylan M. Taylor ab0020d6f4 Pictures: refuse a TIFF whose first image names its compression twice (#5337)
The TIFF guard read the first Compression tag, but the tiff crate keeps the
last copy of a tag. An allowed first value (none, LZW...) could hide a JPEG
compression after it, and the JPEG strips are then decoded by a nested
decoder with its own limits, outside the caller's budget.
2026-11-02 21:30:33 -05:00
Dylan M. Taylor 5702fa08b2 Merge pull request #5224 from benandez/fu-packs-p3
Language packs 2: 75 packs and the merged craft-proof (compounding, en-AU); #5208 review follow-ups
2026-11-02 21:23:33 -05:00
Dylan M. Taylor 1a8932b3ed Read Aloud: look citations up by binary search, not a scan per character (#5335)
Building the queue checked every character of the document against every
citation field, so a document with many citations made Read Aloud do
characters x citations comparisons before playback started.

The citation spans are now sorted and merged once, and each position is
looked up by binary search.
2026-11-02 21:16:49 -05:00
Martin BallandClaude Opus 5.5 8865455cfb CP4.4: review — gap 8 and the localization parity rows say the compounding languages are packs (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 02:07:54 +00:00
Martin BallandClaude Opus 5.5 0842d4faec CP4.4: language-packs-2 pinned (75 packs, adds the compounding languages); craft-proof at craft-libs main's merge of #497 (with #496, en-AU)
- PACK_INDEX: language-packs-2 index.json, sha256 b35c7aef…, built from craft-i18n 20467b63
- craft-proof pinned at storytold/craft-libs 47c9f625 (flag-based compounding, #497; review follow-ups and en-AU, #496)
- Options › Language: ProofLang::EnAu has no Hunspell dictionary name of its own (English uses SCOWL)
- docs: Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian, Persian and Esperanto are packs; Mongolian and Thai stay hidden

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:55:16 +00:00
Martin Ball 14513ae5f6 Merge remote-tracking branch 'origin/main' into fu-packs-p3 2026-11-03 01:50:29 +00:00
Brandon ThomasandClaude Opus 5.5 b47079fc79 Release 0.15.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:25:56 -05:00
Martin Ball 7d9968fd98 Merge remote-tracking branch 'origin/main' into fu-packs-p3 2026-11-02 22:43:02 +00:00
Dylan M. Taylor 866c1f9cac Merge pull request #5208 from benandez/feat/cp4-language-packs
CP4.3–CP4.5: Language packs — proof.packs/installPack/removePack, Options › Language packs, missing-pack bar, About › Language data
2026-11-02 17:32:04 -05:00
Dylan M. Taylor 0bb9add551 Merge main into language packs PR 2026-11-02 16:53:31 -05:00
Dylan M. Taylor 0a5b4221fd Merge pull request #5205 from benandez/feat/cp2-scowl
CP2.3: English spelling reads SCOWL 2026 (#40, #520, #1281, #1045)
2026-11-02 16:52:47 -05:00
Dylan M. Taylor 52a8a5db76 Merge main into SCOWL spelling PR 2026-11-02 16:23:38 -05:00
Dylan M. Taylor 421bc8cdcd Merge pull request #5202 from benandez/feat/cp1-craft-proof
CP1.3: proofing moves to craft-proof (craft-libs) and craft-i18n language-data
2026-11-02 16:23:16 -05:00
Dylan M. Taylor 3ce9a84f81 Merge main into proofing move PR 2026-11-02 15:57:35 -05:00
FredApps bbe3547dd1 Merge pull request #1104 from naxvog/formats/alt-chunks
Files: documents keep their embedded alternative format chunks (w:altChunk), and MHT opens
2026-11-02 19:05:39 +01:00
FredApps 389fa90c29 Merge remote-tracking branch 'origin/main' into pr1104
# Conflicts:
#	ROADMAP.md
2026-11-02 18:47:20 +01:00
FredApps 84b4623e14 Merge pull request #4471 from totoz64/feat/spelling-review-dialog
Review: add a Word-style Proofing task pane with clickable suggestions
2026-11-02 18:40:33 +01:00
FredApps e2bc9e017e Merge remote-tracking branch 'origin/main' into pr1104
# Conflicts:
#	docs/file-format-parity.md
2026-11-02 18:25:52 +01:00
41d46625e0 DOCX: VML horizontal rules take their line (#3956)
* DOCX: VML horizontal rules take their line

A web page's <hr> pasted into Word is a VML rule: `v:rect` with
`o:hr="t"`, often `width:0`. WordCraft took the width as written and
drew a 1 pt dot where Word draws a line across the page.

Measured on Word with a synthetic document: a rule with no width and
no `o:hrpct` spans the line inside the paragraph's indents; `o:hrpct`
is a share of it in tenths of a percent (500 = half); `o:hrpct="0"`
keeps the shape's own width; `o:hralign` places a shorter one (left,
centre, right); `fillcolor` and `stroked` apply as to any VML shape.
WordCraft now matches each case within 0.1 pt.

- wordcraft-doc: ShapeExtra::hr (HorizontalRule: share, alignment,
  Word's standard look).
- wordcraft-docx: `o:hr`, `o:hrpct`, `o:hralign`, `o:hrstd` read; a rule
  is written back as the same VML rule (DrawingML has none), and Word
  opens the re-saved file with the same lines.
- wordcraft-layout: a rule takes its whole line and is drawn at its
  share; measured for a table column it asks for no width, so it
  follows its column. (Word draws a rule in a cell at the page's text
  width, centred on the cell and past its edges; it stays in the cell
  here.)

docs/file-format-parity.md is updated.

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

* Horizontal rules: only an inline, shown rule is one

Review: the rule arm of the writer ignored the shape's placement, so a
rule imported with position:absolute or visibility:hidden came back
inline and visible. Only a rule in the text and shown is read as a rule
now (a pasted <hr> is one); a positioned or hidden one stays a plain
shape, which keeps both when saved. The writer checks the same.

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

* Horizontal rules: a see-through fill is kept; column measuring asks for the right width

Review: `write_hr` wrote only the fill's colour, so a rule with `v:fill
opacity` came back opaque after a save; the opacity now goes back as `v:fill`.

Review: measured for a table column, every rule asked for no width, so one of
its own width (`o:hrpct="0"`) could end up clipped by a narrow column; it now
asks for its width. AutoFit to contents measures on a line of its own width
that the rule took for a real line, so a rule sharing its line made the
column as wide as it could go; any measuring width now counts.

`Placed::Shape` boxes its extras: with the rule, `Placed` went past clippy's
large_enum_variant limit.

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

* Horizontal rules: placed in what the line has left; rotation and flips kept on save

Review: a whole-line rule ignores a first-line indent or a line narrowed by a
float. Measured in Word, it does too: the width stays the share of the
paragraph's width and the rule runs past the margin, or under a float on its
right. What did differ was the placement: Word centres and right-aligns a rule
in what the line has left, with a first-line indent moving the end of that room
along, and a right-aligned rule beside a float was drawn off the page here. The
display now places it that way (`ParaLayout::rule_room`).

Review: a save dropped a rule's rotation and flips; they now go back in its
style, as Word writes them.

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

* docs: VML horizontal-rule revision row at the top of the history

---------

Co-authored-by: Daniele Mori <Dinasty@hotmail.it>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 10:55:20 -05:00
Fin WarmanandBrandon Thomas 4f2d0da04c Shapes: align soft-edge paint and caret opacity (#1600)
* Shapes: a solid fill keeps its transparency

A shape's fill colour can carry a:alpha, its opacity. WordCraft dropped
it, so a translucent white wash laid over a picture was drawn opaque and
hid the picture. The transparency is now read into the shape, drawn on
screen and in the PDF, and written back.

* Fix #1600 freeform and soft-edge fill opacity

* #1600: wrap canonical caret sampler signature

* #1600: format canonical integration

* #1600: assert canonical DOCX alpha round-trip

* #1600: document canonical shape opacity API

* Adapt alpha fixture to rectangle render bounds

* docs: one file-format-parity row for soft-edge fill opacity, status line

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 10:53:00 -05:00
242e850aaa #3660: refuse a package that declares too many zip entries before zip parses them (#5260)
* #3660: refuse a package that declares too many zip entries before zip parses them

zip::ZipArchive::new parses and keeps every central directory header
the end record declares before its caller can look at len(). A ZIP64
end record can declare one per 46 bytes of the file: a 47 MB package of
one stored file listed 1,000,000 times takes zip 3.0.0 about half a
second and a large transient allocation, and len() then says 1, since
the copies share a name, so the docx reader's MAX_ENTRIES cap never
fires. ODT and spreadsheet packages had no cap at all.

wordcraft_doc::zip_guard::check finds the end records the way zip finds
them (backwards; the ZIP64 record from the locator's offset on; a plain
record when it has no entries or a central header before it) and refuses
a package declaring more than the reader's limit. The docx package
reader, the ODT reader, the spreadsheet reader (mail-merge data) and the
template-title probe call it first, with 20,000 entries as the limit.

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

* formats: keep the extensions doc line on EXTENSIONS

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 04:58:18 -05:00
Hamza NaveedandBrandon Thomas ee8622ddb3 Count the runs proofing text walks, instead of timing it (#5185) (#5190)
* Count the runs proofing text walks, instead of timing it (#5185)

`proof_text_walks_the_runs_once` was the one linear-time test here that timed
its walk, and the ratio crept over its 24x limit when the crate's tests ran in
parallel -- 28.2, 26.4, 25.6 -- from cache and memory contention rather than
from any regression.

The file's own module doc says why the others don't have this problem: "The
cost is the steps the lookups' walks count (crate::work), not a timing, so a
busy machine can't make a linear lookup look quadratic or the other way round."
It then says this one is timed because "proofing text, whose walk is the
document model's" counts no steps -- but `proof_text` does the walk itself,
advancing a runs iterator alongside the text, so it can count the runs it looks
at like every other walk here. Nothing in `wordcraft-doc` had to change.

    before   8x the runs took 28.2x the time (limit 24), under load
    after    50000 steps for n, 400000 for 8n, ratio 8.0 (limit 12)

The count is the same on any machine under any load, so the flakiness is gone
rather than made less likely: raising the limit to 32x would have left the same
failure available on a busier machine. It is also about 100x faster -- 0.03 s
against three rounds of eight repetitions -- and `work::steps` is `#[cfg(test)]`
inside, so release builds are untouched.

The test still catches what it was written for. Putting the quadratic walk back
(the run for each character found from the first run, every time) gives:

    proof_text: 1250025001 steps for n, 80000200001 for 8n, ratio 64.0
    panicked: 8x the input took 64.0x the steps: not linear

Verified under the reported condition: three runs of the whole crate suite in
parallel, 559 tests, all green.

* linear tests: proofing text is counted now, not timed

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-02 04:55:59 -05:00
Martin BallandClaude Opus 5.5 72327bdd36 CP4.4: pin language-packs-1 index
craft-i18n's first Language pack release is published
(https://github.com/storytold/craft-i18n/releases/tag/language-packs-1, 61 packs,
built from craft-i18n main 4504e288). PACK_INDEX now pins its index.json sha256
instead of the all-zero placeholder. Docs say which languages it covers and which
wait on craft-proof (compounding) or are excluded on licence grounds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 09:40:07 +00:00
Martin BallandClaude Opus 5.5 fbe68547f4 CP1.3: review — CLAUDE.md describes no-data proofing the same as AGENTS.md (English falls back to Hunspell or the Moby list; pattern hyphenation off)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 09:28:04 +00:00
NaxvogandClaude Opus 5.5 868fffa6fe Files: a Word chunk that asks to keep its source formatting keeps it (w:matchSrc)
An alternative format chunk holding a Word document with w:matchSrc keeps its
own styles: a style the document already has comes along under a new id, the
chunk's document defaults (and Word's, where they say nothing) go into its
base styles, and its unstyled paragraphs use its own Normal. Without
w:matchSrc the document's styles win, as before. Adopting another document
now also renumbers table styles and the revisions of table, row and cell
property changes.

A document whose chunk shares style names with it (Normal with an exact line
height, an italic Subtitle) ran a page longer than in Word: the chunk's tables
took the document's styles. It now keeps its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 07:04:31 +01:00
NaxvogandClaude Opus 5.5 89c0d516ae Files: documents keep their embedded alternative format chunks, and MHT opens
Word brings a document's alternative format chunks (w:altChunk) in where they
stand when it opens the file; WordCraft left them out, so a document made of
one (as generators and mail-merge tools write them) opened empty.

- docx: read_with/ReadOptions with a ChunkImporter for formats other than
  WordprocessingML; nested Word documents are read here (at most 4 deep,
  256 chunks and 512 MB of chunk bytes a file, XML depth counted on).
- doc: Document::adopt (moved from paste) brings another document's media,
  lists, styles, notes, comments and revisions along under new ids; pictures
  in groups and charts are relinked. Insert > Text from File uses it and now
  keeps the file's pictures and lists.
- engine: chunks in MHT, HTML (in their charset), RTF and plain text go
  through the format importers.
- formats: MHT web archives open (MIME, quoted-printable/base64, charsets,
  pictures by Content-Location or cid). HTML reads style element rules
  (simple selectors, cascade, budgets), paragraph margins, indents, line
  height, cell widths and borders; an opened page takes Word's web-page
  defaults (Times New Roman 12 pt, single lines, black headings, automatic
  spacing). html::decode knows the single-byte Windows code pages.

docxide-pdf suite: croatian_regulations_altchunk goes from one empty page to
Word's 8 pages, SSIM 2.7% -> 78.7%; no other document changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 07:04:31 +01:00
NetterSilverandClaude Opus 5.5 3e2c79407d review.compare: keep text boxes of both documents in the result (#5248)
Fixes #5235

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 00:24:32 -05:00
ReesandBrandon Thomas bf9e9fb8ae DOCX: keep alternate-content table rows and cells (#5221)
* Fix #5219: retain alternate-content table rows and cells

* Re-wrap the collect() doc comment

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 23:17:19 -05:00
Martin BallandClaude Opus 5.5 7aab0b202c FU.3: language packs — the status bar's pack lookup is cached by index generation, not copied every frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:52:21 +00:00
Martin BallandClaude Opus 5.5 c4d7948e68 FU.2: language packs — tag characters and line/paragraph separators are stripped from shown text; font store errors go through the same sanitiser
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:52:21 +00:00
Martin BallandClaude Opus 5.5 d393e99879 FU.1: language packs — a user's cancel isn't an error; Install right after Cancel starts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:52:21 +00:00
Martin BallandClaude Opus 5.5 1376a5476b CP4.3: restack on #5205 9ffd30d; craft-proof at storytold/craft-libs#495 89fa1559 (contains #485), font store and catalog at #486 762a06a4 (store-lock race fix)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:23:48 +00:00
Martin BallandClaude Opus 5.5 34525f1cb1 CP4.3: review — regression test: a same-length index refresh still changes index_generation, so the missing-pack bar works it out again (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:04:00 +00:00
Martin BallandClaude Opus 5.5 905570a90b CP4.3: restack on #5205 3a666d3 — craft-proof at storytold/craft-libs#485 8f16fe5a (on #483 6e45fda); Cargo.toml and Cargo.lock rebuilt from the base, so main's version and other pins stay
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:04:00 +00:00
Martin BallandClaude Opus 5.5 03c50f8af2 CP4.3: restack on #5205 — craft-proof #485 bdd434bc and font store #486 83c2e7b from storytold/craft-libs, craft-i18n 059cbf9; SCOWL notice in About; no "(no dictionary)" or bar for a language with no pack; Swiss German and Portugal's Portuguese keep their names
- craft-proof now names ja, zh and th in missing_dictionary: packs::missing_dictionary hides it
  for a pack language when no pack for it is listed (status bar and Options).
- de-CH and pt-PT are pack languages in craft-proof (de-ch, pt-pt): names, hyphenation and
  grammar stay German and Portuguese (tests); pack names fall back to the region-aware name.
- About › Language data shows craft_proof::scowl::notice() when SCOWL is embedded (test).
- Flatpak manifest's craft-i18n commit follows craft-i18n.rev.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:04:00 +00:00
Martin BallandClaude Opus 5.5 a8250e6472 CP4.3: restack — drop the ROADMAP rows the rebase doubled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:04:00 +00:00
Martin BallandClaude Opus 5.5 1146691b90 CP4.3: review — craft-libs at #485 f88bd48 / #486 83c2e7b; test-source warning in proof.packs, the CLI and the bar; About › Language data a fixed-height list; Cancel reaches a stalled download
- proof.packs reports index_override; the CLI prints the test source; the bar's Install tooltip
  says it turns on downloading of fonts and Language packs, and names a test source.
- Docs: Install works once language-packs-1 is published (until then only a test index); with a
  test index the sha256 only catches a broken download.
- About › Language data keeps its size: a fixed-height scrolling list, licence texts read again
  only when the dictionaries change.
- The engine's Cancel is the font store's own flag (no snapshot), so a cancel stops a fetch or a
  stalled download too.
- Index text shown without control, bidi or format characters; long names cut; the bar works out
  what to offer again when the list changes (index generation, not its length).
- ui_shot's store is a temporary folder (or storeDir); tests reset the backend even on panic; a
  fixture pack proves per-run proofing and hyphenation from a pack.
- The default-build check follows craft-proof's carry-over (de, pl, pt, it, ru embedded until packs
  ship; CRAFT_PROOF_EMBED=en is English only); the Polish hyphenation check runs again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:04:00 +00:00
Martin BallandClaude Opus 5.5 c1490c5ba9 CP4.3: review — a click from the control channel never turns on downloading; installs and refreshes run in the background in the app; parked documents proof again after a pack change
- Install, Show available languages and Refresh turn on the font store's downloading only on the
  person's own click (input_from_control, as in #959); a faked click says why instead.
- proof.installPack and proof.packs {refresh} run on the engine's thread in the app ("started"),
  with progress, results and errors in proof.packs; the CLI and headless MCP stay synchronous.
- A session's layout is keyed on the dictionaries' generation, so parked documents proof again.
- proof.removePack refuses while that pack installs; pack commands that need a locale are left out
  of Search; options text names Refresh; licence tooltip says Source; errors go through tl!;
  the bar rescans at most once a second while typing; English names for every pack language and
  sr-latn; About has one Close button; xtask doc comment back on cmd_i18n_report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:04:00 +00:00
Martin BallandClaude Opus 5.5 82418896f0 CP4.3: layout test checks Polish hyphenation only when the Polish patterns are embedded (a Language build); otherwise they come from the pack
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:04:00 +00:00
Martin BallandClaude Opus 5.5 efb7d8ef20 CP4.3: craft-proof pinned to storytold/craft-libs#485's rebased head (6e8eb65), font store to #486 (d342a50); Pack languages report their tag
- `review.issues` and `file.options` report a run's language with `ProofLang::tag()`, so a
  Pack language reports its locale, not "".
- The test-source warning reads craft-font-store's `language_pack_index_overridden()`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:04:00 +00:00
Martin BallandClaude Opus 5.5 2ef046bad9 CP4.3: craft-libs pinned to Lane 4's pushed heads; test-source warning, upstream source links, code-drawn bar close button
- craft-proof (benandez/craft-libs 6ae4f03, storytold/craft-libs#485) and craft-font-store /
  craft-font-catalog (630e42f, #486) by git rev instead of local paths.
- ProofLang::Pack: the bar offers every language with a pack (zxx/und never).
- Options › Language warns when CRAFT_LANGUAGE_PACK_INDEX supplies a test index.
- Pack `source` (upstream link) when the index gives it, else the archive.
- The bar's close button is the code-drawn close icon: "✕" had no glyph in the interface fonts
  and egui panicked drawing it.
- `cargo xtask language` allows our own ru-extra, which stays embedded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:03:59 +00:00
Martin BallandClaude Opus 5.5 e5d46bb242 CP4.3: Language packs: proof.packs, proof.installPack, proof.removePack; Options › Language list, missing-pack bar, About › Language data
- engine `packs`: the packs Backend (the font store on the desktop and CLI, a fixed list in tests
  and ui_shot), the pinned index, installs and removals through craft-font-store's
  `language-pack` kind, craft-proof reading the store's `language-packs/` folder at once.
  Commands open nothing and never turn downloading on (only an Install click in the app does);
  confined automation can't write the packs folder outside its write root.
- ui-egui `language_packs`: File › Options › Language › Language packs (name, size, licence,
  source, Install/Remove, progress, errors), the missing-pack bar (dismissible per document,
  never for a language without a pack), About › Language data (full licence texts).
- CP4.4: `cargo xtask language` (in `ci`): the default build embeds only en-* Language data.
- CP4.5: docs (help, MCP, control protocol, gaps #8, localization parity, roadmap).
- Path deps to Lane 4's worktrees while developing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 01:03:59 +00:00
Martin BallandClaude Opus 5.5 9ffd30d968 CP2.3: review — NOTICE carries the SCOWL, Titze and Ispell notices; gap 8 records SCOWL English; status lines for localization-tr and architecture
CodeRabbit on #5205:
- NOTICE: the SCOWL English dictionaries (en_US, en_GB-ise, en_CA) with
  Kevin Atkinson's SCOWLv2 notice, Benjamin Titze's notice and Geoff
  Kuenning's Ispell BSD licence for the affix files, as their licences ask of
  distributions.
- docs/gaps.md: gap 8's evidence says each English checks its own SCOWL
  2026 dictionary, with the #40 and #520 regression tests; #40 (closed) is no
  longer listed as open. Status line and revision history.

Also: docs/localization-tr.md's craft-proof path change (3110dcf02, #5202)
and docs/architecture.md's no-data wording (b7dad64d9) get their status lines
and revision rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 00:39:15 +00:00
Martin BallandClaude Opus 5.5 96c6c19b9e CP2.3: review — pin craft-proof to craft-libs#483's re-review fix (British -ize only for real -ize words)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 00:31:38 +00:00
Martin BallandClaude Opus 5.5 919b8a5838 CP2.3: Cargo.toml — the craft-proof pin needs no follow-up (merge commits keep #483's head in storytold main)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 00:31:38 +00:00
Martin BallandClaude Opus 5.5 211377f550 CP2.3: review — pin craft-proof to craft-libs#483's review fixes (each English checks its own SCOWL dictionary) and craft-i18n to #114's merge
craft-proof moves to 0675d58: an English no longer takes another English's
SCOWL words (en-US flagged colourblind, en-GB colorblind), British English
keeps -ize, and English suggestions drop junk splits and names.
craft-i18n.rev and the Flatpak source move to storytold/craft-i18n#114's merge
commit (059cbf9: judgment/judgement right in every English).
No wordcraft test depended on the dialects crossing
(engine 1120 + 43, layout 578 pass). localization-parity's English row says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 00:31:38 +00:00
Martin BallandClaude Opus 5.5 09e9522a35 CP2.3: English spelling reads SCOWL 2026; regression tests for #40, #520, #1281, #1045
craft-proof moves to storytold/craft-libs#483's head (e6e948f, on #482's
9e5862a): each English checks against its own SCOWL rel-2026.02.25 dictionary
through the affix engine (ordinals via COMPOUNDRULE), with en-variants.txt as
the dialect policy. Lookup order: dictionaries folder, [English pack], SCOWL,
system Hunspell, the built-in Moby list; Moby stays for hyphenation.
craft-i18n.rev and the Flatpak source move to storytold/craft-i18n#109's merge
commit (efb1127), which adds the SCOWL files.

Typed-text tests pin British spellings in en-GB (#40), modern words (#520),
Canadian/Australian/NZ/Indian English (#1281, #1045) and ordinals; they skip
without the Language data, as the other data tests do. Docs: typing-parity,
localization-parity, ROADMAP progress log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 00:31:38 +00:00
Martin BallandClaude Opus 5.5 b7dad64d94 CP1.3: review — no-data proofing described right, NOTICE names craft-i18n's hyphenation.pat files, Flatpak dictionaries folder, printing checkout without credentials, data gate asks the variant table too
CodeRabbit on #5202:
- AGENTS.md, ATTRIBUTION.md, README.md, docs/architecture.md: without
  CRAFT_I18N_DIR English spelling falls back to an installed Hunspell English
  or craft-proof's built-in Moby list, and only pattern hyphenation is off
  (Bulgarian, Turkish and Russian hyphenate by rule); they said English
  wasn't spell-checked. (CLAUDE.md carries the same sentence; left for the
  maintainer, as agents don't edit it.)
- NOTICE: craft-i18n's patterns are language-data/<lang>/hyphenation.pat,
  not en-us.pat, pl.pat, pt.pat, de.pat, it.pat.
- docs/spelling-dictionaries.md: the Flatpak's dictionaries folder is
  ~/.var/app/ai.storyteller.wordcraft/data/wordcraft/dictionaries; the host's
  folders are outside its sandbox.
- printing.yml: the repository checkout doesn't persist credentials.
- tests.rs no_language_data: also probes the American/British variant table,
  so a craft-i18n checkout missing some files skips the dialect tests
  instead of failing them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-02 00:31:32 +00:00
Martin BallandClaude Opus 5.5 b545bc2757 CP1.3: Printing checks out craft-fonts too, so the CJK interface languages from craft-i18n have glyphs in the UI tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-01 20:39:14 +00:00
Martin BallandClaude Opus 5.5 36400f69eb CP1.3: review — no_language_data says what it probes (the embedded en-us list, present exactly when CRAFT_I18N_DIR's data is)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-01 20:13:12 +00:00
Martin BallandClaude Opus 5.5 6bb3f085aa CP1.3: rebase onto main — remove the crates/proof files #5000 added (affix_suggest, slot, spelling, tr/test_dict; already in craft-proof) and resolve craft-proof from git in Cargo.lock
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-01 20:13:12 +00:00
Martin BallandClaude Opus 5.5 9d5b98eb18 CP1.3: review — six engine tests run again without the Language data now that craft-proof's built-in English is the Moby list
Polish/Portuguese/Czech review commands, the custom dictionary file and the two typing squiggle
tests pass without CRAFT_I18N_DIR at 48ed6f8. English dialects, regions' English and Hyphenation
Options still need the variant table and patterns, so they keep the skip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-01 20:13:12 +00:00
Martin BallandClaude Opus 5.5 452d38da0b CP1.3: pin craft-proof 48ed6f8
storytold/craft-libs#482's new head: without Language data English spells from the built-in
Moby list plus en-extra with edit-1 suggestions, and a system English Hunspell loads leniently.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-01 20:13:12 +00:00
Martin BallandClaude Opus 5.5 3181e502c5 CP1.3: review — the Printing workflow checks out craft-i18n before its app and UI tests, as the gate does
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-01 20:13:12 +00:00
Martin BallandClaude Opus 5.5 8c3a792b49 CP1.3: review — engine tests that need the Language data skip with a note naming CRAFT_I18N_DIR when none is embedded, as craft-proof's do
cargo test -p wordcraft-engine now passes without CRAFT_I18N_DIR (10 tests return early) and
still runs them all with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-01 20:13:12 +00:00
Martin BallandClaude Opus 5.5 c967f934ca CP1.3: review — gaps and localization-parity point at craft-proof (craft-libs) and craft-i18n language-data instead of the removed crates/proof and assets/spelling
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-01 20:13:12 +00:00
Martin BallandClaude Opus 5.5 d6cb183e6d CP1.3: craft-proof pinned by git to storytold/craft-libs#482's head (961162a) instead of a local path
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-01 20:13:03 +00:00
Martin BallandClaude Opus 5.5 449d2c2392 CP1.3: craft-i18n pinned to the merge of its Language data review fixes (storytold/craft-i18n#107, 062a637)
craft-proof reads hyphenation patterns as language-data/<locale>/hyphenation.pat,
which #107 introduced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-01 20:13:03 +00:00
Martin BallandClaude Opus 5.5 4ea6fea512 CP1.3: craft-i18n pinned to its Language data merge (storytold/craft-i18n#106, 5e78e5b)
craft-i18n.rev and the flatpak source move from the feat/cp1-language-data
commit to the merge on craft-i18n main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-01 20:13:03 +00:00
ccc8780b5e CP1.3: spelling-dictionaries lists WordCraft's own folder on macOS and Linux
craft-proof now searches it there too (ported from storytold/craft-libs#31).

Co-Authored-By: Michał Marcin Brzuchalski <michal.brzuchalski@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-01 20:13:03 +00:00
Martin BallandClaude Opus 5.5 3110dcf02b CP1.3: proofing comes from craft-proof (craft-libs), its data from craft-i18n
- wordcraft-proof is now `package = "craft-proof"` from craft-libs (local
  path for now; TODO in Cargo.toml for the git+rev pin after the craft-libs
  PR merges). `wordcraft_proof::` paths are unchanged.
- crates/proof and assets/{spelling,hyphenation} deleted with their
  ATTRIBUTION.md rows; one row now points at craft-i18n's Language data;
  NOTICE paths updated.
- craft-i18n.rev (and the flatpak source) moved to the craft-i18n commit that
  adds language-data/ (CI sets CRAFT_I18N_REQUIRED=1, so the pin must have it).
- xtask layers: proof removed. Engine tests carry their own copy of the
  synthetic Czech fixture (they reached into crates/proof/src).
- Docs: architecture.md, CLAUDE.md/AGENTS.md layer table, README build note
  (CRAFT_I18N_DIR), localization-tr path, ROADMAP progress log.

Before/after wordcraft-cli on a 17-language proofing document: issues,
suggestions, manual hyphenation, PDF text and PDF bytes identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-01 20:13:03 +00:00
fd7f6621a1 Markdown export: a list inside a block quote stays in the quote (#5156)
* Markdown export: a list inside a block quote stays in the quote

`> - one / > - two` was imported as quoted list paragraphs (the quote kind on each item) but
the list branch of the exporter ignored that kind and wrote `- one / - two`, so every save as
Markdown moved the list out of its quote. A quoted list item now takes `> ` on each of its
lines, nested levels and continuation lines included. The same paragraphs come back from a
.docx (Quote style with numbering), so the quote now survives that round trip too.

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

* Markdown export: a list after a quoted list stays outside the quote

* fmt

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 14:48:32 -05:00
8a191fbf11 Shapes: arrowheads keep their kind and size and are drawn at Word's sizes (#5192)
Word draws an arrowhead 2, 3 or 5 times the line's width long and wide
(small, medium, large; `a:headEnd`/`a:tailEnd` `len`/`w`), a line under
2 points as a 2-point one. Every arrowhead was drawn one size and saved
as a medium triangle: the kind, width and length are now read (DrawingML
and VML), kept and written back, and the size is drawn as Word does.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 14:46:16 -05:00
499fc6289e Columns: the line between them is drawn for selected text, as tall as the section's text (#5178) (#5183)
* #5178: the line between columns is drawn for a section starting mid-page

Columns for selected text are a continuous section in the middle of a page, and
the line between columns was only added when a page started, down to the page
bottom. The lines are now drawn per section on each page, from the top of its
text there to the bottom of its tallest column, when the page or the section ends.

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

* #5178: the line between columns spans the text's lines, not its paragraph spacing

Review: the line started above the section's first line (its space before) and
ended below its last (its space after). It now runs from the highest text line or
table cell of the section on the page to the lowest, falling back on the flow's
extent when there are none.

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

* #5178: the column lines find the section's text by place, and draw nothing without it

Review: the section's items were the page's items after an index, which objects
behind the text (inserted at the front) shift; the flow's extent drew a line
where the section had only spacing; overflow lines of exact-height cells counted.
The text is now the page's top-level body lines and table cells below the
section's top; lines in cells count by their cell's box; no text, no line.

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

* #5178: the column lines take the section's text by its blocks

Review: a floating table of an earlier section placed lower on the page would
pass a filter by place. The section's text is now the page's items of top-level
blocks from the section's first block on the page.

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-11-01 14:39:35 -05:00
935aaf7147 Layout tests: compare pairs of layouts drawn with the same faces (#5167)
The numeral and automatic-space tests compare two layouts of one text. Tests
running alongside share the font database, and one loading a font between the
two layouts can change the face a character falls back to: U+06DD drew in
Segoe UI in one layout and Noto Sans Arabic in the other on Windows, failing
arabic_mode_keeps_arabic_indic_digits_as_typed and
context_mode_follows_run_direction now and then, and
spaces_punctuation_hangul_and_latin_alone_get_nothing failed the same way. The
pairs are now laid out again until both use the same faces.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 14:36:14 -05:00
1bd71f72bc .doc import: the default font is the stylesheet's standard font, so Word 365 files open in Aptos (#5066) (#5069)
* #5066: .doc default font is the stylesheet's standard font

The .doc reader took the default character font from the font table's
first entry. A Word 365 .doc keeps Times New Roman there and names Aptos as
the stylesheet's standard font (Stshif.rgftcStandardChpStsh), so its text
came out in Times New Roman. The defaults are now the standard ASCII, East
Asian and complex-script (ftcBi) fonts, else entry 0 as before; runs'
sprmCRgFtc1 and sprmCFtcBi are read too.

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

* #5066: the complex-script standard font is read only when the header has it

ftcBi is optional in the Stshi, and the latent style data that follows it starts
with cbLSD (4): a header that leaves ftcBi out no longer makes cbLSD the
complex-script font.

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

* #5066: a header ending with ftcBi = 4 keeps it

Review: a 4 at ftcBi's offset was taken for cbLSD even when the header ends
there, so a complex-script standard font of index 4 was lost. It is read as
left out only when more data follows.

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-11-01 14:24:47 -05:00
Brandon ThomasandClaude Opus 5.5 bf80a31d10 docs: merge the Tables row duplicated by the #5145/#5161 doc merge
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:23:38 -05:00
a64d9748f2 Tables: a row isn't split to leave a picture cell empty beside its text; it moves on whole, as in Word (#4272) (#5161)
When a row's first part on a page that already holds something would leave
a cell empty because that cell's inline picture (one line, never split) or
first line runs past the room, the row moves on whole instead of leaving the
other cells' text beside the empty cell. An at-least row grown taller than its
minimum by its picture used to pass the #1742 check (room >= declared minimum)
and then split, the picture going on alone. At the top of a page such a row
still splits.

Co-authored-by: Harrison Smith <harrison@hsmith.dev>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 14:23:01 -05:00
c395760959 Tables: a middle or bottom cell of a row split across pages lines up on the first page (#5145)
Measured in Word for Windows: when a table row breaks across pages, a cell
aligned to the middle or bottom whose text all fits in the part of the row
left on the page is aligned within that part, as tall as the other cells'
text there (with its space after), and a cell whose text doesn't fit runs
from the top of the cell with the rest on the next page. WordCraft kept the
offset the whole row's height gave the cell, so a short centred label
beside a long cell went to the top of the next page and left its cell empty
on the first, and a long centred cell started far down the page.

The vertical offset each cell's content got is kept with the row, so a split
moves the content back to the top before it decides what fits.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 14:21:25 -05:00
6ab1023ed6 Layout: tab leader dots spaced and placed as in Word (#5144)
* Layout: tab leader dots spaced and placed as in Word

Measured in Word for Windows with six fonts at four sizes: Word spaces leader
dots by the dot's advance at the font size in whole 600-dpi pixels (the size
itself in whole pixels), so a TH SarabunPSK dot at 16 pt (2.59 pt) is drawn
every 2.64 pt and a Tahoma one at 22 pt every 6.6 pt. The dots sit on the
page's grid of that pitch, from the first place after the text before the
tab to the last one that ends before the text after it. WordCraft drew them
at the unrounded advance, half a dot after the text and 1.2 dots short of
the number, so a table of contents line had a dot or two more than Word's.

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

* Layout: each leader dot's place comes from its grid index

The leader's dots were placed by adding the pitch to the last place, so on
a long leader the rounding of each addition added up. From 72 to 528 pt at
2.64 pt it came to more than the 0.001 pt allowed at the end, and the last
dot, which ends exactly on 528 pt, was left out. `leader_xs` now works out
each place as its grid index times the pitch.

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

* Layout: name the leader slack and glyph limit

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

* Layout: leader dots use the font size rounded to whole pixels

Word rounds the font size to the nearest 600-dpi pixel before it rounds the
dot's advance (Arial 14 pt: 116.7 px, so 117, dots every 3.96 pt); taking the
floor drew them 3.84 pt apart, as measured at 11, 14, 17, 20 and 26 pt.

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

* leader_xs: saturate the glyph range end

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 14:19:41 -05:00
639b2342c2 Lists: a level above counts its first number at the first deeper item (#5108)
* Lists: a level above counts its first number at the first deeper item

Word counts a list level that hasn't been used yet as started at its first
number when a deeper item comes first: after 10.1-10.4 with no level-1 item
before them, the next level-1 item is 11. WordCraft showed 10 in the deeper
labels but didn't count it, so the next level-1 item repeated it ("10.
Qualifications", then "10. Penalties" in a tender document whose sub-items
come first).

Counters::next_label now starts each unstarted level above the one it counts,
at that num's start override if it has one, else the level's start. Measured
in Word with ListFormat.ListString: 1.1, 1.2, 2., 3.; (1), (2), 2. when the
deeper text doesn't show level 1; 1.1.1., 1.2., 2. from level 3.
a_list_s_own_level_definitions_win expected the old count; its level 0 item
after level 1 items is now 2., as in Word.

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

* Lists: record the counting fix in the progress log and gaps

AGENTS.md asks for the progress log and the gap list when work lands. The
ROADMAP progress log has the entry; gaps.md records that no ranked gap
changed. Each with its status line and revision.

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

* Lists: test a num's start override for the level above

The deeper-item-first test now also gives the num a start override for
level 0 (10) under level-1 items: 10.1., 10.2., then 11. It fails if the
override is ignored for the level above (1.1., 1.2., 2.).

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

* Test a start override of 5 and a restart after a level-1 item

Review: 5.1., 6., 6.1. with the num's start override, and level 2 restarting below the next level-1 item.

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-11-01 14:18:14 -05:00
VoiceLessQandBrandon Thomas 4a3586c686 Paragraph side borders lie outside the text (#5036) (#5088)
* #5036: paragraph side borders lie beyond their distance from the text

push_para centred the left and right rules on the distance, so a 3 pt border
at no distance reached 1.5 pt into the text and, drawn after the lines, covered
the first letter. In a heading whose border matches its shading the letter
looked cropped, on the page and in the ribbon's style tiles. All four lines now
lie outside the distance, as the top and bottom ones did. Top and bottom reach
the corners and the shading fills out to the outer edges, so no seam shows
where rules and shading meet.

* #5036: pin the right border on its own in the side borders test

The test only compared the right border with the end of the top rule, and both come from the same value, so a wrong right edge still passed. Check it against the line's own bound.

* docs: progress lines for #5088

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 14:14:59 -05:00
ba9c4000ed Keep repeated spaces in LaTeX export (#5051)
Found while testing crates/formats/src/latex/export.rs:411-444 (escape_text, `c => o.push(c)` for ' ')

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 14:13:08 -05:00
Fin WarmanandBrandon Thomas e5f2885ecc Use the body boundary for header margin anchors (#2485)
* T280: anchor header drawings below tall headers

* #2485: share rendered header boundary with pins and obstacles

* docs: layout-parity status line for #2485

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-11-01 14:11:56 -05:00
Brandon ThomasandClaude Opus 5.5 5cd53f184a Release 0.14.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:58:07 -05:00
Dylan M. Taylor 9e6fa432f2 docs: sync Craft App Suite list with ArtCraft Launcher 2026-11-01 12:30:02 -05:00
aiongg 9c72ddf56a Merge pull request #5000 from benandez/integration/batch-proof
Integration batch proof: 2 approved proofing PRs (merge commit, please)
2026-11-01 10:46:45 -05:00
.CooCoo e868e2a83f Tests: border lines, tab indents and wide spaces before a word (#4235)
Tests and notes for rules that #4129, #4113, #4109 and #4101 added and
left uncovered:

- A border line at the top of a story with formatting left out of the
  Track Changes markup is not a formatting change, and Reject All keeps
  it.
- The author's own earlier formatting change on that paragraph stays the
  one change.
- Reject All keeps the table of a "+---+" line.
- A border line that a content control's lock stops Backspace from
  deleting stays as it is, and Enter is refused, untracked too.
- With tab indents on, Tab in a table cell moves to the next cell; with
  them off, Backspace before an indented paragraph joins it.
- AutoCorrect and the spelling check's web and e-mail addresses after a
  narrow no-break space and a figure space.

docs/typing-parity.md describes each, and lists a no-break space typed
after a word, which does not finish the word for AutoCorrect, as a
known gap.
2026-11-01 10:46:25 -05:00
totoz64 8686fa45ec M7.REVIEW: use normal shared CI for the Proofing pane
Remove the feature-only preview workflow from this feature PR. Retain all Rust implementation and regressions unchanged; document direct manual rendering and the exact-source historical previews.

Credit: @totoz64
2026-11-01 12:53:38 +01:00
Martin BallandClaude Opus 5.5 78f3089b19 #5000: review — a KEEPCASE word is suggested in its dictionary case, never re-cased into a flagged form
`Kmm` with `km/K` suggested `Km`, which the checker flags; `Km` itself got no
suggestion. A suggestion whose dictionary form is only correct through a
KEEPCASE stem now keeps that case (`km`). Other stems are re-cased as before
(`MIXCASX` → `MIXCASE`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCX8LsYYE8fsj4byBm2
2026-11-01 10:46:06 +00:00
Dylan M. Taylor a88e005256 ci: grant packages write permission to release workflow 2026-10-31 23:43:06 -04:00
Dylan M. Taylor 6e0f7e79ed packaging(flatpak): add OCI image build and GHCR publishing helper (#5126) 2026-10-31 18:53:02 -04:00
totoz64 3abe452b7c M7.SYNC: reconcile Proofing pane with current main
Preserve upstream changes and keep shared documentation headers aligned with main; retain feature status in its dedicated document.

Credit: @totoz64
2026-10-31 17:32:49 +01:00
e6a5cd05f0 DOCX: a paragraph naming an undefined style is formatted as Normal, as in Word (#5077) (#5104)
A pStyle id the style sheet doesn't define gave an empty style chain, so the
paragraph got the document defaults alone. Word formats it as Normal: a Pandoc
footer naming an undefined Footer style was taller than Word's and pushed the
last paragraph of the page over.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 12:03:07 -04:00
401c6c0dc2 References: a table of contents writes its page numbers as PAGEREF fields, as Word does (#5097)
A hyperlinked TOC wrote each entry's page number as plain text inside the
link. Word writes it as a PAGEREF _Toc… \h field, so Update Field in Word
moves the number with its heading; with plain text the numbers stay what
they were when the file was saved.

Entries that link to a bookmark now end with that field (result: the page
number). Entries without a link and \n levels are unchanged. The DOCX span
test looks for the TOC's own end, the last one, since PAGEREF fields now end
inside it.

Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 11:53:56 -04:00
OskarandBrandon Thomas 8afe6bef6d fix: translate highlighting color tooltips (#5008)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:50:22 -04:00
6dae5a1d17 Detect CSV separator from the first non-blank line (#5042)
Found while testing crates/engine/src/cmd/mailings/recipients.rs:6-18 (csv_separator)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:50:10 -04:00
3d966e2418 Treat <!--> and <!---> as empty comments in HTML import (#5040)
* Treat <!--> and <!---> as empty comments in HTML import

Found while testing crates/formats/src/html/lexer.rs:70-73 (Lexer::next, `<!--` branch)

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

* Docs: file-format-parity HTML row for empty comments (#5040)

---------

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:49:58 -04:00
a4bcf05e11 Layout: a kept heading goes over with a three-line paragraph that widow control moves whole (#4991)
Keep with next asked for two lines of the paragraph after a kept heading under widow/orphan
control. A three-line paragraph cannot split at all there (two lines and a widow, or an orphan
and two), so it moved over whole and left the heading alone at the foot of the page. Word moves
the heading with it: ask for all three lines in that case.

Co-authored-by: dfgodvan <180286157+dfgodvan@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:49:46 -04:00
c934977157 Keytips: Alt, F shows the Backstage pages' letters; Alt, F, P and Alt, F, V print (#4960) (#4981)
* Keytips: Alt, F shows the Backstage pages' letters; Alt, F, P and Alt, F, V print (#4960)

Keytips ended as soon as File opened the Backstage, so Alt, F, P and
Word's older Alt, F, V (Print Preview) did nothing. Alt, F now badges the
side menu with Word's letters (Home H, New N, Open O, Info I, Save S,
Save As A, Print P, Export E, Close C, Options T), and V opens the Print
page too, as it does in Word. A page picked this way acts as a click on
it does.

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

* Keytips: closing the Backstage ends its page letters (#4960)

The Backstage closed by its back chevron or a mouse Save while Alt, F's
badges showed left keytips waiting for a page letter. The next key was
eaten instead of typed, and picked a page with the Backstage gone: C
closed the document and S saved it. Keytips now end with the Backstage.

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

* Keytips: keep the doc comment on works_without_document; ui-parity and ROADMAP rows (#4960)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:32:05 -04:00
a2f387567d Printing: the print job's PDF leaves out link annotations, so documents with links print (#4734) (#4817)
* #4734: the print job's PDF leaves out link annotations

craft-print refuses to fit a page with annotations to the paper ("flatten page annotations
before using shrink-to-fit"), and the desktop app always prints with ShrinkToFit, so any
document with a hyperlink or a TOC (its entries are links) failed to print. Links print
nothing; `PdfOptions::links` (default on) lets the print job leave them out. Exported PDFs
keep them.

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

* #4734: review — the print note is in every desktop column; gaps and progress log

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

* #4734: doc wording: the print PDF omits link annotations; hardware still to validate

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

* #4734: only the printer's PDF drops links; Save these pages as PDF keeps them

Session::print_job_pdf is what ui.printSubmit sends to the spooler, without
link annotations; Session::print_pdf (file.printPdf) keeps them again.

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-31 10:26:28 -04:00
54517ace14 Word count: each Chinese and Japanese character is a word (#4547) (#4629)
count_words split only on whitespace, so unspaced Chinese or Japanese
text counted as one word per run. Word counts each Han ideograph and
kana as a word; Hangul (spaced) still counts by tokens. Fixes the status
bar, the Word Count dialog and NUMWORDS, which all use count_words.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:19:24 -04:00
LucienandBrandon Thomas a2446aa330 View › Macros opens a dialog to run, delete and record macros (#4327) (#4426)
* View › Macros opens a dialog to run, delete and record macros (#4327)

* Docs: date the Macros dialog row and bump the ui-parity status line (#4327)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:15:51 -04:00
socrabs151andBrandon Thomas 028d413ad6 #3676: page parameters saturate instead of wrapping to page one (#4289)
* fix(engine): saturate page parameters on 32-bit and wasm (#3676)

The file.exportPng and edit.goto paths cast a u64 page to usize and subtracted one. On 32-bit and wasm, page 2^32 truncated to 0 and the subtraction wrapped to page 1 (a debug panic).

Add p::page_index, which saturates instead of wrapping, and use it in both engine paths. The MCP render path already has the same guard on main; the new tests also pin that behavior.

Regression tests cover the helper, Go To, PNG export and the MCP render path.

* fix(engine): guard caret.set page cast too (#3676)

caret.set takes a 0-based page; pg as usize truncated on 32-bit and wasm and could select page 0. Use the same checked conversion and add a regression test.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:08:13 -04:00
totoz64 7c20316dd5 M7.SYNC: refresh Proofing pane against main 0.13.2 2026-10-31 09:42:33 +01:00
Dylan M. Taylor eecbb8fe57 Merge pull request #5035 from aiongg/docs-tidy
docs: tidy agent, contributor and asset docs
2026-10-31 01:06:36 -04:00
Dylan M. Taylor a20fd6216d Merge pull request #5037 from aiongg/update-result-dialog
Updates: shared check-result dialog from craft-update
2026-10-31 01:05:46 -04:00
aiongg 9baacc310a updates: show check results with craft-update's CheckResultDialog 2026-10-31 04:42:38 +00:00
aiongg e8ea394f72 docs: tidy wording 2026-10-31 04:25:41 +00:00
aiongg 55958eb0ec docs: tidy agent, contributor and asset docs 2026-10-31 04:17:23 +00:00
Brandon ThomasandClaude Opus 5.5 2be33e6dc9 Release 0.13.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:25:32 -04:00
Brandon ThomasandClaude Opus 5.5 5f551346a5 Flatpak bundle: skip the debuginfo pass manifest-wide (runner has no eu-strip)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:25:28 -04:00
Brandon ThomasandClaude Opus 5.5 f2dfa0d28d Release 0.13.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:15:41 -04:00
Brandon ThomasandClaude Opus 5.5 fc41416804 Flatpak bundle: skip the debuginfo pass for cups-client (runner has no eu-strip)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:15:38 -04:00
Brandon ThomasandClaude Opus 5.5 1fb801f6bb Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:59:22 -04:00
Dylan M. Taylor aeae018cfd fix(flatpak): allow reading launcher version pins (#5010)
* fix(flatpak): allow reading launcher version pins

* fix(flatpak): allow reading launcher version pins
2026-10-30 20:12:15 -04:00
Martin Ball 2424bf8caf Merge #4191 2026-10-30 21:16:12 +00:00
Martin Ball 05405d51a9 Merge #4575 2026-10-30 21:16:12 +00: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
totoz64 6e96b5470f PROOF.API: preserve explicit-position suggestion lookup 2026-10-30 10:44:42 +01:00
totoz64 c077c155e4 PROOF.UI: refresh Proofing pane against main 2026-10-30 10:20:06 +01: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
VoiceLessQ 0d41b18301 Engine tests: the dictionaries-folder test checks each reported file again
The shared dictionary slot reports no file while a dictionary given
with install_dictionary is in use, so this test had accepted null
alongside the folder's file, for installs from tests running at the
same time. That also let it pass if file.options stopped reporting the
folder's file at all. The tests' install helpers and this test now take
turns on one lock, and each language must report the folder's file
again. An install from before the test doesn't matter: changing the
folder reloads every slot.
2026-10-29 15:38:01 -02:00
OPUZZLES 01a1bf3921 M9: assert unused recipient labels are empty (#4880) 2026-10-29 18:14:46 +01:00
VoiceLessQ 744addabf2 Merge origin/main
main split Czech's suggest_in into one helper per round for the
cognitive_complexity limit (#4501). On this branch that code is the
shared affix_suggest::suggest_in (36 of 30), so cs/suggest.rs stays the
thin wrapper and the shared function gets the same split. The candidates
are offered in the same order; a dump of 13,081 calls over every
language's test dictionary matches the old engine except one: the typo
round counts the lower-cased letters, so "İ" (two letters lower case)
now gets "I" in Polish, where the old count stopped one letter short.
2026-10-29 14:55:30 -02: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
VoiceLessQ 62948a2c31 Merge remote-tracking branch 'upstream/main' into fix/proof-shared-language-engine
# Conflicts:
#	crates/proof/src/es/mod.rs
#	crates/proof/src/tr/mod.rs
2026-10-27 15:36:17 -02: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
totoz64 55dce8eebf ci: avoid persisting proofing-preview checkout credentials
Set persist-credentials: false for preview tests and join the two newly added documentation blockquotes as requested by review. No Rust source change.

Validation: independently reviewed exact workflow and Markdown diffs and tree hashes. Cargo fmt passed on the unchanged Rust source; cargo xtask ci was attempted but blocked by missing local dlltool.exe. Fresh hosted checks are required for this revision.
2026-10-27 17:13:05 +01:00
totoz64 e490c13cdb M6: sync Proofing pane with main module extractions
Merge main 436d444ef0. Port proofing selection/navigation and app/control/pane hooks to extracted modules, retain per-window state and stale-target guards, preserve upstream menus, and extend preview path filters. Keep the original pane implementation and targeted tests, with explicit imports after test relocation.

Workspace formatting passes. Local cargo xtask ci is blocked during Clippy by missing dlltool.exe in the Windows GNU toolchain; no local test success is claimed. Hosted navigation, UI, rendering and full checks on this tree remain required.
2026-10-27 16:57:57 +01: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
Skaywalker fa657a1c41 proof: U+0085 and U+00A0 are flags in one-flag directives too (#735) 2026-10-27 14:21:39 +01: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
Skaywalker e4765c999d proof: numeric and long flags keep a UTF-8 .aff as text (#735) 2026-10-27 13:09:33 +01:00
Skaywalker a47412aef9 proof: an AF count below 1 rejects the table, flags stay literal (#735) 2026-10-27 13:09:33 +01:00
Skaywalker 3051fab792 proof: .dic lines and alias numbers read as Hunspell reads them (#735)
Checked against the Hunspell 1.7 command line:
- Without a tab or a morphological field the whole line is the entry, so
  a phrase with single-byte flags (tök jó/396 in hu_HU) no longer fails
  the load or loses its alias; leading blanks belong to the entry.
- With an AF table a flag field is the alias its leading digits number
  (fal/2x is alias 2, lab/b has none); an undefined alias, in a .dic
  entry or a continuation class, reads as no flags instead of failing
  the dictionary; a table without its count line is ignored.
- A REP count line may carry a comment, like AF and ICONV.

normalize copies the input only from its first change on, and SET and
FLAG are found in one pass over the .aff.
2026-10-27 13:09:33 +01:00
Skaywalker 74449b87fb proof: read count comments, phrase entries and undefined aliases as Hunspell does (#735)
Checked against the Hunspell 1.7 command line:
- The first AF and ICONV line is the count even with a trailing comment
  (AF 2 # aliases), so alias numbers no longer shift and ICONV 1 # no
  longer turns 1 into #.
- A .dic entry ends where Hunspell ends it (dic_entry_end), so ad hoc/B
  is one entry and ad alone is no word.
- An undefined alias in a .dic entry reads as no flags: the stem loads
  without affixes instead of failing the whole dictionary.
- IGNORE characters are removed from the appended text only; a stripped
  text that holds one never matches a stem, as in Hunspell.
- FLAG UTF-8 and SET are found anywhere in the .aff, not only in the
  first 8 KB.

Also: normalize borrows the input when no conversion or ignored character
applies, the UTF-8 .aff reader counts fields as it goes and builds its
error context only on error, and the single-byte flag reader is
decode_latin1.
2026-10-27 13:09:33 +01:00
Skaywalker bb023684f2 proof: SUBSTANDARD stems are correct but never suggested, as with NOSUGGEST 2026-10-27 13:09:33 +01:00
Skaywalker 9b91b64d60 proof: test that unsupported directives are refused and morphology-only flags load
COMPOUNDFIRST and COMPOUNDLAST, the older names of COMPOUNDBEGIN and
COMPOUNDEND, are refused like them until compounding is supported.
2026-10-27 13:09:33 +01:00
Skaywalker efa570a9be proof: a one-flag field keeps its first byte, a directive reads only its first argument
Without FLAG UTF-8, Hunspell reads a field that names one flag (PFX/SFX,
KEEPCASE, FORBIDDENWORD and the like) through decode_flag, which takes its
first byte: SFX é is the flag 0xC3. Such a field no longer fails the load.
SYLLABLENUM lists flags and is read byte by byte like AF. SET and FLAG
read only their first argument, so FLAG UTF-8 with a trailing comment
keeps Unicode flags.
2026-10-27 13:09:33 +01:00
Skaywalker 7ebfcf0ee2 proof: .aff comments start after a directive's fields, .dic entries end where Hunspell ends them 2026-10-27 13:09:33 +01:00
Skaywalker 46a3067e20 proof: flag aliases, single-byte flags, ICONV, IGNORE and KEEPCASE in affix dictionaries (#735)
The Hungarian Hunspell dictionary declares SET UTF-8 but writes its flags as single bytes, uses AF aliases and 158 distinct flags. Read flag fields byte by byte unless FLAG UTF-8 is set, expand AF aliases, skip AM, apply ICONV and IGNORE, honour KEEPCASE, and widen flag masks to 256 bits. Compounding and NEEDAFFIX stay unsupported for now.
2026-10-27 13:09:33 +01: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
totoz64 0cd06c786c M7: normalize regression module spacing
Remove the extra blank line before the new navigation regression module's closing brace. No behavior change.

Local cargo xtask ci was attempted but cargo is absent; git diff --check passes. Hosted checks must validate this new SHA.
2026-10-26 16:57:22 +01:00
totoz64 62f9050848 M7: resolve exact proofing selections and address pane review
Give the exact selected issue priority over overlapping grammar when querying the focus, including reversed selections. Keep point lookup for explicit other positions. Add command and pane regressions for overlapping repeated misspellings, correction and undo.

Include command/window/document/canvas integration paths in preview workflow triggers. Preserve historical three-platform evidence while recording f126's successful gate/previews/Windows/Linux and outstanding macOS validation after pre-run cancellations.

These address the three CodeRabbit findings on official #4471. Local cargo xtask ci was attempted but cargo is absent; git diff --check passes. New hosted checks must validate this SHA before any claim of success.
2026-10-26 16:53:47 +01:00
totoz64 f1264f3047 M7: synchronize Proofing pane with main's test import repair
Upstream #4401 merged the baseline Page/Rgb import fix. Keep main's exact tests_linear.rs and merge its current head, removing the equivalent temporary import patch from the final official diff. No proofing/UI behavior changes.

The last fully validated pane revision is 056b18b; the subsequent Read Aloud alignment and this merge need hosted checks and new PNG inspection. Local cargo xtask ci was attempted but cargo is absent; git diff --check passes.
2026-10-26 15:51:02 +01:00
totoz64 057350ef6e M7: keep the proofing Read Aloud control at the context edge
The 056b18b preview confirms full-width context and visible actions, but the text child still shrinks to its contents and pulls the Read Aloud control inward. Keep that child at its allocated width so the control aligns at the right edge as observed in Word.

All five workflows passed at parent 056b18b; both light/dark artifacts were downloaded and inspected. This alignment revision needs new hosted checks and preview inspection. Local cargo xtask ci was attempted but cargo is absent; git diff --check passes.
2026-10-26 15:48:32 +01:00
totoz64 056b18b714 M7: match proofing pane geometry after rendered comparison
Inspect the successful 20205a5 light/dark artifacts against the observed Word arrangement. Fill the sentence frame across the pane and bound the suggestion list to roughly three cards, retaining all results through scrolling and keeping ignore/dictionary actions below the list visible.

The direct-correction/undo regression now clicks the first visible engine suggestion rather than relying on an offscreen fourth card. Update comparison/progress records. Normalize the baseline test file's line endings so its final diff contains only the two missing imports.

Parent 20205a5 passed full CI, three-platform Printing/UI checks, targeted proofing tests and preview generation. This layout revision needs new hosted validation and inspected PNGs. Local cargo xtask ci was attempted but cargo is absent; git diff --check passes.
2026-10-26 12:57:39 +01: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
totoz64 20205a5051 M7: repair baseline test imports and publish proofing previews
The full gate found missing Page/Rgb imports in main's tests_linear.rs (also tracked by upstream #4401). Add the imports already validated with the French contribution. No layout behavior changes.

The preview run rendered both images and passed all six correction tests, but upload searched the workspace root. Cargo tests run in the crate directory; collect the PNGs from crates/ui-egui instead.

At parent 574cef, Printing passed on Linux, macOS and Windows, including UI tests and browser compatibility. Local cargo xtask ci was attempted but cargo is absent; git diff --check passes. This revision needs new hosted validation.
2026-10-26 11:11:10 +01:00
totoz64 574cef34c9 M7: use current egui accessibility roles for proofing controls
Replace two deprecated WidgetType aliases with Role::Button, as required by the hosted Windows and macOS Clippy checks.

Local cargo xtask ci was attempted but cargo is not installed on this machine. git diff --check passes. Hosted CI and the preview workflow must validate this revision.
2026-10-26 10:27:57 +01:00
totoz64 9e4adaf320 M7: apply the hosted rustfmt corrections to the proofing pane
Apply the six formatting hunks reported by the exact-head Printing/CI runs. No behavior change. cargo xtask ci was attempted locally but cargo is absent; hosted checks must validate this revision.
2026-10-26 10:21:10 +01:00
totoz64 f6f9d65b0e M7: follow the observed Word for Windows proofing pane
Observe F7 in the installed Word with synthetic text, then replace the central correction form with a right-side task pane. Show sentence context, directly clickable suggestion cards and ignore/dictionary links. Use the existing pane host, palette and original icons; copy no Microsoft assets.

Keep per-window state and clear targets on document switch. Adapt UI tests, pseudo-localization exemptions and light/dark previews. Record observed measurements and remaining feature differences. Local cargo xtask ci was attempted but cargo is absent; exact-head hosted checks and visual comparison remain required.
2026-10-26 10:16:49 +01:00
totoz64 b2787bf36e M7: add a persistent language-aware spelling review dialog
Keep compact context menus, but show a correction form for user spelling/editor commands. Advance without wrapping after change, ignore or dictionary actions; reject stale targets and retain programmatic command results.

Add navigation/UI regressions and light/dark preview CI. cargo xtask ci was attempted on the preparation PC but cargo is absent; hosted validation is required before integration.
2026-10-26 10:00:13 +01: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
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
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
aiongg 823ce76e39 Pin craft-libs at 8a503cf (Adobe-style drop targets, craft-libs#310) 2026-10-25 03:55:42 +00: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
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
VoiceLessQ 4c9ff5db35 Proofing: a dictionary load from before a folder change is not kept
A dictionary is read outside the lock. If the dictionaries folder changed
while it was being read (File › Options › Proofing), the reload emptied
the slot first and the late load then filled it with the old folder's
dictionary. It stayed until the next reload. Every language's copy of
the loader had this window; the shared slot now closes it in one place.

The slot counts reloads. A load that started before a reload still
answers the check that asked for it, but it isn't kept: the next check
reads the new folder.

Found by CodeRabbit on #4191. Tests: a_load_from_before_a_reload_is_not_kept;
not counting reloads fails it. Proof: 288 tests.
2026-10-24 20:57:37 -01:00
VoiceLessQ 4dfdecf3d7 Proofing: a candidate found again at a lower cost ranks at that cost
The suggestion rounds don't run in rising cost: a MAP diacritic drop (11)
comes before a REP confusion (10, 9 for Russian). The engine checked each
candidate once, at the first cost it was offered, so the same word found
again more cheaply kept the dearer cost and could rank one place too low.
Czech's engine always did this, and the merged engine took it over.

The candidate set is now a map to the cheapest cost offered so far, and
an offer that is no cheaper is skipped as before.

Found by CodeRabbit on #4191. Tests: a_cheaper_later_offer_wins; skipping
every repeat offer again fails it. Proof: 288 tests.
2026-10-24 20:57:35 -01:00
VoiceLessQ e0014ff1d9 Proofing: the spelling options are read in one place
Czech, Croatian, Indonesian, Italian, Polish, Portuguese and Spanish began
their spelling checks with the same thirteen lines, byte for byte: no
dictionary means no marks, single letters and words without letters pass,
words with digits and short words in capitals pass when the File › Options
› Proofing choices say so, and words in another script pass. Each also had
its own copy of the Latin-script test. These were the lines #3966 had to fix
in six copies one by one, and where the Russian and Turkish copies stayed
wrong until the first commit here.

spelling.rs has them once (`word_to_check`), and each language goes on
from there with its own rules (Polish apostrophe endings, Italian elisions,
Indonesian clitics, Uruguayan `vos` forms).

Tests: words_needing_no_lookup. Ignoring the uppercase option there fails
it and all seven languages' proofing-options tests. Proof: 286 tests.
2026-10-24 19:58:30 -01:00
VoiceLessQ 9a8f4669fa Proofing: one language table instead of nine lists
lib.rs named every dictionary language in nine places: reloading for a new
folder, the installed list, load errors, the missing-dictionary hint, the
loaded check, the spelling message, spelling, suggestions and grammar. The
lists had drifted. dictionary_load_error had no Russian or Czech entry, so a
Russian or Czech dictionary that failed to load showed no reason in File ›
Options › Proofing, though the status bar, which reads another list, said
why.

LANGUAGES holds one row per language: its dictionary slot, its spelling
message, and its spelling, suggestion and grammar functions. The nine
functions read the row, so a language now joins with its module, its
ProofLang and one row. The per-language reload_dictionary and
installed_path wrappers had no callers left, inside the crate or out, and
are gone.

Tests: every_dictionary_language_reports_its_load_error, which fails for
Russian on the old code, and the_language_table_covers_the_proofed_languages
(each ProofLang::PROOFED language but English has exactly one row). Proof
285, engine 927 and layout 501 (lib).
2026-10-24 19:55:43 -01:00
VoiceLessQ e7eda2aff0 Proofing: one suggestion engine with per-language tuning
Czech's dictionary-driven ranking (case, MAP, REP, missing spaces, KEY,
TRY edits, a stem scan) had five older copies. Polish, Portuguese,
Croatian, Italian and Russian each kept one: 300 to 400 lines of the same
rounds with a few lines of language knowledge mixed in. Fixes reached some
copies only. NOSUGGEST (#1048) and the first-difference tie-break (#797)
never got to all of them, and none had the KEY round or the bound on TRY
letters.

affix_suggest.rs is that ranking once. A language's `Tuning` holds what is
its own: the short words that run into their neighbours (Polish,
Portuguese, Croatian, Italian), Russian's split rule, the letter groups and
confusions its dictionaries lack (Croatian, Russian), letters to try for a
dictionary without a TRY line, and cost hooks for Polish final devoicing
and Russian е/ё. Czech, Indonesian and Spanish use the dictionary's tables
alone. Turkish keeps its own suggester, which reads another dictionary type
with rounds of its own.

The language tables add to the dictionary's instead of replacing them, and
TRY letters are tried in the dictionary's order, commonest first, as the
copies did (the Czech engine sorted them).

Old and new compared on the test dictionaries, with every one-letter typo
of each word plus the tests' misspellings, top eight suggestions: Czech,
Croatian, Italian and Portuguese give the same for all 7,903 inputs.
Polish differs for 44 of 3,244 and Russian for 33 of 2,346, all orderings
among candidates of equal cost, from the tie-break they now share (`ie` →
`nie` before `i`).

New tests pin the moved knowledge: the tuning mechanism, Croatian's groups
without MAP lines, and Russian's costs and split rule. Turning each knob
off fails one. Proof: 283 tests.
2026-10-24 19:49:22 -01:00
VoiceLessQ 577d5d61f8 Proof tests: the user-dictionary file test waits for the dictionary tests
user_dictionary_persists_through_a_file saves the whole user dictionary to
a file and loads it back. Run beside
user_dictionary_and_ignore_all_match_both_normalization_forms, it could
catch a word that test had added and bring it back after the test removed
it, so the other test's last check failed now and then (once in about two
dozen full runs here). It now holds DICTIONARY_TESTS, as the other test
does.
2026-10-24 19:48:47 -01:00
VoiceLessQ d2f45ddd96 Proofing: one dictionary slot for every language
Nine languages each kept their own copy of the same code: the installed
dictionary behind a lock, read on first use and logged, replaced by
install_dictionary, forgotten by reload_dictionary, and its file reported
by installed_path. The copies had drifted already. Every one documented that
a dictionary given with install_dictionary has no file and isn't reported,
but only Russian and Czech did that (#3252); the other seven reported the
disk file anyway.

`DictSlot` (proof/src/slot.rs) is that code once. It is generic over the
dictionary type, so Turkish's `TrDict` fits, and keeps the dictionary and
its in-memory origin under one lock as #3252 made it. Each language keeps
its functions as one-line wrappers over its slot, so no caller changes, and
the log lines are the same.

installed_path now follows the documented rule in every language. Nothing
outside tests installs a dictionary in memory, but tests do, concurrently,
so the dictionaries-folder tests in proof and engine could race on the
reported path of a language another test installs. They now accept the
folder's file or none there, and check that the folder is searched first
with hunspell::find, which no install affects.

Tests: proof 279, engine 927, layout 501 (lib); slot.rs has a test of its
own.
2026-10-24 19:33:17 -01:00
VoiceLessQ 281392ac15 Croatian, Italian and Russian suggestions skip NOSUGGEST words
#1048 (6e25d482c) taught affix.rs about NOSUGGEST, the dictionary flag for
words that are correct but never offered (vulgarisms, rare forms), and
patched the Polish and Portuguese copies of the suggestion engine; Czech's
started with it. The Croatian, Italian and Russian copies never got it: they
test candidates with `contains`, so a suggestion could be a word the
dictionary says not to offer. They now use `contains_suggestible` and drop a
suggestion with any part `can_suggest` refuses, as the others do.

Tests: Croatian and Italian run the shared assert_nosuggest_is_honoured.
Russian gets its own, since its MAP and REP tables are built in and the
shared Latin-script check doesn't reach them. Each fails on the old code.
2026-10-24 19:21:57 -01:00
aiongg 401d811fa0 Task panes: every pane keeps its dock or float place between runs (#1756) 2026-10-24 20:20:20 +00:00
VoiceLessQ 695b2688d8 Proofing options reach the Russian and Turkish checks too
#3966 made Ignore words in UPPERCASE and Ignore words that contain numbers
reach six languages' own checks, one copy at a time, and Russian and Turkish
were left out. lib.rs still called `ru::is_correct(word)` and
`tr::is_correct(word)`, which accept words with digits (both) and short words
in capitals (Russian) whatever the options say, so turning either option off
changed nothing in those languages.

Both now have `is_correct_with_options`, like the others. Russian's rule for
a part in capitals (`ИИ-консультант`) follows the option too, as Indonesian's
does, and its digits rule keeps counting superscripts (`м²`). Turkish had no
test dictionary, so its two dictionary tests returned early wherever none was
installed, CI included; it has a synthetic one now, also in the
dictionaries-folder test.

Tests: proofing_options_reach_the_russian_check and
proofing_options_reach_the_turkish_check. Calling the old checks from lib.rs
fails each, and so does ungating the Russian part rule or the Turkish hyphen
recursion.
2026-10-24 19:19:25 -01: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
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
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
707 changed files with 153497 additions and 184587 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"
+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')
+22
View File
@@ -6,6 +6,7 @@ on:
pull_request:
paths:
- 'Cargo*'
- 'craft-i18n.rev'
- 'apps/wordcraft/**'
- 'apps/wordcraft-web/**'
- 'crates/ui-egui/**'
@@ -14,6 +15,7 @@ on:
branches: [main]
paths:
- 'Cargo*'
- 'craft-i18n.rev'
- 'apps/wordcraft/**'
- 'apps/wordcraft-web/**'
- 'crates/ui-egui/**'
@@ -41,6 +43,26 @@ jobs:
CARGO_INCREMENTAL: '0'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The interface catalogs and craft-proof's Language data come from craft-i18n at the commit in
# craft-i18n.rev, so these tests build with the same data as the gate (ci.yml).
- name: Check out craft-i18n
uses: storytold/craft-common-actions/actions/checkout-craft-i18n@main
# The catalogs include CJK interface languages, so the UI tests need the suite's fonts, as in
# the gate (same commit as ci.yml and release.yml).
- name: Check out craft-fonts
uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main
with:
ref: b076417b5880bd4154bd22f00695b1d28c6bea83
- name: Build with craft-fonts
shell: bash
run: |
echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV"
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
- name: Install Linux build and software-rendering dependencies
if: runner.os == 'Linux'
+5 -1
View File
@@ -11,7 +11,7 @@ on:
default: ""
permissions:
contents: write
contents: read
concurrency:
group: release-${{ github.ref }}
@@ -19,6 +19,10 @@ concurrency:
jobs:
release:
# Only this job publishes the GitHub release, so only it gets write access.
permissions:
contents: write
packages: 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:
+2
View File
@@ -31,3 +31,5 @@ jobs:
with:
app-name: wordcraft
app-dir: WordCraft
install-relative-path: Programs/ArtCraft/WordCraft
install-scope: perUser
+14 -11
View File
@@ -1,13 +1,13 @@
# WordCraft — instructions for agents
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign). Shared rules and learnings: [`storytold/craftrules`](https://github.com/storytold/craftrules) (`../../craftrules`) — read its `AGENTS.md`.
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. `plan/` is gitignored (local only); if it's missing, start from `ROADMAP.md`, `docs/gaps.md` and `docs/roadmap.md` (Current focus).
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §5). Don't stop to ask unless a decision is genuinely the owner's (licensing, publishing, pushing to new remotes, secrets).
## Never crash
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it. Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it.
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or is disabled; it never panics.
- **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.
@@ -20,7 +20,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
- Never copy, trace, screenshot-crop or recreate icons, images, templates, themes, clip art, fonts, cursors, sounds or any other asset from Microsoft Word/Office or any Microsoft, Adobe, Avid or Autodesk product. Not even "temporarily".
- Every asset must be **original** (drawn in code — prefer this: `crates/ui-egui/src/icons.rs`), **public domain / CC0**, **OSI open source**, or **Creative Commons that allows redistribution**, or licensed open source by a contributor who made it.
- **Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (author, source, licence). `cargo xtask assets` (part of `cargo xtask ci`) fails otherwise. Add the row in the same commit as the file.
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Spelling word lists and hyphenation patterns live in [`storytold/craft-i18n`](https://github.com/storytold/craft-i18n) `language-data/`, never this repo; they are embedded from `CRAFT_I18N_DIR` (pinned by `craft-i18n.rev`, which CI checks out). Without it the app builds, but English spelling falls back to an installed Hunspell English or the built-in Moby word list (with no American/British variant table) and pattern hyphenation is off (Bulgarian, Turkish and Russian still hyphenate by rule), so proofing tests need it. Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
- Templates, sample documents and themes are our own text and colours (`crates/engine/src/sample.rs`, `cmd/design.rs`). Demo images are public domain, with sources recorded.
## Clean room
@@ -34,11 +34,12 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
| L0 | `geom` | units, rects, measurement parsing |
| L1 | `doc`, `fonts` | document model + edit primitives; font DB, shaping, Word font substitution |
| L2 | `layout`, `docx`, `formats` | line breaking, pagination, tables, headers/footers, hit testing; OOXML I/O; TXT/MD/HTML/RTF/ODT |
| L3 | `render`, `pdf`, `proof` | vello_cpu rasteriser; krilla PDF; proofing |
| L3 | `render`, `pdf` | vello_cpu rasteriser; krilla PDF |
| L4 | `engine` | Session, undo, **command registry**, Word feature catalog, samples, I/O dispatch |
| L5 | `mcp` | MCP server (headless or bridged to the app) |
| L6 | `ui-egui` | Word-style egui front end (swappable) |
| apps | `apps/wordcraft`, `apps/wordcraft-cli`, `apps/wordcraft-web` | binaries |
| external | `craft-proof` (craft-libs, used as `wordcraft-proof`, sits at L1) | proofing: spelling, grammar, hyphenation; its word lists and patterns are craft-i18n's `language-data/` (`CRAFT_I18N_DIR`) |
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
@@ -50,7 +51,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,22 +64,24 @@ Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's o
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
## 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.
Progress docs: `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.
## Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See `docs/contributors.md`.
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
(copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
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.
+10 -18
View File
@@ -10,15 +10,6 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|---|---|---|---|---|
| `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) |
| `assets/spelling/en-variants.txt` | WordCraft contributors | original list written for this repository from general knowledge of English spelling; no other dictionary or variant list copied | MIT OR Apache-2.0 | ~280 American/British spelling pairs (-or/-our, -er/-re, -ize/-ise, -yze/-yse, -ense/-ence, doubled l, ae/oe…) with a policy per English: en-US, en-CA and the British-spelling Englishes (#1281, #1045) |
| `assets/spelling/ru-extra.txt` | WordCraft contributors | original word list written for this repository (common modern Russian words older Hunspell dictionaries lack, with their declension classes); no other dictionary copied | MIT OR Apache-2.0 | ~200 Russian base forms supplementing spelling; their forms are generated by `crates/proof/src/ru/extra.rs` |
| `assets/hyphenation/pl.pat` | Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko (Polish TeX hyphenation patterns, 1987–1995); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pl.tex`, SHA-256 `83ddbbd21bc06a44bc4f4a4c49c38782e809cff5f026b764c044daa29e818843`), bundled unmodified | MIT (the file is offered under MIT or Knuth's public-domain terms; WordCraft uses MIT, whose notice is in the file's header) | 4,053 Liang patterns and 20 exception words; Polish hyphenation |
| `assets/hyphenation/pt.pat` | Pedro J. de Rezende, J. Joao Dias Almeida, Leonardo Araujo, Aline Benevides (Portuguese TeX hyphenation patterns, 1987–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-pt.tex`, SHA-256 `c110a39e501db372d1d755e1c64464321a2efa7d8e9617f66001000389b941f2`), bundled unmodified | BSD 3-clause (the file's licence header names it and carries the notice) | 426 Liang patterns and 2 exception words; Portuguese hyphenation |
| `assets/hyphenation/it.pat` | Claudio Beccari (Italian TeX hyphenation patterns, 2008–2014); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-it.tex`, SHA-256 `6ce56ed6ed2ca7c0688838e366e6595e0429f984f4fc6e96ec045cebb1e76a47`), bundled unmodified | MIT (the file is offered under LPPL 1.3+ or MIT; WordCraft uses MIT, whose notice is in the file's header) | 355 Liang patterns, no exception words; Italian hyphenation |
| `assets/hyphenation/de.pat` | Deutschsprachige Trennmustermannschaft: Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven, Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr (German TeX hyphenation patterns, reformed spelling, 2013–2024); hyph-utf8 package | https://github.com/hyphenation/tex-hyphen (`hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-de-1996.tex`, SHA-256 `374ad1ce3263f8a2791ec070ef9b516c532dae64456f3fb656e617e8ae53a9d3`), bundled unmodified | MIT (the notice is in the file's header) | 36,709 Liang patterns, no exception words; German hyphenation (de-DE, de-AT, de-CH) |
| `assets/app-icon/wordcraft-1024.png` | WordCraft contributors | original artwork drawn in SVG (`assets/app-icon/wordcraft.svg`), rendered by `packaging/icons.sh` | MIT OR Apache-2.0 | Placeholder app icon until the WordCraft mascot drawing lands |
| `packaging/macos/dmg/background.svg` | @XusBadia | original work for this repository, derived from the WordCraft app icon (`assets/app-icon/wordcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | WordCraft macOS DMG window background (2026-10-08); used for the Finder window of the macOS DMG, rendered to `background.tiff` by `packaging/macos/dmg/generate.py` |
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0, like the code ([`LICENSE-MIT`](LICENSE-MIT), [`LICENSE-APACHE`](LICENSE-APACHE)) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
@@ -59,14 +50,15 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/images/dark.png` | WordCraft contributors | screenshot of WordCraft itself (sample document written for WordCraft, rendered by `ui_shot`) | MIT OR Apache-2.0 | Original; no Microsoft UI or assets |
| `docs/images/backstage.png` | WordCraft contributors | screenshot of WordCraft itself (sample document written for WordCraft, rendered by `ui_shot`) | MIT OR Apache-2.0 | Original; no Microsoft UI or assets |
| `docs/images/layout.png` | WordCraft contributors | screenshot of WordCraft itself (sample document written for WordCraft, rendered by `ui_shot`) | MIT OR Apache-2.0 | Original; no Microsoft UI or assets |
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Not open source; usable unmodified only |
| craft-fonts (optional build input, not files in this repo): BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic | see https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | embedded only when built with `CRAFT_FONTS_DIR` (all official releases) | OFL-1.1 | Japanese and Chinese UI, Japanese document fallback |
| craft-i18n (optional build input, not files in this repo): the interface translations | see https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md, which credits every translator of WordCraft's former in-repo catalogs (`crates/ui-egui/src/i18n/*.tsv`, migrated at `wordcraft@806a945`) | embedded only when built with `CRAFT_I18N_DIR`, at the commit in `craft-i18n.rev` (all official releases) | MIT OR Apache-2.0 | Interface languages other than English; clean-room translations from the English interface labels |
| craft-i18n Language data (optional build input, not files in this repo): the spelling word lists and hyphenation patterns (`language-data/`: the Moby Hyphenator II list and our patterns for English, `en-extra.txt`, `en-variants.txt`, `ru-extra.txt`, and the hyph-utf8 German, Polish, Portuguese and Italian patterns) | see the Language data section of https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md (authors, upstream sources with SHA-256, licences); moved there from `assets/hyphenation/` and `assets/spelling/` byte for byte | embedded by craft-proof (storytold/craft-libs) only when built with `CRAFT_I18N_DIR`, at the commit in `craft-i18n.rev` (all official releases) | Public Domain / CC0-1.0, MIT, BSD-3-Clause, MIT OR Apache-2.0 (per file; see NOTICE) | Spelling and hyphenation; without it English spelling falls back to an installed Hunspell English or craft-proof's built-in Moby list, and pattern hyphenation is off |
+13 -7
View File
@@ -1,26 +1,26 @@
# WordCraft — instructions for agents
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign). Shared rules and learnings: [`storytold/craftrules`](https://github.com/storytold/craftrules) (`../../craftrules`) — read its `AGENTS.md`.
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. `plan/` is gitignored (local only); if it's missing, start from `ROADMAP.md`, `docs/gaps.md` and `docs/roadmap.md` (Current focus).
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §5). Don't stop to ask unless a decision is genuinely the owner's (licensing, publishing, pushing to new remotes, secrets).
## Never crash
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it. Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it.
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or is disabled; it never panics.
- **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.
- Never copy, trace, screenshot-crop or recreate icons, images, templates, themes, clip art, fonts, cursors, sounds or any other asset from Microsoft Word/Office or any Microsoft, Adobe, Avid or Autodesk product. Not even "temporarily".
- Every asset must be **original** (drawn in code — prefer this: `crates/ui-egui/src/icons.rs`), **public domain / CC0**, **OSI open source**, or **Creative Commons that allows redistribution**, or licensed open source by a contributor who made it.
- **Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (author, source, licence). `cargo xtask assets` (part of `cargo xtask ci`) fails otherwise. Add the row in the same commit as the file.
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Spelling word lists and hyphenation patterns live in [`storytold/craft-i18n`](https://github.com/storytold/craft-i18n) `language-data/`, never this repo; they are embedded from `CRAFT_I18N_DIR` (pinned by `craft-i18n.rev`, which CI checks out). Without it the app builds, but English spelling falls back to an installed Hunspell English or the built-in Moby word list (with no American/British variant table) and pattern hyphenation is off (Bulgarian, Turkish and Russian still hyphenate by rule), so proofing tests need it. Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
- Templates, sample documents and themes are our own text and colours (`crates/engine/src/sample.rs`, `cmd/design.rs`). Demo images are public domain, with sources recorded.
## Clean room
@@ -34,11 +34,12 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
| L0 | `geom` | units, rects, measurement parsing |
| L1 | `doc`, `fonts` | document model + edit primitives; font DB, shaping, Word font substitution |
| L2 | `layout`, `docx`, `formats` | line breaking, pagination, tables, headers/footers, hit testing; OOXML I/O; TXT/MD/HTML/RTF/ODT |
| L3 | `render`, `pdf`, `proof` | vello_cpu rasteriser; krilla PDF; proofing |
| L3 | `render`, `pdf` | vello_cpu rasteriser; krilla PDF |
| L4 | `engine` | Session, undo, **command registry**, Word feature catalog, samples, I/O dispatch |
| L5 | `mcp` | MCP server (headless or bridged to the app) |
| L6 | `ui-egui` | Word-style egui front end (swappable) |
| apps | `apps/wordcraft`, `apps/wordcraft-cli`, `apps/wordcraft-web` | binaries |
| external | `craft-proof` (craft-libs, used as `wordcraft-proof`, sits at L1) | proofing: spelling, grammar, hyphenation; its word lists and patterns are craft-i18n's `language-data/` (`CRAFT_I18N_DIR`) |
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
@@ -49,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).
@@ -62,4 +63,9 @@ Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's o
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
## 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.
Progress docs: `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
+195 -54
View File
@@ -235,7 +235,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -809,9 +809,9 @@ dependencies = [
[[package]]
name = "clipboard-win"
version = "5.4.1"
version = "5.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a"
dependencies = [
"error-code",
]
@@ -1011,10 +1011,54 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-atomic"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=762a06a4c6b48b522b7d4de84a87917ef90265e6#762a06a4c6b48b522b7d4de84a87917ef90265e6"
dependencies = [
"rustix 1.1.5",
]
[[package]]
name = "craft-font-catalog"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=762a06a4c6b48b522b7d4de84a87917ef90265e6#762a06a4c6b48b522b7d4de84a87917ef90265e6"
dependencies = [
"fontique",
"skrifa 0.47.0",
"thiserror 2.0.21",
"windows-registry",
]
[[package]]
name = "craft-font-store"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=762a06a4c6b48b522b7d4de84a87917ef90265e6#762a06a4c6b48b522b7d4de84a87917ef90265e6"
dependencies = [
"craft-atomic",
"craft-font-catalog",
"flate2",
"log",
"ruzstd",
"serde",
"serde_json",
"sha2 0.10.9",
"skrifa 0.47.0",
"tar",
"thiserror 2.0.21",
"toml",
"ureq",
]
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1037,7 +1081,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -1046,34 +1090,58 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"craft-winprint-gdi",
"miniz_oxide 0.8.9",
"miniz_oxide 0.9.1",
"roxmltree",
"tempfile",
"winprint",
"winsafe",
]
[[package]]
name = "craft-proof"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=47c9f62530db72bc5c3831c86653b47b20c640be#47c9f62530db72bc5c3831c86653b47b20c640be"
dependencies = [
"log",
"miniz_oxide 0.9.1",
"toml",
"unicode-normalization",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"craft-fonts-build",
"craft-i18n",
"egui",
"epaint",
"js-sys",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"skrifa 0.44.0",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"semver",
@@ -1089,6 +1157,17 @@ dependencies = [
"zip 9.1.0",
]
[[package]]
name = "craft-window"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"egui",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"wayland-client",
]
[[package]]
name = "craft-winprint-gdi"
version = "0.1.0"
@@ -1179,9 +1258,9 @@ dependencies = [
[[package]]
name = "ctutils"
version = "0.4.3"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a"
dependencies = [
"cmov",
]
@@ -1573,7 +1652,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1772,6 +1851,28 @@ dependencies = [
"bytemuck",
]
[[package]]
name = "fontique"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a"
dependencies = [
"hashbrown 0.17.1",
"linebender_resource_handle",
"memmap2",
"objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-text",
"objc2-foundation 0.3.2",
"parlance",
"read-fonts 0.41.0",
"roxmltree",
"smallvec",
"windows",
"windows-core",
"yeslogic-fontconfig-sys",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -2826,7 +2927,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -3305,6 +3406,16 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.13.2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -3544,7 +3655,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3576,6 +3687,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "parlance"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e"
[[package]]
name = "pdf-writer"
version = "0.15.0"
@@ -4038,7 +4155,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4338,9 +4455,12 @@ dependencies = [
[[package]]
name = "roxmltree"
version = "0.20.0"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
dependencies = [
"memchr",
]
[[package]]
name = "rustc-hash"
@@ -4386,7 +4506,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4445,7 +4565,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4484,6 +4604,12 @@ dependencies = [
"wait-timeout",
]
[[package]]
name = "ruzstd"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
[[package]]
name = "ryu"
version = "1.0.23"
@@ -5229,10 +5355,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5691,6 +5817,7 @@ dependencies = [
"rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
@@ -6074,6 +6201,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
@@ -6269,7 +6405,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6616,12 +6752,13 @@ dependencies = [
[[package]]
name = "wordcraft"
version = "0.11.0"
version = "0.15.0"
dependencies = [
"arboard",
"chrono",
"clipboard-win",
"craft-print",
"craft-proof",
"craft-update",
"dispatch2",
"eframe",
@@ -6644,14 +6781,13 @@ dependencies = [
"wordcraft-doc",
"wordcraft-engine",
"wordcraft-fonts",
"wordcraft-proof",
"wordcraft-ui-egui",
"zbus",
]
[[package]]
name = "wordcraft-cli"
version = "0.11.0"
version = "0.15.0"
dependencies = [
"serde_json",
"sys-locale",
@@ -6664,11 +6800,11 @@ dependencies = [
[[package]]
name = "wordcraft-control-key"
version = "0.11.0"
version = "0.15.0"
[[package]]
name = "wordcraft-doc"
version = "0.11.0"
version = "0.15.0"
dependencies = [
"base64 0.23.1",
"log",
@@ -6682,7 +6818,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.11.0"
version = "0.15.0"
dependencies = [
"cfb",
"chrono",
@@ -6699,7 +6835,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.11.0"
version = "0.15.0"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6723,10 +6859,13 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.11.0"
version = "0.15.0"
dependencies = [
"cfb",
"chrono",
"craft-font-catalog",
"craft-font-store",
"craft-proof",
"encoding_rs",
"getrandom 0.3.4",
"image",
@@ -6746,7 +6885,6 @@ dependencies = [
"wordcraft-geom",
"wordcraft-layout",
"wordcraft-pdf",
"wordcraft-proof",
"wordcraft-render",
"zeroize",
"zip 3.0.0",
@@ -6754,7 +6892,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.11.0"
version = "0.15.0"
dependencies = [
"brotli-decompressor",
"harfrust 0.13.3",
@@ -6768,7 +6906,7 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.11.0"
version = "0.15.0"
dependencies = [
"cfb",
"chardetng",
@@ -6781,12 +6919,13 @@ dependencies = [
"quick-xml 0.41.0",
"wordcraft-doc",
"wordcraft-geom",
"wordcraft-metafile",
"zip 3.0.0",
]
[[package]]
name = "wordcraft-geom"
version = "0.11.0"
version = "0.15.0"
dependencies = [
"image",
"serde",
@@ -6794,8 +6933,9 @@ dependencies = [
[[package]]
name = "wordcraft-layout"
version = "0.11.0"
version = "0.15.0"
dependencies = [
"craft-proof",
"image",
"js-sys",
"kurbo",
@@ -6809,12 +6949,11 @@ dependencies = [
"wordcraft-fonts",
"wordcraft-geom",
"wordcraft-metafile",
"wordcraft-proof",
]
[[package]]
name = "wordcraft-mcp"
version = "0.11.0"
version = "0.15.0"
dependencies = [
"log",
"serde_json",
@@ -6826,7 +6965,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.11.0"
version = "0.15.0"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -6834,7 +6973,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.11.0"
version = "0.15.0"
dependencies = [
"chrono",
"craft-pdf",
@@ -6853,19 +6992,9 @@ dependencies = [
"wordcraft-metafile",
]
[[package]]
name = "wordcraft-proof"
version = "0.11.0"
dependencies = [
"log",
"miniz_oxide 0.9.1",
"proptest",
"unicode-normalization",
]
[[package]]
name = "wordcraft-render"
version = "0.11.0"
version = "0.15.0"
dependencies = [
"image",
"log",
@@ -6879,13 +7008,14 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.11.0"
version = "0.15.0"
dependencies = [
"cfb",
"chrono",
"craft-i18n",
"craft-ui",
"craft-update",
"craft-window",
"egui",
"egui_kittest",
"image",
@@ -6909,7 +7039,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.11.0"
version = "0.15.0"
dependencies = [
"eframe",
"egui",
@@ -6928,7 +7058,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.11.0"
version = "0.15.0"
dependencies = [
"log",
"proptest",
@@ -7091,11 +7221,22 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.11.0"
version = "0.15.0"
dependencies = [
"serde_json",
]
[[package]]
name = "yeslogic-fontconfig-sys"
version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
dependencies = [
"dlib",
"once_cell",
"pkg-config",
]
[[package]]
name = "yoke"
version = "0.8.4"
+17 -6
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.15.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -21,7 +21,15 @@ wordcraft-docbin = { path = "crates/docbin" }
wordcraft-formats = { path = "crates/formats" }
wordcraft-render = { path = "crates/render" }
wordcraft-pdf = { path = "crates/pdf" }
wordcraft-proof = { path = "crates/proof" }
# Proofing is craft-libs' craft-proof (it was crates/proof); its Language data is craft-i18n's
# language-data/, embedded from CRAFT_I18N_DIR (craft-i18n.rev). The package keeps the
# `wordcraft-proof` name here so `wordcraft_proof::` paths are unchanged.
# craft-proof is storytold/craft-libs main at the merge of #497 (flag-based compounding), which
# also has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
# `language-pack` kind is #486 (CP4.1).
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "47c9f62530db72bc5c3831c86653b47b20c640be", version = "=0.1.0" }
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
wordcraft-engine = { path = "crates/engine" }
wordcraft-mcp = { path = "crates/mcp" }
wordcraft-zotero = { path = "crates/zotero" }
@@ -58,8 +66,9 @@ wuff = "0.2.9"
brotli-decompressor = "5"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", 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.
@@ -96,9 +105,11 @@ arboard = { version = "3.7", default-features = false, features = ["wayland-data
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
@@ -120,7 +131,7 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "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" }
+71 -7
View File
@@ -20,13 +20,77 @@ list (author, source, license) is in ATTRIBUTION.md.
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, BIZ UDMincho,
Shippori Mincho), under the SIL Open Font License 1.1. Their licence texts ship with the
packages as OFL-<family>.txt. Builds without CRAFT_FONTS_DIR do not include them.
- English word list (assets/hyphenation/en-us.dic) — derived from the Moby Hyphenator II list by
- Language data (craft-i18n): the word lists, dictionaries and hyphenation patterns below are embedded at build
time from https://github.com/storytold/craft-i18n (language-data/, CRAFT_I18N_DIR at the commit
in craft-i18n.rev; all official releases) by craft-proof (storytold/craft-libs). Builds without
CRAFT_I18N_DIR do not include them.
- English word list (craft-i18n language-data/en-us/en-us.dic) — derived from the Moby Hyphenator II list by
Grady Ward, public domain (Project Gutenberg eBook #3204). The hyphenation patterns
(assets/hyphenation/en-us.pat) were trained from it and are released as CC0-1.0.
- Polish hyphenation patterns (assets/hyphenation/pl.pat) — hyph-pl.tex from the hyph-utf8
(craft-i18n language-data/en-us/hyphenation.pat) were trained from it and are released as CC0-1.0.
- English spelling dictionaries (craft-i18n language-data/en-us, en-gb and en-ca: spelling.aff and
spelling.dic) — the SCOWL rel-2026.02.25 Hunspell dictionaries en_US, en_GB-ise and en_CA from
https://wordlist.aspell.net, bundled unmodified. Their upstream notice (embedded too, for the
About window) is language-data/en-us/LICENCE-SCOWL.txt in craft-i18n. The word lists:
Copyright 2000-2026 by Kevin Atkinson
Permission to use, copy, modify, distribute, and sell any part of SCOWLv2, or
word lists created from it, is hereby granted without fee, provided that the
above copyright notice appears in all copies and that both the above
copyright notice and this notice appear in supporting documentation. Kevin
Atkinson makes no representations about the suitability of this database for
any purpose. It is provided "as is" without express or implied warranty.
The Australian data:
Copyright 2016 by Benjamin Titze
Permission to use, copy, modify, distribute and sell this array, the
associated software, and its documentation for any purpose is hereby
granted without fee, provided that the above copyright notice appears
in all copies and that both that copyright notice and this permission
notice appear in supporting documentation. Benjamin Titze makes no
representations about the suitability of this array for any
purpose. It is provided "as is" without express or implied warranty.
The affix files, a modified version of Geoff Kuenning's Ispell english.aff:
Copyright 1993, Geoff Kuenning, Granada Hills, CA
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. All modifications to the source code must be clearly marked as
such. Binary redistributions based on modified source code
must be clearly marked as modified versions in the documentation
and/or other materials provided with the distribution.
(clause 4 removed with permission from Geoff Kuenning)
5. The name of Geoff Kuenning may not be used to endorse or promote
products derived from this software without specific prior
written permission.
THIS SOFTWARE IS PROVIDED BY GEOFF KUENNING AND CONTRIBUTORS ``AS IS'' AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL GEOFF KUENNING OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
SUCH DAMAGE.
- Polish hyphenation patterns (craft-i18n language-data/pl/hyphenation.pat) — hyph-pl.tex from the hyph-utf8
package, Copyright (C) 1987-1995 Hanna Kołodziejska, Bogusław Jackowski, Marek Ryćko, bundled
unmodified and used under the MIT License; the copyright and permission notice is in the file.
- Portuguese hyphenation patterns (assets/hyphenation/pt.pat) — hyph-pt.tex from the hyph-utf8
- Portuguese hyphenation patterns (craft-i18n language-data/pt/hyphenation.pat) — hyph-pt.tex from the hyph-utf8
package, bundled unmodified and used under the BSD 3-Clause License:
Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida,
@@ -52,7 +116,7 @@ list (author, source, license) is in ATTRIBUTION.md.
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
OF THE POSSIBILITY OF SUCH DAMAGE.
- German hyphenation patterns (assets/hyphenation/de.pat) — hyph-de-1996.tex (reformed spelling)
- German hyphenation patterns (craft-i18n language-data/de/hyphenation.pat) — hyph-de-1996.tex (reformed spelling)
from the hyph-utf8 package, bundled unmodified and used under the MIT License; the copyright
and permission notice is in the file:
@@ -74,7 +138,7 @@ 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.
- Italian hyphenation patterns (assets/hyphenation/it.pat) — hyph-it.tex from the hyph-utf8
- Italian hyphenation patterns (craft-i18n language-data/it/hyphenation.pat) — hyph-it.tex from the hyph-utf8
package, Copyright (C) 2008-2011 Claudio Beccari, bundled unmodified and used under the MIT
License (offered under LPPL 1.3+ or MIT); the copyright and permission notice is in the file:
@@ -101,7 +165,7 @@ list (author, source, license) is in ATTRIBUTION.md.
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
17744430be327d0257496a6a31c8d38fe20a6756. 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.
+34 -12
View File
@@ -124,7 +124,7 @@ Every screenshot below is WordCraft itself, rendered offscreen by its own UI tes
| **References** | Table of contents, footnotes and endnotes, citations and bibliography (APA, MLA, Chicago, IEEE), captions, table of figures, cross-references, index, table of authorities |
| **Review** | Spelling and grammar with suggestions (English and Polish, by each run's language), thesaurus, word count, comments in margin balloons or a pane, track changes, accept/reject, compare documents, restrict editing, accessibility checker, document inspector |
| **Mailings** | Mail merge from CSV, merge fields, address block, greeting line, rules, preview, finish to a document; envelopes and labels |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski or Slovenščina (follows the system language by default) |
| **View** | Print layout, web layout, draft, read mode, focus, zoom, one/multiple pages, page width, Navigation pane, rulers, gridlines, dark mode; interface in English, 简体中文, 繁體中文, 日本語, Português (Brasil), Español, Українська, Српски / Srpski, Eesti, Türkçe, Русский, Italiano, Deutsch, Polski, Čeština, Magyar, Français, Български, Tiếng Việt, 한국어, Svenska, Hrvatski, Nederlands, Dansk, Română, العربية (mirrored) or Slovenščina (follows the system language by default) |
| **Files** | .docx read/write, legacy 97-2003 .doc import, PDF export, .odt, .rtf, .html, .md, .tex, .txt import/export, page images |
The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the
@@ -140,6 +140,19 @@ cargo run --release -p wordcraft -- --sample # the desktop app with the s
cargo run --release -p wordcraft -- report.docx # open a document
```
The interface translations and the spelling and hyphenation data come from
[craft-i18n](https://github.com/storytold/craft-i18n), an optional build input. To build with them,
check it out at the commit in `craft-i18n.rev` and point `CRAFT_I18N_DIR` at it (an absolute
path); without it WordCraft builds with an English interface, English spelling falls back to an
installed Hunspell English or craft-proof's built-in word list, and only Bulgarian, Turkish and
Russian hyphenate (by rule):
```sh
git clone https://github.com/storytold/craft-i18n ../craft-i18n
git -C ../craft-i18n checkout "$(cat craft-i18n.rev)"
CRAFT_I18N_DIR="$PWD/../craft-i18n" cargo run --release -p wordcraft -- --sample
```
Command line:
```sh
@@ -194,7 +207,7 @@ covers every target starting with it (`wordcraft*=debug`). The logger is
| Layer | Crate | Job |
|---|---|---|
| L0 | `wordcraft-geom` | units and measurements |
| L1 | `wordcraft-doc`, `wordcraft-fonts`, `wordcraft-proof` | document model and editing; fonts and shaping; spelling, grammar, hyphenation |
| L1 | `wordcraft-doc`, `wordcraft-fonts`, `wordcraft-proof` ([`craft-proof`](https://github.com/storytold/craft-libs/tree/main/crates/proof) from craft-libs) | document model and editing; fonts and shaping; spelling, grammar, hyphenation (data: craft-i18n `language-data/`, `CRAFT_I18N_DIR`) |
| L2 | `wordcraft-layout`, `wordcraft-docx`, `wordcraft-formats` | line breaking, pagination, tables, notes, hit testing; OOXML; ODT/RTF/HTML/Markdown/TXT |
| L3 | `wordcraft-render`, `wordcraft-pdf` | rasteriser (vello_cpu); PDF ([craft-pdf](docs/shared-pdf.md), Krilla) |
| L4 | `wordcraft-engine` | session, undo, 422 commands, document feature catalog |
@@ -221,16 +234,17 @@ closer pagination fidelity, a stability sweep and native printing. Details and e
### Windows
| Build | Installer (all users) | Installer (just me) | Portable |
|---|---|---|---|
| x64 (64-bit Intel/AMD) | `wordcraft-<ver>-windows-x64.msi` | `wordcraft-<ver>-windows-user-x64.msi` | `wordcraft-<ver>-windows-x64-portable.zip` |
| arm64 (Snapdragon and other ARM PCs) | `wordcraft-<ver>-windows-arm64.msi` | `wordcraft-<ver>-windows-user-arm64.msi` | `wordcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `wordcraft-<ver>-windows-x86.msi` | `wordcraft-<ver>-windows-user-x86.msi` | `wordcraft-<ver>-windows-x86-portable.zip` |
| Build | Installer | Portable |
|---|---|---|
| x64 (64-bit Intel/AMD) | `wordcraft-<ver>-windows-x64.msi` | `wordcraft-<ver>-windows-x64-portable.zip` |
| arm64 (Snapdragon and other ARM PCs) | `wordcraft-<ver>-windows-arm64.msi` | `wordcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `wordcraft-<ver>-windows-x86.msi` | `wordcraft-<ver>-windows-x86-portable.zip` |
The all-users installer puts WordCraft in Program Files and needs administrator rights. The
just-me installer needs none: it installs into `%LOCALAPPDATA%\Programs\WordCraft` for your
account only. Choose one: installing both gives two WordCraft entries in Settings › Apps and two
in the Start menu. Installers and executables are code-signed.
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
@@ -344,7 +358,15 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | **Writing and document design · you are here** | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+127 -7
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)).
+39 -573
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,27 @@ 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> {
@@ -470,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
}
};
@@ -738,6 +758,12 @@ fn parity_markdown(p: &Value) -> String {
fn main() -> ExitCode {
let args: Vec<OsString> = std::env::args_os().skip(1).collect();
// Language packs installed in the suite's font store are used; `proof.installPack` installs
// only while the person has turned downloading on in the app.
wordcraft_engine::packs::use_font_store(false);
if let Some(source) = wordcraft_engine::packs::index_override() {
eprintln!("wordcraft-cli: Language packs come from a test source: {source}");
}
match run(&args) {
Ok(()) | Err(Failure::StdoutClosed) => ExitCode::SUCCESS,
Err(Failure::Error(e)) => {
@@ -748,564 +774,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 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);
}
}
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,
+2 -1
View File
@@ -9,7 +9,8 @@ use wordcraft_engine::Session;
use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome};
use wordcraft_ui_egui::{Inbox, Services, WordApp};
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
const DOC_EXTS: &[&str] =
&["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"];
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS;
mod font_store;
+1 -1
View File
@@ -73,7 +73,7 @@ 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.
+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();
}
}
}
}
+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");
}
+74 -7
View File
@@ -62,8 +62,17 @@ impl eframe::App for App {
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 = WindowGeometry::track(prev, &window);
// 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(|_| window.monitor_size.is_some());
@@ -73,9 +82,11 @@ impl eframe::App for App {
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
@@ -318,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.
@@ -329,7 +360,9 @@ 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);
}
}
}
@@ -339,7 +372,7 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::WORDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
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`).
@@ -349,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() {
@@ -418,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);
@@ -453,11 +490,13 @@ fn main() -> eframe::Result {
app.set_startup_update_check(true);
load_prefs(&mut app);
load_user_dictionary(&mut app);
// Language packs from the suite's font store; an Install click is the person's own.
wordcraft_engine::packs::use_font_store(true);
// A region chosen in File › Options replaces the system's for the first blank
// document too (#838).
if !sample && app.session.prefs.region.is_some() {
let doc = app.session.blank_document();
app.session.set_document(doc);
app.replace_startup_document(doc);
}
app.watch_desktop_theme(&cc.egui_ctx);
if prefs_enabled()
@@ -469,6 +508,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() {
@@ -490,7 +530,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 {
@@ -576,6 +616,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;
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
-431
View File
@@ -1,431 +0,0 @@
% title: Hyphenation patterns for Italian
% copyright: Copyright (C) 2008-2011 Claudio Beccari
% notice: This file is part of the hyph-utf8 package.
% See http://www.hyphenation.org/tex for more information.
% language:
% name: Italian
% tag: it
% version: 4.9 2014/04/22
% authors:
% -
% name: Claudio Beccari
% contact: claudio.beccari (at) gmail.com
% licence:
% - This file is available under any of the following licences:
% -
% name: LPPL
% version: 1.3
% or_later: true
% url: http://www.latex-project.org/lppl.txt
% status: maintained
% maintainer: Claudio Beccari, e-mail claudio dot beccari at gmail dot com
% -
% name: MIT
% url: https://opensource.org/licenses/MIT
% text: >
% 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.
% hyphenmins:
% typesetting:
% left: 2
% right: 2
% changes:
% - 2014-04-22 - Add few patterns involving `h'
% - 2011-08-16 - Change the licence from GNU LGPL into LPPL v1.3.
% - 2010-05-24 - Fix for Italian patterns for proper hyphenation of -ich and Ljubljana.
% - 2008-06-09 - Import of original ithyph.tex into hyph-utf8 package.
% - 2008-03-08 - (last change in ithyph.tex)
% texlive:
% encoding: ascii
% babelname: italian
% legacy_patterns: ithyph.tex
% message: Italian hyphenation patterns
% description: |-
% Hyphenation patterns for Italian in ASCII encoding.
% Compliant with the Recommendation UNI 6461 on hyphenation
% issued by the Italian Standards Institution
% (Ente Nazionale di Unificazione UNI).
% ==========================================
%
% These hyphenation patterns for the Italian language are supposed to comply
% with the Recommendation UNI 6461 on hyphenation issued by the Italian
% Standards Institution (Ente Nazionale di Unificazione UNI). No guarantee
% or declaration of fitness to any particular purpose is given and any
% liability is disclaimed.
%
\patterns{
.a3p2n % After the Garzanti dictionary: a-pnea, a-pnoi-co,...
.anti1
.anti3m2n
.bio1
.ca4p3s
.circu2m1
.contro1
.di2s3cine
.e2x1eu
.fran2k3
.free3
.li3p2sa
.narco1
.opto1
.orto3p2
.para1
.ph2l
.ph2r
.poli3p2
.pre1
.p2s
.re1i2scr
.sha2re3
.tran2s3c
.tran2s3d
.tran2s3l
.tran2s3n
.tran2s3p
.tran2s3r
.tran2s3t
.su2b3lu
.su2b3r
.wa2g3n
.wel2t1
2'2
a1ia
a1ie
a1io
a1iu
a1uo
a1ya
2at.
e1iu
e2w
o1ia
o1ie
o1io
o1iu
1b
2bb
2bc
2bd
2bf
2bm
2bn
2bp
2bs
2bt
2bv
b2l
b2r
2b.
2b'
1c
2cb
2cc
2cd
2cf
2ck
2cm
2cn
2cq
2cs
2ct
2cz
2chh
c2h
2ch.
2ch'.
2ch''.
2chb
ch2r
2chn
c2l
c2r
2c.
2c'
.c2
1d
2db
2dd
2dg
2dl
2dm
2dn
2dp
d2r
2ds
2dt
2dv
2dw
2d.
2d'
.d2
1f
2fb
2fg
2ff
2fn
f2l
f2r
2fs
2ft
2f.
2f'
1g
2gb
2gd
2gf
2gg
g2h
g2l
2gm
g2n
2gp
g2r
2gs
2gt
2gv
2gw
2gz
2gh2t
2g.
2g'
.h2
1h
2hb
2hd
2hh
hi3p2n
h2l
2hm
2hn
2hr
2hv
2h.
2h'
.j2
1j
2j.
2j'
.k2
1k
2kg
2kf
k2h
2kk
k2l
2km
k2r
2ks
2kt
2k.
2k'
1l
2lb
2lc
2ld
2l3f2
2lg
l2h
l2j
2lk
2ll
2lm
2ln
2lp
2lq
2lr
2ls
2lt
2lv
2lw
2lz
2l.
2l'.
2l''
1m
2mb
2mc
2mf
2ml
2mm
2mn
2mp
2mq
2mr
2ms
2mt
2mv
2mw
2m.
2m'
1n
2nb
2nc
2nd
2nf
2ng
2nk
2nl
2nm
2nn
2np
2nq
2nr
2ns
n2s3fer
2nt
2nv
2nz
n2g3n
2nheit
2n.
2n'
1p
2pd
p2h
p2l
2pn
3p2ne
2pp
p2r
2ps
3p2sic
2pt
2pz
2p.
2p'
1q
2qq
2q.
2q'
1r
2rb
2rc
2rd
2rf
r2h
2rg
2rk
2rl
2rm
2rn
2rp
2rq
2rr
2rs
2rt
r2t2s3
2rv
2rx
2rw
2rz
2r.
2r'
1s2
2shm
2sh.
2sh'
2s3s
s4s3m
2s3p2n
2stb
2stc
2std
2stf
2stg
2stm
2stn
2stp
2sts
2stt
2stv
2sz
4s.
4s'.
4s''
.t2
1t
2tb
2tc
2td
2tf
2tg
t2h
2th.
t2l
2tm
2tn
2tp
t2r
t2s
3t2sch
2tt
t2t3s
2tv
2tw
t2z
2tzk
tz2s
2t.
2t'.
2t''
1v
2vc
v2l
v2r
2vv
2v.
2v'.
2v''
1w
w2h
wa2r
2w1y
2w.
2w'
1x
2xb
2xc
2xf
2xh
2xm
2xp
2xt
2xw
2x.
2x'
y1ou
y1i
1z
2zb
2zd
2zl
2zn
2zp
2zt
2zs
2zv
2zz
2z.
2z'.
2z''
.z2
} % Pattern end
File diff suppressed because it is too large Load Diff
-503
View File
@@ -1,503 +0,0 @@
% title: Hyphenation patterns for Portuguese
% copyright: Copyright (C) 1987, 1994, 1996, 2015 Pedro J. de Rezende, 1996, 2015 J. Joao Dias Almeida, 2024 Leonardo Araujo and Aline Benevides
% notice: This file is part of the hyph-utf8 package.
% See http://www.hyphenation.org/tex for more information.
% language:
% name: Portuguese
% tag: pt
% version: 1.4 2024-07-13
% authors:
% -
% name: Pedro J. de Rezende
% contact: rezende (at) ic.unicamp.br
% -
% name: J. Joao Dias Almeida
% contact: jj (at) di.uminho.pt
% -
% name: Leonardo Araujo
% contact: leolca (at) gmail.com
% -
% name: Aline Benevides
% contact: benevides.aline12 (at) gmail.com
% licence:
% name: BSD 3-clause licence
% url: https://opensource.org/licenses/BSD-3-Clause
% text: >
% Redistribution and use in source and binary forms, with or without
% modification, are permitted provided that the following conditions
% are met:
% * Redistributions of source code must retain the above copyright
% notice, this list of conditions and the following disclaimer.
% * Redistributions in binary form must reproduce the above copyright
% notice, this list of conditions and the following disclaimer in the
% documentation and/or other materials provided with the
% distribution.
% * Neither the name of the University of Campinas, of the University
% of Minho nor the names of its contributors may be used to endorse
% or promote products derived from this software without specific
% prior written permission.
%
% THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
% "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
% LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
% A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL PEDRO J. DE
% REZENDE OR J.JOAO DIAS ALMEIDA BE LIABLE FOR ANY DIRECT, INDIRECT,
% INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
% BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
% OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
% AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
% LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY
% WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
% POSSIBILITY OF SUCH DAMAGE.
% hyphenmins:
% typesetting:
% left: 2
% right: 3
% changes:
% - Version 1.4 Release date: 13/07/2024 Leonardo Araujo and Aline Benevides
% - Version 1.3 Release date: 12/08/2015 Pedro J. de Rezende and J. Joao Dias Almeida
% - Version 1.2 Release date: 07/21/1996 Pedro J. de Rezende and J. Joao Dias Almeida
% - Version 1.1 Release date: 04/12/1994 Pedro J. de Rezende
% - Version 1.0 Release date: 02/13/1987 Pedro J. de Rezende
% texlive:
% synonyms:
% - portuges
% encoding: ec
% babelname: portuguese
% legacy_patterns: pthyph.tex
% message: Portuguese hyphenation patterns
% description: Hyphenation patterns for Portuguese in T1/EC and UTF-8 encodings.
%
\patterns{
1b2l
1b2r
1ba
1be
1bi
1bo
1bu
1bá
1bâ
1bã
1bé
1bí
1bó
1bú
1bê
1bõ
1c2h
1c2l
1c2r
1ca
1ce
1ci
1co
1cu
1cá
1câ
1cã
1cé
1cí
1có
1cú
1cê
1cõ
1ça
1çe
1çi
1ço
1çu
1çá
1çâ
1çã
1çé
1çí
1çó
1çú
1çê
1çõ
1d2l
1d2r
1da
1de
1di
1do
1du
1dá
1dâ
1dã
1dé
1dí
1dó
1dú
1dê
1dõ
1f2l
1f2r
1fa
1fe
1fi
1fo
1fu
1fá
1fâ
1fã
1fé
1fí
1fó
1fú
1fê
1fõ
1g2l
1g2r
1ga
1ge
1gi
1go
1gu
1gu4a
1gu4e
1gu4i
1gu4o
1gá
1gâ
1gã
1gé
1gí
1gó
1gú
1gê
1gõ
1ja
1je
1ji
1jo
1ju
1já
1jâ
1jã
1jé
1jí
1jó
1jú
1jê
1jõ
1k2l
1k2r
1ka
1ke
1ki
1ko
1ku
1ká
1kâ
1kã
1ké
1kí
1kó
1kú
1kê
1kõ
1l2h
1la
1le
1li
1lo
1lu
1lá
1lâ
1lã
1lé
1lí
1ló
1lú
1lê
1lõ
1ma
1me
1mi
1mo
1mu
1má
1mâ
1mã
1mé
1mí
1mó
1mú
1mê
1mõ
1n2h
1na
1ne
1ni
1no
1nu
1ná
1nâ
1nã
1né
1ní
1nó
1nú
1nê
1nõ
1p2l
1p2r
1pa
1pe
1pi
1po
1pu
1pá
1pâ
1pã
1pé
1pí
1pó
1pú
1pê
1põ
1qu4a
1qu4e
1qu4i
1qu4o
1ra
1re
1ri
1ro
1ru
1rá
1râ
1rã
1ré
1rí
1ró
1rú
1rê
1rõ
1sa
1se
1si
1so
1su
1sá
1sâ
1sã
1sé
1sí
1só
1sú
1sê
1sõ
1t2l
1t2r
1ta
1te
1ti
1to
1tu
1tá
1tâ
1tã
1té
1tí
1tó
1tú
1tê
1tõ
1v2l
1v2r
1va
1ve
1vi
1vo
1vu
1vá
1vâ
1vã
1vé
1ví
1vó
1vú
1vê
1võ
1w2l
1w2r
1xa
1xe
1xi
1xo
1xu
1xá
1xâ
1xã
1xé
1xí
1xó
1xú
1xê
1xõ
1za
1ze
1zi
1zo
1zu
1zá
1zâ
1zã
1zé
1zí
1zó
1zú
1zê
1zõ
a3a
a3e
a3o
c3c
e3a
e3e
e3o
i3a
i3e
i3i
i3o
i3â
i3ê
i3ô
o3a
o3e
o3o
r3r
s3s
u3a
u3e
u3o
u3u
.p2si
.p2sí
.g2no
.g2nó
.g2nô
t2c
1p2neu
.t2m
.p2t
su2b3r
su2b3l
.su3b4li
a1ir.
u1ir.
1qu
1vô
1lô
1cô
1gô
1bô
1tô
1rô
1pô
a1é
a1í
a1ó
a1ú
e1á
e1â
e1ã
e1é
e1ê
e1í
e1ó
é1o
e1ú
i1á
i1ã
í1a
i1é
i1í
i1ó
í1o
i1u
i1ú
o1á
o1é
o1í
o1ó
u1á
u1ã
u1â
u1í
ú1o
1qu2á
1qu2â
1qu2í
1gu2í
a1ind
a1i1nh
e1imp
e1inc
e1inf
e1ing
e1ins
e1int
e1inv
u1iz.
a1iz.
4a.
4e.
4o.
1gu2á
1gu2ã
1qu2ã
.m2n
c2za
.s2
1p2seu1d
1dô
1fô
1mô
1nô
1sô
1zô
tu1i
tu2it
tu2id
bu1i
nu1i
o1in
u1in
su1i
í1e
ju1i
fu1i
du1i
do1im
au1i
u1i1ç
u1ê
1gu2ê
1qu2ê
1çô
u1é
1gu2é
1qu2é
1xô
a1â
a1ã
a1ô
e1ô
.ne4o
o1ã
o1ê
o1im
o2i1na
pro1i1b
co2ima
.t2
1-
}
\hyphenation{% Do NOT make any alterations to this list! --- PdR
hard-ware
soft-ware
}
-241
View File
@@ -1,241 +0,0 @@
# English spelling supplement: common words the public-domain Moby list (1990s) lacks.
# Written by WordCraft contributors (MIT OR Apache-2.0); see ATTRIBUTION.md.
# One lowercase base form per line, sorted. Plurals, -ed, -ing, -er, -ly… come from the
# inflection rules in crates/proof/src/lib.rs, so list "desktop", not "desktops".
airbag
airfare
artcraft
autocomplete
autocorrect
autofill
autoformat
autosave
backend
bitcoin
bitmap
blockchain
blog
blogger
bluetooth
captcha
caregiver
carpool
cellphone
chatbot
chatroom
checkbox
checklist
childcare
clamour
clickable
colourful
colourisation
colourise
colourless
configurable
copyedit
coronavirus
covid
crowdfunding
crowdsource
crypto
cryptocurrency
customizable
customization
cyber
cybersecurity
cyberspace
database
dataset
daycare
desktop
download
downloadable
downsize
dropdown
earbud
ebook
ecommerce
email
emoji
emoticon
endeavour
esports
facebook
favicon
fintech
firewall
flowchart
foodie
freeware
frontend
fundraiser
gameplay
gamification
geeky
geolocation
gigabit
gigabyte
github
glamour
globalization
google
groupthink
hackathon
hardcode
hashtag
healthcare
homepage
homeschool
honour
honourable
honourably
hotspot
hyperlink
hypertext
inbox
incentivize
infographic
instagram
internet
interoperability
intranet
ipad
iphone
javascript
kickstart
kilobyte
laptop
lifestyle
linkedin
linux
livestream
lockdown
login
logout
lowercase
makeover
malware
manoeuvrable
megabyte
megapixel
meme
metadata
microchip
microsoft
middleware
mindset
multicultural
multiplayer
multitask
nerdy
netbook
netflix
newsfeed
offboard
offline
onboard
online
onscreen
outsource
paediatric
paralegal
passcode
petabyte
phishing
photobomb
playdate
playlist
plugin
podcast
popup
prequel
printout
prioritise
prioritization
prioritize
privatise
ransomware
recyclable
rigour
ringtone
roadmap
rollout
runtime
scalability
screencast
screensaver
screenshot
scrollbar
selfie
signup
sitemap
skillset
smartphone
smartwatch
smiley
snowboard
spellcheck
spellchecker
spreadsheet
spyware
stakeholder
standup
startup
subfolder
sunscreen
superfood
superuser
synthesise
tagline
takeout
techie
telecommute
teleconference
telehealth
telemedicine
terabyte
textbox
timeline
timestamp
toolbar
toolkit
touchpad
touchscreen
trackball
trackpad
travelogue
upcycle
upload
uppercase
upsell
upskill
usability
username
videoconference
videogame
vlog
vlogger
voicemail
walkthrough
webcam
webcast
webinar
weblog
webmaster
webpage
website
whatsapp
whiteboard
wifi
wiki
wikipedia
wireframe
wordcraft
workaround
workflow
workforce
workplace
workspace
workstation
youtube
-305
View File
@@ -1,305 +0,0 @@
# English spelling variants: American and British forms of the same word, for the English
# dialects (#1281, #1045). Written by WordCraft contributors from general knowledge of English
# spelling (MIT OR Apache-2.0); no other word list copied. See ATTRIBUTION.md.
#
# Each line: POLICY AMERICAN BRITISH
# POLICY is three letters, for American English (en-US), Canadian English (en-CA) and the
# British-spelling Englishes (en-GB, en-AU, en-NZ, en-IE, en-ZA, en-IN…), in that order:
# u only the American form is right there (the British one is flagged, American suggested)
# b only the British form is right there
# * both are right
# Inflections (colours, centred, organising, organisation…) come from the rules in
# crates/proof/src/en_variants.rs for -or/-our, -er/-re and -ize/-ise style pairs; other pairs
# get only a plural, so list their other forms on lines of their own. When in doubt a form is
# accepted: a false alarm is worse than a missed variant.
# -or / -our
ubb arbor arbour
ubb ardor ardour
ubb armor armour
ubb armory armoury
ubb behavior behaviour
ubb behavioral behavioural
ubb candor candour
ubb clamor clamour
ubb color colour
ubb demeanor demeanour
ubb discolor discolour
ubb dishonor dishonour
ubb endeavor endeavour
ubb favor favour
ubb favorite favourite
ubb favoritism favouritism
ubb fervor fervour
ubb flavor flavour
ubb harbor harbour
ubb honor honour
ubb humor humour
ubb labor labour
ubb misbehavior misbehaviour
ubb neighbor neighbour
ubb neighborhood neighbourhood
ubb neighborly neighbourly
ubb odor odour
ubb parlor parlour
ubb rancor rancour
ubb rigor rigour
ubb rumor rumour
ubb savior saviour
ubb savor savour
ubb savory savoury
ubb splendor splendour
ubb succor succour
ubb tumor tumour
ubb unfavorable unfavourable
ubb valor valour
ubb vapor vapour
ubb vigor vigour
# -er / -re
ubb caliber calibre
ubb center centre
ubb centimeter centimetre
ubb epicenter epicentre
ubb fiber fibre
ubb kilometer kilometre
ubb liter litre
ubb luster lustre
ubb maneuver manoeuvre
ubb meager meagre
ubb millimeter millimetre
ubb miter mitre
ubb ocher ochre
ubb saber sabre
ubb sepulcher sepulchre
ubb somber sombre
ubb specter spectre
*bb theater theatre
# A meter is also a measuring device in British English.
u** meter metre
# -ize / -ise (British English accepts both: Oxford spelling uses -ize)
uu* agonize agonise
uu* apologize apologise
uu* authorize authorise
uu* capitalize capitalise
uu* categorize categorise
uu* centralize centralise
uu* characterize characterise
uu* civilize civilise
uu* colonize colonise
uu* commercialize commercialise
uu* computerize computerise
uu* criticize criticise
uu* crystallize crystallise
uu* customize customise
uu* decentralize decentralise
uu* demonize demonise
uu* deodorize deodorise
uu* destabilize destabilise
uu* digitize digitise
uu* dramatize dramatise
uu* economize economise
uu* emphasize emphasise
uu* energize energise
uu* equalize equalise
uu* familiarize familiarise
uu* fantasize fantasise
uu* fertilize fertilise
uu* finalize finalise
uu* formalize formalise
uu* fossilize fossilise
uu* galvanize galvanise
uu* generalize generalise
uu* globalize globalise
uu* harmonize harmonise
uu* hospitalize hospitalise
uu* humanize humanise
uu* hypnotize hypnotise
uu* hypothesize hypothesise
uu* idealize idealise
uu* idolize idolise
uu* immobilize immobilise
uu* immortalize immortalise
uu* immunize immunise
uu* industrialize industrialise
uu* initialize initialise
uu* italicize italicise
uu* itemize itemise
uu* jeopardize jeopardise
uu* legalize legalise
uu* legitimize legitimise
uu* liberalize liberalise
uu* localize localise
uu* magnetize magnetise
uu* marginalize marginalise
uu* maximize maximise
uu* mechanize mechanise
uu* memorize memorise
uu* mesmerize mesmerise
uu* minimize minimise
uu* mobilize mobilise
uu* modernize modernise
uu* monopolize monopolise
uu* moralize moralise
uu* nationalize nationalise
uu* naturalize naturalise
uu* neutralize neutralise
uu* normalize normalise
uu* optimize optimise
uu* organize organise
uu* ostracize ostracise
uu* oxidize oxidise
uu* pasteurize pasteurise
uu* patronize patronise
uu* penalize penalise
uu* personalize personalise
uu* plagiarize plagiarise
uu* polarize polarise
uu* popularize popularise
uu* pressurize pressurise
uu* prioritize prioritise
uu* privatize privatise
uu* publicize publicise
uu* pulverize pulverise
uu* randomize randomise
uu* rationalize rationalise
uu* realize realise
uu* recognize recognise
uu* reorganize reorganise
uu* revolutionize revolutionise
uu* satirize satirise
uu* scrutinize scrutinise
uu* sensitize sensitise
uu* socialize socialise
uu* specialize specialise
uu* stabilize stabilise
uu* standardize standardise
uu* sterilize sterilise
uu* stigmatize stigmatise
uu* subsidize subsidise
uu* summarize summarise
uu* symbolize symbolise
uu* sympathize sympathise
uu* synchronize synchronise
uu* synthesize synthesise
uu* systematize systematise
uu* tantalize tantalise
uu* terrorize terrorise
uu* theorize theorise
uu* trivialize trivialise
uu* urbanize urbanise
uu* utilize utilise
uu* vandalize vandalise
uu* verbalize verbalise
uu* victimize victimise
uu* visualize visualise
uu* vocalize vocalise
uu* westernize westernise
# -yze / -yse
u*b analyze analyse
u*b catalyze catalyse
u*b dialyze dialyse
u*b electrolyze electrolyse
u*b hydrolyze hydrolyse
u*b paralyze paralyse
u*b psychoanalyze psychoanalyse
# -ense / -ence
ubb defense defence
ubb offense offence
ubb pretense pretence
ubb defenseless defenceless
# In British English "license" and "practise" are the verbs, so only American English flags.
u** license licence
u** practice practise
# Doubled l (American English accepts both)
*bb canceled cancelled
*bb canceling cancelling
*bb channeled channelled
*bb channeling channelling
*bb counseled counselled
*bb counseling counselling
*bb counselor counsellor
*bb counselors counsellors
*bb dueling duelling
*bb equaled equalled
*bb equaling equalling
*bb fueled fuelled
*bb fueling fuelling
*bb jeweler jeweller
*bb jewelers jewellers
*bb jewelry jewellery
*bb labeled labelled
*bb labeling labelling
*bb leveled levelled
*bb leveling levelling
*bb marveled marvelled
*bb marveling marvelling
*bb marvelous marvellous
*bb modeled modelled
*bb modeling modelling
*bb quarreled quarrelled
*bb quarreling quarrelling
*bb signaled signalled
*bb signaling signalling
*bb totaled totalled
*bb totaling totalling
*bb traveled travelled
*bb traveler traveller
*bb travelers travellers
*bb traveling travelling
*bb tunneled tunnelled
*bb tunneling tunnelling
*bb woolen woollen
# -og / -ogue
*bb catalog catalogue
*bb cataloged catalogued
*bb cataloging cataloguing
# ae / oe
u*b anemia anaemia
u*b anemic anaemic
u*b anesthesia anaesthesia
u*b anesthetic anaesthetic
**b archeology archaeology
**b archeologist archaeologist
u*b diarrhea diarrhoea
u*b edema oedema
u*b esophagus oesophagus
u*b estrogen oestrogen
u*b gynecologist gynaecologist
u*b gynecology gynaecology
u*b hematology haematology
u*b hemoglobin haemoglobin
u*b hemorrhage haemorrhage
u*b leukemia leukaemia
u*b orthopedic orthopaedic
u*b pediatric paediatric
u*b pediatrician paediatrician
u*b pediatrics paediatrics
# Other words
u*b aluminum aluminium
u*b cozy cosy
ubb mold mould
ubb molded moulded
ubb molding moulding
ubb moldy mouldy
ubb molt moult
ubb pajamas pyjamas
ubb plow plough
ubb plowed ploughed
ubb plowing ploughing
u** program programme
u*b skeptic sceptic
u*b skeptical sceptical
u*b skepticism scepticism
ubb smolder smoulder
ubb smoldering smouldering
# "Tire" and "curb" are also verbs everywhere, so only American English flags.
uu* tire tyre
uu* curb kerb
-214
View File
@@ -1,214 +0,0 @@
# Russian spelling supplement: common words older Russian Hunspell dictionaries lack.
# Written by WordCraft contributors (MIT OR Apache-2.0); see ATTRIBUTION.md.
# One word per line: the base form, a TAB, then its declension, from which
# crates/proof/src/ru/extra.rs makes every form:
# m masculine noun ending in a consonant (маркетплейс, -а, -у, -ом, -е, -ы/-и, -ов/-ей…)
# f feminine noun in -а (инфографика, -и, -е, -у, -ой…; genitive plural without ending)
# fia feminine noun in -ия (интеграция, -ии, -ию, -ией, -ий…)
# fs feminine noun in -ь (соцсеть, -и, -ью, -ей, -ям…)
# nie neuter noun in -ие (согласие, -ия, -ию, -ием, -ий…)
# no neuter noun in -о (юрлицо, -а, -у, -ом, -е; genitive plural without ending)
# adj adjective or participle in -ый, -ий, -ой (бонусный, газоиспользующий)
# ovat verb in -овать with its conjugation and participles (колеровать, администрировать)
# = the forms themselves, space-separated (verbs, nouns with a fleeting vowel)
# A word alone on its line doesn't decline (онлайн, медиа). Capitalized words are names and match
# only when written with a capital.
# Commerce, marketing and the web
маркетплейс m
селлер m
фулфилмент m
ритейл m
ритейлер m
дистрибьютор m
дропшиппинг m
мерчандайзер m
эквайринг m
промокод m
кешбэк m
кэшбэк m
лендинг m
лид m
тренд m
бренд m
брендбук m
ребрендинг m
таргетинг m
геотаргетинг m
ретаргетинг m
таргетолог m
маркетолог m
копирайтер m
рерайтер m
контент m
блогер m
инфлюенсер m
хештег m
хэштег m
репост m
лайк m
подкаст m
стриминг m
вебинар m
чат m
бот m
чат-бот m
стартап m
франчайзинг m
аутсорсинг m
аутстаффинг m
коворкинг m
дедлайн m
фидбэк m
фидбек m
кейс m
скриншот m
скринкаст m
баннер m
виджет m
дашборд m
сервис m
сайт m
скан m
токен m
аккаунт m
логин m
пароль m
смартфон m
гаджет m
планшет m
ноутбук m
апгрейд m
апдейт m
бэкап m
бэкенд m
фронтенд m
датасет m
промпт m
интерфейс m
хостинг m
домен m
сниппет m
трафик m
юзер m
стикер m
чекбокс m
инпут m
плейсхолдер m
тултип m
гипсокартон m
пеноплекс m
праймер m
колер m
колорант m
Яндекс m
Битрикс m
Вебвизор m
Дзен m
инфографика f
аналитика f
оплата f
предоплата f
постоплата f
доплата f
переплата f
недоплата f
маркировка = маркировка маркировки маркировке маркировку маркировкой маркировкою маркировок маркировкам маркировками маркировках
отгрузка = отгрузка отгрузки отгрузке отгрузку отгрузкой отгрузкою отгрузок отгрузкам отгрузками отгрузках
колеровка = колеровка колеровки колеровке колеровку колеровкой колеровкою колеровок колеровкам колеровками колеровках
рассылка = рассылка рассылки рассылке рассылку рассылкой рассылкою рассылок рассылкам рассылками рассылках
выгрузка = выгрузка выгрузки выгрузке выгрузку выгрузкой выгрузкою выгрузок выгрузкам выгрузками выгрузках
интеграция fia
пагинация fia
госрегистрация fia
геолокация fia
монетизация fia
кастомизация fia
персонализация fia
сегментация fia
верификация fia
валидация fia
авторизация fia
аутентификация fia
оптимизация fia
визуализация fia
соцсеть fs
согласие nie
домовладение nie
рождение nie
паллетирование nie
неисполнение nie
непредоставление nie
бонусный adj
маркетинговый adj
мотивационный adj
гипоаллергенный adj
тиксотропный adj
сополимерный adj
покупочный adj
таргетированный adj
брендированный adj
кастомный adj
онлайновый adj
офлайновый adj
колеруемый adj
колерованный adj
заколерованный adj
газоиспользующий adj
колеровать ovat
заколеровать ovat
администрировать ovat
модерировать ovat
отмодерировать ovat
таргетировать ovat
монетизировать ovat
брендировать ovat
кастомизировать ovat
детектировать ovat
верифицировать ovat
валидировать ovat
генерировать ovat
сгенерировать ovat
мониторить = мониторить мониторю мониторишь мониторит мониторим мониторите мониторят мониторил мониторила мониторили мониторя мониторинг
гуглить = гуглить гуглю гуглишь гуглит гуглим гуглите гуглят гуглил гуглила гуглили
скачивать = скачивать скачиваю скачиваешь скачивает скачиваем скачиваете скачивают скачивал скачивала скачивали скачивается скачиваются
оценивание nie
медучреждение nie
юрлицо no
учётка = учётка учётки учётке учётку учёткой учёток учёткам учётками учётках учетка учетки учетке учетку учеткой учеток учеткам учетками учетках
перелинковка = перелинковка перелинковки перелинковке перелинковку перелинковкой перелинковок
поклейка = поклейка поклейки поклейке поклейку поклейкой поклеек
детекция fia
досудебный adj
категорийный adj
оркестратор m
оффер m
трекер m
бриф m
гайд m
монтажёр m
тенант m
мерчант m
амбассадор m
Роскомнадзор m
Директ m
Маркет m
вовлечённость fs
вовлеченность fs
укрывистость fs
# Words that don't decline
онлайн
офлайн
оффлайн
медиа
демо
франчайзи
киноа
смс
пн
вт
ср
чт
пт
сб
вс
+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
+11 -5
View File
@@ -1,13 +1,10 @@
# Contributor names for the About window (craftrules standards/contributors.md).
# Contributor names for the About window.
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
# Copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
@@ -181,3 +178,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 @@
9205108cfdced5177706264038848b68b2422d2b
059cbf95c2a3eb95d637ca8584faca1129d17f48
+2 -1
View File
@@ -9,7 +9,8 @@ description = "WordCraft document model: stories, paragraphs, runs, tables, sect
workspace = true
[features]
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`); turned on only as a
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`,
# `zip_guard::zip64_entry_count_sample`); turned on only as a
# dev-dependency, so never built into the app.
test-support = []
+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
}
}
+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))
}
+201 -37
View File
@@ -48,6 +48,131 @@ impl LineCap {
}
}
/// An arrowhead's width across its line or length along it (ST_LineEndWidth and
/// ST_LineEndLength, ECMA-376 §20.1.10.39–40): 2, 3 or 5 times the line's width, as Word draws it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ArrowSize {
Small,
#[default]
Medium,
Large,
}
impl ArrowSize {
/// Read a DrawingML size (`sm`, `med`, `lg`); absent or unknown values are medium.
pub fn from_ooxml(value: &str) -> Self {
match value {
"sm" => Self::Small,
"lg" => Self::Large,
_ => Self::Medium,
}
}
/// The DrawingML spelling.
pub fn ooxml(self) -> &'static str {
match self {
Self::Small => "sm",
Self::Medium => "med",
Self::Large => "lg",
}
}
/// Read a VML width (`narrow`, `medium`, `wide`) or length (`short`, `medium`, `long`).
pub fn from_vml(value: &str) -> Self {
match value {
"narrow" | "short" => Self::Small,
"wide" | "long" => Self::Large,
_ => Self::Medium,
}
}
/// How many [`arrow_unit`]s the arrowhead measures.
pub fn factor(self) -> f32 {
match self {
Self::Small => 2.0,
Self::Medium => 3.0,
Self::Large => 5.0,
}
}
}
/// The kind of an arrowhead (ST_LineEndType, ECMA-376 §20.1.10.33), kept for saving; every kind
/// is drawn as a triangle.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ArrowKind {
#[default]
Triangle,
Stealth,
Diamond,
Oval,
Arrow,
}
impl ArrowKind {
/// Read a DrawingML end type other than `none`; unknown values are triangles.
pub fn from_ooxml(value: &str) -> Self {
match value {
"stealth" => Self::Stealth,
"diamond" => Self::Diamond,
"oval" => Self::Oval,
"arrow" => Self::Arrow,
_ => Self::Triangle,
}
}
/// The DrawingML spelling.
pub fn ooxml(self) -> &'static str {
match self {
Self::Triangle => "triangle",
Self::Stealth => "stealth",
Self::Diamond => "diamond",
Self::Oval => "oval",
Self::Arrow => "arrow",
}
}
/// Read a VML arrow (`block`, `classic`, `diamond`, `oval`, `open`) other than `none`.
pub fn from_vml(value: &str) -> Self {
match value {
"classic" => Self::Stealth,
"diamond" => Self::Diamond,
"oval" => Self::Oval,
"open" => Self::Arrow,
_ => Self::Triangle,
}
}
}
/// An arrowhead's kind and size (`a:headEnd` / `a:tailEnd`). The default is a medium triangle.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ArrowStyle {
pub kind: ArrowKind,
pub width: ArrowSize,
pub length: ArrowSize,
}
impl ArrowStyle {
pub fn is_default(&self) -> bool {
*self == Self::default()
}
/// The arrowhead's length along its line and width across it, for a line `line_width` wide.
pub fn dims(self, line_width: f32) -> (f32, f32) {
let unit = arrow_unit(line_width);
(self.length.factor() * unit, self.width.factor() * unit)
}
}
/// What an arrowhead's size is a multiple of: the line's width, but at least 2 points (Word draws
/// a thinner line's arrowheads as for a 2-point line).
pub fn arrow_unit(line_width: f32) -> f32 {
let w = if line_width.is_finite() { line_width.clamp(0.25, 200.0) } else { 0.75 };
w.max(2.0)
}
/// The rest of a shape's properties. Empty (the default) for most shapes.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -69,6 +194,11 @@ pub struct ShapeExtra {
pub arrow_start: bool,
#[serde(skip_serializing_if = "is_false")]
pub arrow_end: bool,
/// The kind and size of the start and end arrowheads, when there are any.
#[serde(skip_serializing_if = "ArrowStyle::is_default")]
pub start_style: ArrowStyle,
#[serde(skip_serializing_if = "ArrowStyle::is_default")]
pub end_style: ArrowStyle,
/// A rounded rectangle's corner radius (`roundRect`'s `adj`, ECMA-376 §20.1.10.56): in
/// 100000ths of the shorter side, at most [`MAX_CORNER`]; `None` draws [`DEFAULT_CORNER`].
#[serde(skip_serializing_if = "Option::is_none")]
@@ -83,6 +213,39 @@ pub struct ShapeExtra {
/// main colour (the shape's `stroke`).
#[serde(skip_serializing_if = "Option::is_none")]
pub outline_fill: Option<crate::fill::Fill>,
/// A horizontal rule (VML `o:hr`: a web page's `<hr>` pasted into Word): it takes its whole
/// line, and is drawn across [`HorizontalRule::pct`] of it or at the shape's own width.
#[serde(skip_serializing_if = "Option::is_none")]
pub hr: Option<HorizontalRule>,
}
/// How a horizontal rule sits on its line (VML `o:hrpct`, `o:hralign`), as Word draws one:
/// across `pct` percent of the line's width (the paragraph's, inside its indents), or at the
/// shape's own width when `pct` is 0; `align` places it when it is narrower than the line.
#[derive(Clone, Copy, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct HorizontalRule {
pub pct: f32,
pub align: crate::props::Align,
/// Word's standard look (`o:hrstd`), kept for saving.
pub standard: bool,
}
impl HorizontalRule {
/// The rule's width on a line `line` wide, for a shape `own` wide.
pub fn width(&self, line: f32, own: f32) -> f32 {
let line = line.max(0.0);
if self.pct.is_finite() && self.pct > 0.0 { line * (self.pct.min(100.0) / 100.0) } else { own.clamp(0.0, line) }
}
/// Where a rule `w` wide starts on a line from `x`, `line` wide.
pub fn left(&self, x: f32, line: f32, w: f32) -> f32 {
match self.align {
crate::props::Align::Center => x + (line - w) / 2.0,
crate::props::Align::Right => x + line - w,
_ => x,
}
}
}
fn is_zero(v: &u32) -> bool {
@@ -362,6 +525,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 +569,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);
}
}
}
+26 -13
View File
@@ -14,7 +14,7 @@
//! outside what's deleted (so deleting across a control's edge empties it rather than breaking
//! it); writers call [`Document::balance_controls`] so a saved file always has whole controls.
use std::collections::{BTreeMap, BTreeSet};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use serde::{Deserialize, Serialize};
@@ -422,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() {
+865
View File
@@ -0,0 +1,865 @@
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 ([`crate::count_words`], plus 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
}
}
+453 -657
View File
File diff suppressed because it is too large Load Diff
+911
View File
@@ -0,0 +1,911 @@
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_formatting_they_copy() {
// A story holding a text box and a run whose font name is 1 MiB: each copy clones the
// formatting too, so 64 copies used to copy 64 MiB of it past the 32 MiB cap.
let props = CharProps { font: Some("f".repeat(1 << 20)), ..CharProps::default() };
let mut p = Paragraph::with_text("x", props);
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();
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
let added = d.parts.len() - before;
let font = |p: &Paragraph| p.runs.iter().map(|r| r.props.font.as_ref().map_or(0, String::len)).sum::<usize>();
let copied: usize = d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { font(p) } else { 0 }).sum();
assert!((1..64).contains(&added), "{added}");
assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}");
}
#[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");
}
#[test]
fn adopting_a_document_renumbers_what_it_refers_to() {
use crate::para::NoteKind;
use crate::{Comment, Revision, RevisionKind};
// Ours already has a footnote, a comment, a list and a revision with the ids the other's use.
let mut ours = doc(&["ours"]);
let our_note = ours.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("our note", CharProps::default()))]);
ours.comments.insert(0, Comment { part: our_note, ..Comment::default() });
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() });
let mut theirs = doc(&["theirs"]);
let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("their note", CharProps::default()))]);
let said = theirs.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("their comment", CharProps::default()))]);
theirs.comments.insert(0, Comment { author: "Them".into(), part: said, durable_id: Some("0A0B0C0D".into()), ..Comment::default() });
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
assert_eq!(list, our_list);
theirs.revisions.push(Revision { kind: RevisionKind::Delete, ..Revision::default() });
let mut style = crate::styles::Style { id: "TheirList".into(), name: "Their List".into(), ..Default::default() };
style.para.numbering = Some(crate::props::NumRef { num: list, level: 0 });
theirs.styles.upsert(style);
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.props.numbering = Some(crate::props::NumRef { num: list, level: 0 });
p.runs[0].props.del = Some(0);
// An insertion naming a revision it doesn't have.
p.runs[0].props.ins = Some(7);
let props = p.props_of_char(0).clone();
p.insert_object(0, InlineObject::NoteRef { kind: NoteKind::Footnote, id: note, custom: String::new() }, &props).unwrap();
p.insert_object(0, InlineObject::CommentStart { id: 0 }, &props).unwrap();
let frag = ours.adopt(theirs);
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
let new_list = p.props.numbering.unwrap().num;
assert_ne!(new_list, our_list);
assert!(ours.numbering.num(new_list).is_some());
assert_eq!(ours.styles.get("TheirList").and_then(|s| s.para.numbering).map(|n| n.num), Some(new_list));
// Their deletion is their revision, after ours.
let del = p.runs.iter().find_map(|r| r.props.del).unwrap();
assert_eq!(ours.revisions.get(del as usize).map(|r| r.kind), Some(RevisionKind::Delete));
assert!(p.runs.iter().all(|r| r.props.ins.is_none()));
// Their note and comment come along under ids of their own.
let (note_id, comment_id) = p.objects.iter().fold((None, None), |(n, c), o| match o {
InlineObject::NoteRef { id, .. } => (Some(*id), c),
InlineObject::CommentStart { id } => (n, Some(*id)),
_ => (n, c),
});
let text = |id: u32| {
ours.parts
.get(&id)
.map(|p| p.blocks.iter().map(|b| if let Block::Para(p) = &**b { p.text.clone() } else { String::new() }).collect::<String>())
};
assert_eq!(text(note_id.unwrap()).as_deref(), Some("their note"));
let comment = ours.comments.get(&comment_id.unwrap()).unwrap();
assert_eq!((comment.author.as_str(), text(comment.part).as_deref()), ("Them", Some("their comment")));
// Its durable id is the other document's: the writer gives the copy one of its own.
assert_eq!(comment.durable_id, None);
assert_eq!(text(our_note).as_deref(), Some("our note"));
}
#[test]
fn a_kept_character_style_leaves_what_it_doesnt_set_to_the_paragraph_style() {
use crate::styles::{Style, StyleKind, StyleSheet};
let mut ours = doc(&["ours"]);
let mut theirs = doc(&["theirs"]);
theirs.styles = StyleSheet::empty();
theirs.styles.default_chr = CharProps { size: Some(9.0), ..Default::default() };
let big = CharProps { size: Some(20.0), ..Default::default() };
theirs.styles.upsert(Style { id: "Big".into(), name: "Big".into(), chr: big, ..Default::default() });
theirs.styles.upsert(Style { id: "Link".into(), name: "Link".into(), kind: StyleKind::Character, ..Default::default() });
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.props.style = Some("Big".into());
p.runs[0].props.style = Some("Link".into());
let frag = ours.adopt_with(theirs, true);
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
assert_eq!(ours.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).size, 20.0);
}
#[test]
fn kept_styles_renamed_on_adoption_are_renamed_in_list_levels_too() {
use crate::styles::Style;
let mut ours = doc(&["ours"]);
ours.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
let mut theirs = doc(&["theirs"]);
theirs.styles.upsert(Style { id: "Heading1".into(), name: "heading 1".into(), ..Default::default() });
let list = theirs.numbering.add_list(crate::numbering::ListKind::Numbered);
let abs = theirs.numbering.nums.iter().find(|n| n.id == list).map(|n| n.abstract_id).unwrap();
let a = theirs.numbering.abstracts.iter_mut().find(|a| a.id == abs).unwrap();
a.levels[0].style = Some("Heading1".into());
let level = a.levels[0].clone();
theirs.numbering.nums.iter_mut().find(|n| n.id == list).unwrap().level_overrides.push((1, level));
let before = ours.numbering.abstracts.len();
ours.adopt_with(theirs, true);
let copied = &ours.numbering.abstracts[before..];
let styles: Vec<_> = copied
.iter()
.flat_map(|a| a.levels.iter())
.chain(ours.numbering.nums.iter().flat_map(|n| n.level_overrides.iter().map(|(_, l)| l)))
.filter_map(|l| l.style.clone())
.collect();
assert_eq!(styles.len(), 2, "{styles:?}");
for st in &styles {
assert_ne!(st, "Heading1");
assert!(ours.styles.get(st).is_some(), "{st}");
}
}
#[test]
fn an_adopted_section_breaks_tracked_change_names_its_revision() {
use crate::{Revision, RevisionKind, SectionProps};
let mut ours = doc(&["ours"]);
ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() });
let mut theirs = doc(&["theirs"]);
theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() });
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.section = Some(Box::new(SectionProps { fmt_change: crate::props::PropChange::boxed(0, SectionProps::default()), ..SectionProps::default() }));
let frag = ours.adopt(theirs);
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
let rev = p.section.as_ref().and_then(|s| s.fmt_change.as_ref()).map(|c| c.rev);
assert_eq!(rev.and_then(|r| ours.revisions.get(r as usize)).map(|r| r.kind), Some(RevisionKind::Format));
}
#[test]
fn an_adopted_tracked_format_change_keeps_its_old_styles_and_list() {
use crate::props::{NumRef, ParaProps, PropChange};
use crate::{Revision, RevisionKind};
for keep in [false, true] {
let mut ours = doc(&["ours"]);
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
let mut theirs = doc(&["theirs"]);
theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() });
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
assert_eq!(list, our_list);
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
// Live and old props name the same styles (ours has them too) and their list.
p.props.style = Some("Quote".into());
p.props.numbering = Some(NumRef { num: list, level: 0 });
let old = ParaProps { style: Some("Quote".into()), numbering: Some(NumRef { num: list, level: 0 }), ..ParaProps::default() };
p.props.fmt_change = PropChange::boxed(0, old);
p.runs[0].props.style = Some("Strong".into());
p.runs[0].props.fmt_change = PropChange::boxed(0, CharProps { style: Some("Strong".into()), ..CharProps::default() });
let frag = ours.adopt_with(theirs, keep);
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
let old = &p.props.fmt_change.as_ref().unwrap().old;
assert_eq!((&old.style, old.numbering), (&p.props.style, p.props.numbering), "keep_styles {keep}");
assert_ne!(old.numbering.map(|n| n.num), Some(our_list));
let run = &p.runs[0].props;
assert_eq!(run.fmt_change.as_ref().unwrap().old.style, run.style, "keep_styles {keep}");
}
}
#[test]
fn adopted_styles_never_become_our_defaults() {
use crate::styles::{Style, StyleKind};
for keep in [false, true] {
let mut ours = doc(&["ours"]);
let mut theirs = doc(&["theirs"]);
// A localized default character style, and one whose id we have too.
theirs.styles.upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() });
theirs.styles.upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() });
ours.adopt_with(theirs, keep);
assert!(ours.styles.styles.iter().all(|s| !s.default), "keep_styles {keep}");
}
}
#[test]
fn adopted_permission_ranges_keep_ids_of_their_own() {
let perm = |id, editor: &str| InlineObject::PermStart { id, group: String::new(), editor: editor.into() };
let mut ours = doc(&["ours"]);
ours.insert_object(&Pos::body(0, 4), InlineObject::PermEnd { id: 0 }, &CharProps::default()).unwrap();
ours.insert_object(&Pos::body(0, 0), perm(0, "us"), &CharProps::default()).unwrap();
// Theirs: a closed range and one left open, numbered from 0 like ours.
let mut theirs = doc(&["theirs"]);
theirs.insert_object(&Pos::body(0, 6), perm(1, "them"), &CharProps::default()).unwrap();
theirs.insert_object(&Pos::body(0, 6), InlineObject::PermEnd { id: 0 }, &CharProps::default()).unwrap();
theirs.insert_object(&Pos::body(0, 0), perm(0, "them"), &CharProps::default()).unwrap();
let frag = ours.adopt(theirs);
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
let ids: Vec<u32> = p
.objects
.iter()
.filter_map(|o| match o {
InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } => Some(*id),
_ => None,
})
.collect();
assert_eq!(ids.len(), 3);
assert!(!ids.contains(&0), "{ids:?}");
assert_eq!(ids[0], ids[1], "a range's start and end still pair: {ids:?}");
assert_ne!(ids[1], ids[2]);
}
#[test]
fn an_adopted_notes_text_box_gets_a_story_of_its_own() {
use crate::para::NoteKind;
// Ours has a text box under the id their note's text box has.
let mut ours = doc(&["ours"]);
ours.add_part(PartKind::Footnote, Vec::new());
let our_box = text_box(&mut ours, &Pos::body(0, 0), "our box");
let mut theirs = doc(&["theirs"]);
let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("note", CharProps::default()))]);
let their_box = theirs.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("their box", CharProps::default()))]);
assert_eq!(their_box, our_box);
theirs
.insert_object(&Pos { story: StoryRef::Part(note), path: Path::top(0), off: 0 }, box_shape(Some(their_box)), &CharProps::default())
.unwrap();
theirs
.insert_object(&Pos::body(0, 0), InlineObject::NoteRef { kind: NoteKind::Footnote, id: note, custom: String::new() }, &CharProps::default())
.unwrap();
let blocks = ours.adopt(theirs).blocks;
let Some(Block::Para(p)) = blocks.first() else { panic!() };
let new_note = p.objects.iter().find_map(|o| if let InlineObject::NoteRef { id, .. } = o { Some(*id) } else { None }).unwrap();
let story = ours.parts[&new_note]
.blocks
.iter()
.find_map(|b| if let Block::Para(p) = &**b { p.objects.iter().find_map(|o| o.text_boxes().first().copied()) } else { None })
.unwrap();
assert_ne!(story, our_box);
let part = &ours.parts[&story];
assert_eq!(part.kind, PartKind::TextBox);
assert!(matches!(part.blocks.first().map(|b| &**b), Some(Block::Para(p)) if p.text == "their box"));
}
+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>;
+23 -1785
View File
File diff suppressed because it is too large Load Diff
+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() {
+233 -214
View File
@@ -863,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::*;
+1 -1
View File
@@ -570,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`).
+54 -2
View File
@@ -338,6 +338,23 @@ impl Counters {
*f = LevelFlags { started: false, first: Some(*start) };
}
}
// A level above that hasn't counted yet counts its first number here, as in Word: after
// 1.1 and 1.2 with no level-1 item before them, the next level-1 item is 2, whether or not
// the deeper level's text shows it.
for (k, (v, f)) in st.iter_mut().zip(fl.iter_mut()).enumerate().take(lv) {
if f.started {
continue;
}
let start = match self.seen.entry((num, k as u8)) {
std::collections::hash_map::Entry::Vacant(e) => {
e.insert(true);
n.start_overrides.iter().find(|(l, _)| *l as usize == k).map(|(_, s)| *s)
}
std::collections::hash_map::Entry::Occupied(_) => None,
};
*v = start.or(f.first).unwrap_or_else(|| level_def(k).map_or(1, |l| l.start));
*f = LevelFlags { started: true, first: None };
}
if let (Some(v), Some(f)) = (st.get_mut(lv), fl.get_mut(lv)) {
*v = if f.started { v.saturating_add(1) } else { f.first.unwrap_or(def.start) };
*f = LevelFlags { started: true, first: None };
@@ -426,8 +443,9 @@ mod tests {
assert_eq!(c.next_label(&n, id, 2).unwrap().0, "1.02");
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "");
assert_eq!(c.next_label(&n, id, 2).unwrap().0, "2.01");
// Levels it doesn't define still come from the abstract list.
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "1.");
// Levels it doesn't define still come from the abstract list; level 0 took its first number
// with the first level-1 item, so this is its second.
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "2.");
assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero));
}
@@ -476,6 +494,40 @@ mod tests {
assert_eq!(l(2), "(1)", "level 1 restarts level 3");
}
#[test]
fn a_deeper_item_first_counts_the_levels_above_it() {
// Measured in Word: a level above that hasn't counted yet takes its first number at the
// first deeper item, whether or not that item's text shows it.
let labels_with = |starts: [u32; 3], texts: [&str; 3], overrides: &[(u8, u32)], seq: &[u8]| {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Legal);
if let Some(a) = n.abstracts.first_mut() {
for (l, (s, t)) in a.levels.iter_mut().zip(starts.into_iter().zip(texts)) {
l.start = s;
l.text = t.into();
}
}
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.start_overrides = overrides.to_vec();
}
let mut c = Counters::default();
seq.iter().map(|lv| c.next_label(&n, id, *lv).unwrap().0).collect::<Vec<_>>()
};
let labels = |starts: [u32; 3], texts: [&str; 3], seq: &[u8]| labels_with(starts, texts, &[], seq);
let std = ["%1.", "%1.%2.", "%1.%2.%3."];
assert_eq!(labels([1, 1, 1], std, &[1, 1, 0, 0]), ["1.1.", "1.2.", "2.", "3."]);
assert_eq!(labels([10, 1, 1], std, &[1, 1, 1, 1, 0, 0]), ["10.1.", "10.2.", "10.3.", "10.4.", "11.", "12."]);
assert_eq!(labels([1, 1, 1], ["%1.", "(%2)", "%1.%2.%3."], &[1, 1, 0, 0]), ["(1)", "(2)", "2.", "3."]);
assert_eq!(labels([1, 1, 1], std, &[2, 1, 0, 1]), ["1.1.1.", "1.2.", "2.", "2.1."]);
assert_eq!(labels([1, 1, 1], ["%1.", "%1.%2.", "(%3)"], &[2, 1, 0]), ["(1)", "1.2.", "2."]);
assert_eq!(labels([1, 1, 1], std, &[0, 2, 1, 0]), ["1.", "1.1.1.", "1.2.", "2."]);
// The num's own start for the level above counts in its place, as the level's start does.
assert_eq!(labels_with([1, 1, 1], std, &[(0, 10)], &[1, 1, 0]), ["10.1.", "10.2.", "11."]);
assert_eq!(labels_with([1, 1, 1], std, &[(0, 5)], &[1, 0, 1]), ["5.1.", "6.", "6.1."]);
// A level-1 item after the first deeper ones restarts level 2 below it, as usual.
assert_eq!(labels([1, 1, 1], std, &[1, 1, 0, 1, 1, 0, 2]), ["1.1.", "1.2.", "2.", "2.1.", "2.2.", "3.", "3.1.1."]);
}
#[test]
fn a_level_starting_at_zero_counts_up() {
let mut n = Numbering::default();
+10 -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)]
@@ -1338,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()) {
+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])))
}
}
+36 -2
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),
@@ -295,8 +300,12 @@ impl StyleSheet {
self.para_style_props_over(style, self.default_para.clone(), self.default_chr.clone())
}
/// The paragraph style chain of `style` applied over `pp` / `cp`.
/// The paragraph style chain of `style` applied over `pp` / `cp`. A style the document doesn't
/// define is the default paragraph style, Normal, as in Word: a footer naming a `Footer` style
/// that `styles.xml` leaves out is formatted as Normal, not with the document defaults alone
/// (#5077).
fn para_style_props_over(&self, style: &str, mut pp: ParaProps, mut cp: CharProps) -> (ParaProps, CharProps) {
let style = if self.get(style).is_some() { style } else { "Normal" };
for s in self.chain(style) {
if s.kind == StyleKind::Paragraph {
let mut sp = s.para.clone();
@@ -395,6 +404,31 @@ mod tests {
assert_eq!(s.resolve_char(None, &direct).font, "Wingdings");
}
/// #5077: a paragraph naming a style the document doesn't define is formatted as Normal,
/// paragraph and run alike, not with the document defaults alone.
#[test]
fn an_undefined_paragraph_style_is_normal() {
let mut s = StyleSheet::builtin();
s.default_para.space_after = Some(10.0);
if let Some(n) = s.get_mut("Normal") {
n.para.space_after = Some(2.0);
n.chr.size = Some(13.0);
}
let undefined = ParaProps { style: Some("NoSuchStyle".into()), ..Default::default() };
let normal = s.resolve_para(&ParaProps::default());
let p = s.resolve_para(&undefined);
assert_eq!(p.space_after, 2.0);
assert_eq!(p.space_after, normal.space_after);
assert_eq!(p.line_spacing, normal.line_spacing);
assert_eq!(s.resolve_char(Some("NoSuchStyle"), &CharProps::default()).size, 13.0);
// The paragraph keeps the style it names; direct formatting still wins.
assert_eq!(p.style, "NoSuchStyle");
let direct = ParaProps { space_after: Some(7.0), ..undefined };
assert_eq!(s.resolve_para(&direct).space_after, 7.0);
// A defined style is unchanged.
assert_eq!(s.resolve_para(&ParaProps { style: Some("Heading1".into()), ..Default::default() }).space_after, 4.0);
}
#[test]
fn heading_resolves() {
let s = StyleSheet::builtin();
+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";
+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,
}
}
}
+137
View File
@@ -77,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);
@@ -566,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);
+115
View File
@@ -1,5 +1,7 @@
use super::*;
mod note_conversion;
#[test]
fn new_document_is_valid() {
let d = Document::new();
@@ -16,6 +18,20 @@ fn word_count() {
assert_eq!(count_words(" -- "), 0);
}
/// #4547: each Han ideograph and kana counts as a word; punctuation and Hangul spacing don't change.
#[test]
fn word_count_counts_each_chinese_and_japanese_character() {
// #4547: unspaced Chinese counted as one word per run.
assert_eq!(count_words("我们今天去公园。"), 7);
assert_eq!(count_words("WordCraft是一个文字处理器,很好用!"), 1 + 11);
assert_eq!(count_words("ひらがなとカタカナ"), 9);
assert_eq!(count_words("カタカナ"), 4);
assert_eq!(count_words("ガギ"), 2, "voiced sound marks belong to their kana");
assert_eq!(count_words("。、・「」"), 0);
// Korean is spaced: Hangul counts by tokens.
assert_eq!(count_words("안녕하세요 세계"), 2);
}
#[test]
fn word_count_includes_numbered_list_labels_but_not_bullets() {
let mut d = Document::new();
@@ -61,6 +77,23 @@ fn word_count_includes_numbered_labels_with_nothing_suffix() {
assert_eq!(d.word_count(), 5);
}
/// #4547: a generated CJK list label such as 第十二章 still counts as one word.
#[test]
fn word_count_keeps_a_cjk_list_label_one_word() {
let mut d = Document::new();
let num_id = d.numbering.add_list(ListKind::Numbered);
let aid = d.numbering.num(num_id).expect("list num exists").abstract_id;
let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists");
a.levels[0].format = section::NumFormat::ChineseCounting;
a.levels[0].text = "第%1章".into();
a.levels[0].start = 12;
let mut p = Paragraph::with_text("总结", CharProps::default());
p.props.numbering = Some(props::NumRef { num: num_id, level: 0 });
d.body = vec![Arc::new(Block::Para(p))];
// The label 第十二章 stays one word (#4547 changed only document text); 总结 is two.
assert_eq!(d.word_count(), 1 + 2);
}
#[test]
fn sections_and_section_mut() {
let mut d = Document::from_text("a\nb\nc");
@@ -415,6 +448,25 @@ fn box_para(story: u32, id: u32) -> Blocks {
vec![para_block(p)]
}
#[test]
fn adopting_relinks_lists_in_deeply_nested_tables() {
// As #1339: a list paragraph 20 tables down takes the list it is brought in with.
let mut ours = Document::from_text("ours");
let our_list = ours.numbering.add_list(numbering::ListKind::Bullet);
let mut theirs = Document::from_text("theirs");
let list = theirs.numbering.add_list(numbering::ListKind::Bullet);
assert_eq!(list, our_list);
let mut p = Paragraph::with_text("item", CharProps::default());
p.props.numbering = Some(props::NumRef { num: list, level: 0 });
theirs.body = nested_tables(20, vec![para_block(p)]);
let mut nums = Vec::new();
for b in &ours.adopt(theirs).blocks {
edit::each_para(b, 0, &mut |p| nums.extend(p.props.numbering.map(|n| n.num)));
}
assert_eq!(nums.len(), 1);
assert_ne!(nums[0], our_list);
}
#[test]
fn text_boxes_shown_from_deeply_nested_tables_are_kept() {
// #1339: the paragraph walk stopped 16 tables down, so a box shown only from deeper was taken
@@ -651,3 +703,66 @@ fn control_markers_kept_are_sorted() {
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");
}
}
+190
View File
@@ -0,0 +1,190 @@
use super::block::{Block, Blocks};
use super::parts::PartKind;
use super::position::Path;
use super::{InlineObject, Numbering, numbering, para, section, styles};
/// Words in `s`, as Word counts them: tokens separated by whitespace that contain at least one
/// alphanumeric char, plus each Han ideograph and kana as a word of its own (Chinese and Japanese
/// are written without spaces, #4547; Korean is spaced, so Hangul counts by tokens).
pub fn count_words(s: &str) -> usize {
// The half-width voiced sound marks belong to the kana before them, not a word of their own.
let word_char = |c: char| c.is_alphanumeric() && !matches!(c, '\u{FF9E}' | '\u{FF9F}');
let tokens = s.split(|c: char| c.is_whitespace() || c == para::OBJ || is_cjk_word(c)).filter(|w| w.chars().any(word_char)).count();
tokens.saturating_add(s.chars().filter(|c| is_cjk_word(*c)).count())
}
/// Words in a generated list label: whitespace-separated tokens holding a letter or digit, so a
/// CJK label such as 第十二章 stays one word (how Word counts those is unverified).
fn count_label_words(s: &str) -> usize {
s.split(char::is_whitespace).filter(|w| w.chars().any(char::is_alphanumeric)).count()
}
/// A Han ideograph or kana letter (not CJK punctuation), which Word counts as one word.
fn is_cjk_word(c: char) -> bool {
matches!(c,
'\u{3005}'..='\u{3007}' // 々 〆 〇
| '\u{3031}'..='\u{3035}' | '\u{303B}' // vertical kana repeat marks, 〻
| '\u{3040}'..='\u{30FF}' // hiragana, katakana
| '\u{31F0}'..='\u{31FF}' // katakana phonetic extensions
| '\u{3400}'..='\u{4DBF}' // extension A
| '\u{4E00}'..='\u{9FFF}' // unified ideographs
| '\u{F900}'..='\u{FAFF}' // compatibility ideographs
| '\u{FF66}'..='\u{FF9D}' // half-width katakana
| '\u{1B000}'..='\u{1B16F}' // kana supplement and extended
| '\u{20000}'..='\u{3FFFF}' // extensions B and later
) && c.is_alphanumeric()
}
/// 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_label_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
}
+191
View File
@@ -0,0 +1,191 @@
//! A bytes-only check of a ZIP package's declared entry count before the `zip` crate opens it
//! (#3660). Every reader of `.docx`, `.odt` and spreadsheet packages calls [`check`] first.
//!
//! `zip::ZipArchive::new` reserves room for, and parses, as many central directory headers as the
//! end record declares before its caller can look at `len()`. A ZIP64 end record can declare
//! millions of entries for a file of a few hundred megabytes (only 46 bytes each have to fit), and
//! each parsed entry costs several times that in memory. Here the end records are found the way
//! `zip` finds them (APPNOTE.TXT §4.3.14 to §4.3.16), and a package declaring more than the
//! reader's limit is refused before `zip` parses anything. Anything else that is wrong is left for
//! `zip` to report.
const EOCD: &[u8; 4] = b"PK\x05\x06";
const EOCD64_LOCATOR: &[u8; 4] = b"PK\x06\x07";
const EOCD64: &[u8; 4] = b"PK\x06\x06";
const CENTRAL_HEADER: &[u8; 4] = b"PK\x01\x02";
/// End records looked at, at most. A package has one; a few more can turn up inside stored data.
const MAX_END_RECORDS: usize = 16;
/// Refuse `bytes` when the end record `zip` would use declares more than `max_entries` entries.
///
/// `zip` searches the file backwards for an end of central directory record. A ZIP64 one points
/// through its locator at the ZIP64 end record, which `zip` looks for from the locator's offset
/// on: every such record there is checked, and as `zip` may fall back to an earlier end record
/// when one doesn't parse, the search goes on. A plain one is used when it declares no entries, or
/// a central directory header lies between its directory offset and itself: it is the last one
/// looked at.
pub fn check(bytes: &[u8], max_entries: u64) -> Result<(), String> {
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]]));
let u64_at = |at: usize| bytes.get(at..at.checked_add(8)?).and_then(|b| <[u8; 8]>::try_from(b).ok()).map(u64::from_le_bytes);
let refuse = |n: u64| -> Result<(), String> {
if n > max_entries { Err(format!("the package declares {n} entries, more than {max_entries}")) } else { Ok(()) }
};
let mut end = bytes.len();
let mut seen = 0;
while let Some(at) = bytes.get(..end).and_then(|b| b.windows(4).rposition(|w| w == EOCD)) {
end = at;
seen += 1;
if seen > MAX_END_RECORDS {
return Err(format!("more than {MAX_END_RECORDS} end of central directory records"));
}
// `zip` skips a record whose comment runs past the end of the file.
let Some(comment) = u16_at(at + 20) else { continue };
if at + 22 + usize::from(comment) > bytes.len() {
continue;
}
let (Some(on_disk), Some(total), Some(cd_offset)) = (u16_at(at + 8), u16_at(at + 10), u32_at(at + 16)) else { continue };
let zip64 = total == u16::MAX || cd_offset == u32::MAX;
if zip64
&& let Some(locator) = at.checked_sub(20)
&& bytes.get(locator..locator + 4) == Some(EOCD64_LOCATOR.as_slice())
{
let (Some(eocd64), Some(disks)) = (u64_at(locator + 8), u32_at(locator + 16)) else { continue };
let Some(from) = usize::try_from(eocd64).ok().filter(|&f| f < locator) else { continue };
if disks > 1 {
continue;
}
let records = bytes.get(from..locator).unwrap_or_default().windows(4).enumerate().filter(|(_, w)| *w == EOCD64);
for (i, _) in records {
let p = from + i;
refuse(u64_at(p + 24).unwrap_or(0).max(u64_at(p + 32).unwrap_or(0)))?;
}
continue;
}
if total == 0 {
return Ok(());
}
let cd = cd_offset as usize;
if cd < at && bytes.get(cd..at).is_some_and(|b| b.windows(4).any(|w| w == CENTRAL_HEADER)) {
return refuse(u64::from(on_disk.max(total)));
}
}
Ok(())
}
/// A package of one empty stored file, `a`, whose central directory lists it `entries` times (at
/// most as many as fit in 64 MiB) under a ZIP64 end record that declares them all: `zip` 3.0.0
/// parses and keeps every one of them before its caller can count them. 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 zip64_entry_count_sample(entries: u64) -> Vec<u8> {
// Local file header (APPNOTE.TXT §4.3.7): version 10, stored, no data, name "a".
let mut f = b"PK\x03\x04".to_vec();
f.extend_from_slice(&[10, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]); // version, flags, method, time, date
f.extend_from_slice(&[0; 12]); // crc, sizes
f.extend_from_slice(&[1, 0, 0, 0]); // name and extra lengths
f.push(b'a');
let cd_at = f.len() as u64;
// Its central header (§4.3.12), 47 bytes.
let mut header = CENTRAL_HEADER.to_vec();
header.extend_from_slice(&[10, 0, 10, 0, 0, 0, 0, 0, 0, 0, 0x21, 0]); // versions, flags, method, time, date
header.extend_from_slice(&[0; 12]); // crc, sizes
header.extend_from_slice(&[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); // name, extra, comment, disk, attributes
header.extend_from_slice(&[0; 4]); // local header offset
header.push(b'a');
let count = entries.min((64 << 20) / header.len() as u64);
for _ in 0..count {
f.extend_from_slice(&header);
}
let cd_len = f.len() as u64 - cd_at;
let eocd64_at = f.len() as u64;
// ZIP64 end of central directory record (§4.3.14), 56 bytes.
f.extend_from_slice(EOCD64);
f.extend_from_slice(&44u64.to_le_bytes()); // size of the rest of the record
f.extend_from_slice(&[45, 0, 45, 0]); // versions
f.extend_from_slice(&[0; 8]); // this disk, disk with the central directory
f.extend_from_slice(&entries.to_le_bytes()); // entries on this disk
f.extend_from_slice(&entries.to_le_bytes()); // entries
f.extend_from_slice(&cd_len.to_le_bytes());
f.extend_from_slice(&cd_at.to_le_bytes());
// Its locator (§4.3.15), 20 bytes.
f.extend_from_slice(EOCD64_LOCATOR);
f.extend_from_slice(&0u32.to_le_bytes());
f.extend_from_slice(&eocd64_at.to_le_bytes());
f.extend_from_slice(&1u32.to_le_bytes());
// End of central directory record (§4.3.16), 22 bytes, each count and offset "see ZIP64".
f.extend_from_slice(EOCD);
f.extend_from_slice(&[0; 4]);
f.extend_from_slice(&[0xFF; 12]);
f.extend_from_slice(&[0; 2]);
f
}
#[cfg(test)]
mod tests {
use super::{CENTRAL_HEADER, EOCD, check, zip64_entry_count_sample};
/// A plain package's tail: one central header at 0, then an end record declaring `entries`.
fn plain(entries: u16, comment: &[u8]) -> Vec<u8> {
let mut f = CENTRAL_HEADER.to_vec();
f.resize(46, 0);
f.extend_from_slice(EOCD);
f.extend_from_slice(&[0, 0, 0, 0]);
f.extend_from_slice(&entries.to_le_bytes());
f.extend_from_slice(&entries.to_le_bytes());
f.extend_from_slice(&46u32.to_le_bytes());
f.extend_from_slice(&0u32.to_le_bytes());
f.extend_from_slice(&(comment.len() as u16).to_le_bytes());
f.extend_from_slice(comment);
f
}
#[test]
fn a_zip64_end_record_declaring_too_many_entries_is_refused() {
// #3660: `zip` would reserve and parse them all before the reader's cap.
let sample = zip64_entry_count_sample(20_001);
assert!(check(&sample, 20_000).is_err_and(|e| e.contains("20001 entries")));
assert!(check(&zip64_entry_count_sample(5), 20_000).is_ok());
// Junk before the package (a self-extracting stub) doesn't hide it.
let mut prefixed = vec![0x55u8; 4096];
prefixed.extend_from_slice(&sample);
assert!(check(&prefixed, 20_000).is_err());
}
#[test]
fn a_plain_end_record_is_checked_as_zip_reads_it() {
assert!(check(&plain(3, b""), 20_000).is_ok());
assert!(check(&plain(30_000, b"a comment"), 20_000).is_err_and(|e| e.contains("30000 entries")));
// A record whose comment runs past the end is not the one `zip` uses.
let mut cut = plain(30_000, b"");
cut.truncate(cut.len() - 2);
cut.extend_from_slice(&500u16.to_le_bytes());
assert!(check(&cut, 20_000).is_ok());
assert!(check(b"not a package", 20_000).is_ok());
assert!(check(b"", 20_000).is_ok());
}
#[test]
fn a_fake_end_record_after_the_real_one_does_not_hide_it() {
// `zip` skips a last record with no central header before it and uses the one before.
let mut f = zip64_entry_count_sample(1_000_000);
f.extend_from_slice(EOCD);
f.extend_from_slice(&[0, 0, 0, 0]);
f.extend_from_slice(&7u16.to_le_bytes());
f.extend_from_slice(&7u16.to_le_bytes());
f.extend_from_slice(&0u32.to_le_bytes());
f.extend_from_slice(&((f.len() - 12) as u32).to_le_bytes());
f.extend_from_slice(&0u16.to_le_bytes());
assert!(check(&f, 20_000).is_err());
}
#[test]
fn many_end_records_are_refused() {
// Each declares one entry and has no central header before it, so `zip` would try them all.
let record = [0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
let f: Vec<u8> = (0..20).flat_map(|_| EOCD.iter().copied().chain(record)).collect();
assert!(check(&f, 20_000).is_err_and(|e| e.contains("end of central directory records")));
}
}
+21 -4
View File
@@ -20,7 +20,9 @@ const C_ICO: u16 = 0x2A42; // palette colour
const C_CR: u16 = 0x6870; // explicit COLORREF
const C_HPS: u16 = 0x4A43; // half-points
const C_HPS_POS: u16 = 0x4845; // super/subscript by half-points
const C_FTC0: u16 = 0x4A4F; // font index
const C_FTC0: u16 = 0x4A4F; // font index (ASCII)
const C_FTC1: u16 = 0x4A50; // East Asian font index (sprmCRgFtc1)
const C_FTC_BI: u16 = 0x4A5E; // complex-script font index (sprmCFtcBi)
const C_HIGHLIGHT: u16 = 0x2A0C;
const C_ISTD: u16 = 0x4A30; // character style
@@ -254,6 +256,16 @@ pub(crate) fn apply_char(p: &mut CharProps, prl: &Prl, base: &CharProps, fonts:
p.font = Some(name.clone());
}
}
C_FTC1 => {
if let Some(name) = u16_of(prl).and_then(|i| fonts.get(i as usize)) {
p.font_ea = Some(name.clone());
}
}
C_FTC_BI => {
if let Some(name) = u16_of(prl).and_then(|i| fonts.get(i as usize)) {
p.font_cs = Some(name.clone());
}
}
C_HIGHLIGHT => {
if let Some(h) = HL.get(prl.operand.first().copied().unwrap_or(0) as usize) {
p.highlight = Some(*h);
@@ -417,10 +429,15 @@ pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) {
/// Build the model stylesheet: resolve each raw style's chain (base → this) and the ids we
/// reference from paragraphs.
pub(crate) fn stylesheet(raw: &[crate::stsh::RawStyle], fonts: &[String]) -> StyleSheet {
pub(crate) fn stylesheet(raw: &[crate::stsh::RawStyle], fonts: &[String], standard: crate::stsh::StandardFonts) -> StyleSheet {
let mut sheet = StyleSheet::empty();
// Word's base character properties: font index 0 of the table, 10 pt ([MS-DOC] sprmCHps).
sheet.default_chr.font = fonts.first().cloned();
// Word's base character properties: the stylesheet's standard fonts (Aptos in a Word 365
// file, whose font table still starts with Times New Roman, #5066), else font index 0 of the
// table; 10 pt ([MS-DOC] sprmCHps).
let font = |i: Option<u16>| i.and_then(|i| fonts.get(usize::from(i))).cloned();
sheet.default_chr.font = font(standard.ascii).or_else(|| fonts.first().cloned());
sheet.default_chr.font_ea = font(standard.east_asian);
sheet.default_chr.font_cs = font(standard.bidi);
if sheet.default_chr.size.is_none() {
sheet.default_chr.size = Some(10.0);
}
+10 -7
View File
@@ -233,6 +233,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
Some((f, l)) => stsh::parse(&table, f, l)?,
None => Vec::new(),
};
let standard_fonts = fib.pair(fib::pair::STSHF).map(|(f, l)| stsh::standard_fonts(&table, f, l)).unwrap_or_default();
let chpx_bins = match fib.pair(fib::pair::PLCF_BTE_CHPX) {
Some((f, l)) => Bins::parse(&table, f, l)?,
None => Bins::parse(&[], 0, 0)?,
@@ -247,7 +248,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
doc.core = core;
}
doc.body.clear();
doc.styles = fmt::stylesheet(&raw_styles, &fonts);
doc.styles = fmt::stylesheet(&raw_styles, &fonts, standard_fonts);
doc.numbering = list::parse(&table, &fib, &fonts);
let sheet: StyleSheet = doc.styles.clone();
let sects = sections::parse(&word, &table, &fib);
@@ -259,7 +260,7 @@ 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
@@ -363,6 +364,11 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
// Sections: each section's properties attach to the paragraph that ends it (its story of
// header/footer parts included); the section reaching the end of the text is the final one.
let mut paras = walk(&ctx, 0, fib.ccp.text, &mut pics);
// The first paragraph ending at each CP, found once rather than by a scan per section.
let mut ending: std::collections::HashMap<u32, usize> = std::collections::HashMap::new();
for (i, p) in paras.iter().enumerate() {
ending.entry(p.end_cp).or_insert(i);
}
let last = sects.last().map(|s| s.props.clone());
for (si, sec) in sects.iter().enumerate() {
let mut props = sec.props.clone();
@@ -373,11 +379,8 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
if is_final {
continue; // handled below through `last`
}
for p in paras.iter_mut() {
if p.end_cp == sec.end_cp {
p.para.section = Some(Box::new(props));
break;
}
if let Some(p) = ending.get(&sec.end_cp).and_then(|&i| paras.get_mut(i)) {
p.para.section = Some(Box::new(props));
}
}
if let Some(mut l) = last {
+39 -20
View File
@@ -21,6 +21,20 @@ pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20;
/// 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
@@ -32,16 +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>>,
/// (foDelay, end) of a BLIP record outside the store → media key: store entries that alias
/// one record share one decode (`None`: failed, not retried).
by_delay: HashMap<(usize, usize), 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: usize,
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).
@@ -57,7 +72,7 @@ impl Default for Pictures {
media: BTreeMap::new(),
total: 0,
limit: MAX_MEDIA_BYTES,
work: MAX_DECODE_WORK,
work: officeart::Decode::new(MAX_DECODE_WORK),
dropped: 0,
placeholders: 0,
}
@@ -75,6 +90,12 @@ 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.
@@ -96,16 +117,13 @@ impl Pictures {
if room == 0 {
return None;
}
match locate(data, fc_pic, room, &mut self.work)? {
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 }),
}
}
@@ -127,6 +145,7 @@ impl Pictures {
/// 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();
}
@@ -174,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
@@ -183,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, work: &mut 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;
@@ -203,12 +222,12 @@ fn locate(data: &[u8], fc_pic: u32, room: usize, work: &mut usize) -> Option<Fou
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, work)?;
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);
@@ -220,16 +239,16 @@ fn locate(data: &[u8], fc_pic: u32, room: usize, work: &mut usize) -> Option<Fou
}
/// 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, work: &mut 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, work)?;
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
+67 -32
View File
@@ -112,16 +112,50 @@ 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, work: &mut 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`, end) location is returned too (store entries can share one record).
/// 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)?;
@@ -129,15 +163,14 @@ pub(crate) fn fbse_record<'a>(fbse: &'a [u8], delay: &'a [u8]) -> Option<(Rec<'a
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)?)?;
Some((rec, Some((fo_delay, end))))
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). `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 usize) -> Option<Blip> {
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 {
@@ -163,11 +196,12 @@ pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut usize) -> Option<Blip> {
let raw = raw.get(..cb_save.min(raw.len()))?;
let cap = limit.min(MAX_METAFILE);
let bytes = if compression == 0 {
let want = (if cb_size > 0 { cb_size.min(cap) } else { cap }).min(*work);
if want == 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;
}
*work -= want;
miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, want).ok()?
} else {
if raw.len() > cap {
@@ -192,7 +226,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut usize) -> Option<Blip> {
/// the 14-byte BMP file header it lacks, is decoded with size limits and re-encoded. When the
/// 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 usize) -> Option<Blip> {
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;
@@ -211,6 +245,20 @@ fn dib_to_png(dib: &[u8], limit: usize, work: &mut 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());
@@ -218,16 +266,9 @@ fn dib_to_png(dib: &[u8], limit: usize, work: &mut usize) -> Option<Blip> {
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 });
// 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 Some(left) = w.saturating_mul(h).saturating_mul(4).try_into().ok().and_then(|cost: usize| work.checked_sub(cost)) else {
if !decode {
return placeholder(bmp);
};
*work = left;
}
let mut reader = image::ImageReader::new(std::io::Cursor::new(&bmp));
reader.set_format(image::ImageFormat::Bmp);
let mut limits = image::Limits::default();
@@ -336,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),
+35
View File
@@ -20,6 +20,41 @@ pub(crate) struct RawStyle {
pub(crate) chpx: Vec<u8>,
}
/// The stylesheet's standard fonts, the ones text takes when neither its style nor its own
/// formatting names a font: font-table indexes of the ASCII, East Asian and other
/// (non-East-Asian, non-ASCII) fonts (`Stshif.rgftcStandardChpStsh`, [MS-DOC] §2.9.273) and the
/// complex-script font (`Stshi.ftcBi`, §2.9.272). `None` where the header is too short for one.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct StandardFonts {
pub(crate) ascii: Option<u16>,
pub(crate) east_asian: Option<u16>,
pub(crate) other: Option<u16>,
pub(crate) bidi: Option<u16>,
}
/// `StshiLsd.cbLSD`: the size of a latent style's `LSD` ([MS-DOC] `StshiLsd`).
const LSD_SIZE: u16 = 4;
/// The [`StandardFonts`] in the header (`Stshi`) of the `STSH` at `table[fc..fc+lcb]`: Word 365
/// writes Aptos there while the font table's first entry is still Times New Roman (#5066).
pub(crate) fn standard_fonts(table: &[u8], fc: u32, lcb: u32) -> StandardFonts {
let Some(stsh) = (fc as usize).checked_add(lcb as usize).and_then(|end| table.get(fc as usize..end)) else { return StandardFonts::default() };
let cb_stshi = stsh.get(0..2).map_or(0, |b| u16::from_le_bytes([b[0], b[1]]) as usize);
let stshi = stsh.get(2..).map_or(&[][..], |s| s.get(..cb_stshi.min(s.len())).unwrap_or(&[]));
// Stshif: cstd, cbSTDBaseInFile, flags, stiMaxWhenSaved, istdMaxFixedWhenSaved,
// nVerBuiltInNamesWhenSaved (2 bytes each), then rgftcStandardChpStsh (3 × 2); ftcBi follows.
let at = |o: usize| stshi.get(o..o + 2).map(|b| u16::from_le_bytes([b[0], b[1]]));
// ftcBi is optional, and the latent style data (`StshiLsd`, whose `cbLSD` MUST be 4) follows
// it: a 4 right after it says it is there, a 4 in its place with more data after says it was
// left out. A header that ends right after the field (as Word 97's) has it, a 4 included.
let bidi = match (at(18), at(20)) {
(b, Some(LSD_SIZE) | None) => b,
(Some(LSD_SIZE), Some(_)) => None,
(b, _) => b,
};
StandardFonts { ascii: at(12), east_asian: at(14), other: at(16), bidi }
}
/// Parse the `STSH` at `table[fc..fc+lcb]`.
pub(crate) fn parse(table: &[u8], fc: u32, lcb: u32) -> Result<Vec<RawStyle>, DocbinError> {
let fc = fc as usize;
+26 -8
View File
@@ -101,6 +101,12 @@ impl RowInfo {
self.widths.iter().rfind(|(k, _)| *k == i).map(|(_, w)| *w)
}
/// How many per-cell overrides the row keeps, for tests.
#[cfg(test)]
pub(crate) fn overrides(&self) -> usize {
self.vmerge.len() + self.shading.len() + self.widths.len() + self.width_pcts.len() + self.valign.len() + self.merges.len()
}
/// The width percentage cell `i` ends up with, for tests.
#[cfg(test)]
pub(crate) fn width_pct_of(&self, i: u8) -> Option<f32> {
@@ -108,6 +114,15 @@ impl RowInfo {
}
}
/// Set cell `c`'s override to `v`, replacing an earlier one: one slot per cell, however often
/// a PAPX repeats the sprm, so a shared PAPX can't pile up copies on every paragraph mark.
fn set<T>(slots: &mut Vec<(u8, T)>, c: u8, v: T) {
match slots.iter_mut().find(|(k, _)| *k == c) {
Some(slot) => slot.1 = v,
None => slots.push((c, v)),
}
}
pub(crate) fn decode(papx: &[u8]) -> RowInfo {
let mut r = RowInfo::default();
let mut gap_half: Option<i16> = None;
@@ -144,8 +159,10 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
T_DXA_GAP_HALF => gap_half = s16(&prl),
T_TABLE_BORDERS_80 => r.borders = table_borders(prl.operand),
T_MERGE => {
// Rows apply only the first MAX_COLS merges.
if let [a, b, ..] = prl.operand
&& b > a
&& r.merges.len() < MAX_COLS
{
r.merges.push((*a, *b));
}
@@ -158,7 +175,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
1 => VMerge::Continue,
_ => VMerge::None,
};
if m != VMerge::None {
// The first override for a cell wins.
if m != VMerge::None && !r.vmerge.iter().any(|(k, _)| k == itc) {
r.vmerge.push((*itc, m));
}
}
@@ -168,7 +186,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
&& let [a, b, ..] = prl.operand.get(1..3).unwrap_or(&[])
{
for c in *a..(*b).min(MAX_COLS as u8) {
r.shading.push((c, Some(rgb)));
set(&mut r.shading, c, Some(rgb));
}
}
}
@@ -176,7 +194,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
let cells = prl.operand.get(1..).unwrap_or(&[]);
for (i, b) in cells.as_chunks::<2>().0.iter().take(MAX_COLS).enumerate() {
if let Some(rgb) = shd80_rgb(b) {
r.shading.push((i as u8, Some(rgb)));
set(&mut r.shading, i as u8, Some(rgb));
}
}
}
@@ -196,7 +214,7 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
if raw && b.get(8..10) == Some(&[0xFF, 0xFF]) {
continue;
}
r.shading.push(((first + i) as u8, shd_rgb(b)));
set(&mut r.shading, (first + i) as u8, shd_rgb(b));
}
}
T_CELL_WIDTH => {
@@ -207,12 +225,12 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
if *fts == 3 {
let w = (i16::from_le_bytes([*w0, *w1]) as f32 / 20.0).clamp(0.0, 1584.0);
for c in *a..(*b).min(MAX_COLS as u8) {
r.widths.push((c, w));
set(&mut r.widths, c, w);
}
} else if *fts == 2 {
let pct = (u16::from_le_bytes([*w0, *w1]) as f32 / 50.0).clamp(0.0, 100.0);
for c in *a..(*b).min(MAX_COLS as u8) {
r.width_pcts.push((c, pct));
set(&mut r.width_pcts, c, pct);
}
}
}
@@ -224,8 +242,8 @@ pub(crate) fn decode(papx: &[u8]) -> RowInfo {
2 => VAlign::Bottom,
_ => VAlign::Top,
};
for c in *a..*b {
r.valign.push((c, v));
for c in *a..(*b).min(MAX_COLS as u8) {
set(&mut r.valign, c, v);
}
}
}
+118 -2
View File
@@ -421,7 +421,17 @@ fn std_bytes_next(stk: u8, istd_base: u16, istd_next: u16, name: &str, papx: &[u
/// An STSH with the given styles.
fn stsh_bytes(styles: &[Vec<u8>]) -> Vec<u8> {
let stshi = [(styles.len() as u16).to_le_bytes(), 10u16.to_le_bytes()].concat(); // cstd, cbSTDBaseInFile
stsh_bytes_with(styles, None)
}
/// An STSH with the given styles; with `standard`, a full Stshif and ftcBi giving the standard
/// fonts' indexes (ASCII, East Asian, other, complex script), as Word writes them.
fn stsh_bytes_with(styles: &[Vec<u8>], standard: Option<[u16; 4]>) -> Vec<u8> {
let mut stshi = [(styles.len() as u16).to_le_bytes(), 10u16.to_le_bytes()].concat(); // cstd, cbSTDBaseInFile
if let Some(fonts) = standard {
stshi.extend_from_slice(&[0u8; 8]); // flags, stiMaxWhenSaved, istdMaxFixedWhenSaved, nVerBuiltInNamesWhenSaved
stshi.extend(fonts.iter().flat_map(|f| f.to_le_bytes()));
}
let mut v = (stshi.len() as u16).to_le_bytes().to_vec();
v.extend_from_slice(&stshi);
for s in styles {
@@ -532,6 +542,8 @@ struct RichExtra {
prm: u16,
alt_fonts: Vec<Option<&'static str>>,
style_next: Vec<u16>,
/// The stylesheet's standard fonts (ASCII, East Asian, other, complex script).
standard_fonts: Option<[u16; 4]>,
}
fn rich_doc(spec: &RichSpec) -> Vec<u8> {
@@ -571,7 +583,7 @@ fn rich_doc_with(spec: &RichSpec, extra: &RichExtra) -> Vec<u8> {
.enumerate()
.map(|(i, s)| std_bytes_next(s.0, s.1, extra.style_next.get(i).copied().unwrap_or(0x0FFF), s.2, &s.3, &s.4))
.collect();
let stsh = stsh_bytes(&styles);
let stsh = stsh_bytes_with(&styles, extra.standard_fonts);
let fonts: Vec<(&str, Option<&str>)> = spec.fonts.iter().enumerate().map(|(i, n)| (*n, extra.alt_fonts.get(i).copied().flatten())).collect();
let ffn = ffn_bytes_alt(&fonts);
let clx_at = 0x40;
@@ -654,6 +666,67 @@ fn font_and_size_from_style_and_direct() {
assert!(p.runs.first().is_some_and(|r| r.props.font.as_deref() == Some("Arial")), "run font: {:?}", p.runs);
}
/// #5066: the default font is the stylesheet's standard font (Aptos in a Word 365 file), not
/// the font table's first entry (still Times New Roman there); the East Asian and complex-script
/// standard fonts are the defaults for those scripts, and runs name theirs with sprmCRgFtc1 and
/// sprmCFtcBi.
#[test]
fn the_default_font_is_the_stylesheets_standard_font() {
let spec = |runs: Vec<(u32, Vec<u8>)>| RichSpec {
text: "Hello\r",
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), grpprl(&[(0x4A43, &24u16.to_le_bytes())]))],
fonts: vec!["Times New Roman", "Symbol", "Arial", "Aptos", "MS Mincho", "Arial Unicode"],
chpx_runs: runs,
papx_entries: vec![(0, 0, Vec::new())],
};
let with = |standard: Option<[u16; 4]>, runs| {
read(&rich_doc_with(&spec(runs), &RichExtra { standard_fonts: standard, ..Default::default() })).expect("opens")
};
let doc = with(Some([3, 4, 3, 2]), vec![(0, Vec::new())]);
let d = &doc.styles.default_chr;
assert_eq!((d.font.as_deref(), d.font_ea.as_deref(), d.font_cs.as_deref()), (Some("Aptos"), Some("MS Mincho"), Some("Arial")));
let p = para0(&doc);
let resolved = doc.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
assert_eq!(resolved.font, "Aptos", "Normal names no font, so the text takes the standard one");
// A run's own East Asian and complex-script fonts.
let doc = with(Some([3, 4, 3, 2]), vec![(0, grpprl(&[(0x4A50, &5u16.to_le_bytes()), (0x4A5E, &3u16.to_le_bytes())]))]);
let run = &para0(&doc).runs[0].props;
assert_eq!((run.font_ea.as_deref(), run.font_cs.as_deref()), (Some("Arial Unicode"), Some("Aptos")));
// Indexes past the font table, or a header too short to hold them, keep entry 0.
for standard in [Some([99, 99, 99, 99]), None] {
let doc = with(standard, vec![(0, Vec::new())]);
let d = &doc.styles.default_chr;
assert_eq!((d.font.as_deref(), d.font_ea.as_deref(), d.font_cs.as_deref()), (Some("Times New Roman"), None, None), "{standard:?}");
}
}
/// #5066: `ftcBi` is optional in the stylesheet header, and the latent style data (`cbLSD`, 4,
/// then the LSDs) follows it: a header that leaves it out doesn't make `cbLSD` the complex-script
/// font.
#[test]
fn the_complex_script_standard_font_is_read_only_when_present() {
let header = |tail: &[u16]| -> Vec<u8> {
// cstd, cbSTDBaseInFile, flags, stiMaxWhenSaved, istdMaxFixedWhenSaved,
// nVerBuiltInNamesWhenSaved; ASCII, East Asian, other.
let words = [&[0u16, 10, 0, 1, 0, 0, 3, 4, 3][..], tail].concat();
let stshi: Vec<u8> = words.iter().flat_map(|w| w.to_le_bytes()).collect();
[(stshi.len() as u16).to_le_bytes().to_vec(), stshi].concat()
};
let bidi = |tail: &[u16]| {
let b = header(tail);
crate::stsh::standard_fonts(&b, 0, b.len() as u32).bidi
};
assert_eq!(bidi(&[2]), Some(2), "a header that ends with it (Word 97)");
assert_eq!(bidi(&[4]), Some(4), "font 4, at the end of the header");
assert_eq!(bidi(&[2, 4, 0, 0]), Some(2), "followed by the latent style data");
assert_eq!(bidi(&[4, 4, 0, 0]), Some(4), "font 4, followed by the latent style data");
assert_eq!(bidi(&[4, 0, 0]), None, "left out: cbLSD is in its place");
assert_eq!(bidi(&[]), None, "a header too short for it");
let b = header(&[4, 0, 0]);
let fonts = crate::stsh::standard_fonts(&b, 0, b.len() as u32);
assert_eq!((fonts.ascii, fonts.east_asian, fonts.other), (Some(3), Some(4), Some(3)), "the other fonts are read all the same");
}
#[test]
fn paragraph_props_and_heading_style() {
let spec = RichSpec {
@@ -945,6 +1018,32 @@ fn two_sections_box_the_first() {
assert!(!doc.last_section.title_page);
}
#[test]
fn sections_ending_off_a_paragraph_mark_attach_nowhere() {
// Hostile section ends that miss every paragraph mark are skipped; the one that ends a
// paragraph still boxes it. Paragraphs are found by their end, not scanned per section.
let mut sections: Vec<(u32, Vec<u8>)> = (1..6).map(|cp| (cp, Vec::new())).collect();
sections.push((6, grpprl(&[(0x300A, &[0x01])])));
sections.push((12, Vec::new()));
let spec = StructSpec {
text: "first\rsecond\r",
header_stories: vec![],
sections,
chpx_runs: vec![(0, Vec::new())],
papx_entries: vec![(0, 0, Vec::new()), (6, 0, Vec::new())],
plf_lst: Vec::new(),
plf_lfo: Vec::new(),
note_stories: vec![],
endnote_stories: vec![],
extra_pairs: Vec::new(),
data_stream: Vec::new(),
};
let doc = read(&struct_doc(&spec)).expect("opens");
let paras: Vec<_> = doc.body.iter().filter_map(|b| b.as_para()).collect();
assert!(paras.first().and_then(|p| p.section.as_ref()).expect("first paragraph boxed").title_page);
assert_eq!(paras.iter().filter(|p| p.section.is_some()).count(), 1, "the missing ends attach nowhere");
}
#[test]
fn header_story_becomes_part() {
let spec = StructSpec {
@@ -2302,6 +2401,23 @@ fn second_table_gets_its_own_grid() {
assert_eq!(tables[1].props.indent, None, "indent reset");
}
#[test]
fn repeated_cell_sprms_keep_one_override_per_cell() {
// A PAPX repeating cell-range sprms (shared by every paragraph mark that names it) keeps one
// slot per cell, the last value winning, instead of a copy per repetition.
let mut sprms = Vec::new();
for w in 0..200u16 {
let [w0, w1] = (w * 20).to_le_bytes();
sprms.push(sprm_cb(0xD635, &[0, 63, 3, w0, w1]));
sprms.push(sprm_cb(0xD62C, &[0, 255, 1]));
sprms.push([0x24, 0x56, 0, 2].to_vec());
}
let row = crate::table::decode(&sprms.concat());
assert_eq!(row.width_of(0), Some(199.0), "the last width wins");
assert_eq!(row.width_of(62), Some(199.0));
assert!(row.overrides() <= 63 + 255 + 63, "{}", row.overrides());
}
#[test]
fn sprm_t_cell_width_twips_and_percentage() {
// [MS-DOC] §2.9.290 sprmTCellWidth (0xD635):
+140 -18
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, &mut { usize::MAX }).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, &mut { usize::MAX }).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, &mut { usize::MAX }).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, &mut { usize::MAX }).is_none());
assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20, &mut officeart::Decode::new(usize::MAX)).is_none());
}
proptest! {
@@ -322,12 +322,12 @@ 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, &mut { usize::MAX }).is_none(), "the BMP header also needs room");
let fallback = officeart::blip(&record, bmp_size, &mut { usize::MAX }).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, &mut { usize::MAX }).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);
@@ -434,10 +434,10 @@ fn store_entries_aliasing_one_delayed_metafile_decode_it_once() {
let fbse = delayed_fbse(delay.len(), 0);
let store: Vec<&[u8]> = vec![&fbse; 5_000];
let mut pics = crate::media::Pictures::default();
pics.work = 8 << 20;
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, (8 << 20) - wmf.len(), "inflated once");
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
@@ -456,11 +456,11 @@ fn failing_metafile_inflations_spend_the_decode_budget() {
.collect();
let store: Vec<&[u8]> = fbses.iter().map(Vec::as_slice).collect();
let mut pics = crate::media::Pictures::default();
pics.work = 3 << 20;
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, 0, "three attempts spent the budget; the rest weren't tried");
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
@@ -470,12 +470,13 @@ 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 = 3;
assert_eq!(officeart::blip(&one, 512, &mut work).map(|b| (b.ext, b.placeholder)), Some(("bmp", true)));
assert_eq!(work, 3, "nothing decoded, nothing charged");
let mut work = 4;
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, 0);
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<_>>();
@@ -489,7 +490,128 @@ fn dib_decoding_is_charged_its_declared_pixels() {
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 = 512 << 20;
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, 512 << 20);
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);
}
+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]
+1 -376
View File
@@ -984,379 +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());
}
/// 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);
}
}
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 {
+1 -1
View File
@@ -76,7 +76,7 @@ pub fn sniff_xml(bytes: &[u8]) -> XmlKind {
/// Read a Flat OPC file.
pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
crate::read::read_opened(&unpack(bytes)?)
crate::read::read_opened(&unpack(bytes)?, None)
}
/// The data element of the part being read.
+9 -4
View File
@@ -8,7 +8,7 @@ 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;
@@ -41,11 +41,15 @@ pub fn embedded_fonts(doc: &Document) -> Vec<EmbeddedFont> {
/// [`embedded_fonts`], copying at most `budget` bytes of font files.
fn fonts_within(doc: &Document, mut budget: u64) -> Vec<EmbeddedFont> {
let budget_total = budget;
let Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else {
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();
@@ -118,6 +122,7 @@ 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)`).
+23 -8
View File
@@ -32,7 +32,7 @@ pub use crypt::{
};
pub use flat::{XmlKind, sniff_xml, write as write_flat};
pub use fonts::{EmbeddedFont, embedded_fonts};
pub use read::read;
pub use read::{AltChunk, ChunkImporter, ReadOptions, read};
pub use smart_art::smart_art_items;
pub use wordml2003_write::write as write_wordml2003;
pub use write::{write, write_as};
@@ -56,11 +56,19 @@ pub fn read_xml(bytes: &[u8]) -> Result<wordcraft_doc::Document, DocxError> {
/// The title (`dc:title`) in a package's core properties part (`docProps/core.xml`, ECMA-376
/// Part 2 §11), trimmed; `None` when the part can't be read or has no title. Only the package's
/// directory and at most `max_part` bytes of that part are read: for listing templates by name
/// without reading the whole package.
pub fn package_title<R: std::io::Read + std::io::Seek>(package: R, max_part: u64) -> Option<String> {
/// directory and at most `max_part` bytes of that part are inflated: for listing templates by
/// name without unpacking the whole package. A package over 64 MiB, or one declaring more entries
/// than `MAX_ENTRIES` (20,000), has none (#3660).
pub fn package_title<R: std::io::Read>(package: R, max_part: u64) -> Option<String> {
use std::io::Read;
let mut zip = zip::ZipArchive::new(package).ok()?;
const MAX_PACKAGE: u64 = 64 << 20;
let mut bytes = Vec::new();
package.take(MAX_PACKAGE + 1).read_to_end(&mut bytes).ok()?;
if bytes.len() as u64 > MAX_PACKAGE {
return None;
}
wordcraft_doc::zip_guard::check(&bytes, package::MAX_ENTRIES as u64).ok()?;
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).ok()?;
let part = zip.by_name("docProps/core.xml").ok()?;
let mut xml = Vec::new();
part.take(max_part).read_to_end(&mut xml).ok()?;
@@ -84,11 +92,18 @@ pub(crate) const CANVAS_SPIN_EXT: &str = "urn:wordcraft:canvas-xfrm";
/// `password` first. An encrypted package without a password gives
/// [`DocxError::PasswordRequired`]; a wrong one, [`DocxError::WrongPassword`].
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<wordcraft_doc::Document, DocxError> {
read_with(bytes, &ReadOptions { password, ..Default::default() })
}
/// Read a package with options: the password of an encrypted one ([`read_with_password`]) and
/// an importer for `w:altChunk` content in formats other than WordprocessingML (see
/// [`ChunkImporter`]).
pub fn read_with(bytes: &[u8], opts: &ReadOptions) -> Result<wordcraft_doc::Document, DocxError> {
if is_encrypted(bytes) {
let package = zeroize::Zeroizing::new(decrypt(bytes, password)?);
return read::read_package(&package);
let package = zeroize::Zeroizing::new(decrypt(bytes, opts.password)?);
return read::read_package(&package, opts.chunks);
}
read(bytes)
read::read_package(bytes, opts.chunks)
}
/// Which kind of WordprocessingML package to write. The main part's content type differs per
+35 -2
View File
@@ -47,6 +47,7 @@ pub mod rt {
pub const DIAGRAM_COLORS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramColors";
pub const CUSTOM_XML: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
pub const GLOSSARY: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/glossaryDocument";
pub const AF_CHUNK: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk";
}
/// Content type of a glossary document part (ECMA-376 Part 1 §11.3.8).
@@ -96,6 +97,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";
@@ -203,6 +213,8 @@ impl Package {
}
pub fn open_limited(bytes: &[u8], max_part: u64, max_total: u64) -> Result<Package, DocxError> {
// Before `zip` parses every entry the end record declares (#3660).
wordcraft_doc::zip_guard::check(bytes, MAX_ENTRIES as u64).map_err(DocxError::Limit)?;
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| DocxError::Zip(e.to_string()))?;
if zip.len() > MAX_ENTRIES {
return Err(DocxError::Limit(format!("{} zip entries", zip.len())));
@@ -243,6 +255,11 @@ impl Package {
Ok(Package::new(files))
}
/// The bytes of all its parts.
pub fn size(&self) -> u64 {
self.files.values().map(|f| f.len() as u64).sum()
}
/// 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 {
@@ -412,7 +429,8 @@ fn rel_targets(files: &BTreeMap<String, Arc<Vec<u8>>>) -> HashSet<String> {
let mut named = HashSet::new();
for (name, bytes) in files {
// `dir/_rels/file.rels` relates `dir/file`, so its targets resolve against `dir`.
let Some(at) = name.rfind("_rels/").filter(|_| name.to_ascii_lowercase().ends_with(".rels")) else { continue };
let 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") {
@@ -514,6 +532,21 @@ mod tests {
assert!(folded.iter().all(|w| w.contains("2 zip entries")), "{folded:?}");
}
#[test]
fn a_package_declaring_too_many_entries_is_refused_before_zip_reads_them() {
// #3660: a ZIP64 end record declaring more than MAX_ENTRIES.
let bomb = wordcraft_doc::zip_guard::zip64_entry_count_sample(MAX_ENTRIES as u64 + 1);
assert!(matches!(Package::open(&bomb), Err(DocxError::Limit(e)) if e.contains("declares")));
assert_eq!(crate::package_title(std::io::Cursor::new(bomb), 1 << 20), None);
// A real package still opens, and its title still reads.
let z = zip_of(&[(
"docProps/core.xml",
br#"<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Plan</dc:title></cp:coreProperties>"#.to_vec(),
)]);
assert!(Package::open(&z).is_ok());
assert_eq!(crate::package_title(std::io::Cursor::new(z), 1 << 20).as_deref(), Some("Plan"));
}
#[test]
fn decompression_bomb_is_rejected() {
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
@@ -577,7 +610,7 @@ mod tests {
("word/media/pic.png", b"\x89PNG picture bytes".to_vec()),
]);
let pkg = Package::open(&z).unwrap();
let doc = crate::read::read_opened(&pkg).unwrap();
let doc = crate::read::read_opened(&pkg, None).unwrap();
let held = doc.media.values().next().expect("the picture is read");
assert!(Arc::ptr_eq(held, pkg.get_shared("word/media/pic.png").unwrap()), "the same bytes, not a copy");
assert_eq!(pkg.get("/WORD/media/PIC.png"), Some(&b"\x89PNG picture bytes"[..]), "lookups are unchanged");
+459
View File
@@ -0,0 +1,459 @@
//! DOCX → [`Document`].
pub(crate) mod chart;
mod chunk;
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::{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};
use chunk::ChunkState;
pub use chunk::{AltChunk, ChunkImporter, ReadOptions};
/// 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>,
/// The next permission range id to give out (chunks' ranges take theirs from it too).
pub next_perm: 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>>,
/// 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,
/// What alternative format chunks may still bring in, and how (see [`chunk`]).
chunks: ChunkState<'p>,
/// 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, None)
}
/// [`read`] for bytes known not to be an encrypted package; `chunks` imports alternative format
/// chunks in formats other than WordprocessingML.
pub(crate) fn read_package(bytes: &[u8], chunks: Option<ChunkImporter>) -> Result<Document, DocxError> {
read_opened(&Package::open(bytes)?, chunks)
}
/// 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, chunks: Option<ChunkImporter>) -> Result<Document, DocxError> {
read_opened_in(pkg, ChunkState::new(chunks)).map(|(doc, ..)| doc)
}
/// [`read_opened`] with what chunks may still bring in; also gives the chunks, chunk bytes and
/// graphic work left of that.
fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize, u64, usize), 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(),
next_perm: 0,
graphic_parts: HashMap::new(),
graphics: HashMap::new(),
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,
chunks,
};
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, r.chunks.depth);
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 (left, bytes, graphic) = (r.chunks.left, r.chunks.bytes, r.chunks.graphic);
let mut doc = r.doc;
doc.ensure_nonempty();
Ok((doc, left, bytes, graphic))
}
/// 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 });
}
}
}
+206
View File
@@ -0,0 +1,206 @@
use super::canvas::{Rect, clip_text, fill, stroke, text, text_w, widest};
use super::format::format_value;
use super::model::{DataLabel, Kind, Series};
use super::plot::{Axes, has_values};
use super::{Chart, FRAC_PI_2, GAP, GRID, GraphicItem, LINE, PathSeg, Rgb, ShapeKind, TAU, TEXT, TextAlign};
pub(super) fn pie(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
let Some(s) = c.series.first() else { return };
let vals: Vec<f64> = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect();
let total: f64 = vals.iter().sum();
let r = (area.w.min(area.h) / 2.0 - 2.0) as f64;
if !(total > 0.0 && total.is_finite() && r >= 1.0) {
return;
}
let (cx, cy) = ((area.x + area.w / 2.0) as f64, (area.y + area.h / 2.0) as f64);
let inner = if c.kind == Kind::Doughnut { r * c.hole as f64 } else { 0.0 };
let mut a = c.first_angle.to_radians();
let mut labels = Vec::new();
for (i, v) in vals.iter().enumerate() {
if *v <= 0.0 {
continue;
}
let b = a + v / total * TAU;
let mut segs = Vec::new();
if inner > 0.0 {
segs.push(pt(polar(cx, cy, r, a)));
arc(&mut segs, (cx, cy, r), a, b);
segs.push(line_to(polar(cx, cy, inner, b)));
arc(&mut segs, (cx, cy, inner), b, a);
} else {
segs.push(pt((cx, cy)));
segs.push(line_to(polar(cx, cy, r, a)));
arc(&mut segs, (cx, cy, r), a, b);
}
segs.push(PathSeg::Close);
fill(out, segs, c.point_colour(0, i));
if let Some(text) = label_of(s, i) {
// Halfway across the ring (pie: two thirds out from the centre).
let at = if inner > 0.0 { (r + inner) / 2.0 } else { r * 0.65 };
let (x, y) = polar(cx, cy, at, (a + b) / 2.0);
labels.push(DataLabel { x: x as f32, y: y as f32, text, inside: true });
}
a = b;
}
draw_data_labels(out, &labels, c.text_size, false);
}
/// A radar chart in `area`: category spokes from the centre, concentric grid polygons at the value
/// ticks, one polygon per series (filled, or outlined with markers) and the category labels at the
/// spoke ends. The lowest tick is the centre.
pub(super) fn radar(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
const MARKER: f32 = 5.0;
if c.n == 0 {
return;
}
let fs = c.text_size;
let lh = fs * LINE;
let labelled = c.cats.iter().any(|s| !s.is_empty());
let side = if labelled { widest(&c.cats, fs).min(area.w * 0.25) + GAP + 2.0 } else { 0.0 };
let vert = if labelled { lh + GAP } else { 0.0 };
let r = ((area.w / 2.0 - side).min(area.h / 2.0 - vert) - 2.0) as f64;
if r.is_nan() || r < 8.0 {
return;
}
let (cx, cy) = ((area.x + area.w / 2.0) as f64, (area.y + area.h / 2.0) as f64);
let vs = Axes::of(c).vs;
let span = vs.hi - vs.lo;
let angle = |i: usize| i as f64 / c.n as f64 * TAU;
let at = |v: f64, i: usize| {
let t = if span > 0.0 { ((v - vs.lo) / span).clamp(0.0, 1.0) } else { 0.0 };
polar(cx, cy, r * t, angle(i))
};
let ticks = vs.ticks();
if c.val.grid {
for t in ticks.iter().skip(1) {
let mut segs: Vec<PathSeg> = (0..c.n).map(|i| if i == 0 { pt(at(*t, i)) } else { line_to(at(*t, i)) }).collect();
segs.push(PathSeg::Close);
stroke(out, segs, GRID, 0.75);
}
}
for i in 0..c.n {
let end = polar(cx, cy, r, angle(i));
stroke(out, vec![pt((cx, cy)), line_to(end)], GRID, 0.75);
}
let mut labels = Vec::new();
for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) {
let col = c.series_colour(j);
// A missing value counts as the centre, as Word draws it.
let ring: Vec<(f64, f64)> = (0..c.n).map(|i| at(s.values.get(i).copied().flatten().unwrap_or(vs.lo), i)).collect();
let mut segs: Vec<PathSeg> = ring.iter().enumerate().map(|(i, p)| if i == 0 { pt(*p) } else { line_to(*p) }).collect();
segs.push(PathSeg::Close);
if c.radar_fill {
fill(out, segs.clone(), col);
}
stroke(out, segs, col, s.line_w);
for (i, p) in ring.iter().enumerate() {
if c.radar_marks && s.values.get(i).copied().flatten().is_some() {
let rect = [p.0 as f32 - MARKER / 2.0, p.1 as f32 - MARKER / 2.0, MARKER, MARKER];
out.push(GraphicItem::Shape { rect, kind: ShapeKind::Ellipse, fill: Some(col), stroke: None, stroke_width: 0.0 });
}
if let Some(text) = label_of(s, i) {
labels.push(DataLabel { x: p.0 as f32, y: p.1 as f32, text, inside: false });
}
}
}
draw_data_labels(out, &labels, fs, false);
if !c.val.deleted {
for t in &ticks {
let y = at(*t, 0).1 as f32;
let s = format_value(*t, &c.val_fmt, vs.step);
let w = text_w(&s, fs) + 4.0;
text(out, Rect { x: cx as f32 - GAP / 2.0 - w, y: y - lh / 2.0, w, h: lh }, &s, fs, TextAlign::Right, false);
}
}
if labelled {
for (i, s) in c.cats.iter().enumerate().filter(|(_, s)| !s.is_empty()) {
let a = angle(i);
let (x, y) = polar(cx, cy, r + GAP as f64, a);
let (x, y) = (x as f32, y as f32);
let w = side - GAP;
let txt = clip_text(s, w, fs);
let (sx, sy) = (a.sin(), a.cos());
let (rect, align) = if sx > 0.2 {
(Rect { x, y: y - lh / 2.0, w, h: lh }, TextAlign::Left)
} else if sx < -0.2 {
(Rect { x: x - w, y: y - lh / 2.0, w, h: lh }, TextAlign::Right)
} else {
let cw = (text_w(&txt, fs) + 4.0).max(1.0);
(Rect { x: x - cw / 2.0, y: if sy > 0.0 { y - lh } else { y }, w: cw, h: lh }, TextAlign::Center)
};
text(out, rect, &txt, fs, align, false);
}
}
}
/// The data label of point `i` of series `s`, when it shows one: the value as the file has it,
/// in the series' number format.
pub(super) fn label_of(s: &Series, i: usize) -> Option<String> {
if s.labels != Some(true) {
return None;
}
let v = s.raw.get(i).copied().flatten()?;
let fmt = s.fmt.as_deref().filter(|f| !f.eq_ignore_ascii_case("General"));
Some(match fmt {
Some(f) => format_value(v, f, 1.0),
None => general_value(v),
})
}
/// A value in the `General` format: whole numbers without decimals, others with up to six.
fn general_value(v: f64) -> String {
if v.fract() == 0.0 && v.abs() < 1e15 {
return format!("{v:.0}");
}
let s = format!("{v:.6}");
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".to_string() } else { s.to_string() }
}
/// Data labels over the drawn chart. Beside labels sit above their point, or right of it for
/// horizontal bars (`beside_right`).
pub(super) fn draw_data_labels(out: &mut Vec<GraphicItem>, labels: &[DataLabel], fs: f32, beside_right: bool) {
let lh = fs * LINE;
for l in labels {
let w = text_w(&l.text, fs) + 4.0;
let (rect, align) = if l.inside {
(Rect { x: l.x - w / 2.0, y: l.y - lh / 2.0, w, h: lh }, TextAlign::Center)
} else if beside_right {
(Rect { x: l.x + GAP / 2.0, y: l.y - lh / 2.0, w, h: lh }, TextAlign::Left)
} else {
(Rect { x: l.x - w / 2.0, y: l.y - lh, w, h: lh }, TextAlign::Center)
};
let color = if l.inside { Rgb::WHITE } else { TEXT };
out.push(GraphicItem::Text { rect: rect.arr(), text: l.text.clone(), size: fs, color, bold: false, align, font: None });
}
}
/// A point at clockwise angle `t` (radians from 12 o'clock) on a circle of radius `r`.
fn polar(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) {
(cx + r * t.sin(), cy - r * t.cos())
}
fn pt(p: (f64, f64)) -> PathSeg {
PathSeg::Move(p.0 as f32, p.1 as f32)
}
fn line_to(p: (f64, f64)) -> PathSeg {
PathSeg::Line(p.0 as f32, p.1 as f32)
}
/// Cubic segments along a circle from angle `a` to `b` (the current point is at `a`). Each piece
/// spans at most a quarter turn.
fn arc(segs: &mut Vec<PathSeg>, (cx, cy, r): (f64, f64, f64), a: f64, b: f64) {
let n = (((b - a).abs() / FRAC_PI_2).ceil() as usize).clamp(1, 16);
let d = (b - a) / n as f64;
let k = 4.0 / 3.0 * (d / 4.0).tan();
for j in 0..n {
let t0 = a + d * j as f64;
let t1 = t0 + d;
let p0 = polar(cx, cy, r, t0);
let p1 = polar(cx, cy, r, t1);
let c1 = (p0.0 + k * r * t0.cos(), p0.1 + k * r * t0.sin());
let c2 = (p1.0 - k * r * t1.cos(), p1.1 - k * r * t1.sin());
segs.push(PathSeg::Cubic(c1.0 as f32, c1.1 as f32, c2.0 as f32, c2.1 as f32, p1.0 as f32, p1.1 as f32));
}
}
+530
View File
@@ -0,0 +1,530 @@
use super::*;
use wordcraft_doc::THEME_COLORS;
const NS: &str =
r#"xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#;
fn space(plot: &str) -> El {
let x = format!(r#"<c:chartSpace {NS}><c:chart><c:plotArea>{plot}</c:plotArea></c:chart></c:chartSpace>"#);
crate::xml::parse(x.as_bytes()).unwrap()
}
fn theme() -> Vec<Rgb> {
THEME_COLORS.to_vec()
}
fn ser(i: usize, name: &str, color: Option<&str>, cats: &[&str], vals: &[&str]) -> String {
let fill = color.map_or(String::new(), |c| format!(r#"<c:spPr><a:solidFill><a:srgbClr val="{c}"/></a:solidFill></c:spPr>"#));
let cat_pts: String = cats.iter().enumerate().map(|(k, c)| format!(r#"<c:pt idx="{k}"><c:v>{c}</c:v></c:pt>"#)).collect();
let val_pts: String = vals.iter().enumerate().map(|(k, v)| format!(r#"<c:pt idx="{k}"><c:v>{v}</c:v></c:pt>"#)).collect();
format!(
r#"<c:ser><c:idx val="{i}"/><c:order val="{i}"/><c:tx><c:strRef><c:strCache><c:ptCount val="1"/><c:pt idx="0"><c:v>{name}</c:v></c:pt></c:strCache></c:strRef></c:tx>{fill}<c:cat><c:strRef><c:strCache><c:ptCount val="{nc}"/>{cat_pts}</c:strCache></c:strRef></c:cat><c:val><c:numRef><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="{nv}"/>{val_pts}</c:numCache></c:numRef></c:val></c:ser>"#,
nc = cats.len(),
nv = vals.len()
)
}
fn filled(items: &[GraphicItem]) -> Vec<(Rgb, f32, f32)> {
items
.iter()
.filter_map(|it| match it {
GraphicItem::Path { segs, fill: Some(c), .. } => {
let ys: Vec<f32> = segs
.iter()
.filter_map(|s| match s {
PathSeg::Move(_, y) | PathSeg::Line(_, y) => Some(*y),
_ => None,
})
.collect();
let lo = ys.iter().copied().fold(f32::INFINITY, f32::min);
let hi = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max);
Some((*c, lo, hi))
}
_ => None,
})
.collect()
}
#[test]
fn clustered_columns_draw_one_bar_per_point() {
let plot = format!(
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/>{}{}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/><c:majorGridlines/></c:valAx>"#,
ser(0, "North", Some("FF0000"), &["Q1", "Q2", "Q3"], &["4", "8", "2"]),
ser(1, "South", Some("00FF00"), &["Q1", "Q2", "Q3"], &["2", "6", "4"]),
);
let items = chart_items(&space(&plot), &theme(), 300.0, 200.0);
let bars = filled(&items);
assert_eq!(bars.len(), 6);
let heights: Vec<f32> = bars.iter().map(|(_, lo, hi)| hi - lo).collect();
// Scale 0..8: North Q2 is the full plot height, North Q1 half of it.
assert!((heights[1] / heights[0] - 2.0).abs() < 1e-3, "{heights:?}");
// South (second series) Q1 is 2 of 8, Q2 is 6 of 8.
assert!((heights[3] / heights[1] - 0.25).abs() < 1e-3, "{heights:?}");
assert!((heights[4] / heights[1] - 0.75).abs() < 1e-3, "{heights:?}");
assert_eq!(bars[0].0, Rgb(0xFF, 0, 0));
assert_eq!(bars[3].0, Rgb(0, 0xFF, 0));
assert!(items.iter().any(|it| matches!(it, GraphicItem::Text { text, .. } if text == "Q2")));
}
#[test]
fn data_labels_show_the_values() {
let texts = |items: &[GraphicItem]| -> Vec<String> {
items.iter().filter_map(|it| if let GraphicItem::Text { text, .. } = it { Some(text.clone()) } else { None }).collect()
};
let labels = r#"<c:dLbls><c:showLegendKey val="0"/><c:showVal val="1"/></c:dLbls>"#;
let plot = format!(
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/>{}{labels}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
ser(0, "North", None, &["Q1", "Q2"], &["4.25", "17"]),
);
let shown = texts(&chart_items(&space(&plot), &theme(), 300.0, 200.0));
assert!(shown.contains(&"4.25".to_string()) && shown.contains(&"17".to_string()), "{shown:?}");
let pie = format!(r#"<c:pieChart><c:varyColors val="1"/>{}{labels}</c:pieChart>"#, ser(0, "S", None, &["A", "B"], &["1", "3"]));
let shown = texts(&chart_items(&space(&pie), &theme(), 200.0, 150.0));
assert!(shown.contains(&"3".to_string()), "{shown:?}");
// Without `c:dLbls`, no values are written next to the points.
let plain = plot.replace(labels, "");
assert!(!texts(&chart_items(&space(&plain), &theme(), 300.0, 200.0)).contains(&"4.25".to_string()));
}
#[test]
fn pie_draws_one_slice_per_point() {
let plot =
format!(r#"<c:pieChart><c:varyColors val="1"/>{}</c:pieChart>"#, ser(0, "Share", None, &["A", "B", "C", "D"], &["50", "25", "15", "10"]));
let items = chart_items(&space(&plot), &theme(), 200.0, 150.0);
let slices = filled(&items);
assert_eq!(slices.len(), 4);
assert_eq!(slices[0].0, theme()[4], "first slice is accent1");
}
#[test]
fn doughnut_slices_are_annuli() {
let plot =
format!(r#"<c:doughnutChart><c:varyColors val="1"/>{}<c:holeSize val="50"/></c:doughnutChart>"#, ser(0, "S", None, &["A", "B"], &["1", "3"]));
let items = chart_items(&space(&plot), &theme(), 200.0, 150.0);
assert_eq!(items.iter().filter(|it| matches!(it, GraphicItem::Path { fill: Some(_), .. })).count(), 2);
}
#[test]
fn nice_scale_rounds_to_round_steps() {
assert_eq!(nice_scale(0.0, 22.3, true, None, None), Scale { lo: 0.0, hi: 25.0, step: 5.0 });
assert_eq!(nice_scale(0.0, 100.0, true, None, None), Scale { lo: 0.0, hi: 120.0, step: 20.0 });
assert_eq!(nice_scale(0.0, 22.3, true, Some(0.0), Some(30.0)).hi, 30.0);
let flat = nice_scale(0.0, 0.0, true, None, None);
assert!(flat.lo == 0.0 && (flat.hi - 1.2).abs() < 1e-9 && flat.step == 0.2, "{flat:?}");
}
#[test]
fn number_formats() {
assert_eq!(format_value(1234567.0, "#,##0", 1.0), "1,234,567");
assert_eq!(format_value(-1500.0, "#,##0", 500.0), "-1,500");
assert_eq!(format_value(0.25, "0%", 0.05), "25%");
assert_eq!(format_value(0.5, "General", 0.25), "0.50");
assert_eq!(format_value(10.0, "\"€\"0.00", 5.0), "€10.00");
}
#[test]
fn hostile_values_never_panic() {
let bodies = [
format!(
r#"<c:barChart><c:barDir val="col"/><c:grouping val="stacked"/><c:gapWidth val="-9999999999999999999999"/><c:overlap val="NaN"/>{}<c:axId val="1"/></c:barChart><c:valAx><c:axId val="1"/><c:scaling><c:max val="1e400"/><c:min val="-1e308"/></c:scaling></c:valAx>"#,
r#"<c:ser><c:tx><c:v>x</c:v></c:tx><c:cat><c:strRef><c:strCache><c:ptCount val="99999999999"/><c:pt idx="18446744073709551615"><c:v>a</c:v></c:pt><c:pt idx="-3"><c:v>b</c:v></c:pt></c:strCache></c:strRef></c:cat><c:val><c:numRef><c:numCache><c:ptCount val="3"/><c:pt idx="0"><c:v>1e400</c:v></c:pt><c:pt idx="1"><c:v>NaN</c:v></c:pt><c:pt idx="2"><c:v>-1e308</c:v></c:pt><c:pt idx="99999"><c:v>5</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser><c:ser><c:val><c:numRef><c:numCache><c:pt idx="0"><c:v>1e308</c:v></c:pt><c:pt idx="0"><c:v>1e308</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser>"#
),
r#"<c:scatterChart><c:ser><c:xVal><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>-1e308</c:v></c:pt><c:pt idx="1"><c:v>1e308</c:v></c:pt></c:numLit></c:xVal><c:yVal><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>1</c:v></c:pt><c:pt idx="1"><c:v>2</c:v></c:pt></c:numLit></c:yVal></c:ser></c:scatterChart>"#.to_string(),
r#"<c:pieChart><c:ser><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>-4</c:v></c:pt><c:pt idx="1"><c:v>1e308</c:v></c:pt></c:numLit></c:val><c:dPt><c:idx val="9999999999999"/></c:dPt></c:ser><c:firstSliceAng val="-9"/></c:pieChart>"#.to_string(),
];
for body in &bodies {
let s = space(body);
for (w, h) in [(0.0, 10.0), (f32::NAN, 10.0), (-1.0, 1.0), (1e30, 1e30), (50.0, 40.0)] {
let _ = chart_items(&s, &theme(), w, h);
}
}
assert!(chart_items(&space(&bodies[0]), &theme(), f32::NAN, 10.0).is_empty());
assert!(chart_items(&El::default(), &theme(), 100.0, 100.0).is_empty());
}
fn texts(items: &[GraphicItem]) -> Vec<&str> {
items.iter().filter_map(|it| if let GraphicItem::Text { text, .. } = it { Some(text.as_str()) } else { None }).collect()
}
#[test]
fn unsupported_chart_types_draw_a_placeholder() {
let ser = r#"<c:ser><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val></c:ser>"#;
let title = r#"<c:title><c:tx><c:rich><a:p><a:r><a:t>Skills</a:t></a:r></a:p></c:rich></c:tx></c:title>"#;
let x = format!(r#"<c:chartSpace {NS}><c:chart>{title}<c:plotArea><c:bubbleChart>{ser}</c:bubbleChart></c:plotArea></c:chart></c:chartSpace>"#);
let bubble = chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), 300.0, 200.0);
assert_eq!(texts(&bubble), ["Skills", "Bubble chart (not supported yet)"]);
let framed = bubble.iter().any(|it| matches!(it, GraphicItem::Shape { rect, stroke: Some(_), .. } if *rect == [0.0, 0.0, 300.0, 200.0]));
assert!(framed, "a light border marks the chart's frame: {bubble:?}");
let stock = chart_items(&space(&format!("<c:stockChart>{ser}{ser}{ser}</c:stockChart>")), &theme(), 300.0, 200.0);
assert_eq!(texts(&stock), ["Stock chart (not supported yet)"]);
let other = chart_items(&space(&format!("<c:fancyChart>{ser}</c:fancyChart>")), &theme(), 300.0, 200.0);
assert_eq!(texts(&other), ["Chart (not supported yet)"]);
// A supported type in the plot area is drawn, an unsupported one beside it is skipped.
let combo = chart_items(&space(&format!("<c:stockChart>{ser}</c:stockChart><c:lineChart>{ser}</c:lineChart>")), &theme(), 300.0, 200.0);
assert!(!texts(&combo).iter().any(|t| t.contains("not supported")), "{combo:?}");
}
fn radar_chart(style: &str, sers: &[String]) -> Vec<GraphicItem> {
let plot = format!(r#"<c:radarChart><c:radarStyle val="{style}"/>{}</c:radarChart><c:valAx><c:majorGridlines/></c:valAx>"#, sers.concat());
chart_items(&space(&plot), &theme(), 300.0, 220.0)
}
#[test]
fn radar_charts_draw_spokes_grid_and_series_polygons() {
let cats = ["Speed", "Power", "Range", "Armor", "Luck"];
let sers = [ser(0, "A", Some("FF0000"), &cats, &["3", "4", "5", "2", "1"]), ser(1, "B", Some("0000FF"), &cats, &["2", "2", "3", "5", "4"])];
let std = radar_chart("standard", &sers);
assert!(!texts(&std).iter().any(|t| t.contains("not supported")), "{std:?}");
for c in cats {
assert!(texts(&std).contains(&c), "category label {c}: {:?}", texts(&std));
}
let stroked = |items: &[GraphicItem], col: Rgb| {
items.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == col && segs.len() == 6)).count()
};
assert_eq!(stroked(&std, Rgb(0xFF, 0, 0)), 1, "one closed pentagon per series: {std:?}");
assert_eq!(stroked(&std, Rgb(0, 0, 0xFF)), 1);
assert!(filled(&std).is_empty(), "standard radar is not filled");
assert!(!std.iter().any(|it| matches!(it, GraphicItem::Shape { kind: ShapeKind::Ellipse, .. })));
// 5 spokes (2 segs each) plus grid pentagons.
let spokes = std.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == GRID && segs.len() == 2)).count();
assert_eq!(spokes, 5);
assert!(std.iter().any(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == GRID && segs.len() == 6)));
let marked = radar_chart("marker", &sers);
let dots = marked.iter().filter(|it| matches!(it, GraphicItem::Shape { kind: ShapeKind::Ellipse, .. })).count();
assert_eq!(dots, 10, "a marker per point");
let solid = radar_chart("filled", &sers);
let f = filled(&solid);
assert_eq!(f.len(), 2);
assert!(f.iter().any(|(c, ..)| *c == Rgb(0xFF, 0, 0)) && f.iter().any(|(c, ..)| *c == Rgb(0, 0, 0xFF)));
}
#[test]
fn radar_charts_show_a_legend() {
let cats = ["a", "b", "c"];
let plot = format!("<c:radarChart>{}</c:radarChart>", ser(0, "Team", None, &cats, &["1", "2", "3"]));
let items = with_legend(&plot, "r", 300.0, 200.0);
assert!(texts(&items).contains(&"Team"), "{:?}", texts(&items));
}
#[test]
fn hostile_radar_charts_never_panic() {
// No categories and no values.
let empty = r#"<c:ser><c:cat><c:strLit><c:ptCount val="0"/></c:strLit></c:cat><c:val><c:numLit><c:ptCount val="0"/></c:numLit></c:val></c:ser>"#;
let _ = chart_items(&space(&format!("<c:radarChart>{empty}</c:radarChart>")), &theme(), 300.0, 200.0);
let none = chart_items(&space("<c:radarChart/>"), &theme(), 300.0, 200.0);
assert!(none.is_empty(), "{none:?}");
// One category: a degenerate polygon, nothing to crash on; two as well.
for n in 1..=2 {
let cats: Vec<String> = (0..n).map(|i| format!("c{i}")).collect();
let refs: Vec<&str> = cats.iter().map(String::as_str).collect();
let vals = vec!["5"; n];
let items = radar_chart("filled", &[ser(0, "S", None, &refs, &vals)]);
assert!(texts(&items).contains(&"c0"), "{items:?}");
}
// NaN, infinities, junk and a tiny frame.
let junk = ser(0, "S", None, &["a", "b", "c"], &["NaN", "inf", "1e999"]);
let _ = radar_chart("filled", &[junk]);
let small =
chart_items(&space(&format!("<c:radarChart>{}</c:radarChart>", ser(0, "S", None, &["a", "b", "c"], &["1", "2", "3"]))), &theme(), 20.0, 20.0);
let _ = small;
// Caps: thousands of categories and series stay bounded.
let cats: Vec<String> = (0..5000).map(|i| format!("c{i}")).collect();
let refs: Vec<&str> = cats.iter().map(String::as_str).collect();
let vals = vec!["1"; 5000];
let many: Vec<String> = (0..300).map(|i| ser(i, "S", None, &refs, &vals)).collect();
let items = radar_chart("marker", &many);
let polygons = items.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), .. } if *c != GRID)).count();
assert!(polygons <= MAX_RADAR_SERIES, "{polygons}");
let spokes = items.iter().filter(|it| matches!(it, GraphicItem::Path { stroke: Some(c), segs, .. } if *c == GRID && segs.len() == 2)).count();
assert_eq!(spokes, MAX_SPOKES);
}
/// Chart items for a chart with a legend at `pos` (`r`, `b`…).
fn with_legend(plot: &str, pos: &str, w: f32, h: f32) -> Vec<GraphicItem> {
let x = format!(
r#"<c:chartSpace {NS}><c:chart><c:plotArea>{plot}</c:plotArea><c:legend><c:legendPos val="{pos}"/></c:legend></c:chart></c:chartSpace>"#
);
chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), w, h)
}
#[test]
fn series_are_capped_to_the_cell_budget() {
// 256 series each declaring 10,000 points: only the 10 that fit the cell budget are kept.
let sers: String = (0..256)
.map(|i| format!(r#"<c:ser><c:idx val="{i}"/><c:val><c:numRef><c:numCache><c:ptCount val="10000"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser>"#))
.collect();
let plot = format!(r#"<c:lineChart><c:grouping val="standard"/>{sers}<c:axId val="1"/></c:lineChart>"#);
let items = chart_items(&space(&plot), &theme(), 300.0, 200.0);
let strokes = items.iter().filter(|it| matches!(it, GraphicItem::Path { fill: None, stroke: Some(_), .. })).count();
assert_eq!(strokes, MAX_CELLS / 10_000);
}
#[test]
fn series_without_values_draw_nothing() {
let plot = format!(
r#"<c:areaChart><c:grouping val="standard"/>{}{}<c:axId val="1"/></c:areaChart>"#,
ser(0, "A", Some("FF0000"), &["Q1", "Q2"], &["4", "8"]),
ser(1, "Empty", Some("00FF00"), &["Q1", "Q2"], &[]),
);
let fills = filled(&chart_items(&space(&plot), &theme(), 300.0, 200.0));
assert_eq!(fills.len(), 1);
assert_eq!(fills[0].0, Rgb(0xFF, 0, 0));
}
#[test]
fn value_scale_starts_bars_at_zero_for_negative_data() {
// All-negative data pads below only: the axis ends at zero, as positive data's starts there.
assert_eq!(nice_scale(-10.0, -2.0, true, None, None), Scale { lo: -12.0, hi: 0.0, step: 2.0 });
assert_eq!(nice_scale(-1050.0, -1000.0, false, None, None).hi, -1000.0);
}
#[test]
fn narrow_line_scales_leave_zero_out_but_bars_keep_it() {
assert!(narrow(1000.0, 1050.0) && narrow(-1050.0, -1000.0));
assert!(!narrow(10.0, 100.0) && !narrow(0.0, 1.0));
assert_eq!(nice_scale(1000.0, 1050.0, false, None, None).lo, 1000.0);
assert_eq!(nice_scale(1000.0, 1050.0, true, None, None).lo, 0.0);
}
#[test]
fn horizontal_bar_labels_wrap_in_a_column_of_forty_percent() {
let long = "A category label that is much too long for its column";
let plot = format!(
r#"<c:barChart><c:barDir val="bar"/><c:grouping val="clustered"/>{}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
ser(0, "S", Some("FF0000"), &[long, "Short"], &["4", "8"]),
);
let items = chart_items(&space(&plot), &theme(), 300.0, 200.0);
let labels: Vec<(&str, f32)> = items
.iter()
.filter_map(|it| match it {
GraphicItem::Text { rect, text, align: TextAlign::Right, .. } => Some((text.as_str(), rect[2])),
_ => None,
})
.collect();
assert!(labels.iter().all(|(_, w)| *w <= 0.4 * 300.0), "{labels:?}");
assert!(labels.iter().filter(|(t, _)| long.contains(t)).count() > 1, "wrapped: {labels:?}");
}
#[test]
fn stacked_lines_add_up_like_areas() {
let stacked = format!(
r#"<c:lineChart><c:grouping val="stacked"/>{}{}<c:axId val="1"/></c:lineChart>"#,
ser(0, "A", Some("FF0000"), &["Q1", "Q2"], &["0", "10"]),
ser(1, "B", Some("00FF00"), &["Q1", "Q2"], &["0", "5"]),
);
let single = format!(
r#"<c:lineChart><c:grouping val="standard"/>{}<c:axId val="1"/></c:lineChart>"#,
ser(0, "T", Some("0000FF"), &["Q1", "Q2"], &["0", "15"])
);
// Where the last point of the line drawn in `colour` is.
let end_y = |items: &[GraphicItem], colour: Rgb| {
items.iter().find_map(|it| match it {
GraphicItem::Path { segs, stroke: Some(c), .. } if *c == colour => match segs.last() {
Some(PathSeg::Line(_, y)) => Some(*y),
_ => None,
},
_ => None,
})
};
let top = end_y(&chart_items(&space(&stacked), &theme(), 300.0, 200.0), Rgb(0, 0xFF, 0)).expect("stacked line");
let total = end_y(&chart_items(&space(&single), &theme(), 300.0, 200.0), Rgb(0, 0, 0xFF)).expect("single line");
assert!((top - total).abs() < 1e-3, "stacked {top} vs {total}");
}
#[test]
fn category_numbers_show_in_their_format() {
// Excel serial days: 45292 is 1 January 2024, 45323 is 1 February 2024.
let cat = |code: &str| -> Vec<String> {
let x = format!(
r#"<c:cat {NS}><c:numRef><c:numCache><c:formatCode>{code}</c:formatCode><c:ptCount val="2"/><c:pt idx="0"><c:v>45292</c:v></c:pt><c:pt idx="1"><c:v>45323</c:v></c:pt></c:numCache></c:numRef></c:cat>"#
);
labels(Some(&crate::xml::parse(x.as_bytes()).unwrap()), 10, false)
};
assert_eq!(cat("m/d/yyyy"), ["1/1/2024", "2/1/2024"]);
assert_eq!(cat("d/m/yyyy"), ["1/1/2024", "1/2/2024"]);
assert_eq!(cat("mmm-yy"), ["Jan-24", "Feb-24"]);
assert_eq!(cat("yyyy"), ["2024", "2024"]);
assert_eq!(cat("0.00"), ["45292.00", "45323.00"]);
let ymd = |v: f64| excel_stamp(v, false).map(|t| (t.y, t.m, t.d));
assert_eq!(ymd(61.0), Some((1900, 3, 1)));
assert_eq!(ymd(45292.0), Some((2024, 1, 1)));
}
#[test]
fn category_format_code_length_is_capped() {
let cat = |code: &str| -> Vec<String> {
let x = format!(
r#"<c:cat {NS}><c:numRef><c:numCache><c:formatCode>{code}</c:formatCode><c:ptCount val="1"/><c:pt idx="0"><c:v>5</c:v></c:pt></c:numCache></c:numRef></c:cat>"#
);
labels(Some(&crate::xml::parse(x.as_bytes()).unwrap()), 10, false)
};
let ok = format!("{}0", "x".repeat(254));
assert!(cat(&ok)[0].starts_with("xxx"));
let long = format!("{}0", "x".repeat(300));
assert_eq!(cat(&long), ["5"]);
}
#[test]
fn value_format_code_length_is_capped() {
let val = |code: &str| -> Option<String> {
let x = format!(
r#"<c:val {NS}><c:numRef><c:numCache><c:formatCode>{code}</c:formatCode><c:ptCount val="1"/><c:pt idx="0"><c:v>5</c:v></c:pt></c:numCache></c:numRef></c:val>"#
);
numbers(Some(&crate::xml::parse(x.as_bytes()).unwrap()), 10).1
};
let ok = format!("{}0", "x".repeat(254));
assert_eq!(val(&ok), Some(ok.clone()));
assert_eq!(val(&format!("{}0", "x".repeat(300))), None);
}
#[test]
fn axis_format_code_length_is_capped() {
let fmt = |code: &str| -> Option<String> {
let x = format!(r#"<c:valAx {NS}><c:numFmt formatCode="{code}" sourceLinked="0"/></c:valAx>"#);
axis(Some(&crate::xml::parse(x.as_bytes()).unwrap())).fmt
};
let ok = format!("{}0", "x".repeat(254));
assert_eq!(fmt(&ok), Some(ok.clone()));
assert_eq!(fmt(&format!("{}0", "x".repeat(300))), None);
}
#[test]
fn civil_is_exact_beyond_chronos_range() {
// Serial 1 is 1899-12-31, serial 60 Excel's phantom 1900-02-29 handled by `excel_stamp`.
assert_eq!(civil(1), (1899, 12, 31));
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the year, exactly, both sides of
// the epoch; values from the pre-chrono proleptic Gregorian formula.
assert_eq!(civil(100_000_000), (275_690, 9, 11));
assert_eq!(civil(-100_000_000), (-271_891, 4, 18));
// The extremes must not overflow or panic (i128 intermediates).
let _ = (civil(i64::MAX), civil(i64::MIN));
}
#[test]
fn number_formats_keep_literal_text_and_currency() {
assert_eq!(format_value(1234.5, "#,##0.00 [$€-407]", 0.25), "1,234.50 €");
assert_eq!(format_value(5.0, "[Red]0", 1.0), "5");
assert_eq!(format_value(3.0, "\"Items: \"0", 1.0), "Items: 3");
assert_eq!(format_value(3.0, "0\" units\"", 1.0), "3 units");
}
#[test]
fn side_legend_keeps_only_the_entries_that_fit() {
let sers: String = (0..10).map(|i| ser(i, &format!("S{i}"), Some("FF0000"), &["Q1"], &["1"])).collect();
let plot = format!(
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/>{sers}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#
);
let items = with_legend(&plot, "r", 300.0, 60.0);
let swatches = items.iter().filter(|it| matches!(it, GraphicItem::Shape { rect, .. } if rect[2] == 7.0)).count();
assert!(swatches > 0 && swatches < 10, "{swatches} swatches");
let bottom =
items.iter().filter_map(|it| if let GraphicItem::Text { rect, .. } = it { Some(rect[1] + rect[3]) } else { None }).fold(0.0, f32::max);
assert!(bottom <= 60.0, "legend text passes the chart's bottom: {bottom}");
}
/// The swatches of a legend: their (x, y).
fn swatches(items: &[GraphicItem]) -> Vec<(f32, f32)> {
items
.iter()
.filter_map(|it| if let GraphicItem::Shape { rect, .. } = it { (rect[2] == SWATCH).then_some((rect[0], rect[1])) } else { None })
.collect()
}
/// A column chart of one category with a series per name.
fn columns(names: &[&str]) -> String {
let sers: String = names.iter().enumerate().map(|(i, n)| ser(i, n, Some("FF0000"), &["Q1"], &["1"])).collect();
format!(
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/>{sers}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#
)
}
#[test]
fn bottom_legend_wraps_into_rows_inside_the_chart() {
let names: Vec<String> = (0..12).map(|i| format!("Region number {i}")).collect();
let names: Vec<&str> = names.iter().map(String::as_str).collect();
let items = with_legend(&columns(&names), "b", 200.0, 200.0);
let sw = swatches(&items);
let rows: std::collections::BTreeSet<i32> = sw.iter().map(|(_, y)| *y as i32).collect();
assert!(rows.len() > 1 && rows.len() <= MAX_LEGEND_ROWS, "rows {rows:?}");
assert!(sw.iter().all(|(x, _)| *x >= 0.0 && *x <= 200.0), "{sw:?}");
for it in &items {
if let GraphicItem::Text { rect, text, .. } = it {
assert!(rect[0] >= 0.0 && rect[0] + text_w(text, FONT) <= 200.0 + 1e-3, "{text:?} at {rect:?}");
}
}
}
#[test]
fn long_legend_names_are_cut_to_a_share_of_the_chart() {
let long = "A very long series name that would push the legend past the left edge of the chart";
let items = with_legend(&columns(&[long, "Short"]), "r", 200.0, 150.0);
assert!(swatches(&items).iter().all(|(x, _)| *x >= 0.0), "{:?}", swatches(&items));
let cut = items.iter().find_map(|it| match it {
GraphicItem::Text { text, rect, .. } if text.ends_with('…') => Some((text.clone(), rect[2])),
_ => None,
});
let (text, w) = cut.expect("the long name is cut");
assert!(text_w(&text, FONT) <= 200.0 * LEGEND_SHARE && w <= 200.0 * LEGEND_SHARE + 2.0, "{text:?} {w}");
}
#[test]
fn scatter_dates_show_as_dates() {
let pts = |vals: &[&str]| -> String { vals.iter().enumerate().map(|(k, v)| format!(r#"<c:pt idx="{k}"><c:v>{v}</c:v></c:pt>"#)).collect() };
let plot = format!(
r#"<c:scatterChart><c:ser><c:idx val="0"/><c:xVal><c:numRef><c:numCache><c:formatCode>m/d/yyyy</c:formatCode><c:ptCount val="3"/>{}</c:numCache></c:numRef></c:xVal><c:yVal><c:numRef><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="3"/>{}</c:numCache></c:numRef></c:yVal></c:ser><c:axId val="1"/><c:axId val="2"/></c:scatterChart><c:valAx><c:axId val="1"/><c:axPos val="b"/></c:valAx><c:valAx><c:axId val="2"/><c:axPos val="l"/></c:valAx>"#,
pts(&["45292", "45323", "45352"]),
pts(&["1", "2", "3"])
);
let items = chart_items(&space(&plot), &theme(), 300.0, 200.0);
let texts: Vec<&str> = items.iter().filter_map(|it| if let GraphicItem::Text { text, .. } = it { Some(text.as_str()) } else { None }).collect();
assert!(texts.iter().any(|t| t.ends_with("/2024")), "{texts:?}");
assert!(!texts.iter().any(|t| t.starts_with("45")), "serials shown: {texts:?}");
}
#[test]
fn date1904_counts_from_1904() {
assert_eq!(category_text(0.0, "m/d/yyyy", 1.0, true), "1/1/1904");
assert_eq!(category_text(1462.0, "m/d/yyyy", 1.0, false), "1/1/1904");
let x = format!(
r#"<c:chartSpace {NS}><c:date1904 val="1"/><c:chart><c:plotArea><c:lineChart><c:ser><c:cat><c:numRef><c:numCache><c:formatCode>yyyy-mm-dd</c:formatCode><c:ptCount val="1"/><c:pt idx="0"><c:v>366</c:v></c:pt></c:numCache></c:numRef></c:cat><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val></c:ser></c:lineChart><c:catAx/><c:valAx/></c:plotArea></c:chart></c:chartSpace>"#
);
let chart = Chart::parse(&crate::xml::parse(x.as_bytes()).unwrap(), &theme()).expect("chart");
assert_eq!(chart.cats, ["1905-01-01"]);
}
#[test]
fn date_codes_show_times_weekdays_and_early_serials() {
let t = |v: f64, code: &str| category_text(v, code, 1.0, false);
// 45292 is Monday 1 January 2024; 13:05:09 into it.
let at = 45292.0 + (13.0 * 3600.0 + 5.0 * 60.0 + 9.0) / 86_400.0;
assert_eq!(t(at, "hh:mm:ss"), "13:05:09");
assert_eq!(t(at, "h:mm AM/PM"), "1:05 PM");
assert_eq!(t(at, "m:ss"), "5:09");
assert_eq!(t(45292.0, "h:mm a/p"), "12:00 A");
assert_eq!(t(at, "mm/dd"), "01/01");
assert_eq!(t(45292.0, "dddd, mmmm d"), "Monday, January 1");
assert_eq!(t(45292.0, "ddd d mmm"), "Mon 1 Jan");
// Serials before March 1900, Excel's phantom 29 February included.
assert_eq!(t(0.0, "m/d/yyyy"), "1/0/1900");
assert_eq!(t(1.0, "m/d/yyyy"), "1/1/1900");
assert_eq!(t(59.0, "m/d/yyyy"), "2/28/1900");
assert_eq!(t(60.0, "m/d/yyyy"), "2/29/1900");
assert_eq!(t(61.0, "m/d/yyyy dddd"), "3/1/1900 Thursday");
// Outside the calendar: a number.
assert_eq!(t(-1.0, "m/d/yyyy"), "-1");
}
#[test]
fn negative_sections_and_scientific_numbers() {
assert_eq!(format_value(-5.0, "#,##0;(#,##0)", 1.0), "(5)");
assert_eq!(format_value(5.0, "#,##0;(#,##0)", 1.0), "5");
assert_eq!(format_value(-1500.0, "#,##0_);(#,##0)", 500.0), "(1,500)");
assert_eq!(format_value(-2.0, "0\";\"0;-0", 1.0), "-2");
assert_eq!(format_value(12345.0, "0.00E+00", 1.0), "1.23E+04");
assert_eq!(format_value(0.00012, "0.0E+0", 0.0001), "1.2E-4");
assert_eq!(format_value(0.0, "0.00E+00", 1.0), "0.00E+00");
assert_eq!(format_value(-12345.0, "0.0E-00", 1.0), "-1.2E04");
}
+187
View File
@@ -0,0 +1,187 @@
//! `w:altChunk` (ECMA-376 §17.17.2.1): content kept in another format (HTML, MHT, RTF, plain
//! text, another WordprocessingML document) that Word brings into the document where the chunk
//! stands when it opens the file. A nested WordprocessingML package is read here; other formats
//! go to the caller's [`ChunkImporter`] (the format crates sit beside this one), and without one
//! the chunk is left out.
use std::sync::Arc;
use wordcraft_doc::{Block, Blocks, Document};
use super::story::StoryCtx;
use super::{Reader, part_of};
use crate::package::{ContentTypes, MAX_PART, MAX_TOTAL, Package, Rels, rt};
use crate::xml::El;
/// Most chunks brought in from one file (nested documents' chunks included).
pub(crate) const MAX_CHUNKS: usize = 256;
/// Deepest a WordprocessingML chunk may nest in another.
pub(crate) const MAX_CHUNK_NESTING: u8 = 4;
/// An alternative format chunk's content, as a [`ChunkImporter`] gets it.
pub struct AltChunk<'a> {
/// The part's content type (`application/xhtml+xml`, `message/rfc822`, `application/rtf`,
/// `text/plain`…), empty when the package doesn't give one.
pub content_type: &'a str,
/// The part's name (`word/afchunk.mht`): its extension tells the format when the content type
/// doesn't.
pub part: &'a str,
pub bytes: &'a [u8],
}
/// Turns a chunk the docx reader can't read itself into a document whose body takes the chunk's
/// place; `None` leaves the chunk out.
pub type ChunkImporter<'a> = &'a dyn Fn(&AltChunk) -> Option<Document>;
/// How [`crate::read_with`] reads a package.
#[derive(Clone, Copy, Default)]
pub struct ReadOptions<'a> {
/// The password of an encrypted package.
pub password: Option<&'a str>,
/// Imports `w:altChunk` content in formats other than WordprocessingML.
pub chunks: Option<ChunkImporter<'a>>,
}
impl Reader<'_> {
/// Bring the chunk `e` names into `out` where it stands (`depth`: of the XML around it).
pub(crate) fn read_alt_chunk(&mut self, sc: &mut StoryCtx, e: &El, rels: &Rels, out: &mut Blocks, depth: usize) {
let Some(part) = e.attr("r:id").and_then(|id| part_of(rels, id, rt::AF_CHUNK)) else { return };
let pkg = self.pkg;
let Some(bytes) = pkg.get(&part) else {
log::warn!("docx: alternative format chunk {part} is missing");
return;
};
// Every chunk's bytes count against one budget for the whole file, however often the same
// part is named and however deep chunks nest.
if self.chunks.left == 0 || bytes.len() as u64 > self.chunks.bytes {
log::warn!("docx: more than {MAX_CHUNKS} alternative format chunks or {MAX_TOTAL} bytes of them; the rest are left out");
return;
}
self.chunks.left -= 1;
self.chunks.bytes -= bytes.len() as u64;
let ct = self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&part).unwrap_or("").to_string();
let chunk = AltChunk { content_type: &ct, part: &part, bytes };
// A WordprocessingML chunk is a package; one that isn't Word's fails to read (logged).
let is_docx = bytes.starts_with(b"PK\x03\x04");
let doc = if is_docx { self.read_nested(bytes, depth) } else { self.chunks.import.and_then(|f| f(&chunk)) };
let Some(mut doc) = doc else {
log::warn!("docx: alternative format chunk {part} ({ct}) is in a format WordCraft can't bring in");
return;
};
// The chunk flows in the section it stands in; its own section breaks don't come along.
for b in &mut doc.body {
if let Block::Para(p) = &**b
&& p.section.is_some()
&& let Block::Para(p) = Arc::make_mut(b)
{
p.section = None;
}
}
// `w:matchSrc`: a Word document's content keeps its own styles (other formats' formatting
// is their own already).
let keep = is_docx && e.child("w:altChunkPr").and_then(|p| p.child("w:matchSrc")).is_some_and(crate::units::on_off);
let mut blocks = self.doc.adopt_blocks(doc, keep, &mut self.next_perm);
// Bookmarks and comment marks read before the chunk stay before it.
self.flush_pending_before(sc, out, &mut blocks);
out.extend(blocks.into_iter().map(Arc::new));
}
/// A WordprocessingML chunk: its document, read with the same importer and what is left of
/// the budgets, its XML depth counting on from `depth`.
fn read_nested(&mut self, bytes: &[u8], depth: usize) -> Option<Document> {
if self.chunks.nesting >= MAX_CHUNK_NESTING {
log::warn!("docx: alternative format chunks nest deeper than {MAX_CHUNK_NESTING}; the inner ones are left out");
return None;
}
let pkg =
Package::open_limited(bytes, MAX_PART, self.chunks.bytes).map_err(|e| log::warn!("docx: unreadable nested document chunk: {e}")).ok()?;
self.chunks.bytes = self.chunks.bytes.saturating_sub(pkg.size());
let state = ChunkState { nesting: self.chunks.nesting + 1, depth: depth + 1, ..self.chunks };
let (doc, left, bytes, graphic) =
super::read_opened_in(&pkg, state).map_err(|e| log::warn!("docx: unreadable nested document chunk: {e}")).ok()?;
self.chunks.left = left;
self.chunks.bytes = bytes;
self.chunks.graphic = graphic;
Some(doc)
}
}
/// What a reader may still bring in from chunks.
#[derive(Clone, Copy)]
pub(crate) struct ChunkState<'a> {
/// Chunks left in the file's budget ([`MAX_CHUNKS`]).
pub left: usize,
/// Chunk bytes (nested packages unpacked) left in the file's budget.
pub bytes: u64,
/// Graphic work left in the file's budget ([`super::MAX_GRAPHIC_WORK`]): nested documents'
/// charts and diagrams count against it too.
pub graphic: usize,
/// How deep in nested WordprocessingML chunks this reader is.
pub nesting: u8,
/// The XML depth its stories start at (a nested document's: that of the chunk naming it).
pub depth: usize,
pub import: Option<ChunkImporter<'a>>,
}
impl<'a> ChunkState<'a> {
pub fn new(import: Option<ChunkImporter<'a>>) -> Self {
ChunkState { left: MAX_CHUNKS, bytes: MAX_TOTAL, graphic: super::MAX_GRAPHIC_WORK, nesting: 0, depth: 0, import }
}
}
#[cfg(test)]
mod tests {
use std::io::Write;
use wordcraft_doc::{Block, Document, InlineObject};
use super::ChunkState;
use crate::package::Package;
/// A package whose body is `body`, with the document relationship `rel` (id, type, target) to
/// the part `(name, bytes)`.
fn package(body: &str, (id, ty, target): (&str, &str, &str), (name, bytes): (&str, &[u8])) -> Vec<u8> {
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#;
let rels =
|inner: &str| format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{inner}</Relationships>"#);
let rel = |id: &str, ty: &str, target: &str| {
format!(r#"<Relationship Id="{id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/{ty}" Target="{target}"/>"#)
};
let files: [(&str, Vec<u8>); 4] = [
("_rels/.rels", rels(&rel("rId1", "officeDocument", "word/document.xml")).into_bytes()),
("word/document.xml", format!(r#"<w:document {ns}><w:body>{body}</w:body></w:document>"#).into_bytes()),
("word/_rels/document.xml.rels", rels(&rel(id, ty, target)).into_bytes()),
(name, bytes.to_vec()),
];
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in files {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(&bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
/// Whether the body's first paragraph's chart is drawn.
fn drawn(d: &Document) -> bool {
let Some(Block::Para(p)) = d.body.first().map(|b| &**b) else { panic!("no paragraph") };
p.objects.iter().any(|o| matches!(o, InlineObject::Graphic { graphic, .. } if !graphic.items.is_empty()))
}
#[test]
fn a_nested_documents_charts_count_against_the_files_graphic_budget() {
let chart = r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:ser><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>1</c:v></c:pt><c:pt idx="1"><c:v>2</c:v></c:pt></c:numLit></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>"#;
let drawing = r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="2743200" cy="1828800"/><wp:docPr id="5" name="G"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" r:id="rIdChart"/></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#;
let inner = package(drawing, ("rIdChart", "chart", "charts/chart1.xml"), ("word/charts/chart1.xml", chart.as_bytes()));
let outer = package(r#"<w:altChunk r:id="rIdC"/>"#, ("rIdC", "aFChunk", "afchunk.docx"), ("word/afchunk.docx", &inner));
let pkg = Package::open(&outer).unwrap();
let read = |graphic| {
let (d, .., left) = super::super::read_opened_in(&pkg, ChunkState { graphic, ..ChunkState::new(None) }).unwrap();
(drawn(&d), left)
};
let (shown, left) = read(super::super::MAX_GRAPHIC_WORK);
assert!(shown);
assert!(left < super::super::MAX_GRAPHIC_WORK, "the nested chart's work is the file's");
// With the file's budget spent, the nested document doesn't get one of its own.
assert_eq!(read(0), (false, 0));
}
}
+359
View File
@@ -0,0 +1,359 @@
use super::{
Arc, Blocks, Comment, DocxError, El, HashMap, MAX_PARTS, MAX_SOURCES, MAX_TEMPLATE_PATH, NoteKind, NumFormat, Paragraph, PartKind, Reader, Rels,
Seen, StoryCtx, Zoom, ZoomFit, int, math, on_off, para_block, props, tw, u32_of,
};
impl Reader<'_> {
pub(super) fn read_settings(&mut self, path: &str) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
if let Some(b) = self.pkg.get(path).filter(|b| crate::write::settings_has_unowned(b)) {
self.doc.passthrough.insert(crate::package::SETTINGS_ORIG.into(), Arc::new(b.to_vec()));
}
let rels = self.pkg.rels(path);
let s = &mut self.doc.settings;
for k in root.els() {
match k.name.as_str() {
// §17.15.1.6: the template, by a relationship (usually external) of the settings part.
"w:attachedTemplate" => {
s.attached_template = k
.attr("r:id")
.and_then(|id| rels.by_id(id))
.map(|r| r.target.trim().to_string())
.filter(|t| !t.is_empty() && t.len() <= MAX_TEMPLATE_PATH);
}
"w:linkStyles" => s.link_styles = on_off(k),
"w:removePersonalInformation" => s.remove_personal_info = on_off(k),
"w:removeDateAndTime" => s.remove_date_time = on_off(k),
"w:compat" => {
s.allow_space_of_same_style_in_table = k.child("w:allowSpaceOfSameStyleInTable").is_some_and(on_off);
// Only Word's own setting: another `w:uri` names a different application's.
let mode = k
.children("w:compatSetting")
.find(|c| {
c.attr("w:name") == Some("compatibilityMode")
&& c.attr("w:uri").is_none_or(|u| u == "http://schemas.microsoft.com/office/word")
})
.and_then(|c| c.attr("w:val"));
if let Some(m) = mode.and_then(u32_of) {
s.compat_mode = m.clamp(11, 99);
}
s.sum_paragraph_spacing = k.children("w:doNotUseHTMLParagraphAutoSpacing").any(on_off);
s.do_not_expand_shift_return = k.children("w:doNotExpandShiftReturn").any(on_off);
}
"w:trackRevisions" => s.track_changes = on_off(k),
"w:doNotTrackMoves" => s.track_moves = !on_off(k),
"w:defaultTabStop" => {
if let Some(v) = tw(k, "w:val").filter(|v| *v > 0.0) {
s.default_tab = v.clamp(1.0, 1584.0);
}
}
"w:evenAndOddHeaders" => s.even_odd_headers = on_off(k),
"w:drawingGridHorizontalSpacing" | "w:drawingGridVerticalSpacing" => {
if let Some(v) = tw(k, "w:val").filter(|v| *v > 0.0) {
let v = v.clamp(0.5, 1584.0);
if k.name == "w:drawingGridHorizontalSpacing" { s.grid_h = v } else { s.grid_v = v }
}
}
"m:mathPr" => s.math = Some(math::read_math_pr(k)),
"w:mirrorMargins" => s.mirror_margins = on_off(k),
"w:autoHyphenation" => s.auto_hyphenation = on_off(k),
"w:doNotHyphenateCaps" => s.hyphenate_caps = !on_off(k),
"w:hyphenationZone" => {
if let Some(v) = tw(k, "w:val").filter(|v| v.is_finite() && *v >= 0.0) {
s.hyphenation_zone = v.min(1584.0);
}
}
"w:consecutiveHyphenLimit" => {
if let Some(v) = k.attr("w:val").and_then(u32_of) {
s.consecutive_hyphen_limit = v.min(32_767);
}
}
"w:footnotePr" => {
if let Some(f) = k.child_val("w:numFmt") {
s.footnote_format = NumFormat::from_ooxml(f);
}
s.footnote_pr = props::note_pr(k, false);
}
"w:endnotePr" => {
if let Some(f) = k.child_val("w:numFmt") {
s.endnote_format = NumFormat::from_ooxml(f);
}
s.endnote_pr = props::note_pr(k, false);
}
"w:documentProtection" => read_protection(k, s),
"w:zoom" => {
let percent = k.attr("w:percent").and_then(|v| u32_of(v.trim_end_matches('%'))).unwrap_or(100).clamp(10, 500);
let fit = match k.attr("w:val") {
Some("fullPage") => Some(ZoomFit::OnePage),
Some("bestFit") => Some(ZoomFit::PageWidth),
Some("textFit") => Some(ZoomFit::TextWidth),
_ => None,
};
s.zoom = Some(Zoom { percent, fit }).filter(|z| z.percent != 100 || z.fit.is_some());
}
_ => {}
}
}
Ok(())
}
pub(super) fn read_notes(&mut self, path: &str, foot: bool) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
let rels = self.pkg.rels(path);
let tag = if foot { "w:footnote" } else { "w:endnote" };
for n in root.children(tag) {
match n.attr("w:type") {
None | Some("normal") => {}
Some(t @ ("separator" | "continuationSeparator" | "continuationNotice")) => {
self.read_note_sep(n, &rels, foot, t);
continue;
}
Some(_) => continue,
}
let Some(fid) = n.attr("w:id").and_then(int) else { continue };
if self.doc.parts.len() >= MAX_PARTS {
break;
}
// Reserve the id first: the note's own reference mark points at it.
let (pk, kind) = if foot { (PartKind::Footnote, NoteKind::Footnote) } else { (PartKind::Endnote, NoteKind::Endnote) };
let id = self.doc.add_part(pk, Blocks::new());
let mut sc = StoryCtx::default();
let mut blocks = Blocks::new();
self.current_note = Some((kind, id));
self.read_blocks(&mut sc, n, &rels, &mut blocks, self.chunks.depth);
self.flush_pending(&mut sc, &mut blocks);
self.current_note = None;
if let Some(p) = self.doc.parts.get_mut(&id)
&& !blocks.is_empty()
{
p.blocks = blocks;
}
if foot {
self.footnotes.insert(fid, id);
} else {
self.endnotes.insert(fid, id);
}
}
Ok(())
}
/// A document's own separator, continuation separator or continuation notice (`kind`, the
/// note's `w:type`) for its footnotes or endnotes. The first of each kind counts.
fn read_note_sep(&mut self, n: &El, rels: &Rels, foot: bool, kind: &str) {
let seps = if foot { &self.doc.footnote_separators } else { &self.doc.endnote_separators };
let taken = match kind {
"separator" => seps.separator.is_some(),
"continuationSeparator" => seps.continuation_separator.is_some(),
_ => seps.continuation_notice.is_some(),
};
if taken {
return;
}
let mut sc = StoryCtx::default();
let mut blocks = Blocks::new();
self.in_note_sep = true;
self.read_blocks(&mut sc, n, rels, &mut blocks, self.chunks.depth);
self.flush_pending(&mut sc, &mut blocks);
self.in_note_sep = false;
if blocks.is_empty() {
blocks.push(para_block(Paragraph::new()));
}
let seps = if foot { &mut self.doc.footnote_separators } else { &mut self.doc.endnote_separators };
let slot = match kind {
"separator" => &mut seps.separator,
"continuationSeparator" => &mut seps.continuation_separator,
_ => &mut seps.continuation_notice,
};
*slot = Some(blocks);
}
/// The extended properties a save carries over ([`crate::package::APP_KEPT_PROPS`]).
pub(super) fn read_app(&mut self, path: &str) {
let Ok(Some(root)) = self.xml(path) else { return };
let mut kept = String::new();
for e in root.els() {
let name = e.local();
let value = e.text();
if crate::package::APP_KEPT_PROPS.contains(&name) && value.len() <= 2048 && !value.contains(['\t', '\n']) {
kept.push_str(&format!("{name}\t{value}\n"));
}
}
if !kept.is_empty() {
self.doc.passthrough.insert(crate::package::APP_KEPT.into(), Arc::new(kept.into_bytes()));
}
}
pub(super) fn read_comments(&mut self, path: &str, ext: Option<&str>, ids: Option<&str>, cex: Option<&str>) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
let rels = self.pkg.rels(path);
let mut by_para: HashMap<String, u32> = HashMap::new();
let mut next_id = 0u32;
// Straight into the document, beside the comments chunks in notes brought in.
for c in root.children("w:comment") {
if self.doc.parts.len() >= MAX_PARTS {
break;
}
let file_id = c.attr("w:id").unwrap_or("").to_string();
if self.comment_map.contains_key(&file_id) {
continue;
}
let id = match file_id.parse::<u32>() {
Ok(v) if !self.doc.comments.contains_key(&v) => v,
_ => {
// Every id handed out so far is a key of `doc.comments` (inserted below).
while self.doc.comments.contains_key(&next_id) {
next_id += 1;
}
next_id
}
};
// Taken before its text is read: a chunk there brings comments of its own.
self.doc.comments.insert(id, Comment::default());
let mut sc = StoryCtx::default();
let mut blocks = Blocks::new();
self.read_blocks(&mut sc, c, &rels, &mut blocks, self.chunks.depth);
self.flush_pending(&mut sc, &mut blocks);
let part = self.doc.add_part(PartKind::Comment, blocks);
if let Some(pid) = c.children("w:p").last().and_then(|p| p.attr("w14:paraId")) {
by_para.insert(pid.to_string(), id);
}
self.doc.comments.insert(
id,
Comment {
author: c.attr("w:author").unwrap_or("").to_string(),
initials: c.attr("w:initials").unwrap_or("").to_string(),
date: c.attr("w:date").unwrap_or("").to_string(),
parent: None,
resolved: false,
part,
..Default::default()
},
);
self.comment_map.insert(file_id, id);
}
if let Some(ext) = ext
&& let Ok(Some(x)) = self.xml(ext)
{
for e in x.els().filter(|e| e.local() == "commentEx") {
let Some(cid) = e.attr("w15:paraId").and_then(|p| by_para.get(p)).copied() else { continue };
let parent = e.attr("w15:paraIdParent").and_then(|p| by_para.get(p)).copied().filter(|p| *p != cid);
let done = e.attr("w15:done").is_some_and(|v| v == "1" || v == "true");
if let Some(c) = self.doc.comments.get_mut(&cid) {
c.parent = parent;
c.resolved = done;
}
}
}
// Durable ids (by paragraph id) and UTC dates (by durable id), which the writer
// carries over (`write_comment_ids`). Their namespaces (w16cid, w16cex) have no canonical
// prefix here, so attributes are found by local name.
let local = |e: &crate::xml::El, name: &str| -> Option<String> {
e.attrs.iter().find(|(k, _)| k.rsplit(':').next() == Some(name)).map(|(_, v)| v.clone())
};
let mut by_durable: HashMap<String, u32> = HashMap::new();
if let Some(ids) = ids
&& let Ok(Some(x)) = self.xml(ids)
{
for e in x.els().filter(|e| e.local() == "commentId") {
let Some(cid) = local(e, "paraId").and_then(|p| by_para.get(&p).copied()) else { continue };
let Some(durable) = local(e, "durableId").filter(|d| crate::write::durable_id_ok(d)) else { continue };
if let Some(c) = self.doc.comments.get_mut(&cid) {
c.durable_id = Some(durable.to_ascii_uppercase());
by_durable.insert(durable.to_ascii_uppercase(), cid);
}
}
}
if let Some(cex) = cex
&& let Ok(Some(x)) = self.xml(cex)
{
for e in x.els().filter(|e| e.local() == "commentExtensible") {
let Some(cid) = local(e, "durableId").and_then(|d| by_durable.get(&d.to_ascii_uppercase()).copied()) else { continue };
let date = local(e, "dateUtc").filter(|d| d.len() <= 40 && d.chars().all(|c| c.is_ascii_alphanumeric() || ":-.+".contains(c)));
if let (Some(c), Some(date)) = (self.doc.comments.get_mut(&cid), date) {
c.date_utc = Some(date);
}
}
}
Ok(())
}
/// Sources from a `b:Sources` custom XML part (other custom XML is left alone); at most
/// [`MAX_SOURCES`] in all, and the document's citation style from `StyleName`.
pub(super) fn read_bibliography(&mut self, path: &str, seen: &mut Seen) -> Result<(), DocxError> {
let Some(root) = self.xml(path)? else { return Ok(()) };
if root.local() != "Sources" {
return Ok(());
}
let room = MAX_SOURCES.saturating_sub(self.doc.sources.len());
let bib = crate::bibliography::parse(&root, seen, room);
if self.doc.settings.bib_style.is_none() {
self.doc.settings.bib_style = bib.style;
}
self.doc.sources.extend(bib.sources);
Ok(())
}
pub(super) fn read_core(&mut self, path: &str) {
let Ok(Some(root)) = self.xml(path) else { return };
let c = &mut self.doc.core;
for k in root.els() {
let t = k.text().trim().to_string();
match k.name.as_str() {
"dc:title" => c.title = t,
"dc:subject" => c.subject = t,
"dc:creator" => c.creator = t,
"cp:keywords" => c.keywords = t,
"dc:description" => c.description = t,
"cp:lastModifiedBy" => c.last_modified_by = t,
"cp:revision" => c.revision = u32_of(&t).unwrap_or(0),
"dcterms:created" => c.created = t,
"dcterms:modified" => c.modified = t,
"cp:category" => c.category = t,
_ => {}
}
}
}
}
/// The password hash attributes of `w:documentProtection` that are kept (ECMA-376 §17.15.1.29:
/// the current `algorithmName`/`hashValue`/`saltValue`/`spinCount`, and the older transitional
/// `crypt*`/`hash`/`salt` set).
pub(crate) const PROTECTION_HASH_ATTRS: [&str; 16] = [
"w:algorithmName",
"w:hashValue",
"w:saltValue",
"w:spinCount",
"w:cryptProviderType",
"w:cryptAlgorithmClass",
"w:cryptAlgorithmType",
"w:cryptAlgorithmSid",
"w:cryptSpinCount",
"w:cryptProvider",
"w:algIdExt",
"w:algIdExtSource",
"w:cryptProviderTypeExt",
"w:cryptProviderTypeExtSource",
"w:hash",
"w:salt",
];
/// Restrict Editing (`w:documentProtection`, §17.15.1.29): the editing restriction, whether
/// formatting is limited to unlocked styles, and the password hash. Only enforced protection is
/// kept; a hostile attribute value can't be longer than a hash needs.
fn read_protection(k: &El, s: &mut wordcraft_doc::Settings) {
let on = |v: &str| !matches!(v, "0" | "false" | "off");
if !k.attr("w:enforcement").is_some_and(on) {
return;
}
let edit = k.attr("w:edit").filter(|e| matches!(*e, "readOnly" | "comments" | "trackedChanges" | "forms"));
let formatting = k.attr("w:formatting").is_some_and(on);
if edit.is_none() && !formatting {
return;
}
s.protection = Some(edit.unwrap_or("none").to_string());
s.protect_formatting = formatting;
s.protect_hash = PROTECTION_HASH_ATTRS
.iter()
.filter_map(|n| k.attr(n).filter(|v| !v.is_empty() && v.len() <= 1024).map(|v| (n.to_string(), v.to_string())))
.collect();
}
File diff suppressed because it is too large Load Diff
+256
View File
@@ -0,0 +1,256 @@
use super::{
Arc, Blocks, ContentTypes, DocxError, El, MAX_FONT_NAME, MAX_HF_DEPTH, MAX_PARTS, MAX_TABLE_FONTS, MAX_VBA_RELATED, PartKind, Reader, Rels,
Revision, RevisionKind, Rgb, StoryCtx, VBA_PROJECT_PART, VBA_RELATED, props, shape_style,
};
impl Reader<'_> {
/// Keep the parts the VBA project at `project` relates to, with their relationship types and
/// content types (see [`VBA_RELATED`]). Parts outside `word/`, relationship parts, the project
/// itself and missing targets are skipped (logged); at most [`MAX_VBA_RELATED`] are kept.
pub(super) fn read_vba_related(&mut self, project: &str) {
let pkg = self.pkg;
let types = self.content_types.get_or_insert_with(|| ContentTypes::read(pkg));
let mut manifest = String::new();
let mut kept = 0usize;
for rel in self.pkg.rels(project).list.iter().filter(|r| !r.external) {
// Part names compare case-insensitively (OPC): `/WORD/x` names the part the writer
// stores as `word/x`.
let target = rel.target.as_str();
let path = match (target.get(..5), target.get(5..)) {
(Some(dir), Some(rest)) if dir.eq_ignore_ascii_case("word/") => format!("word/{rest}"),
_ => target.to_string(),
};
let path = path.as_str();
let ct = types.of(path).unwrap_or("application/octet-stream");
let usable = path.strip_prefix("word/").is_some_and(|t| !t.is_empty())
&& !path.to_ascii_lowercase().ends_with(".rels")
&& !path.eq_ignore_ascii_case(project)
&& !path.eq_ignore_ascii_case(VBA_PROJECT_PART)
&& ![rel.kind.as_str(), path, ct].iter().any(|f| f.is_empty() || f.contains(['\t', '\r', '\n']));
if !usable {
log::warn!("docx: not carrying VBA-related part {path:?}");
continue;
}
let Some(bytes) = self.pkg.get_shared(path) else {
log::warn!("docx: VBA-related part {path} is missing; keeping the project without it");
continue;
};
if kept >= MAX_VBA_RELATED {
log::warn!("docx: more than {MAX_VBA_RELATED} VBA-related parts; the rest are dropped");
break;
}
kept += 1;
manifest.push_str(&format!("{}\t{path}\t{ct}\n", rel.kind));
self.doc.passthrough.entry(path.to_string()).or_insert_with(|| bytes.clone());
}
if !manifest.is_empty() {
self.doc.passthrough.insert(VBA_RELATED.into(), Arc::new(manifest.into_bytes()));
}
}
/// The fonts the font table classes as roman (`w:font/w:family w:val="roman"`).
pub(super) fn read_font_families(&mut self, path: &str) {
let Ok(Some(table)) = self.xml(path) else { return };
self.doc.settings.roman_fonts = table
.children("w:font")
.filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman"))
.filter_map(|f| f.attr("w:name"))
.filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME)
.take(MAX_TABLE_FONTS)
.map(str::to_string)
.collect();
}
pub(super) fn xml(&self, path: &str) -> Result<Option<El>, DocxError> {
self.pkg.xml(path)
}
pub fn revision(&mut self, kind: RevisionKind, e: &El) -> u32 {
self.revision_named(kind, e, None)
}
pub(super) fn revision_named(&mut self, kind: RevisionKind, e: &El, move_name: Option<String>) -> u32 {
let author = e.attr("w:author").unwrap_or("").chars().take(256).collect::<String>();
let date = e.attr("w:date").unwrap_or("").chars().take(64).collect::<String>();
let k = match kind {
RevisionKind::Insert => 0,
RevisionKind::Delete => 1,
RevisionKind::Format => 2,
};
let key = (k, author.clone(), date.clone(), move_name.clone());
if let Some(i) = self.revs.get(&key) {
return *i;
}
let i = self.doc.revisions.len() as u32;
self.doc.revisions.push(Revision { kind, author, date, move_name });
self.revs.insert(key, i);
i
}
/// Media key for an image relationship (loads the bytes once).
pub fn media_for(&mut self, rels: &Rels, id: &str) -> Option<String> {
let rel = rels.by_id(id)?;
if rel.external {
return None;
}
if let Some(k) = self.media_by_path.get(&rel.target) {
return Some(k.clone());
}
let bytes = self.pkg.get_shared(&rel.target)?;
let base: String =
rel.target.rsplit('/').next().unwrap_or("image").chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')).collect();
let base = if base.is_empty() || base.starts_with('.') { format!("image{base}") } else { base };
let mut key = base.clone();
let mut n = 1;
while self.doc.media.contains_key(&key) {
n += 1;
key = match base.rsplit_once('.') {
Some((stem, ext)) => format!("{stem}_{n}.{ext}"),
None => format!("{base}_{n}"),
};
}
self.doc.media.insert(key.clone(), bytes.clone());
self.media_by_path.insert(rel.target.clone(), key.clone());
Some(key)
}
/// A chart or diagram part, parsed once however often it's referenced (`None` when unreadable).
pub(super) fn graphic_part(&mut self, path: &str) -> Option<Arc<El>> {
if let Some(p) = self.graphic_parts.get(path) {
return p.clone();
}
let p = self.xml(path).ok().flatten().map(Arc::new);
self.graphic_parts.insert(path.to_string(), p.clone());
p
}
pub fn load_header_footer(&mut self, path: &str, footer: bool) -> Option<u32> {
if let Some(id) = self.hf_by_path.get(path) {
return Some(*id);
}
if self.doc.parts.len() >= MAX_PARTS || self.hf_depth >= MAX_HF_DEPTH {
return None;
}
// Reserve the id first so a (malformed) self-reference can't recurse.
let kind = if footer { PartKind::Footer } else { PartKind::Header };
let id = self.doc.add_part(kind, Blocks::new());
self.hf_by_path.insert(path.to_string(), id);
let root = match self.xml(path) {
Ok(Some(r)) => r,
_ => return Some(id),
};
let rels = self.pkg.rels(path);
let mut sc = StoryCtx::default();
sc.in_hf = true;
let mut blocks = Blocks::new();
self.hf_depth += 1;
self.read_blocks(&mut sc, &root, &rels, &mut blocks, self.chunks.depth);
self.hf_depth = self.hf_depth.saturating_sub(1);
self.flush_pending(&mut sc, &mut blocks);
if let Some(p) = self.doc.parts.get_mut(&id)
&& !blocks.is_empty()
{
p.blocks = blocks;
}
Some(id)
}
pub(super) fn read_theme(&mut self, path: &str) {
let Ok(Some(t)) = self.xml(path) else { return };
if let Some(n) = t.attr("name").filter(|n| !n.is_empty()) {
self.doc.settings.theme_name = n.to_string();
}
let Some(el) = t.child("a:themeElements") else { return };
if let Some(cs) = el.child("a:clrScheme") {
let names = ["dk1", "lt1", "dk2", "lt2", "accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "hlink", "folHlink"];
let mut colors = self.doc.settings.theme_colors.clone();
colors.resize(12, Rgb::BLACK);
for (i, n) in names.iter().enumerate() {
let Some(c) = cs.child(&format!("a:{n}")) else { continue };
let rgb = c
.child("a:srgbClr")
.and_then(|x| x.attr("val"))
.and_then(Rgb::parse)
.or_else(|| c.child("a:sysClr").and_then(|x| x.attr("lastClr")).and_then(Rgb::parse));
if let (Some(rgb), Some(slot)) = (rgb, colors.get_mut(i)) {
*slot = rgb;
}
}
self.pc.theme_colors = colors.clone();
self.doc.settings.theme_colors = colors;
}
self.theme_fills = shape_style::ThemeFills::read(el.child("a:fmtScheme"));
if let Some(fs) = el.child("a:fontScheme") {
let face =
|n: &str| fs.child(n).and_then(|f| f.child("a:latin")).and_then(|l| l.attr("typeface")).filter(|t| !t.is_empty()).map(str::to_string);
if let Some(f) = face("a:majorFont") {
self.doc.settings.major_font = f;
}
if let Some(f) = face("a:minorFont") {
self.doc.settings.minor_font = f;
}
let cs = |n: &str| fs.child(n).and_then(|f| f.child("a:cs")).and_then(|c| c.attr("typeface")).unwrap_or_default().to_string();
self.pc.major_cs = cs("a:majorFont");
self.pc.minor_cs = cs("a:minorFont");
// Per-script fonts (`a:font script="Arab"`); Office's themes list about 30.
let scripts = |n: &str| -> Vec<(String, String)> {
let Some(f) = fs.child(n) else { return Vec::new() };
f.els()
.filter(|e| e.name == "a:font")
.filter_map(|e| Some((e.attr("script")?, e.attr("typeface")?)))
.filter(|(s, t)| s.len() <= 8 && !t.is_empty() && t.len() < 256)
.take(256)
.map(|(s, t)| (s.to_string(), t.to_string()))
.collect()
};
self.pc.major_scripts = scripts("a:majorFont");
self.pc.minor_scripts = scripts("a:minorFont");
}
self.pc.major_font = self.doc.settings.major_font.clone();
self.pc.minor_font = self.doc.settings.minor_font.clone();
self.pc.theme = self.doc.settings.theme_colors.clone();
}
/// `w:settings/w:clrSchemeMapping`: which scheme colour text1, background1… stand for. Also
/// `w:themeFontLang/@w:bidi`, the language whose script picks the `…Bidi` theme fonts.
pub(super) fn read_color_mapping(&mut self, path: &str) {
let Ok(Some(root)) = self.xml(path) else { return };
self.pc.bidi_script = root.child("w:themeFontLang").and_then(|l| l.attr("w:bidi")).filter(|v| !v.trim().is_empty()).map(props::lang_script);
let Some(m) = root.child("w:clrSchemeMapping") else { return };
let names = [
"dark1",
"light1",
"dark2",
"light2",
"accent1",
"accent2",
"accent3",
"accent4",
"accent5",
"accent6",
"hyperlink",
"followedHyperlink",
];
let attrs = [
"w:t1",
"w:bg1",
"w:t2",
"w:bg2",
"w:accent1",
"w:accent2",
"w:accent3",
"w:accent4",
"w:accent5",
"w:accent6",
"w:hyperlink",
"w:followedHyperlink",
];
let mut map = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
for (slot, a) in map.iter_mut().zip(attrs) {
if let Some(i) = m.attr(a).and_then(|v| names.iter().position(|n| *n == v)) {
*slot = i;
}
}
self.pc.color_map = Some(map);
}
}

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