Compare commits

..
Author SHA1 Message Date
skylar taylor-barrick 2d0c8b7b0d #504: test that the Backstage side menu edge is not a resize handle
The File side menu is a fixed 200-point panel, but egui left it resizable, so hovering its right edge drew a resize line and showed a resize cursor that did nothing. The test hovers the edge and checks the pointer is not a resize cursor. Without the fix it reports ResizeEast.
2026-10-22 15:59:06 -06:00
skylar taylor-barrick 7290677207 #2: test that style gallery labels are truncated and hanging previews stay in view
Long custom style names were painted at full width and ran into neighbouring tiles, and a style with a hanging indent larger than its left indent previewed with its first line off the tile's left edge. The tests render the Styles gallery with one such style: the label must be no wider than its 74-point tile and elided, and the preview's text must start at the tile's left edge and stay whole. Without the fix the label measures 291 points and the preview's ink spans only columns 0..24 (cut by the edge).
2026-10-22 15:59:06 -06:00
Brandon ThomasandClaude Opus 5.5 9130181a5f Release 0.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:37:16 -04:00
BFlat 00bc6a4b02 Merge pull request #3598 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 18:02:41 +07:00
bflatastic 1928957bef README: the app icon above the title 2026-10-22 17:58:32 +07:00
aiongg 8e986366c7 Header and footer: a click in the other band edits it there (#3557) 2026-10-22 01:03:11 -04:00
aiongg 653a2311b5 IME: the caret follows the composition text (#3556) 2026-10-22 01:03:06 -04:00
aiongg 169581d786 Merge 178 open PRs (backlog round 1) (#3555)
Integrates the backlog round's ready set from aiongg:next.
2026-10-22 00:24:38 -04:00
aiongg b07e045d7e Integration: #3335 header Esc test allows the half-pixel return scroll that #3201 brings
#3201 makes entering an empty header scroll to it; Esc then scrolls back to the caret, landing 0.5 px from the start (17291.0 vs 17290.5). The test checks the view is back within a pixel.
2026-10-22 02:19:18 +00:00
aiongg 6e8113891e Integration: #2722 English text for four labels the Dutch and Danish catalogs lack
every_tab_and_command_is_translated needs Format Shape Pane, Full Screen View, Personal Templates and Underline Color, added by other merged PRs. Rows carry the English text until translated.
2026-10-22 02:11:24 +00:00
aiongg 54003ebc69 Integration: #2041 English text in the Polish catalog for the keys Italian gained
polish_covers_every_key_of_every_other_catalog expects Italian to have only "Style: APA" beyond Polish; #2041 translated 14 newer strings into Italian. Rows carry the English text until translated.
2026-10-22 02:11:17 +00:00
aiongg f2b06167aa Integration: #2244 VML on/off values read case-insensitively by the shape fill reader
Main (#1500) moved VML fill reading into shape_style.rs, where filled="False" still counted as on; #2244's test vml_shape_wrap_side_and_anchor_case_insensitivity expects no fill.
2026-10-22 02:05:32 +00:00
aiongg 079a743e93 Integration: #1832 drop a useless .into() in the end-of-document citation test
clippy::useless_conversion failed cargo xtask ci.
2026-10-22 02:00:08 +00:00
aiongg eb12fc6ee4 Integration: cargo fmt 2026-10-22 01:59:03 +00:00
aiongg c04d670117 Integration: cargo xtask parity 2026-10-22 01:58:53 +00:00
aiongg 3f46d9c65f Integration: status lines list each change once
Combining the tracks' status lines repeated a few entries (#2852, #1500, #2617).
2026-10-22 01:54:49 +00:00
aiongg 2060f86e8e Merge PR #3139: Fix #3137: Cannot insert a tab inside table cells (missing Ctrl+Tab shortcut)
docs/typing-parity.md: status entry prepended to main's line.
2026-10-22 01:54:35 +00:00
aiongg 64f98d824a Merge PR #2280: fix(formats): support xs:time, string-value, and case-insensitive cell types in spreadsheets 2026-10-22 01:54:24 +00:00
aiongg ef39b78adf Merge PR #2886: Recovery: keep explicit New/Open visible after startup discovery
crates/ui-egui/src/backstage.rs: #3125's t.nav fill kept, without the recovery black. Docs: status entries prepended to main's lines; the recovery paragraph placed after the status line.
2026-10-22 01:54:24 +00:00
aiongg 55c31bdbb5 Integration: #1076 English text for the keys the Slovenian catalog lacks
Its complete-catalog test needs every key of every other catalog. Rows carry the English text until translated.
2026-10-22 01:53:54 +00:00
aiongg 946dea4327 Integration: #3203 English text for the keys the Romanian catalog lacks
Its complete-catalog test needs every key of every other catalog; the merged PRs added 72 since it was written. Rows carry the English text until translated.
2026-10-22 01:53:54 +00:00
aiongg 8efd6667ec Merge PR #1076: i18n: add Slovenian (sl) interface translation
i18n: registry holds 27 languages (sl after nl, da, ro); audit.rs and the Polish test list sl beside pl and ro; tests: both sides kept. Docs: language counts and lists name ro, sl, nl and da; status entries prepended to main's lines.
2026-10-22 01:53:21 +00:00
aiongg 5d59afae74 Merge PR #3203: G14: Romanian (ro) interface catalog
crates/ui-egui/src/i18n/mod.rs: registry holds nl, da (#2722) and ro (26 languages). tests.rs: both sides' tests kept.
2026-10-22 01:52:28 +00:00
aiongg 5cfdd7c0dd Integration: #2722 Dutch registry comment on its own line 2026-10-22 01:51:45 +00:00
aiongg 92bdefc3e8 Merge PR #2722: i18n: add Dutch and Danish interfaces 2026-10-22 01:51:39 +00:00
aiongg c354162712 Merge PR #3056: Insert Table of Figures: a dialog with the caption label (Table, Equation, your own) (#3013)
ROADMAP.md: the PR's status entry prepended to main's first status line. i18n catalogs: union by key.
2026-10-22 01:51:21 +00:00
aiongg d99ac8c5a7 Merge PR #2784: #2738: Home › Underline drop-down shows its lines, None, More Underlines… and Underline Color
ROADMAP.md: the PR's status entry prepended to main's first status line. docs/parity-checklist.md: main's side, regenerated after the merges. ko.tsv: union.
2026-10-22 01:50:43 +00:00
aiongg acfdc0cb5f Merge PR #2500: Resolve installed fonts by PostScript face name
Test-font pins: #3389's kept, #2500's dropped (layout tests.rs, tests_pages.rs, render lib.rs). crates/fonts/src/fontdb.rs: #2500's PostScript-name match moved into #3078's family_paths.
2026-10-22 01:50:29 +00:00
aiongg 22124867d0 Merge PR #1249: Ribbon: names for screen readers on the tab strip's drawn buttons (#1110)
crates/ui-egui/src/ribbon.rs: main's Share button (already named) kept; the » button's "More tabs" name added in main's restructured strip; both sides' tests kept. i18n: "More tabs" rows added after main's.
2026-10-22 01:50:03 +00:00
aiongg 65421603e8 Merge PR #2984: #2959: show the formatting mini toolbar with text context menus
crates/ui-egui/src/canvas.rs: #2984's context-menu open logic, opened also by #3419's equation options strip (eq_strip).
2026-10-22 01:49:36 +00:00
aiongg ec988829c9 Merge PR #1800: Layout: paragraph borders take their room and same-bordered paragraphs join in one shaded box
crates/layout/src/lib.rs: #1800's border room in the keep-with-next lookahead, with #2431's list counters passed to next_first_line.
2026-10-22 01:49:08 +00:00
aiongg 5dc68481ed Integration: track t09
crates/ui-egui/src/backstage.rs: t03's bidi_display on the Recent names over t09's borrowed name/path. people.toml: union. Tests: both tracks' tests kept whole. Docs: status lines combined, rows unioned or word-merged.
2026-10-22 01:48:25 +00:00
aiongg e1e258e4d9 Integration: track t08
contributors/people.toml: union. crates/layout/src/tests.rs: both tracks' tests kept whole. Docs: status lines combined, rows unioned or word-merged.
2026-10-22 01:47:56 +00:00
aiongg 3581f80251 Integration: track t07
# Conflicts:
#	ROADMAP.md
#	docs/file-format-parity.md
#	docs/gaps.md
#	docs/layout-parity.md
2026-10-22 01:47:46 +00:00
aiongg 04b3881e3e Integration: track t06
docs/gaps.md row 4: one row (t06 had kept a second, older copy of it); t05's #1840 and t06's #2964 clauses both in, main's #2963 clause kept. docs/recovery.md: t06's retry sentence with t01's base64 size text. Test files: both tracks' tests kept whole.
2026-10-22 01:47:34 +00:00
aiongg 16d1c8a933 Integration: track t05
crates/pdf/src/lib.rs: t01's vector_text kept before t05's watermark (box area signature). Test files and canvas.rs tests: both tracks' tests kept whole. Docs: status lines combined, rows unioned or word-merged.
2026-10-22 01:46:47 +00:00
aiongg 0d3b8eb0cb Integration: track t04
crates/formats/src/odt.rs: t01's Body::new/take_blocks kept; take_blocks finishes tables with t04's TableB::finish (column widths). Test files: both tracks' tests kept whole. Docs: status lines combined, rows unioned or word-merged.
2026-10-22 01:46:17 +00:00
aiongg 86df1e70ce Integration: track t03
# Conflicts:
#	ROADMAP.md
#	crates/formats/src/html.rs
#	docs/gaps.md
#	docs/ui-parity.md
2026-10-22 01:44:54 +00:00
aiongg 245b5bd8d0 Integration: track t02
Docs: status lines combined (t02's entries first); log rows unioned; file-format-parity .rtf row from t02 (#1653, duplicate .rtf row dropped as t02 did), .odt row word-merged.
2026-10-22 01:44:01 +00:00
aiongg 674e0cef5b Integration: track t01 2026-10-22 01:42:06 +00:00
aiongg 3017804459 Integration: cargo fmt 2026-10-22 01:40:29 +00:00
aiongg 520f54cb81 Integration: cargo fmt 2026-10-22 01:34:26 +00:00
aiongg d4debe0592 Integration: cargo fmt 2026-10-22 01:34:25 +00:00
aiongg 3bf53830c5 Integration: #1728 close the layout test the merge resolution left open 2026-10-22 01:34:15 +00:00
aiongg 5c260a16fd Integration: cargo fmt 2026-10-22 01:31:48 +00:00
aiongg ed2ecefac2 Integration: #3257 separate its Find-box test from #2407's recent-files test (auto-merge interleaved them) 2026-10-22 01:29:54 +00:00
aiongg 789256d2d1 Integration: #2453 drawing_payload test builds the shape fill as a Fill 2026-10-22 01:29:12 +00:00
aiongg 406694ea58 Integration: #3429 the based-on-chain test's doc comment back above its test 2026-10-22 01:25:08 +00:00
aiongg ef00b55870 Merge PR #3429: #3415: banded rows shade the same rows in the reference app and WordCraft (table style band sizes)
crates/docx/tests/fixtures.rs: union, keeps main's shape-gradient test and the PR's band-size tests.
ROADMAP.md, docs/file-format-parity.md: main's status lines with this change added to the newest; log rows from both sides in date order.
2026-10-22 01:25:02 +00:00
aiongg d015988e93 Integration: #2334 test a formula string result continued in a CONTINUE record 2026-10-22 01:25:01 +00:00
aiongg c91ac7f68f Merge PR #3291: #2428: the table's move and resize handles show while the caret is in it
ROADMAP.md, docs/ui-parity.md: main's status lines with this change added to the newest; log rows from both sides in date order.
2026-10-22 01:24:49 +00:00
aiongg 31ba42a013 Integration: #3419 doc comments back on their own items (EquationFrame and its test above them) 2026-10-22 01:24:36 +00:00
aiongg c7d5a949a7 Merge PR #3232: #2038: a click below a floating table goes to the text, not its last row
ROADMAP.md, docs/layout-parity.md: main's status lines with this change added to the newest; log rows from both sides in date order.
2026-10-22 01:24:36 +00:00
aiongg 7b95c59980 Merge PR #3419: Equations: the equation being edited is framed as in the reference app
Doc status lines keep main's and add the PR's entry; log rows unioned: docs/ui-parity.md
2026-10-22 01:24:20 +00:00
aiongg 0255a44d9a Merge PR #3389: M2.1: stabilize font and shadow geometry checks
docs/gaps.md: status line keeps main's and adds the PR's entry; main's last log row kept above the PR's new section.
2026-10-22 01:24:20 +00:00
aiongg f62c6f99ed Merge PR #3202: Layout: justified lines shrink their spaces by the reference app's measured limits
Doc status lines keep main's and add the PR's entry; log rows unioned: docs/layout-parity.md
2026-10-22 01:24:10 +00:00
aiongg 61570835bc Merge PR #3156: Watermark: the text fills its shape's box, as the reference app's textpath does
crates/docx/src/read/story.rs: main's vml_opacity with the PR's box size.
crates/pdf/src/tests.rs: both sides' tests kept; the glyph_frames helper both #3155 and #3156 add stays once.
2026-10-22 01:24:10 +00:00
aiongg 412fc47300 Merge PR #3231: i18n: File › Home greeting in every interface language
crates/ui-egui/src/i18n/{de,et,ja,pt-br,sr,sr-latn,uk,zh-hans,zh-hant}.tsv: union by key, no duplicates.
docs/localization-parity.md: main's status line with this change added; both progress-log rows.
2026-10-22 01:24:08 +00:00
aiongg eb5d0b74ed Merge PR #3205: #2802: Delete on whole table cells leaves one empty paragraph in each
ROADMAP.md: main's status lines with this change added to the newest; progress-log rows from both sides in date order.
2026-10-22 01:23:54 +00:00
aiongg f90a9896ad Merge PR #3481: fix(engine): toggle off style-inherited lists (#3459)
ROADMAP.md, docs/gaps.md, docs/typing-parity.md: status lines and revision histories carry both sides.
2026-10-22 01:23:52 +00:00
aiongg e311bd410f Merge PR #3155: Text: scaled letters (w:w) are stretched, not spaced out
crates/pdf/src/tests.rs: both sides' new tests kept.
2026-10-22 01:23:50 +00:00
aiongg d985d0ecce Merge PR #3289: M15: fix Russian ё ordering and decomposed Cyrillic keys
ROADMAP.md: status line and revision history carry both sides.
docs/gaps.md, docs/localization-parity.md: status lines carry both sides.
2026-10-22 01:23:41 +00:00
aiongg f9d0295d98 Merge PR #3068: Rendering reuses one render context, so typing starts no threads (#3027)
Doc status lines keep main's and add the PR's entry; log rows unioned: ROADMAP.md
2026-10-22 01:23:41 +00:00
aiongg 6336f7b521 Merge PR #2947: Render: fade dimmed headers and footers as one layer
crates/render/src/lib.rs: imports keep main's Paint and take the PR's page_layers (page_display moves to the test imports).
2026-10-22 01:23:41 +00:00
aiongg 92b36ceae0 Merge PR #2557: #1681: HTML export and import keep furigana (ruby)
docs/file-format-parity.md: main's status line with this change added; both progress-log rows, this one first.
docs/localization-parity.md: main's status line with this change added.
2026-10-22 01:23:40 +00:00
aiongg 9cd24539bd Merge PR #2549: #1653: RTF import decodes the East Asian multibyte code pages (932/936/949/950)
docs/file-format-parity.md: main's status line with this change added.
2026-10-22 01:23:23 +00:00
aiongg b7776ee6ac Merge PR #2739: Layout: a line can break after a hyphen before a digit, like the reference app (#2712)
crates/layout/src/tests.rs: both sides' new tests kept.
2026-10-22 01:23:22 +00:00
aiongg a05c650a16 Merge PR #3391: ODT: table column widths are read, so a resized column survives a save
crates/formats/src/odt.rs: StyleInfo keeps both cell_valign (#2416) and column_width.
ROADMAP.md, docs/file-format-parity.md, docs/gaps.md: main's status lines kept; the PR's log rows added.
2026-10-22 01:23:19 +00:00
aiongg 2dc0e4adf7 Merge PR #3440: A document with many content controls saves in the time they take
ROADMAP.md: status line once (this change first, then main's); progress-log entries and history rows union
docs/file-format-parity.md: status line once; history rows union in date order
docs/gaps.md: status line once (this change first, then main's)
2026-10-22 01:23:17 +00:00
aiongg 5b19acb4ec Merge PR #3193: ODT: read an embedded equation's own MathML instead of its undecodable metafile preview
ROADMAP.md: progress log carries both sides.
docs/file-format-parity.md: ODT row keeps #873's headers and footers and adds #3193's formula objects.
2026-10-22 01:23:17 +00:00
aiongg 423fc3f50d Merge PR #2691: Equation arrays: centre &-less rows (First Property of Radicals, #2617)
crates/layout/src/math.rs: main already centres &-less rows (#2617); kept main's code and added the PR's two tests.
ROADMAP.md: main already has the #2617 log row; the PR's duplicate row dropped.
2026-10-22 01:23:13 +00:00
aiongg 57205e310a Merge PR #2392: PDF export: lam-alef drawn as two glyphs at the same x extracts in reading order (#2310)
ROADMAP.md: main's status lines, with this change added to the newest.
docs/file-format-parity.md: main's status line with this change added; PDF export row keeps main's outline note and adds the lam-alef note.
2026-10-22 01:23:07 +00:00
aiongg 264bfdaed3 Merge PR #3333: M15: correct Cuban and Peruvian Spanish decimal separators
ROADMAP.md, docs/gaps.md, docs/localization-parity.md: main's status lines kept; the PR's log rows and gaps note added.
2026-10-22 01:23:06 +00:00
aiongg d64ce28903 Merge PR #3204: DOCX: majorBidi/minorBidi theme fonts follow the theme font language
docs/file-format-parity.md: status line once (this change first, then main's); history rows union
2026-10-22 01:23:01 +00:00
aiongg 9c3df446ff Merge PR #3247: #1899: a picture cropped to a sliver is drawn at the size the file gives
crates/metafile/src/lib.rs: re-export both TextLayout (#1915) and crop_window.
ROADMAP.md, docs/layout-parity.md: main's status lines kept; the PR's log rows added.
2026-10-22 01:22:55 +00:00
aiongg c37c693b46 Merge PR #3190: #2986: Search and three other boxes stop resetting the input method every frame
ROADMAP.md: status line, progress log and revision history carry both sides.
2026-10-22 01:22:54 +00:00
aiongg 1c757ece6b Merge PR #3199: Signature lines stay signable after saving a .docx
ROADMAP.md: status line once (this change first, then main's); progress-log rows union in date order
docs/file-format-parity.md: status line once; VML row merged word by word (main's #1500 fill text plus the PR's signature-line text)
2026-10-22 01:22:51 +00:00
aiongg b0fb2b9386 Merge PR #2101: File: recovery copies and .json files store pictures as base64 (#586 follow-up)
ROADMAP.md: status line, progress log and revision history carry both sides.
docs/file-format-parity.md: status line and change log carry both sides.
2026-10-22 01:22:47 +00:00
aiongg 932b8028be Merge PR #3153: Layout: anchored objects thinner than a point keep their size
crates/layout/src/tests.rs: both sides' appended tests kept.
2026-10-22 01:22:40 +00:00
aiongg 2df0803aea Merge PR #2099: fix(pdf): shapes with a description are figures in tagged PDF
crates/layout/src/display.rs: main's Paint path fill and fill.as_ref() with the PR's Option<GraphicKind> figure and described-shape figure.
crates/pdf/src/lib.rs: main's holds_picture and rotated_pictures arm kept beside the PR's figure_alt.
crates/pdf/src/tests.rs: union; the PR's shape test builds its fill with Fill::solid (main's shape fill type).
2026-10-22 01:22:37 +00:00
aiongg b639f355f1 Merge PR #2431: Keep headings with indented list paragraphs
crates/layout/src/tests.rs: both sides' new tests kept.
2026-10-22 01:22:36 +00:00
aiongg 9e2b624792 Integration: #3434 drop review-bot notes from test doc comments 2026-10-22 01:22:31 +00:00
aiongg bfa5925433 Merge PR #2704: SmartArt Text Pane: Tab demotes, Shift+Tab promotes the focused item (#2701)
ROADMAP.md: main's status line kept; the PR's log row added.
2026-10-22 01:22:31 +00:00
aiongg 66c01b6663 Integration: #2034 objects without a stacking order draw above the ones before them 2026-10-22 01:22:28 +00:00
aiongg 833a75d59c Merge PR #3434: Save As no longer swallows a failed save silently
crates/ui-egui/src/lib.rs: kept main's removal of the Focus Escape block from logic() (Escape handling moved in #1733); took #3434's comment on the Recent/Save As error reset.
2026-10-22 01:22:25 +00:00
aiongg 3f52369269 Merge PR #2453: DOCX: preserve text boxes containing pictures
ROADMAP.md, docs/file-format-parity.md, docs/gaps.md: main's status lines kept; the PR's log rows added.
2026-10-22 01:22:24 +00:00
aiongg a7b8e32fde Merge PR #2331: fix(mathml): support linethickness=none and menclose notations
crates/formats/src/mathml.rs: menclose keeps main's box-with-strikes reading (the writer emits "box horizontalstrike") and adds the PR's radical, actuarial, cross and case-insensitive notations.
2026-10-22 01:22:19 +00:00
aiongg 3c97026297 Merge PR #3476: UI: the ribbon's font and size boxes say what they are to a screen reader (#3172)
ROADMAP.md: kept main's status lines; progress-log entries from both sides
docs/gaps.md: kept main's status line
docs/ui-parity.md: kept main's status line; change-log rows from both sides
2026-10-22 01:22:15 +00:00
aiongg f2c8042093 Integration: #2185 hyperlink_target slices with get() and needs the whole field name 2026-10-22 01:22:14 +00:00
aiongg 601af83f2c Integration: #2416 collapse the nested vertical-align if (clippy::collapsible_if) 2026-10-22 01:22:13 +00:00
aiongg 61addba013 Merge PR #3078: #1549: a Thai run with Latin finds one installed font covering it all
ROADMAP.md: status line once (this change first, then main's); progress-log and history rows union in date order
docs/layout-parity.md: status line once (this change first, then main's); history rows union in date order
2026-10-22 01:22:12 +00:00
aiongg 544647318d Merge PR #873: ODT: read headers and footers from the master page (#863)
crates/formats/src/odt.rs: Body::new from #873 with main's xml_space_end; parse_tracked returns main's document font as 4th element and #873's page parts as 5th (the reviewed integration fix for #873).
docs/file-format-parity.md: status line, ODT row and change log carry both sides.
2026-10-22 01:22:10 +00:00
aiongg f247a0f1c9 Integration: #1985 only ^0nnn codes read as Windows-1252 2026-10-22 01:22:10 +00:00
aiongg 2928ef10c0 Merge PR #3342: #3341: the scrollbar page tip stays on one line
ROADMAP.md, docs/ui-parity.md: status lines kept main's, #3341 added as a previous change; both sides' progress-log and revision rows, newest first.
2026-10-22 01:22:10 +00:00
aiongg c8b657ff9b Merge PR #2416: fix(formats): support table cell vertical alignment in rtf, html, and odt
crates/formats/src/html.rs: both sides' appended tests kept.
2026-10-22 01:22:05 +00:00
aiongg e362772dee Integration: #1851 DOCPROPERTY accepts the reference app's LastSavedBy name 2026-10-22 01:22:02 +00:00
aiongg 0b81f22d5b Merge PR #3258: Docs: control protocol's Methods table renders again
docs/control-protocol.md: one engine.execute row, main's current text (the template save command, focusRibbon) with the PR's \| escapes and its ui.contextMenu entry
2026-10-22 01:22:01 +00:00
aiongg 19701dbd18 Merge PR #2041: i18n(it): translate the 15 strings other catalogs added since #1244
crates/ui-egui/src/i18n/it.tsv: union by key, no duplicates.
2026-10-22 01:21:58 +00:00
aiongg ca14f46b05 Merge PR #2378: Objects: preview rotated picture and shape pixels while dragging (#2277)
crates/ui-egui/src/objects.rs: main's tests kept; the PR's rotation_window helper and two rotation tests appended after them.
ROADMAP.md, docs/ui-parity.md: main's status lines kept; the PR's log rows added.
docs/gaps.md: main's status line and row 10 kept, #2277 clause added to the row; log row added.
2026-10-22 01:21:56 +00:00
aiongg 1d141de189 Merge PR #3273: #3176: File › Home and New name each template and recent document for screen readers
docs/ui-parity.md: status line kept main's, #3176 added as a previous change; both revision rows (the #3234 row moved above the #2388 row, newest first).
2026-10-22 01:21:55 +00:00
aiongg 96dd01bb30 Integration: #2273 Latin weight/style and line styles take precedence over Asian, complex and line-type attributes 2026-10-22 01:21:51 +00:00
aiongg 94b5f4a871 Merge PR #1967: i18n(sv): translate 32 new strings
crates/ui-egui/src/i18n/sv.tsv: union by key, no duplicates.
2026-10-22 01:21:49 +00:00
aiongg 029945a313 Integration: #2673 Khmer substitutes return the (list, flag) pair after #3245 2026-10-22 01:21:47 +00:00
aiongg 13e33ad072 Merge PR #2681: Layout: a Symbol bullet's line keeps Symbol's height without the font
docs/layout-parity.md: one status line naming this change first, then main's; history rows union
2026-10-22 01:21:47 +00:00
aiongg 1d0916bfec Merge PR #3478: fix(engine, layout): evaluate FILENAME field and support \p switch
crates/engine/src/cmd/references.rs: Update Field keeps both the DOCPROPERTY (#1851) and FILENAME arms.
crates/layout/src/tests.rs: main's #2048 tests and the PR's cache tests kept.
2026-10-22 01:21:44 +00:00
aiongg f79372538c Merge PR #3460: Layout: superscript and subscript lines are as tall as full-size text
docs/layout-parity.md: status lines keep main's with the PR's item added; tables and lists union.
2026-10-22 01:21:41 +00:00
aiongg 6bdd901cc9 Merge PR #3335: #3331: closing the header or footer returns to the body selection
ROADMAP.md,docs/ui-parity.md: status lines keep main's with the PR's item added; tables and lists union.
2026-10-22 01:21:41 +00:00
aiongg 1cb405ea48 Merge PR #3269: #3003: Save to a NAS share mounted by GVFS keeps working when it refuses a mode change
ROADMAP.md,docs/file-format-parity.md: status lines keep main's with the PR's item added; tables and lists union.
2026-10-22 01:21:41 +00:00
aiongg 5c63e25ddc Merge PR #3178: Dragging a picture near the bottom of a page moves it off the page (#3169)
ROADMAP.md,docs/layout-parity.md: status lines keep main's with the PR's item added; tables and lists union.
2026-10-22 01:21:41 +00:00
aiongg 3df5e8ec39 Merge PR #2373: Tables: dragging a single selected cell's border resizes only that cell (UI gap)
ROADMAP.md, docs/ui-parity.md: main's status lines kept; the PR's log rows added.
2026-10-22 01:21:40 +00:00
aiongg 54d7597afc Merge PR #3157: Borders: dotted, dashed and dash-dot borders take the reference app's dash patterns
crates/layout/src/display.rs: border_stroke maps Dotted, Dashed and DotDash to the PR's Border* strokes and keeps main's Triple; Stroke::dashes lists Triple as undashed.
crates/pdf/src/tests.rs: kept both new tests.
2026-10-22 01:21:40 +00:00
aiongg 0ac7ba58ae Merge PR #3125: Dark theme: the File page's side bar uses a deeper blue
crates/ui-egui/src/backstage.rs: side bar fills with t.nav (main dropped the recovery branch and APP_COLOR)
2026-10-22 01:21:35 +00:00
aiongg 93bfd4671c Merge PR #3234: Restrict Editing: forms protection greys out the content-control buttons
ROADMAP.md: status line kept main's, #3234's change added as a previous change; both progress-log and revision rows.
docs/ui-parity.md: status line kept main's, #3234's change added as a previous change; Ribbon row keeps main's text plus the forms-protection sentence; both revision rows.
2026-10-22 01:21:35 +00:00
aiongg ecef6d4ab6 Merge PR #3299: M15: persist the default proofing language across restarts (#2882)
ROADMAP.md: main's status lines; both progress-log rows kept.
docs/gaps.md: main's status line; revision rows unioned.
docs/localization-parity.md: main's status line; revision rows unioned.
2026-10-22 01:21:35 +00:00
aiongg 82b9795c62 Merge PR #2273: fix(formats): support asian/complex scripts and underline/strike types in ODT
crates/formats/src/odt.rs: kept main's tests and added the PR's test after them.
2026-10-22 01:21:33 +00:00
aiongg cb8d365923 Merge PR #2673: Fonts: Times New Roman keeps Liberation Serif under the roman-font rule (#1613)
docs/file-format-parity.md: one status line naming this change first, then main's
2026-10-22 01:21:32 +00:00
aiongg ec81170136 Merge PR #2254: fix(docx): inspect textbox style for v-text-anchor and handle case-insensitivity
crates/docx/tests/fixtures.rs: both sides' appended tests kept.
2026-10-22 01:21:32 +00:00
aiongg 23f919338a Merge PR #3327: Translate the spooled sheet count in print confirmations 2026-10-22 01:21:30 +00:00
aiongg 503b52eb67 Merge PR #3218: A successful native print returns to the document (#3210)
crates/ui-egui/src/lib.rs: union, keeps both print-page tests (#2958 and #3210).
2026-10-22 01:21:29 +00:00
aiongg 3bcb07633f Merge PR #3121: Insert Caption picks the label used last, as the reference app does (#3120)
ROADMAP.md: main's status lines; both progress-log and revision rows kept.
docs/ui-parity.md: main's status line; revision rows unioned.
2026-10-22 01:21:28 +00:00
aiongg 7290d7df20 Merge PR #3441: List labels: a right-to-left label mirrors its brackets (#2916) 2026-10-22 01:21:25 +00:00
aiongg 6e9bea465a Merge PR #3425: Scope the File page's back-arrow hit area to the chevron 2026-10-22 01:21:24 +00:00
aiongg 7e159a35f2 Merge PR #3420: Options: list the languages alphabetically by their own name 2026-10-22 01:21:24 +00:00
aiongg 558f6c85af Merge PR #2343: Fix numbered list levels that start at 0 or are overridden to 1
crates/doc/src/numbering.rs: kept #3417's per-(num, level) seen map and its single-level override; the override now sets that level's LevelFlags { started: false, first: Some(start) } from #2343 instead of start - 1 (the reviewed integration fix for #2343).
2026-10-22 01:21:21 +00:00
aiongg cbaa8ab403 Merge PR #2043: feat(navigation): implement collapsible headings in the navigation pane
crates/ui-egui/src/lib.rs: kept main's nav_pane field next to the new nav_collapsed.
crates/ui-egui/src/panes.rs: nav_heading keeps main's wrapping label (#3015) and gains the PR's collapse triangle and (toggled, response) return.
2026-10-22 01:21:20 +00:00
aiongg eedcd7e98c Merge PR #2244: fix(docx, doc): support VML wrap side and case-insensitive float anchors
crates/docx/tests/fixtures.rs: both sides' appended tests kept.
2026-10-22 01:21:19 +00:00
aiongg ec16888bab Merge PR #3213: A floating picture in a table cell grows the row to hold it (#3108) 2026-10-22 01:21:18 +00:00
aiongg c9ab2db79f Merge PR #3142: #2958: the Print page's button leads the settings, in the primary accent 2026-10-22 01:21:18 +00:00
aiongg dcf2763480 Merge PR #3071: Layout: Format Page Numbers dialog (#1113)
ROADMAP.md,docs/gaps.md,docs/ui-parity.md: status lines keep main's with the PR's item added; tables and lists union.
2026-10-22 01:21:17 +00:00
aiongg 415320056b Integration: #2269 LaTeX lengths keep macro names case-sensitive 2026-10-22 01:21:17 +00:00
aiongg bc3290f38d Merge PR #2991: #2964: align list labels with text after leading page breaks
ROADMAP.md,docs/gaps.md,docs/layout-parity.md: status lines keep main's with the PR's item added; tables and lists union.
2026-10-22 01:21:17 +00:00
aiongg 992c74e5c8 Merge PR #2506: #2233, #2234: inline pictures drag to a dashed insertion cursor and stay inline
crates/ui-egui/src/objects.rs: kept both new functions (dragged_off_page from main, drop_point from the PR).
ROADMAP.md, docs/ui-parity.md: status lines keep main's with the PR's item added; log rows union.
2026-10-22 01:21:17 +00:00
aiongg 43cd083a9f Merge PR #2599: Context menu: the ribbon's paste choices (Paste, Keep Text Only, Merge Formatting, Paste Special…) (#2583)
ROADMAP.md: kept main's status line; progress-log and change-log rows from both sides
docs/ui-parity.md: kept main's status line; change-log rows from both sides
crates/ui-egui/src/canvas.rs: kept both sides' new tests as separate functions
2026-10-22 01:21:16 +00:00
aiongg d1182d5f8d Merge PR #2477: DOCX: HYPERLINK fields read as links, so TOCs from the reference app's web version keep their tabs and page fields (#2174)
ROADMAP.md: main's status lines; both progress-log and revision rows kept.
docs/file-format-parity.md: main's status line and rows; the PR's HYPERLINK sentence added to both of main's Fields rows; revision rows unioned.
2026-10-22 01:21:08 +00:00
aiongg eae3d8496e Merge PR #2269: fix(formats): parse leading plus and case-insensitive units in LaTeX lengths 2026-10-22 01:21:08 +00:00
aiongg f14a0ce13f Integration: #2268 ODT lengths accept only the standard pc unit for picas 2026-10-22 01:20:59 +00:00
aiongg 43b922e667 Merge PR #2692: Formatting revisions: track table, row, cell and section property changes
ROADMAP.md: both progress-log rows, #2692's under main's newest 2026-10-17 row.
docs/gaps.md: status line kept main's, #2692's change added as a previous change.
2026-10-22 01:20:55 +00:00
aiongg 9eb0dba8d0 Merge PR #3417: doc: a start override restarts only its own level 2026-10-22 01:20:54 +00:00
aiongg 7d8f31547e Merge PR #3320: Require hex digits in percent_decode escapes 2026-10-22 01:20:54 +00:00
aiongg d5547f80ec Merge PR #2267: fix(docx): correctly parse emu and case-insensitive units in measure
crates/docx/src/units.rs: union of test assertions (main's line_multiple, the PR's emu/case ones)
2026-10-22 01:20:53 +00:00
aiongg 5a8cb21978 Merge PR #3315: Reject signs and repeated '#' in Rgb::parse 2026-10-22 01:20:53 +00:00
aiongg 53418631eb Merge PR #1739: Tables nest 24 deep at most, so pasting a table into its own cell can't build a document that crashes (#1596)
ROADMAP.md: main's status line kept; both progress-log lines added.
docs/target-app-parity.md: main's status line and Tables row kept, #1596 sentence added to the row; log row added.
2026-10-22 01:20:47 +00:00
aiongg f57d38b3e2 Merge PR #2412: AltGr types ś, ł, ż… instead of opening KeyTips on Linux (#2388)
docs/typing-parity.md: status line kept main's, #2388 added as a previous change; both revision rows.
docs/ui-parity.md: status line kept main's, #2388 added as a previous change; Keytips row keeps main's text plus the AltGr clause; both revision rows.
2026-10-22 01:20:44 +00:00
aiongg 32fe53c0ac Merge PR #2136: Linux: scan the Flatpak host font folders (#1937)
crates/fonts/src/fontdb.rs: PR's new test added before main's renamed run_fallback_is_cached_per_char_set
2026-10-22 01:20:42 +00:00
aiongg 0004566068 Merge PR #1832: test(zotero): verify citation at document end stays closed through typing and reload
crates/zotero/src/tests.rs: both new #898 tests kept.
2026-10-22 01:20:40 +00:00
aiongg 9b1d6c8393 Merge PR #2470: File: a text file saved over itself closes without asking (#2376)
ROADMAP.md,docs/ui-parity.md: status lines keep main's with the PR's item added; tables and lists union.
2026-10-22 01:20:37 +00:00
aiongg 467ad8e279 Merge PR #2460: DOCX: a text frame in the paragraph defaults frames nothing (#2360)
ROADMAP.md, docs/file-format-parity.md: status lines keep main's with the PR's item added; log rows union.
2026-10-22 01:20:37 +00:00
aiongg 56a392cd48 Merge PR #1840: Layout: a tab whose text doesn't fit goes to the next line with it (#1839)
ROADMAP.md, docs/gaps.md, docs/layout-parity.md: status line keeps main's and adds the PR's entry; log rows unioned.
2026-10-22 01:20:35 +00:00
aiongg 429163d857 Merge PR #1852: .doc import: symbol runs read their font and character, and paragraph borders are read
crates/docbin/src/tests.rs: union (main's sprmTCellWidth test, then the PR's two tests)
2026-10-22 01:20:32 +00:00
aiongg 6d90c345d4 Merge PR #1761: Hyphenation: German text follows the German patterns
crates/proof/src/lang.rs: main's English-dialect helpers kept; code() doc lists `de`.
crates/proof/src/lib.rs: hostile-input test loops over main's languages plus De.
crates/ui-egui/src/options.rs: language_name maps EnGb/EnCa and De to no dictionary name.
ROADMAP.md: main's status lines; both progress-log and revision rows kept.
docs/gaps.md: main's status line; gap 8 row is main's with German hyphenation added; revision rows unioned.
docs/layout-parity.md: main's status line, Tabs and Drop caps rows; the PR's Hyphenation row; revision rows unioned.
docs/localization-parity.md: main's status line; revision rows unioned.
2026-10-22 01:20:31 +00:00
aiongg e60f53f716 Merge PR #2211: Typing: Option+Shift+- types an em dash outside Outline view (#1933)
crates/ui-egui/src/keys.rs: kept main's single keymap lookup (bound) and added #2211's Outline-only guard inside it.
2026-10-22 01:20:25 +00:00
aiongg 61fbb080a9 Merge PR #2232: fix(formats): support borderless and bordered tables in LaTeX import and export
crates/formats/src/latex.rs: kept both sides' new tests
2026-10-22 01:20:23 +00:00
aiongg e4c1700ddc Merge PR #1728: Tables: a table with its own width is not squeezed to the margins (#1721)
crates/layout/src/tests.rs: both sides' appended tests kept.
docs/layout-parity.md: main's table rows kept, #1721 sentence added to the autofit row; both log rows kept.
2026-10-22 01:20:13 +00:00
aiongg d15c57a713 Integration: #2153 drop trailing blank line and use assert! for bool checks in tab_separated_list_markers 2026-10-22 01:20:12 +00:00
aiongg 8077310218 Merge PR #3463: fix(docx): preserve East Asian language w:eastAsia on w:lang 2026-10-22 01:20:11 +00:00
aiongg 11f265b807 Merge PR #3304: Map Windows-1255 byte 0xCA to U+05BA 2026-10-22 01:20:11 +00:00
aiongg a69e40353e Integration: #3471 drop duplicate egui options block; install_ui_fonts sets them 2026-10-22 01:20:10 +00:00
aiongg 60b4d57a9b Merge PR #3260: Unstyle TOC entry links whose Hyperlink style has a localized id 2026-10-22 01:20:10 +00:00
aiongg f29dc368e1 Merge PR #3257: Find box and Results list borrow a document font for Georgian etc. (#3248)
crates/ui-egui/src/lib.rs: union with #2407 (recent_fonts_for and find_fonts_for fields, both ensure_* fns and calls)
2026-10-22 01:20:10 +00:00
aiongg 0c583fe58c Merge PR #2153: fix(formats): support tab-separated list markers in markdown
crates/formats/src/markdown.rs: kept both sides' new tests
2026-10-22 01:20:08 +00:00
aiongg 570190aa31 Merge PR #3471: fix(ui): preserve Ctrl+Alt+Q and allow typing @ on German layout 2026-10-22 01:20:02 +00:00
aiongg 44bf5bcfb0 Merge PR #3452: Navigation pane: numbered headings show their list number (#3418) 2026-10-22 01:20:02 +00:00
aiongg ee7dc1ce00 Merge PR #3369: #3368: right-clicking a link hides the hover tooltip 2026-10-22 01:20:02 +00:00
aiongg 0ee24065e6 Merge PR #3358: List AutoFormat triggers in front of already-written text 2026-10-22 01:20:01 +00:00
aiongg 1adecc63b1 Merge PR #3355: Add regression coverage for replying to a comment via its balloon (#3268) 2026-10-22 01:20:01 +00:00
aiongg 41dc49f581 Merge PR #3221: Status bar names footnotes and endnotes, not just header/footer 2026-10-22 01:20:01 +00:00
aiongg 5b9b1f552b Merge PR #2144: fix(formats): support distributed paragraph alignment in RTF 2026-10-22 01:20:00 +00:00
aiongg 84371b5e45 Merge PR #3201: Go to Footer brings a new, empty footer into view 2026-10-22 01:20:00 +00:00
aiongg a18cbda41b Merge PR #3098: Breaks menu tests pin the English interface, so they pass on a non-English machine 2026-10-22 01:20:00 +00:00
aiongg 71c55337af Merge PR #2928: UI: the Full Screen button leaves full screen on a second click (#2864) 2026-10-22 01:20:00 +00:00
aiongg cbc3246c2c Merge PR #2298: Comments: comments in headers, footers, notes and text boxes are listed, get balloons and are reached by Next/Previous (#2289) 2026-10-22 01:20:00 +00:00
aiongg 861236167f Merge PR #2098: Opening a named pipe or a device no longer hangs or eats memory, whatever its name (#1444 follow-up)
contributors/people.toml: union of both sides' new entries.
2026-10-22 01:19:58 +00:00
aiongg 2e67eba0e7 Merge PR #3511: fix(engine): validate bookmark names by character count and character classes 2026-10-22 01:19:56 +00:00
aiongg d8e5b1823d Merge PR #2140: fix(formats): support left alignment in markdown tables
crates/formats/src/markdown.rs: kept both sides' new tests
2026-10-22 01:19:56 +00:00
aiongg 0c039708db Integration: #3267 leave footnote definitions out of link definitions 2026-10-22 01:19:54 +00:00
aiongg 2d4455ff34 Integration: #1847 reject comment IDs above u32::MAX 2026-10-22 01:19:54 +00:00
aiongg 60cd2547e2 Merge PR #3497: Date pictures: pad hh on the 12-hour clock
crates/engine/src/cmd/insert.rs: date_pictures test keeps both PRs' assertions.
2026-10-22 01:19:53 +00:00
aiongg c88b2740d8 Merge PR #2268: fix(formats): parse case-insensitive and pica units in ODT lengths 2026-10-22 01:19:50 +00:00
aiongg 320a62bce8 Merge PR #1430: fix(geom): reject measurements that overflow to infinity in parse_measure (#1359)
crates/geom/src/lib.rs: main already rejects the overflow; kept main's check and added the PR's test.
2026-10-22 01:19:49 +00:00
aiongg 78f2ef78c6 Merge PR #3245: Substitute a system Khmer font for a missing Khmer document font 2026-10-22 01:19:49 +00:00
aiongg 463119f663 Merge PR #2407: fix(ui): borrow fallback fonts and preserve visual order for recent files (#2067) 2026-10-22 01:19:49 +00:00
aiongg 23e1985189 Merge PR #2104: Fonts: emoji prefer a colour emoji font on Windows (#1754, #1874) 2026-10-22 01:19:49 +00:00
aiongg b9f294c769 Merge PR #1831: test(layout): verify Farsi Yeh joins correctly in bidi shaping 2026-10-22 01:19:49 +00:00
aiongg bfbf732047 Merge PR #3496: Date pictures: accept uppercase D and Y 2026-10-22 01:19:47 +00:00
aiongg f2e441862d Merge PR #2249: fix(formats): support 4/8-digit hex, space-separated rgb and hsl in parse_color 2026-10-22 01:19:47 +00:00
aiongg dc1039b475 Merge PR #3351: Spelling & Grammar button now shows suggestions 2026-10-22 01:19:47 +00:00
aiongg 05c34486f5 Merge PR #2086: fix(export): pictures marked decorative are skipped by screen readers 2026-10-22 01:19:47 +00:00
aiongg 3784187781 Merge PR #3252: Russian and Czech dictionaries are reported, not just checked (#3226) 2026-10-22 01:19:47 +00:00
aiongg db55704aef Merge PR #2960: Cross-references are links: Ctrl+click goes to the target (#2945) 2026-10-22 01:19:47 +00:00
aiongg d21c8825c2 Merge PR #2429: DOCX: keep the reference app's Table of Contents content control around a TOC the writer doesn't rebuild 2026-10-22 01:19:47 +00:00
aiongg f6d7d98a9e Merge PR #2185: fix(docbin): preserve external URLs with sublocations in doc hyperlink fields 2026-10-22 01:19:47 +00:00
aiongg c2255f1fe8 Merge PR #2164: proof: emoji flags followed by U+FE0F load (RLA-ES Spanish) 2026-10-22 01:19:47 +00:00
aiongg 81fbf9fb77 Merge PR #3322: wasm: use the browser clock only on wasm32-unknown-unknown, not on WASI 2026-10-22 01:19:46 +00:00
aiongg abd1c7fb36 Merge PR #1760: Tables: rows with gridBefore/gridAfter keep their grid columns 2026-10-22 01:19:46 +00:00
aiongg ade6481d98 Merge PR #1851: engine: Update Field evaluates DOCPROPERTY against custom and built-in properties 2026-10-22 01:19:46 +00:00
aiongg c5a9c6c6d2 Merge PR #1850: engine: Check spelling as you type is saved with the proofing options, so it survives a restart 2026-10-22 01:19:46 +00:00
aiongg e10d20e1a1 Merge PR #3372: #3370: the AutoSave text is part of the switch 2026-10-22 01:19:45 +00:00
aiongg bd3f8d45e7 Merge PR #3287: #3271: edit.replace / edit.replaceAll without text or an earlier search fail 2026-10-22 01:19:45 +00:00
aiongg 57387979f1 Merge PR #3356: Keep '&' in LaTeX \href and \url addresses on import 2026-10-22 01:19:45 +00:00
aiongg 934b3495ad Merge PR #3220: Wait for recovery cleanup before the app exits (#3140) 2026-10-22 01:19:45 +00:00
aiongg 39ba45ce30 Merge PR #3312: fix(ui): shorten a long status message so it stops before the controls 2026-10-22 01:19:45 +00:00
aiongg 885c9fad59 Merge PR #3128: Rename: a name given with the document's extension keeps it, once 2026-10-22 01:19:45 +00:00
aiongg a20cb66f98 Merge PR #3246: Align opens a menu with Left, Center and Right, not just center (#3242) 2026-10-22 01:19:45 +00:00
aiongg 997164757e Merge PR #2689: #2625: recovery does not retry a snapshot that cannot be copied until the document changes 2026-10-22 01:19:45 +00:00
aiongg 8b9c85ded8 Merge PR #2292: fix(formats): support bullet, top, bot, hslash, accents, bm, and mod in LaTeX math 2026-10-22 01:19:45 +00:00
aiongg 50ef30939f Merge PR #2034: Floating objects are drawn in their stacking order (relativeHeight) 2026-10-22 01:19:45 +00:00
aiongg d97e32fd06 Merge PR #2258: fix(formats): support case-insensitive and spaced !important in HTML CSS 2026-10-22 01:19:45 +00:00
aiongg 2bb10e3244 Merge PR #1985: Read ^0nnn character codes 128-159 as Windows-1252 characters 2026-10-22 01:19:45 +00:00
aiongg f2b3fc2491 Merge PR #1943: Double-click switches between header and footer editing 2026-10-22 01:19:45 +00:00
aiongg f43e7f49f8 Merge PR #1786: engine: read_file reads only a bounded regular file, so a pipe or device path can't hang or exhaust memory 2026-10-22 01:19:45 +00:00
aiongg a7568eb86c Merge PR #3267: Markdown import: reference-style links 2026-10-22 01:19:44 +00:00
aiongg a6c31d8efd Merge PR #2172: fix(formats): support small and big tags and relative/large font-size keywords in HTML 2026-10-22 01:19:44 +00:00
aiongg 44d569cf74 Merge PR #3249: Reject non-digit characters in numeric HTML entities 2026-10-22 01:19:44 +00:00
aiongg fd7152826e Merge PR #2171: fix(formats): support column break in RTF import and export 2026-10-22 01:19:44 +00:00
aiongg 5b0ad41cbd Merge PR #1882: Length fields show their unit in the interface language (#1734) 2026-10-22 01:19:44 +00:00
aiongg d3f953a36a Merge PR #3526: fix(engine): do not treat decimal points as sentence boundaries in Sentence case 2026-10-22 01:19:43 +00:00
aiongg d064baeaec Merge PR #2017: #1976: breaks_menu tests use the English interface 2026-10-22 01:19:43 +00:00
aiongg ac334d65f3 Merge PR #1833: test(formats): verify txt export preserves list item labels 2026-10-22 01:19:43 +00:00
aiongg 3bc152f91c Merge PR #3163: CLI: run without --file uses the region's paper, like convert (#3152) 2026-10-22 01:19:43 +00:00
aiongg b9666f7ff8 Merge PR #1892: Charts: draw radar charts instead of a placeholder (#1890) 2026-10-22 01:19:43 +00:00
aiongg 80b19a90c0 Merge PR #2334: fix(sheet/xls): track substream depth and support CONTINUE records on formula strings 2026-10-22 01:19:43 +00:00
aiongg 8837570252 Merge PR #1889: Layout: floats in table cells are not pushed up by align center/bottom (#1887) 2026-10-22 01:19:43 +00:00
aiongg 00b2bfc329 Merge PR #1788: formats: the Markdown code fence outgrows any backtick run in its content, so a round trip keeps it 2026-10-22 01:19:43 +00:00
aiongg 211daa5862 Merge PR #1847: engine: reject malformed comment IDs before editing or caret fallback 2026-10-22 01:19:42 +00:00
aiongg 0af1a197ad Merge PR #3509: fix(engine): preserve existing table cell margins when side is omitted 2026-10-22 01:19:40 +00:00
aiongg 0cf588cc48 Merge PR #3295: Fix table formula ROUND erroring on large numbers with many digits 2026-10-22 01:19:40 +00:00
aiongg 1f37fb230a Merge PR #3147: Crop any part of a picture: a side can take 95% of the source, not 45% (#3135) 2026-10-22 01:19:40 +00:00
aiongg a803888b11 Merge PR #2110: Return 0 for SIGN(0) in table formulas 2026-10-22 01:19:40 +00:00
aiongg 4da4ced7ce Merge PR #1915: Draw EMF text records (EXTTEXTOUTW) in metafile pictures 2026-10-22 01:19:40 +00:00
aiongg 01e3603035 Merge PR #1749: Tests: a table that starts mid-page never leaves a repeated header row alone (#1704) 2026-10-22 01:19:40 +00:00
MichaelandClaude Opus 5.5 29540d03c3 #3108 review: behind-text and in-front-of-text floats don't grow the row
FredApps' review: the row-growth loop counted every non-inline object, so a
page-anchored logo or watermark behind a cell's text would stretch its row to
the object's bottom edge - contradicting table.rs (behind-text frames count
for vertical alignment but don't increase the row's flow height) and Word.
Only floats that take part in the text flow (Square, Tight, Through,
TopAndBottom) grow the row now, the same classification layout already uses
elsewhere for "takes part in wrapping".

New test: a 300pt behind-text and a 300pt in-front-of-text picture each leave
the row at its text height; it fails with the old condition. The existing
Square-wrap tests and cell_frame_centres_empty still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:36:05 +08:00
5a6d2b6070 File › New: personal templates from a user templates folder (#2852) (#3133)
* #2852: personal templates in File › New, Save as Word Template

File › New lists the .dotx/.dotm files in the user templates folder under
Personal (a subfolder per category, search covers them); a click makes an
untitled document attached to the template. Save As › Save as Word Template
starts in that folder (<settings>/Templates, made on first use; File ›
Options › Advanced › File locations › User templates overrides it).
Engine: file.listTemplates (bounded, network folders refused, read root
applies), file.newFromTemplate sets the attached template, file.options
userTemplatesFolder, file.saveTemplate keeps a .dotm name. UI:
ui.saveAsTemplate; Save dialog requests carry a start folder.

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

* #2852: review — a personal template asks about unsaved changes; network templates folder refused before it's looked at; New lists the folder once per visit with Refresh; .dotx/.dotm labels alike

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
2026-10-21 20:18:50 -04:00
dbebb2eca7 #2617: centre equation-array rows without & (#2690)
* #2617: centre equation-array rows without &

A row with no alignment mark sat in the first column and was right-aligned
to it, widening that column and pushing the aligned rows right (Word's
"First Property of Radicals" case equation). Word centres such a row in the
array; it no longer widens the aligned columns.

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

* #2617: review — test a lone eq-array row wider than the aligned columns

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
2026-10-21 20:04:57 -04:00
4913b20259 #1281: English dialects in proofing — en-GB, en-CA, en-AU… flag each other's spellings (#1045) (#2632)
* #1281: English dialects in proofing — en-GB, en-CA, en-AU… flag each other's spellings (#1045)

English was one proofing language: en-GB, en-CA and en-AU all proofed like
US English, and US documents accepted British spellings. Now ProofLang has
three Englishes, picked by the tag's region:

- En (en-US, other regions, no language): British spellings flagged
  (colour → color).
- EnGb (en-GB, en-AU, en-NZ, en-IE, en-ZA, en-IN…): American spellings
  flagged (color → colour, center → centre, analyze → analyse); -ize and
  -ise both right (Oxford spelling).
- EnCa (en-CA): British -our/-re/-ence, American -ize, tire, program.

Data: assets/spelling/en-variants.txt, our own ~280 American/British
pairs with a policy per English (MIT OR Apache-2.0, ATTRIBUTION.md row).
Inflections by rule (crates/proof/src/en_variants.rs). A dialect's form
is right when the shared list knows it or its other spelling, so
"realisation" is right in en-GB. No system dictionary needed.

Suggestions put the dialect's own form first. Review › Language lists
English (Australia) and (Canada); the Language dialog adds India,
Ireland, New Zealand and South Africa (names in every catalog, not
native-reviewed). An English interface on an en-GB/en-CA/en-AU… region
writes new documents in that English. Options › Language shows the
document's language instead of a fixed "English (United States)".

Tests: per-dialect accept/flag/suggest, tag mapping, no-panic proptests,
engine end to end through review.language, DOCX w:lang round trip,
region-based editing language, language menu.

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

* #1281: review — pin the #40 words (judgement, defence, licence, programme, catalogue) in en-GB and en-US

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

* #1281: review — harness picks every proofing language in Review › Language and checks it is ticked

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

* #1281: review — pin more British spellings in en-GB (goitre, sceptre, enrol, fulfil, instalment, skilful, wilful, encyclopaedia…)

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
2026-10-21 19:52:57 -04:00
Dylan M. Taylor 7f38f41d8c Use chrono instead of hand-rolled UTC conversion (#3427)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the hand-rolled civil_from_days / days_from_civil copies and the
Excel serial-date, FILETIME and logging/PDF date conversions with chrono as
a workspace dependency (default-features = false, std only, so wasm is
unaffected). Output formats are unchanged and pinned by the existing (and one
new) test. Excel's 1900 leap-year bug and the 1900/1904 systems keep their
explicit special cases; a small proleptic-Gregorian fallback covers years
outside chrono's NaiveDate range so no input changes behaviour or panics.
AGENTS.md now says time goes through chrono.

Follow-up to storytold/cadcraft#1785

* Never panic or reshape date output for clocks outside chrono's range

chrono's From<SystemTime> unwraps, so a clock past year 262143 panicked
inside the logger. logging::timestamp now uses the fallible
DateTime::from_timestamp and reads such a clock as the epoch, as the old
saturating code did.

iso_from_unix_secs fell back to the Unix epoch for timestamps chrono
rejects; it now keeps the proleptic Gregorian date civil_from_days gives,
restoring the pre-chrono result for supported out-of-range u64 inputs.

Years past 9999 are written with {:04} rather than %Y (which prepends a
'+') in logging::timestamp, iso_from_unix_secs, docbin::filetime_iso and
odt::now_iso, so byte-for-byte output is preserved.

filetime_iso gained a docstring; AGENTS.md's chrono bullet now notes the
narrow out-of-range fallback. Tests cover each boundary.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* Route out-of-range dates through chrono's 400-year cycle, not hand-rolled copies

The PR kept hand-rolled Hinnant `civil_fallback`/`days_from_civil_fallback`
copies for dates outside chrono's ±262143-year range, and AGENTS.md carved a
"narrow fallback" exception for them. That left calendar arithmetic outside
chrono after all.

Replace every copy with the Gregorian 400-year cycle (146_097 days): chrono
converts the day/year within one cycle and whole cycles are added back via i128
(so i64::MIN/MAX can't overflow). This keeps chrono as the only calendar logic
and reproduces the pre-chrono proleptic Gregorian result for every value:

- engine cmd/mod.rs `civil_from_days` (reached by the system clock and by
  `iso_from_unix_secs` for a u64 seconds count past chrono's range).
- engine cmd/controls.rs `days_from_civil` and `days_in_month` (`days_in_month`
  now uses `rem_euclid(400)` since month lengths repeat every 400 years).
- formats sheet.rs `civil` and docx chart.rs `civil` (Excel serials).

Outputs are unchanged: verified the cycle result equals the old formula over a
wide range and at the extremes with a scratch chrono program. New tests pin
accepted inputs past chrono's range on both sides of the epoch, through the
public/lib entry points and the private converters, with values from the old
formula. Removed the AGENTS.md "narrow fallback" exception.

pdf/lib.rs `civil` is left as-is: its only caller is the live system clock and
its year is clamped to 0..9999 by the PDF DateTime, so the epoch fallback past
year 262143 is the accepted live-clock behavior, not a hand-rolled copy.

Tested: cargo test -p wordcraft-engine -p wordcraft-formats -p wordcraft-docx;
fmt --check; clippy --all-targets -D warnings.

* logging/docbin/odt/pdf: keep extended-year dates, no epoch fallback

FredApps (CHANGES_REQUESTED) showed `DateTime::from_timestamp(..).unwrap_or_default()`
changes output: a timestamp the old code converted past chrono's year 262143 range now
collapsed to 1970-01-01 instead of its real proleptic-Gregorian date. The same regression
exists wherever the PR swapped a hand-rolled converter for the epoch fallback. Fix every
such site to keep chrono as the only calendar logic via the 400-year Gregorian cycle
(146_097 days): chrono converts the day within one cycle, whole cycles carry in the year
(i128, saturated into i64). Verified the cycle result equals the old formula across a wide
range and at the extremes.

- apps/wordcraft/src/logging.rs `timestamp`: added `civil_from_days(u64)` (cycle), kept the
  pre-1970 epoch clamp (`duration_since`) and the milliseconds/time-of-day format exactly.
  Pins: UNIX_EPOCH + 10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z (FredApps' example,
  was 1970-...); the checked_add u64::MAX/2 s fixture now asserts 292277026596-12-04T15:30:07.000Z
  instead of the epoch.
- crates/docbin/src/metadata.rs `filetime_iso`: restored a cycle-based `civil(i64)` instead
  of `from_timestamp(..)?` (which dropped the field past chrono's range). A u64 FILETIME caps
  at ~1.83e12 unix s (year 60056), inside chrono's range, so no reachable value changes; pinned
  the largest FILETIME (u64::MAX) -> 60056-05-28T05:36:10Z to lock the extended-year path.
- crates/formats/src/odt.rs `now_iso`: reuse the crate's existing cycle converter
  `sheet::civil` (made pub(crate)) rather than the epoch fallback.
- crates/pdf/src/lib.rs `civil`: cycle converter; year returned unclamped and the caller's
  existing PDF DateTime 0..=9999 year clamp is kept exactly, so month/day/time of a far-future
  clock survive. Pins: civil(10_000_000_000_000)=(318857,5,20,17,46,40),
  civil(-10_000_000_000_000)=(-314918,8,13,6,13,20), civil(253_402_300_800)=(10000,1,1,0,0,0).

engine `cmd/mod.rs::iso_from_unix_secs` already uses the cycle fallback (last round); unchanged.

* Don't build a year-318857 SystemTime unchecked in the logging test

Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-21 19:36:41 -04:00
67ec657af6 Legacy menu keys: Alt, V, U and 17 more Word 2003 sequences (#1697) (#1769)
* #1678: Focus fills the screen; Full Screen View is a bindable command

View › Focus goes full screen with the document on black and a slim bar
whose "⋯" drops the ribbon over the page. Full Screen View
(view.fullScreenView, no default key; bind one in Customize Keyboard)
shows the pages alone with the search box above them; the two turn each
other off. Full Screen (view.fullScreen, F11, #980) keeps its whole-window
behaviour. Escape leaves Focus or Full Screen View, then Full Screen, once
nothing nearer takes it; the system leaving full screen ends all three.
Ctrl+F1 collapses the ribbon (not on macOS). Immersive Reader no longer
turns Focus on. The page widget keeps a fixed id so it keeps focus when
the rulers hide.

Fixes #1678

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

* #1678: one Focus path after the rebase; pin the dropped ribbon staying down on a ribbon click

main's Focus (#1017) consumed Escape in WordApp::ui before
leave_full_screen, so Escape would leave Focus even when it should first
collapse a selection or close a popup. That handler is removed: Focus,
Full Screen View and Full Screen are left through leave_full_screen
only. main's focus_hides_the_ribbon_and_escape_brings_it_back still
pins the panes coming back. Full Screen View and Show the ribbon added to
the hr, ko and sv catalogs, which main gained since.

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

* #1678: Show the ribbon is a named button; a bound Ctrl+F1 wins; Esc in Read Mode leaves a dialog's Escape alone

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

* #1678: parity checklist regenerated after the rebase (Full Screen View counted)

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

* #1678: harness tests for the one Escape path and Ctrl+F1 through the app

In Focus with text selected on the page, the first Escape collapses the
selection and keeps Focus; the second leaves Focus (fails if the old
focus_mode Escape handler in WordApp::ui comes back). Ctrl+F1 sent as a
key event collapses the ribbon when unbound, and runs the user's command
when bound in the keymap, leaving the ribbon alone (not on macOS).

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

* #1678: review — a click on the dropped Focus ribbon gives the page the keyboard; only Ctrl+F1 yields to a binding

The ribbon dropped over the page in Focus lies over the canvas viewport and
in a Middle-order area, so refocus_after_chrome_click skipped clicks on it
and typing went nowhere until the page was clicked (which also put the
ribbon away). Clicks on the focus_ribbon layer now count as chrome. The
Focus test asserts the page has the keyboard after a ribbon click and that
typing reaches it with the ribbon still down.

dispatch checks the keymap ahead of an interface shortcut only for Ctrl+F1;
Alt+Q and Cmd+Option+R keep their place ahead of user bindings, as before
#1678 (test: a_bound_alt_q_still_opens_command_search).

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

* #1678: review — Ctrl+F1 (⌘⌥R on macOS) collapses or expands the ribbon; a shortcut of your own on Ctrl+F1 wins

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

* #1678: review — ui-parity's Word column gives Word for Mac's ribbon shortcut (⌘⌥R)

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

* #1678: review — Focus's dropped ribbon is a command, ui.focusRibbon; "⋯" and a click on the page run it

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

* #1678: review — the Focus UI test turns Focus on from the status bar's Focus button

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

* #1697: legacy menu keys (Alt, V, U and 17 more Word 2003 sequences)

After Alt, a letter no tab keytip or Search uses starts a legacy menu
sequence, as Word for Windows still takes them (observed there): Alt, V, U
turns on Full Screen View; also views, ruler, zoom, Select All, Find /
Replace / Go To, Symbol, Picture, Font, Paragraph, Word Count and Insert
Table. Hidden as in Word; a hint under the tab row names the keys so far.
A letter that leads nowhere is ignored, Esc cancels, Alt goes back to the
tab badges. Tests keep the first letters off every ribbon letter and check
each target exists.

Fixes #1697

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

* #1697: legacy menu keys stay off on macOS, F10's badges included

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

* #1697: review — the legacy hint shows without a ribbon; Alt, V, U through the app; not on macOS in the docs

With no ribbon showing (Focus, Full Screen View) no tab rects are recorded, so
Alt, V's hint didn't show; it now hangs under the slim bar at the top of the
window. A harness test drives Alt, V, U through the whole app and checks no
letter is typed. The parity row and progress log say the sequences are not on
macOS.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
2026-10-21 19:30:13 -04:00
6b01aa967f Shape fills: gradients and transparency (#1500) (#1605)
* #1500: shape fills keep gradients and transparency (model, DOCX read/write)

A shape's, text box's and canvas's fill is now a `Fill` (solid with
transparency, or a gradient kept as the file states it) instead of one
opaque colour, so gradients and transparency survive opening and saving.

- doc: `fill::Fill`/`Gradient` with sanitizing for hostile input; `.json`
  keeps writing opaque solid fills as `[r,g,b]` and reads both forms;
  `ShapeExtra::outline_fill` keeps an outline gradient.
- docx read: `a:solidFill` with `a:alpha`, `a:gradFill` (stops, `a:lin`,
  `a:path`/`fillToRect`, `tileRect`, `rotWithShape`, `flip`), theme fill
  styles through `a:fillRef` 1-999/1001+ with `phClr`, `a:grpFill`,
  `satMod`, outline gradients, VML `v:fill` gradients and opacity, canvas
  backgrounds.
- docx write: `a:solidFill` + `a:alpha`, `a:gradFill` as read.
- engine/docbin/layout: call sites; drawing still uses the main colour
  (next commit draws gradients).

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

* #1500: draw shape gradients and transparency (layout, render, PDF)

- layout: WordArt's paint becomes a shared `display::Paint` (solid,
  linear, radial); `paint_for` lays a fill over the shape's frame
  (linear at its angle, `scaled` honoured; path gradients circular from
  their focus to the farthest corner). Shapes, freeforms and canvas
  backgrounds use it.
- render: one `set_paint` for shapes, paths and WordArt; radial
  gradients; soft edges fade the real fill.
- pdf: axial and radial shadings with per-stop opacity.
- Review fixes: nested groups without a fill pass theirs on to
  `a:grpFill`, text-box contents don't take a group's fill, `phClr`
  transparency only on `phClr` colours, VML focus ends consistent and
  opacity clamped, `rotWithShape`/`scaled` written only when set,
  Shape Outline clears an outline gradient, shared stop sanitizing.
- docs: file-format, layout parity, gaps #1, roadmap progress log.

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

* #1500: shape.fill sets transparency and gradients on every selected shape

- shape.fill (now cmd/shape_fill.rs) takes {"color", "transparency"?} or {"gradient": {stops,
  type, angle, focus, rotateWithShape}}; a colour alone keeps the transparency; values are
  sanitized; applies to every selected shape, text box and canvas as one undo step.
- Formatting (shape.fill, shape.effects) keeps a Shift+click multi-selection, as in Word.
- view.formatShapePane toggles the Format Shape pane.
- Session::undo_mark/merge_undo_since: a pane drag that applies as it goes undoes in one step.
- Helpers for the pane: params(fill), stop add/remove/move, directions, variations and
  presets (variation and preset colours provisional until compared with Word).
- Catalog: Shape Fill Gradient, Format Shape Pane.

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

* #1500: Shape Fill › Gradient and the Format Shape pane

- Shape Fill › Gradient: No Gradient, Light and Dark Variations of the shape's colour, More
  Gradients… (opens the pane).
- Format Shape pane (right): No fill / Solid fill (colour, transparency) / Gradient fill
  (presets, type, direction, angle, stops bar with add/remove/drag and arrow keys, per-stop
  colour, position, transparency, rotate with shape). Drags preview live, one undo step.
- Right-click › Format Shape… on shapes, text boxes and canvases.
- Gradient swatches have accessible names; new labels translated (own wording, clean room).

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

* #1500: preset gradients are our own design (clean room)

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

* #1500: Gradient variations, starting gradient and directions as Word 365 does them

Observed in Word 365 (behaviour and values only):
- Shape Fill › Gradient: a No Gradient swatch, then 13 Light and 13 Dark Variations in a
  grid five wide, in Word's order; light stops tinted 66/44.5/23.5% with saturation ×1.6,
  dark shaded 30/67.5/100% with ×1.15; linear ones scaled; corners with their tile rectangle.
- Format Shape › Gradient fill starts from the theme's first accent (four stops, 0/74/83/100%).
- Corner directions in the pane get the same tile rectangle; Add/Remove sit by the stops bar.
- shape.fill carries "scaled" and "tile"; docx exports color_modified (lumMod, lumOff, tint,
  shade, satMod) so variations use the reader's own colour maths.

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

* #1500: review fixes for the shape fill ribbon controls

- Gradient variations are built from the colour the shown variation came from, so picking
  variations in turn, or No Gradient, doesn't drift the colour.
- A drag's undo mark is forgotten while the Format Shape pane is closed (a stale mark could
  merge later undo steps).
- Dragging on the stops bar away from a handle no longer moves the selected stop; the arrow
  keys are taken by the bar (no focus moves or shape nudges).
- Direction and Type changes keep a linear gradient's "scaled" and an imported tile
  rectangle; only a corner direction sets its tile.
- Colour modifiers (lumMod, lumOff, tint, shade, satMod) live on Rgb in the doc crate, shared
  by the DOCX reader and the gradient builders.
- Previews build their stop list once per swatch, not per vertex.
- Translations for the pane's hint, direction and preset names.

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

* #1500: adapt to main: caret contrast samples gradient fills; upstream tests use Fill/Paint

- Paint::at gives a paint's colour and opacity at a point; the caret's background sampling
  (#299) uses it for shape fills, now gradients or transparent, and the Format Shape
  previews use it too.
- Upstream tests (#886 shapes survive saving, render) build fills as Fill/Paint.

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

* #1500: adapt to main after the rebase: VML presets, rounded corners, new catalogs

- vml_fill takes the shape's shapetype: a VML preset shape (#1377) takes its fill, opacity and
  gradient from its shapetype when it states none of its own, as it already took its colour.
- Rounded-rectangle corners (#1476) and the gradient paints meet in render, PDF and the caret's
  background sampling; ShapeExtra keeps both `corner` and `outline_fill`.
- Tests that landed on main since build fills as Fill/Paint (VML presets, cover pages, VML
  freeforms, path arrowheads, PDF and render shapes).
- The 40 Shape Fill / Format Shape strings in the hr, ko, sv and vi catalogs, which main
  gained since.

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

* #1500: docs: shape gradients and transparency, Shape Fill › Gradient and the Format Shape pane

Re-applied on main's docs after the rebase: progress log, file-format, layout and UI parity
rows, gaps #1, status lines and revision history; parity checklist regenerated.

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

* #1500: a theme picture fill falls back on the reference colour; VML freeform opacity note

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

* #1500: adapt to main after the second rebase: VML shapetype tests compare Fill; parity checklist regenerated

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

* #1500: one VML fraction parser; a preset takes its gradient from its shapetype

Review notes: story.rs's watermark/freeform opacity reuses shape_style's vml_fraction (clamped at the callers) instead of a second copy; a test pins a VML preset whose v:fill gradient comes from its v:shapetype, and the 65536ths form of a fraction.

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

* #1500: adapt to main after the third rebase: the shape duplicate preview test fills with Fill

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

* #1500: review — harness test: right-click a shape, Format Shape… opens the pane and keeps the shape selected

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

* #1500: review — theme keeps every fill style a fillRef can reach (999 per list), still bounded

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
2026-10-21 19:12:26 -04:00
Dylan M. Taylor db7807a5f6 Merge pull request #3531 from storytold/sonar-fix/pin-actions-sha
ci: pin GitHub Actions to full commit SHA (SonarCloud S7637)
2026-10-21 19:10:42 -04:00
Dylan M. Taylor 62dbbe9062 Merge pull request #3532 from storytold/sonar-fix/enforce-https
security: enforce HTTPS on curl downloads (SonarCloud S6506)
2026-10-21 19:10:05 -04:00
726652f088 Focus fills the screen; Full Screen View is a bindable command (#1678) (#1733)
* #1678: Focus fills the screen; Full Screen View is a bindable command

View › Focus goes full screen with the document on black and a slim bar
whose "⋯" drops the ribbon over the page. Full Screen View
(view.fullScreenView, no default key; bind one in Customize Keyboard)
shows the pages alone with the search box above them; the two turn each
other off. Full Screen (view.fullScreen, F11, #980) keeps its whole-window
behaviour. Escape leaves Focus or Full Screen View, then Full Screen, once
nothing nearer takes it; the system leaving full screen ends all three.
Ctrl+F1 collapses the ribbon (not on macOS). Immersive Reader no longer
turns Focus on. The page widget keeps a fixed id so it keeps focus when
the rulers hide.

Fixes #1678

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

* #1678: one Focus path after the rebase; pin the dropped ribbon staying down on a ribbon click

main's Focus (#1017) consumed Escape in WordApp::ui before
leave_full_screen, so Escape would leave Focus even when it should first
collapse a selection or close a popup. That handler is removed: Focus,
Full Screen View and Full Screen are left through leave_full_screen
only. main's focus_hides_the_ribbon_and_escape_brings_it_back still
pins the panes coming back. Full Screen View and Show the ribbon added to
the hr, ko and sv catalogs, which main gained since.

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

* #1678: Show the ribbon is a named button; a bound Ctrl+F1 wins; Esc in Read Mode leaves a dialog's Escape alone

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

* #1678: parity checklist regenerated after the rebase (Full Screen View counted)

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

* #1678: harness tests for the one Escape path and Ctrl+F1 through the app

In Focus with text selected on the page, the first Escape collapses the
selection and keeps Focus; the second leaves Focus (fails if the old
focus_mode Escape handler in WordApp::ui comes back). Ctrl+F1 sent as a
key event collapses the ribbon when unbound, and runs the user's command
when bound in the keymap, leaving the ribbon alone (not on macOS).

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

* #1678: review — a click on the dropped Focus ribbon gives the page the keyboard; only Ctrl+F1 yields to a binding

The ribbon dropped over the page in Focus lies over the canvas viewport and
in a Middle-order area, so refocus_after_chrome_click skipped clicks on it
and typing went nowhere until the page was clicked (which also put the
ribbon away). Clicks on the focus_ribbon layer now count as chrome. The
Focus test asserts the page has the keyboard after a ribbon click and that
typing reaches it with the ribbon still down.

dispatch checks the keymap ahead of an interface shortcut only for Ctrl+F1;
Alt+Q and Cmd+Option+R keep their place ahead of user bindings, as before
#1678 (test: a_bound_alt_q_still_opens_command_search).

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

* #1678: review — Ctrl+F1 (⌘⌥R on macOS) collapses or expands the ribbon; a shortcut of your own on Ctrl+F1 wins

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

* #1678: review — ui-parity's Word column gives Word for Mac's ribbon shortcut (⌘⌥R)

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

* #1678: review — Focus's dropped ribbon is a command, ui.focusRibbon; "⋯" and a click on the page run it

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

* #1678: review — the Focus UI test turns Focus on from the status bar's Focus button

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
2026-10-21 19:09:36 -04:00
ff3c7d8d25 Layout: text above a float's anchor wraps around it (#2048) (#2237)
* #2048: text above a float's anchor wraps around it

A floating object joined the page's wrap areas only when layout reached
its anchor paragraph, so paragraphs above the anchor ran behind a picture
placed higher up the page (fixed on the page, or moved up from its
paragraph). Layout now notes such floats and lays the pass out again
(at most 3 times) with their wrap areas known from the page's start.

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

* #2048: early-float wrap settles when the anchor moves to the next page

Review follow-up. Rewrap passes add to the wrap areas pages start with
instead of replacing them, and a pass is done when every float found
above its anchor sits inside the room its page started with. A float
whose wrap pushes its own anchor onto the next page has no fixed point;
the first page keeps the room and the float goes with its anchor, so
the passes no longer swap between the two pages. The last allowed pass
moves any float still not covered into its seeded room, or down to its
paragraph's top, so the returned layout never has text under it.

Tests: the reviewer's page-boundary case, and a last pass with nothing
seeded keeping floats clear of earlier text.

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

* #2048: review — the early-float page-boundary case is pinned for 4 to 18 paragraphs

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

* #2048: review — a column break into another width keeps the floats where the first piece drew them

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
2026-10-21 18:48:29 -04:00
c02ceef673 Borders: triple borders draw as three lines on screen and in PDF (#1740) (#2210)
* #1740: triple borders draw as three lines on screen and in PDF

Triple borders (tables, paragraphs, the ### AutoFormat line) were drawn as
double lines. Layout now emits a Triple stroke; the renderer and the PDF
exporter draw three thin lines across the line's direction, so vertical
table borders get three side-by-side lines too.

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

* #1740: rebase onto main — triple border strokes use the line's f64 ends

Main now converts the line ends to f64 once and draws through a shared
offset-line closure; the triple stroke uses both instead of dereferencing
the shadowed values.

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

* #1740: review — PDF triple-border test checks the three strokes are distinct and 3 widths apart

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
2026-10-21 18:32:33 -04:00
2451b5a421 The I-beam pointer shows on white paper on Windows (#1746) (#2230)
* #1746: the text pointer shows on white paper on Windows

Windows' inverting I-beam pointer stays white over the GPU-drawn page, so
it vanished on white paper (#1746, #1417, #1126). On Windows the canvas now
hides the system pointer wherever it would be the I-beam and draws one in
its place: black on light paper, white on dark (as the caret, #299), edged
in the other colour. Other systems keep their own I-beam.

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

* #1746: the drawn I-beam snaps to physical pixels, so it stays white on the dark page at 125%/150%

Review note on #2230: the 1 pt fill inside a 3 pt edge was snapped to
half a point, so at fractional scaling the white stem was anti-aliased
into the black edge and read grey. Fill and edge are now whole pixels
wide and lie on pixel edges.

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

* #1746: the drawn I-beam takes the canvas colour between pages and on cut margins

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

* #1746: tests pin whole-pixel strokes at 125%/150% and the canvas colour off the page

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

* #1746: review — hovering the page leaves the selection alone (test)

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
2026-10-21 18:17:49 -04:00
71aeebc2da Double-clicking a later page's footer opens it without scrolling up (#2103) (#2214)
* #2103: double-clicking a later page's footer opens it without scrolling up

The double-click's first press in the footer band moved the caret to the
nearest body text and the caret-follow scrolled the view up to it, away
from the footer being double-clicked. A press in the header/footer band
from the body now leaves the caret and the view alone, as in Word; the
release still opens the footer on the clicked page.

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

* #2103: the footer test checks the caret and the view at the press too

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

* #2103: review — footer test checks the clicked page's exact footer story, with a header present

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: aiongg <aiongtaigi@gmail.com>
2026-10-21 18:03:45 -04:00
b4a1d34996 #1792: Check spelling as you type is remembered across restarts (#1837)
The Proofing checkbox wrote session view state only; ui.json had no key
for it. Saved as UiState.checkSpelling (default on), like darkPage.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-10-21 18:03:39 -04:00
72a08a16a9 #1841: mini toolbar test follows #1501's visible anchor (#1842)
the_bar_scrolls_under_the_ribbon_not_over_it (#1069) expected the bar to
keep an anchor with its selection scrolled off the document area; since
#1501 the bar hides then. The test now checks the bar is gone and the
ruler's left-indent marker still drags.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-10-21 18:01:15 -04:00
Martin BallandClaude Opus 5.5 7d453e7217 #3243: File › Recover follows the app theme (#3435)
The Recover page forced a black fill and white text whatever the theme. It now uses the same fills and text colours as the other Backstage pages.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 17:47:38 -04:00
vangel76 a4572e5376 fmt: rustfmt equation array centring 2026-10-21 23:24:05 +02:00
dmim2502-commitsandClaude Opus 5.5 40ae6ac08c proof: drop U+FE0F from FORBIDDENWORD and NOSUGGEST flags too
Review follow-up: the directives that name one flag read the same emoji
form, so they now skip the variation selector like PFX/SFX and stem flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:21:24 -05:00
Dylan M. Taylor 955d268dc9 ci: pin GitHub Actions to full commit SHA (SonarCloud githubactions:S7637)
Pin third-party actions to full-length commit SHAs (with version comments)
instead of mutable tags/branches to prevent supply-chain tampering via
retagged releases. Resolves SonarCloud rule githubactions:S7637.
2026-10-21 17:04:23 -04:00
Divyanshu Gupta f996654264 fix(engine): do not treat decimal points as sentence boundaries in Sentence case
In crates/engine/src/cmd/format.rs, convert_chars treated any period as a sentence boundary, causing numbers with decimal points (e.g. 3.14, 2.5) to capitalize the subsequent word in Sentence case.

Check if a period is surrounded by ASCII digits, and do not trigger uppercase capitalization when encountering a decimal point.

Closes #3485
2026-10-22 01:56:09 +05:30
Divyanshu Gupta 4f924ca518 fix(engine): allow leading underscore for hidden bookmarks in name validation 2026-10-22 01:49:33 +05:30
Divyanshu Gupta c5de19a287 fix(engine, layout): invalidate layout cache on filename change and unify untitled fallback 2026-10-22 01:01:54 +05:30
Divyanshu Gupta 05827d20fc fix(engine): validate bookmark names by character count and character classes
In bookmark_name_ok, validate length by Unicode character count (at most 40 characters) rather than byte count, require an initial alphabetic letter, and restrict characters to alphanumeric and underscore.

Closes #3491
2026-10-22 00:12:31 +05:30
vangel76andClaude Sonnet 5.5 d8f459e70c Equation arrays: a row without & wider than the aligned columns widens the array and never goes left of the origin
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:38:19 +02:00
Divyanshu Gupta 084b76d9de fix(engine): preserve existing table cell margins when side is omitted
In table.cellMargins, resolve existing margins from the table, its style, or defaults and only overwrite the sides explicitly provided in the command parameters.

Closes #3490
2026-10-22 00:04:20 +05:30
vangel76andClaude Sonnet 5.5 be26db3545 Restore ROADMAP.md; Romanian Undo reads Anulare acțiune
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:26:36 +02:00
Divyanshu Gupta 034e083f58 test(ui): restrict AltGr Ctrl+Alt+Q regression test to non-macOS platforms
On macOS, AltGr does not produce Ctrl+Alt and Command-Q is the native application close chord. Restrict the Windows/Linux AltGr Q regression test to non-macOS platforms as requested by review, preserving the existing macOS Command-Q close test coverage (#3424).
2026-10-21 23:34:15 +05:30
SlavenDj 6c26714802 Pad hh on the 12-hour date clock (#3487)
Word's lowercase hh instruction shows a leading zero, just like HH.
Single-digit 12-hour values now format as 05 rather than 5.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-21 20:01:04 +02:00
SlavenDj fb588fcd46 Accept uppercase D and Y in date pictures (#3486)
Word's date-time instructions accept either case for day and year.
Uppercase codes now format instead of being copied literally.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-21 19:56:50 +02:00
OPUZZLES 5886e12ac1 fix(engine): respect list levels and inherited styles (#3459) 2026-10-21 19:40:59 +02:00
dmim2502-commits fd91a78e71 contributors: add dmim2502-commits (display name Mulai) 2026-10-21 12:27:16 -05:00
dmim2502-commitsandClaude Opus 5.5 6328b25209 proof: emoji flags followed by U+FE0F load (RLA-ES Spanish)
RLA-ES, the Spanish Hunspell dictionaries in LibreOffice and most Linux
distributions, writes one flag as U+260E followed by U+FE0F, in the rules
(PFX ☎️ Y 3) and on stems (banco/S☎️). parse_aff rejected it as "not one
character", so the whole dictionary failed to load. The variation selector
is now dropped wherever flags are read, with a regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:27:06 -05:00
OPUZZLES f5d3ea818c fix(engine): toggle off style-inherited lists (#3459) 2026-10-21 19:08:16 +02:00
Sazid Al Bayazid a3a573cdd6 UI: the ribbon's font and size boxes say what they are to a screen reader (#3172)
The font name box and the font size box announced themselves as bare "edit"
controls, and the dropdown arrows beside them as bare "button" controls: a screen
reader had nothing to say about them.

The combo helper names its field with the hint it already shows, and its arrow
with the same "{name} options" the split buttons' arrow uses; the split button's
arrow now says whose options it opens. No new strings: every name is one the
widget already shows.

Measured the way #3172 measured it — every keyboard-focusable element of the
Home tab and its name: 43 of the 111 had no name before, 31 after.
2026-10-21 22:42:21 +06:00
Divyanshu Gupta 93dc6000f9 fix(engine, layout): evaluate FILENAME field and support \p switch
Fixes #3449
2026-10-21 22:10:54 +05:30
Divyanshu Gupta e9c0def9bf fix(ui): preserve Ctrl+Alt+Q and allow typing @ on German layout
Ensure Ctrl+Alt+Q (German shortcut for @, or AltGr+Q) is not treated as a command shortcut or default quit shortcut, preventing unexpected app closure and allowing the @ symbol to be typed.

Fixes #3424
2026-10-21 21:12:02 +05:30
Mat Strangeandopencode 63e794716b Pin the parts of the package too
The body of the document part is pinned, so the writer's output is compared
byte for byte; the parts of the package are pinned as well, so a part that is
added or lost shows up here too.

Co-Authored-By: opencode <noreply@opencode.ai>
2026-10-21 16:15:46 +01:00
Mat Strangeandopencode 658e13e9ca Pin the document body the writer writes, byte for byte
The test that the controls are written where they were counted the controls
and looked at the paragraphs they were in, so a change in the order or the
place of a control's markers inside a paragraph would have passed it.

The writer's output for a document with two inline controls in one paragraph
and a control around a paragraph is now pinned: the body of the document part,
byte for byte. The golden was taken from the writer before it was made faster,
so it says that the faster writer writes the same thing. It has teeth: a
writer that returns a paragraph's markers in the opposite order fails it.

Co-Authored-By: opencode <noreply@opencode.ai>
2026-10-21 16:06:16 +01:00
Divyanshu Gupta a8ba49e68b fix(docx): preserve East Asian language w:eastAsia on w:lang
Preserve w:lang/@w:eastAsia when reading and writing DOCX documents.
Previously, only w:val and w:bidi were handled, causing w:eastAsia to be
dropped on save.

Fixes #3457
2026-10-21 20:13:15 +05:30
Divyanshu Gupta bfb81b72eb fix(sheet/xls): track substream depth and support CONTINUE records on formula strings 2026-10-21 20:00:37 +05:30
Bortlesboat f6c69172cc contributors: add my name 2026-10-21 10:15:12 -04:00
Dominik StrebelandClaude Opus 5.5 318e7cc315 Layout: superscript and subscript lines are as tall as full-size text
Word sizes a superscript or subscript's line at the full font size and
leaves out the vertical alignment's raise or drop; a w:position raise or
lowering still grows the line. WordCraft added the drop to the line's
descent and used the shrunk size, so lines with a subscript came out taller
and lines of sub/superscript alone shorter than Word's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 16:14:45 +02:00
Midasdf 1678d93f54 #1681: ruby review follow-ups — tracked ODT ruby, HTML start space, docs
- ODT export reads the tracked insertion/deletion from a Ruby inline's formatting
  too (Item::marks and the paragraph's tracked check): an inserted or deleted
  phonetic guide keeps its change range around the base text instead of being
  written as untracked text.
- HTML export computes the paragraph-start state before the ruby's formatting tags,
  so a leading space in the base text becomes &nbsp; instead of a space HTML can
  collapse.
- docs/file-format-parity.md: restore main's format rows (the PR had replaced them
  with stale copies that lost ODT keep-with-next #2813, TXT #2853 and the HTML
  blockquote/MathML details) and add the ruby sentence to the retained .htm/.html
  row; restore the rolled-back header change list.
- docs/localization-parity.md: restore the header change list; date the new
  revision-history rows 2026-10-21.
- Tests: html_ruby_at_the_paragraph_start_keeps_a_leading_space and
  an_inserted_ruby_keeps_its_tracked_insertion.

Validation: cargo fmt --all -- --check; cargo clippy -p wordcraft-formats
--all-targets -- -D warnings; cargo test -p wordcraft-formats -> 204 passed,
0 failed (doctests 0).
2026-10-21 22:41:05 +09:00
Midasdf cc724045dd #1653: review follow-ups — BOM, streamed runs, consolidated docs
- Create the East Asian decoder without BOM handling: the code page is named by
  \ansicpg, \cpg or \fcharset, so a GBK/Big5/UHC run that starts EF BB BF is text,
  not a UTF-8 BOM that switches the rest of the document to UTF-8 (#2549 review).
- Decode raw 8-bit runs through the streaming decoder in one pass instead of one
  byte at a time: ASCII body text in a Japanese/Chinese/Korean document and picture
  hex skip the per-byte path, and a single \'hh byte decodes into a stack buffer
  (no String allocation per byte). Picture hex no longer goes through the code page
  decoder at all.
- Decode loop: decode_to_string writes only into spare capacity, so drain the stack
  buffer and keep decoding until the run is consumed (the old one-call version could
  truncate a run longer than the reserved capacity).
- docs/file-format-parity.md: keep main's format rows (the PR had replaced them with
  stale copies that lost ODT keep-with-next #2813, TXT #2853 and the HTML blockquote
  and MathML details), merge the East Asian sentence into the one RTF row — which also
  removes the duplicate RTF row main carries and restores "export writes every
  section" and the tracked paragraph-mark wording — restore the rolled-back header
  change list, and date the new revision row 2026-10-21.

Validation: cargo fmt --all -- --check; cargo clippy -p wordcraft-formats -p wordcraft-doc
--all-targets -- -D warnings; cargo test -p wordcraft-formats -p wordcraft-doc
→ 200 + 174 passed, 0 failed (doctests 0).
2026-10-21 22:38:07 +09:00
Bortlesboat 51c1847b61 Navigation pane: numbered headings show their list number (#3418) 2026-10-21 09:29:37 -04:00
Divyanshu Gupta 22ae9e8961 fix(docbin): preserve external URLs with sublocations in doc hyperlink fields 2026-10-21 18:59:30 +05:30
Divyanshu Gupta b333885228 fix(formats): support 4/8-digit hex, space-separated rgb and hsl in parse_color 2026-10-21 18:52:08 +05:30
Divyanshu Gupta c60d9df0c3 fix(formats): support table cell vertical alignment in rtf, html, and odt 2026-10-21 18:35:08 +05:30
SofiaAFigueredoandClaude Sonnet 5 56f56f4735 Test: a unique scratch dir, not a reused pid
CodeRabbit catch on PR #3434: a_failed_save_leaves_the_save_stamps_untouched
named its scratch directory by pid alone and removed it on a pre-existing
path, which (if that path somehow already existed) could delete content the
test never created. Matching the pid+nanos pattern already used elsewhere
(backstage.rs's recovery tests): the name is unique enough that there's
nothing to pre-clean, and `dir` itself stays uncreated, which is what makes
the write fail.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-21 09:54:12 -03:00
SofiaAFigueredoandClaude Sonnet 5 6b66f88f7c file.save: don't stamp the document when the write fails
CodeRabbit catch on PR #3434: stamp_save() ran before the fallible
save_path_with(), so a failed write (no permission, disk full, a bad
path) still bumped the document's modified time, revision, author and
zoom, as if it had actually been saved. A later successful save would
then persist those stale stamps for a write that never happened.

Snapshot the stamped fields before stamp_save() and restore them if
save_path_with() errors, before propagating the failure.

Added a_failed_save_leaves_the_save_stamps_untouched in
crates/engine/src/tests.rs. cargo test -p wordcraft-engine --lib: 789
passed. clippy -p wordcraft-engine --lib --tests -D warnings: clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-21 09:39:54 -03:00
Gabriel Matozo 37809cf380 List labels: a right-to-left label mirrors its brackets (#2916)
A list label is shaped with `Dir::Guess`, so a label of digits and brackets has
no run the shaper sees as right to left; the clusters were then reordered for
the right-to-left paragraph and the brackets drawn unmirrored, so "(%1)" came
out ")1(", as #2916 reports.

The label is shaped with the bidi levels of its own text now, as the paragraph
body and the atomic objects already are, so UAX #9 L4 mirrors the brackets of
the runs the levels report as right to left; the visual reorder the paragraph
direction asks for stays. A "1." label is unchanged.
2026-10-21 09:33:05 -03:00
SofiaAFigueredoandClaude Sonnet 5 83771b147e Test: a failed AutoSave-switch Save As stays silent-free too
CodeRabbit nitpick on PR #3434: the AutoSave switch's own Save As
(chrome.rs, clicked while blocked on Unsaved) runs the same
AfterPick::SaveAs arm under AfterSave::AutoSave instead of
AfterSave::Nothing, and had no coverage of its own — a regression
removing fail_save_as from that arm would have passed the suite.

Added a_failed_autosave_switch_save_as_says_so_and_leaves_autosave_off,
asserting the failure reaches status_msg and AutoSave stays off
(autosaves(), not the raw autosave flag, which defaults to true and
isn't by itself what governs coverage).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-21 09:20:13 -03:00
Mat Strangeandopencode 3944664035 Record the content-control save fix in the progress documents
The repository's convention is that landed work updates the gap document, the
parity document and the progress log, with each touched document's status line
and revision history bumped. This does that for the fix in the commit before:

- docs/file-format-parity.md: a section says what saving many content
  controls costs, with the measurement before and after (32,000 controls:
  3.6 s -> 0.1 s), and names the tests.
- docs/gaps.md: gap 20 (performance on large real-world documents) is
  narrowed by it.
- ROADMAP.md: the progress log and its revision history gain the entry.

Fixes #3437.

Co-Authored-By: opencode <noreply@opencode.ai>
2026-10-21 13:12:40 +01:00
Mat Strangeandopencode 7f77660e03 A document with many content controls saves in the time they take
Saving a document with many content controls was quadratic in the number of
controls: 32,000 of them took four seconds to save, and doubling that took
four times as long again.

Two loops in the writer cost that. The plan that decides where each control
is written collects the inline markers of every control in one set, and then,
for each paragraph, looks for that paragraph's markers by filtering the whole
set — so one paragraph's answer costs the size of the document's marker set,
and the save costs paragraphs times markers. And to find the block-level
controls that clash, every control was compared with every other control.

The markers are now grouped by paragraph once, so a paragraph's markers are a
lookup, and the block-level controls are grouped by the block they end in,
each group keeping the two earliest closes, which answers the clash question
the same way in one sweep.

What is written does not change: the packages written for ten shapes of
control nesting — inline, around paragraphs, chained, nested — are byte for
byte the same as before.

Tests: crates/docx/tests/content_control_save_cost.rs. The two timing tests
fail without the fix (16.1x and 13.0x the time for four times the controls,
against a bound of 8.0) and pass with it (4.6x).

Fixes #3437.

Co-Authored-By: opencode <noreply@opencode.ai>
2026-10-21 13:12:36 +01:00
SofiaAFigueredoandClaude Sonnet 5 c329a3be0e Show a failed Save As on the Save As page itself
CodeRabbit caught it on PR #3434: self.status(...) alone is invisible
while Backstage is open, since its render loop skips the status bar
(only backstage::show runs). A failed Save As (no permission, disk
full, a bad path) left the Save As page sitting there with a Browse…
button and no explanation, same silent failure as before, just moved.

fail_save_as now also stores the message in the egui temp data
save_as_page reads and renders inline, cleared once File closes or a
later attempt actually saves. Replaced the status_msg-only regression
test with one that drives the real UI (Save > Save As page > Browse…
> a write that fails) and asserts the same text is rendered on the
page, not just sitting in status_msg.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-21 08:58:21 -03:00
SofiaAFigueredoandClaude Sonnet 5 85c72c2862 Save As no longer swallows a failed save silently
Backstage's File > Save (unnamed document) > Save As page > Browse…
flow, and AutoSave's own Save As, discarded the write error when
file.save failed (no permission, disk full, a bad path): the page
just sat there with nothing to explain why, and the document stayed
unsaved with no visible cause. The Save Changes prompt's own Save As
already surfaced its error; this brings the same handling to the
plain Save As path.

Added a regression test for the silent failure, and one exercising
the full Backstage Save > Save As page > Browse… flow with an
asynchronous native dialog answer end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-21 08:31:13 -03:00
Nghiathan13andClaude Opus 5.5 67d21b0a68 #3415: banded rows shade the same rows in Word and WordCraft
Word reads a table style without w:tblStyleRowBandSize / w:tblStyleColBandSize
as bands of zero and leaves its banded rows and columns unshaded (MS-OE376
§2.7.5.7, MS-OI29500 §17.7.6.5); ECMA-376 says one, which is what we did. Our
writer only wrote a size a style set, and our built-in styles set none, so a
table in Grid Table 4 saved by WordCraft had shaded rows here and none in Word.

- An unset or zero band size now means no bands, as in Word. PartSet::of_cell
  takes row and column band sizes; column bands get a size of their own
  (col_band_size).
- Our banded built-in styles and new table styles set both sizes to one, and
  the writer writes them (and zero), so Word bands them too.
- The reader reads both sizes, zero included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:21:49 +07:00
rabisandClaude Opus 5.5 bfb9ecf948 #1899: a picture's wrap outline is placed where its crop window is drawn
The renderers draw a crop window whose edges fall within pixels (crop
fractions are f32 too: 0.7 is 0.69999...). The outline stretched the
whole pixels it covered over the frame instead, so a narrow window came
out up to a pixel too wide at each edge (a 2.5-5.5 px window put pixel 2
over a quarter of the picture, not a sixth), and a crop of 0.7 took in
the neighbouring column. Grid points now map through the window itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:11:12 +02:00
SofiaAFigueredoandClaude Sonnet 5 52fab678b6 Scope the File page's back-arrow hit area to the chevron
On Windows/Linux, the back arrow in the File (Backstage) page's side
panel was clickable across the whole 200px-wide row, not just the
chevron icon drawn at its left edge. Clicking anywhere in that row —
plain sidebar background to a user's eye — closed the File page the
same as clicking the arrow itself, with no visual cue it was
clickable.

Scope the click region to a narrow band around the chevron, the same
width already used for the macOS chevron fix (#2972). The row still
reserves its full 200x40 space for layout; only the interactive rect
shrinks.

Updated two tests that relied on the old oversized hit box to click
at the chevron's actual position, and added a regression test
asserting a click beside the arrow leaves the File page open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-21 07:44:04 -03:00
Dominik StrebelandClaude Opus 5.5 aa8ca4bf1c Equations: double-clicks on the equation frame's grip and strip stay in the equation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:28:15 +02:00
Nikolai Nyegaard ea61d1fcb8 Options: list the languages alphabetically by their own name
- English (and Auto where offered) stays first as the source language and fallback; the rest sort by native name with accents folded, so Čeština is under C and Magyar under M; Cyrillic and CJK names follow the Latin ones
- The list used the registry order, which is the order the languages were added
2026-10-21 12:01:39 +02:00
Dominik StrebelandClaude Opus 5.5 cb2072bfe7 Equations: the equation being edited is framed as in Word
A thin outline as wide as the equation and as tall as its line, a grip over
its top-left corner and an options strip inside its right edge that opens the
equation menu; the argument the caret is in is shaded. Replaces the faint grey
shading around the equation alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:33:15 +02:00
Marco Arnulfo 5124ee6d76 DOCX: hand Word's TOC control to the TOC when the field ends in a paragraph of its own (#2427)
Word lays out an automatic table as its control around a TOCHeading, the entries with the field
opening in the first, and a last paragraph holding only the field's end. That paragraph isn't a
TOC entry, so the TOC was kept in its control with the markers in the body; Update Table then left
the heading out of the TOC and wrote a second control around the entries alone. The reader checks
that TOC with the end moved into the last entry and hands it over as the range read, as before.
2026-10-21 11:25:28 +02:00
Marco Arnulfo 35fdb45156 DOCX: hand WordCraft's own TOC control back to the TOC, keep it when a block control covers the heading (#2427)
A TOC whose heading isn't a TOCHeading is written with an empty heading paragraph inside the
control. Keeping the control around it on reading left that paragraph non-empty, so each save and
Update Table added a blank line (toc_menu_offers_update_field_after_save_and_reopen, #2182). The
reader now compares against the TOC the writer would actually wrap (written_toc_span), which also
keeps the original control when another block control covers the TOC heading.
2026-10-21 10:59:52 +02:00
Marco Arnulfo 07a9880c14 DOCX: keep Word's Table of Contents content control around a TOC the writer doesn't rebuild (fixes #2427) 2026-10-21 10:24:17 +02:00
Fin Warman cd3944c36e M2.1: check text-shadow geometry with translated masks 2026-10-21 09:06:00 +01:00
Nghiathan13andClaude Opus 5.5 a74b2cb8cf #2959: merge main into codex/2959-context-mini-toolbar
Doc conflicts (ROADMAP.md, docs/gaps.md, docs/ui-parity.md) keep both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:33:01 +07:00
Nghiathan13andClaude Opus 5.5 3b6040ee39 #2959: the context menu panel keeps its own width under the mini toolbar
The toolbar and the command panel share one popup whose justified layout stretched the panel
to the toolbar's width. The panel now takes the width egui measures in the popup's sizing
pass, so it matches the menu shown without the toolbar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 14:28:34 +07:00
FaabyyandClaude 7b19cd02d2 ODT: note table column widths in the progress docs
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 06:42:25 +00:00
FaabyyandClaude 58fda7ee21 ODT: table column widths are read, so a resized column survives a save
The importer ignored table:table-column and style:column-width and gave every
table equal columns, so widths set in LibreOffice (or changed in WordCraft and
exported) were lost on the next open.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 06:41:32 +00:00
Diego Guerini 05b1c5a2e3 #2845 follow-up: clippy passes on Windows and Linux again (#3006)
`graphics.rs` allowed dead code only on macOS (where the Windows items are unused). Since #2845
the two indirect-validation functions are called only on macOS, so on Windows and Linux
`cargo clippy --workspace --all-targets -- -D warnings` stopped with "function is never used".
They now carry `allow(dead_code)` off macOS.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 18c7c1d114)
2026-10-21 13:31:39 +07:00
Fin Warman 5f5a12bf78 M2.1: pin font inputs in geometric layout tests 2026-10-21 07:14:51 +01:00
SlavenDj 67eb71facb #3370: the AutoSave text is part of the switch
Clicking the AutoSave label in the title bar did nothing; only the 30x16 switch
responded. The label was also selectable chrome: double-clicking it highlighted
"AutoSave" and Cmd/Ctrl+C copied it.

The label is now a clickable, non-selectable part of the switch, with the same
hover tooltip and pointing-hand cursor, and clicking either toggles AutoSave.
Regression test: clicking the label turns AutoSave off and on again.
2026-10-21 05:16:14 +02:00
SlavenDj 23c47b5c6f #3368: right-clicking a link hides the hover tooltip
The link tooltip (URL + "Ctrl/⌘+Click to follow link") stayed open when the
context menu opened. Tooltips draw above menus, so the URL box covered the
menu's first items (Cut, Copy, Paste, Font…) and the menu looked truncated.

The tooltip now stands down while the context menu is open, as the mini
toolbar already does, and while the secondary button is opening it.
Regression test: hover a link (tooltip shown), right-click, assert the menu
is open and the tooltip is gone.
2026-10-21 04:46:57 +02:00
MichaelandClaude Sonnet 5 dff6221f4c Don't let list AutoFormat override a style-linked heading number
CodeRabbit review: list_autoformat()'s numbering guard only checked
para.props.numbering (direct formatting). A heading numbered through
its style's linked Multilevel List preset (#1793) carries no direct
numbering at all — it's resolved from the style — so the guard missed
it, and typing "- " at the start of such a heading replaced its real
number with a fresh bullet instead of staying as plain text.

Fix: check the paragraph's resolved numbering (direct formatting over
the style chain), which reflects the list a heading actually has
whichever way it got it.

Reverse-verified: reverting just this guard reproduces the corruption
(the heading's "1.1" becomes "-"); passes with the fix restored. Full
engine suite (790 tests) and cargo fmt/clippy clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-21 09:07:18 +08:00
MichaelandClaude Sonnet 5 9603c68905 List AutoFormat triggers in front of already-written text (#3352)
"- ", "1. " and the other list triggers only turned into a bullet or
numbered item when they were the paragraph's entire content (caret at
the paragraph's end). Typing one at the start of a paragraph that
already has more text after the caret — the common case of going back
to bulletize an existing line — left it as literal text instead.

Checked in Word for Windows: it triggers on the paragraph's first
characters regardless of what follows, keeping the rest of the text
after the new list marker.

Fix: match the pattern against the text up to the caret (not the whole
paragraph), and strip only that matched prefix, not the paragraph's
entire text.

Regression test reverse-verified: fails with the old whole-paragraph
check reinstated (the old behavior literally reproduces the report —
"- " stays as typed text instead of becoming a bullet), passes with the
fix. Full cargo xtask ci passes except the pre-existing, machine-
specific a_thai_run_with_latin_falls_back_to_one_face failure (also
fails identically on a clean upstream/main checkout).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-21 08:55:55 +08:00
NetterSilverandClaude Haiku 5.5 77b6ca0e2a Keep '&' in LaTeX \href and \url addresses on import
Found while testing crates/formats/src/latex.rs:2526-2545 (raw_text)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 00:28:35 +00:00
MichaelandClaude Sonnet 5 1c90d8bb4b Add regression coverage for replying to a comment via its balloon (#3268)
No production code changed: the reported symptom (a typed reply
disappearing instead of posting) doesn't reproduce on current main.
Checked out the exact UI path the report describes (click a balloon's
Reply button, type, click away) via a kittest harness, and it correctly
posts the reply through review.reply.

The fix was apparently already in place — CommentDrafts/commit_reply
(crates/ui-egui/src/panes.rs) land the draft on focus loss, and
deselect_balloon (crates/ui-egui/src/canvas.rs) covers it again when the
balloon itself is deselected — but nothing exercised this specific
balloon-Reply-button path before, unlike comment editing which already
has coverage (a_refused_comment_edit_keeps_the_draft and others).

Reverse-verified: temporarily disabling both commit_reply call sites
reproduces the exact reported symptom (the reply silently never posts,
0 replies instead of 1) and fails this test with that change in place,
confirmed, then restored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-21 08:22:38 +08:00
MichaelandClaude Sonnet 5 e2c9c26dfd Spelling & Grammar button now shows suggestions (#3340)
review.spelling (the Review tab's Spelling & Grammar button and the Home
tab's Editor button) only moved the selection to the next issue, with no
way to see or apply a suggestion — just the generic selection toolbar, so
it looked broken.

Running it now also opens the same suggestions menu Shift+F10 and a
Control-click already open for the word at the caret, via the
WordApp::run() chokepoint (not the ribbon call sites: keytips call
app.run() directly, bypassing a button's own .clicked() check).

Exposing this path also surfaced two latent i18n-audit gaps: the
suggestions popup's dictionary/grammar-rule words aren't interface text
(added an Exempt case, as the audit already does for font names and the
user dictionary), and "Possible spelling mistake" was missing from the
ru/tr/bg catalogs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-21 06:55:31 +08:00
Fin Warman e8b41d157f M2.1: retain textbox parents and picture-fill fallback 2026-10-20 22:41:28 +01:00
ratkoandClaude Opus 5.5 21ceb636f4 #3341: the scrollbar page tip stays on one line
The tip shown while dragging the vertical scrollbar (#2551) broke into two
or three lines ("Page 10 of" / "40") as the page number grew: the egui area
keeps last frame's width and the label wrapped to it, narrowing the tip
each time. The label no longer wraps.

Tested by the developer: the tip stays on one line in the app.

Fixes #3341.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:30:17 +02:00
Francesco Urbani afbfa43351 doc: a start override restarts only its own level (ECMA-376 17.9.27)
A num's startOverride for a level applies when that level of that num is first used, and only
there: applying all of them on the num's first use reset the shared counters' other levels and
renumbered every following chapter.
2026-10-20 22:56:04 +02:00
Mex bf9571d7e1 Merge branch 'main' into fix/reject-invalid-comment-ids 2026-10-20 22:41:14 +02:00
ratkoandClaude Opus 5.5 15d5617e70 #3331: closing the header or footer returns to the body selection
Close Header and Footer (Esc, the ribbon button, MCP) always put the caret
at the start of the document, so the view jumped to page 1. The session now
remembers the body selection when a command moves the caret into a header
or footer, and closing puts it back, as Word does. A double-click on the
body still puts the caret where clicked.

Fixes #3331.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:11:38 +02:00
Mex 1b712863c1 M15: document Cuban and Peruvian decimal separator coverage
Record the regression evidence, preserve broader localization readiness, and update the touched parity document status and history.
2026-10-20 22:02:37 +02:00
Mex 642bf70594 docs: note corrected Cuban and Peruvian table numbers
Record the narrow regional separator correction without changing localization readiness or effort estimates.
2026-10-20 21:49:28 +02:00
Mex d72b5eb2a2 docs: record Cuban and Peruvian number separator fix
Update the progress date, preserve the current status, and add the narrow locale-number correction to the revision history.
2026-10-20 21:48:16 +02:00
Mex e717ae4dc5 fix(doc): use decimal points for Cuban and Peruvian Spanish
Add the two missing regional exceptions and regression coverage for parsing, grouped numbers, formatting, and Spain controls.
2026-10-20 21:44:52 +02:00
Dhruv d51f011a55 Cross-references are links: Ctrl+click goes to the target (#2945) 2026-10-20 13:19:53 -06:00
NetterSilverandClaude Haiku 5.5 c2fa8763e3 Translate the spooled sheet count in print confirmations
Fixes #3317

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 21:14:01 +02:00
Alex Wine f4299ff644 wasm: use the browser clock only on wasm32-unknown-unknown, not on WASI
`now_ms` in layout, `now_unix` in engine, formats and pdf gated
`js_sys::Date::now()` on `target_arch = "wasm32"`, which also matches
wasm32-wasip1 and wasm32-wasip1-threads. There js-sys has no host to call,
so it panics ("cannot call wasm-bindgen imported functions on non-wasm
targets"), and since `layout()` times itself with `now_ms`, every document
laid out on WASI aborts. `std::time` works on WASI, so these now use the
browser clock only on wasm32-unknown-unknown, the gate wordcraft-docx
already uses.
2026-10-20 12:11:57 -06:00
NetterSilverandClaude Haiku 5.5 31b36442d5 Require hex digits in percent_decode escapes
Found while testing crates/formats/src/model.rs:1215-1223 (percent_decode)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 19:51:24 +02:00
NetterSilverandClaude Haiku 5.5 dfb9e1a851 Reject signs and repeated '#' in Rgb::parse
Found while testing crates/doc/src/props.rs:49-56 (Rgb::parse)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 17:37:31 +00:00
LQRD 253fab8a81 #2882: restore the saved proofing default when a command is rejected
Roll back the new preference on final command rejection, including protected-region validation after handler success. Cover prior/default-free preferences, document and undo rollback, serialization and restart. Qualify interface-language fallback documentation and add missing function documentation.
2026-10-20 21:47:13 +05:00
lab 1207 6479743c78 Status bar: a long message is shortened so it stops before the Focus button (#2402)
The status bar painted the message in full, so a long one (the path of a file that isn't there,
followed by "The system cannot find the path specified. (os error 3)") ran over the Focus and
view buttons and was drawn on top of them.

The controls on the right are laid out first now, and the message is shortened to the room they
leave: `fit_text` (new, next to `text_width` in `theme.rs`) cuts the text to the width it has and
ends it with an ellipsis, keeping its beginning. The whole message is in the tooltip.

The chrome test asserts that a long message stops before the Focus button and that it is shorter
than the whole text; the theme test asserts the shortening itself.
2026-10-20 22:11:23 +05:30
NetterSilverandClaude Haiku 5.5 a31743eb6d Map Windows-1255 byte 0xCA to U+05BA
Found while testing crates/doc/src/encoding.rs:82 (CP1255 table, entry for byte 0xCA)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 18:06:51 +02:00
Hamza 172b34b62f Markdown import: a definition-shaped line is not always a definition
Taking the definitions out before the blocks are parsed read each line
without the structure around it, and two kinds of line were deleted that
the author wrote as text.

A link reference definition has to start a block (CommonMark 4.7: one
cannot interrupt a paragraph), so

    Status update
    [Warning]: unstable
    more text

came through as "Status update more text". The line is taken only after
a blank line, at the start of the document, after a heading or thematic
break, or after another definition. Anywhere else it stays and reads as
text, which is what it did before definitions were understood at all: a
missed definition shows the link's own text, while deleting a line that
was part of a paragraph loses it with nothing to show.

Fences opened and closed by their own rules, which were not the block
parser's, so a line that is code there was a definition here:

    ```
    ```rust
    [foo]: /bar
    ```

An info string means ```rust does not close a fence, but this pass
thought it did and deleted the next line from the code block. Four
spaces of indentation is code rather than a fence, on both sides, so an
indented ``` no longer swallows the definitions that follow it.

Both halves have a control in the test: without the block rule the
paragraph comes back as "Status update more text", and without the
fence rules the code block comes back as "```rust\nSee foo.".
2026-10-20 20:39:24 +05:00
rabisandClaude Opus 5.5 a03deeeb26 #2428: the caret table's handles show where it starts and ends, where the pointer handles are
A repeated header row or a row running on to the next page no longer puts them on every page,
and they come from the same geometry as the handles under the pointer, so they can be grabbed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:35:10 +02:00
LQRD ab68111717 M15: persist the default proofing language across restarts (#2882) 2026-10-20 20:07:09 +05:00
NetterSilverandClaude Haiku 5.5 5af42b2748 Fix table formula ROUND erroring on large numbers with many digits
Found while testing crates/engine/src/cmd/table_formula.rs:728-734

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 14:48:14 +00:00
LQRD 3d03c7cc7a M15: fix Russian ё ordering and decomposed Cyrillic collation 2026-10-20 19:07:31 +05:00
Diego GueriniandClaude Sonnet 5.5 1bd8d0b327 #3271: edit.replace and edit.replaceAll without text or an earlier search are an error
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 16:02:35 +02:00
Jayesh Prajapati fdc325fa89 #3003: Save to a NAS share mounted by GVFS keeps working when it refuses a mode change
A replaced document's temporary file takes the original's Unix mode before
it replaces it. A GVFS SMB mount (smb:// in the Linux file manager) refuses
every mode change with ENOTSUP, so every save of an existing file there
failed and the close prompt's Save never closed. A folder that keeps no
Unix modes has no mode to keep, so the save goes on; any other failure to
set the mode still stops it and keeps the original.
2026-10-20 19:24:45 +05:30
Jayesh Prajapati ee51f59602 #3176 review: recent documents with the same file name are told apart by their path
Two Recent entries ending in the same file name had the same accessible name,
so a screen reader couldn't say which one each button opens. A repeated file
name now adds the path painted under it; unique names are unchanged.
2026-10-20 19:04:11 +05:30
Jayesh Prajapati 950a7adaaa #3176: File › Home and New name each template and recent document for screen readers
Each template tile drew its thumbnail as an unnamed click area with the
caption as a separate label, and each Recent row was an unnamed click area,
so a screen reader found no button and no name to announce. The thumbnail
and caption are now one button named by the caption (painted as before, so
the page looks the same), and each Recent row is a button named by its file
name.
2026-10-20 18:52:52 +05:30
Hamza 62533d21a6 Markdown import: reference-style links
A reference link was not read at all. `[text][label]`, `[text][]` and
`[text]` all came through as literal text, and the `[label]: url`
definition line became a paragraph of its own in the document -- so the
markdown syntax was visible twice over.

Definitions are now taken out of the lines before parsing and collected
into a map, which is threaded to the inline parser beside the formatting
it already carries. The link scanner, which required a `(` after the
closing bracket, now also accepts the three reference forms and looks
their label up.

Labels match as CommonMark compares them (section 4.7): case-folded,
with every run of white space one space, so `[  REF1  ]` finds
`[ref1]`. The first definition of a label wins. A destination may be
wrapped in `<...>`, and a definition may carry a title in quotes or
parentheses, which is read and discarded.

An undefined label stays text, so an ordinary bracketed phrase is
unaffected. A definition inside a fenced code block is code and defines
nothing. A definition written across lines, which CommonMark allows, is
still text -- no worse than before, when none of them were read.

The other two constructs in #3238, task lists and footnotes, are not
touched here: the ticket bot asked for one problem per ticket, and these
are separable.

Part of #3238.
2026-10-20 17:42:20 +05:00
rabisandClaude Opus 5.5 37f28eedd0 #2428: the table handles show while the caret is in the table
As in Word, the caret's table shows its move handle past the top-left
corner and its resize handle past the bottom-right one wherever the
pointer is, not only while the pointer is over the table.
table_mouse::caret_handles gives them per page; the app paints them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:00:38 +02:00
NetterSilverandClaude Haiku 5.5 dc5e8cec41 Unstyle TOC entry links whose Hyperlink style has a localized id
Fixes #3239

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:59:41 +00:00
rabisandClaude Opus 5.5 8e799be97e #1899: wrap outlines and PDF bitmaps follow the same crop window
contour::picture_outline turned the crop into a pixel window on its own,
so a sliver or overlapping crop gave an empty window and Tight/Through
wrapping fell back to the frame while the renderers draw a 0.5% sliver.
It now uses wordcraft_metafile::crop_window, with the upper pixel edges
rounded up. A PDF test covers the bitmap and metafile paths of the export
(CodeRabbit on #3247).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:54:17 +02:00
MichaelandClaude Sonnet 5 b57871b6ad Update the dictionary and its in-memory origin under one lock
CodeRabbit (#3252): reload_dictionary() wrote DICT and DICT_IN_MEMORY
under two separate locks. If install_dictionary() ran between them,
the final state could hold an in-memory dictionary with the flag left
false, so installed_path() would report a same-named disk file
instead of None.

Combined both into one DictState struct behind a single RwLock in
both ru and cs, so install_dictionary() and reload_dictionary() each
update the dictionary and its origin in one critical section.

Didn't add a dedicated concurrency test: genuinely proving a race's
absence needs real multi-threaded stress testing, and the previous
commit on this PR already found that tests directly exercising
install_dictionary()/reload_dictionary() concurrency in this crate's
shared global state cause real, reproducible flakiness in unrelated
tests. The fix is correct by construction (one lock acquisition around
both field writes) and all 222 existing proof tests, run 15 times in
a row, stay green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 17:41:20 +08:00
MichaelandClaude Sonnet 5 30b30718e3 Include the document revision in the Find font cache key
CodeRabbit (#3257): the cache key (query, result count) didn't change
when an edit added Georgian text right beside an existing match
without touching the query or the (stale, not recomputed by typing
alone) result count — the Results list's context window would show
the new text, but ensure_find_fonts never looked at it again.

Added the document revision to the key, matching ensure_comment_fonts'
existing (document_id, rev) pattern for the same kind of problem.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 17:37:30 +08:00
luisvacaandClaude Opus 5.5 fffd48c885 #2289: the first-page header comes before the default header in comment order
With Different First Page on, a section's default header was listed and reached by Next
before its first-page header, so Next went to page 2 before page 1. A section's header and
footer versions now go in the order of the first page each one shows on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:33:15 -06:00
Mikael Tollefsen 51ef9b9ebc Docs: control protocol's Methods table renders again
The engine.execute row listed the UI commands with unescaped "|" inside
code spans ("system"|"on"|"off", true|false, ...). GitHub-flavoured
Markdown splits table cells on every unescaped pipe, code spans
included, so the row came out as 32 cells in a 3-column table and was
cut off. A second, older engine.execute row duplicated it with a
shorter command list.

The two rows are now one: the current row, with ui.contextMenu (only in
the older row) added. Every "|" inside its code spans is escaped as
"\|", which GitHub shows as "|". Every row of the file's tables now has
its header's number of cells. Nothing else changes.
2026-10-20 11:31:07 +02:00
MichaelandClaude Sonnet 5 de6a52cc01 installed_path() ignores a matching file while an in-memory dictionary is in use
CodeRabbit (#3252): installed_path() searched the filesystem
unconditionally, so a dictionary given directly with install_dictionary
(a user-picked one, or a test fixture) was reported as the file of a
same-named dictionary that happened to also be installed, even though
spelling was actually using the in-memory one, not that file.

Added a DICT_IN_MEMORY flag to both modules, set in install_dictionary
and cleared in reload_dictionary; installed_path() now returns None
immediately when it's set, before even searching the filesystem.

Tried adding a dedicated test that calls install_dictionary() and
asserts installed_path() is None. It made dictionaries_folder_is_
searched_first flaky (~15-35% of runs across repeated local testing):
ru's and cs's own test modules already call install_dictionary()
directly in several tests of their own, sharing the same process-wide
static, and dictionaries_folder_is_searched_first's exact-path
assertion for Russian/Czech could observe whichever one last touched
it. Reverted the dedicated test; the production fix alone never
touches that shared state (it's a pure read-only check) and caused no
flakiness by itself across 25 repeated runs. Instead, narrowed
dictionaries_folder_is_searched_first's Russian/Czech assertion to the
language only, not the exact path, since unlike the other four
languages, installed_path() for these two is now sensitive to
concurrently-running tests' legitimate in-memory installs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 17:31:06 +08:00
Магда М.Г. 7f2f45743a Layout: Apple's one-em Symbol takes Windows Symbol's line height, as in Word for Mac
Word for Mac spaces Symbol lines at 1.222 em (measured at 11 and 20 pt)
even with Apple's Symbol installed, whose own line is one em. Symbol's
metrics now apply when the resolved face is one em tall as well as when
the font is substituted (fonts::word::symbol_line).
2026-10-20 12:14:18 +03:00
MichaelandClaude Sonnet 5 c3ead8d2c2 Find box and Results list borrow a document font for Georgian etc. (#3248)
The Find box and the Navigation pane's Results list draw the typed
query and document excerpts around each match with the interface
faces (Inter, Source Sans 3), which cover Latin plus the embedded CJK
faces — nothing else. Georgian (and Armenian, Thai, …) text typed into
the search box, or found in the document and shown in a result's
context snippet, was drawn as boxes.

Added ensure_find_fonts(), following the exact pattern already used
for the window caption (#565) and comment balloons/the Comments pane:
scan the query and the visible results' surrounding text for
characters the interface faces can't draw, and borrow a document
font's fallback face for them. Cached by (query, result count) so it
only rescans when either changes.

Verified live: built the app, inserted Georgian text, searched for a
Georgian word, and confirmed both the search box (typed query) and
the Results list (match context) now render the glyphs instead of
boxes — matching the issue's screenshot, now fixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 17:12:49 +08:00
rabisandClaude Opus 5.5 fc5063c38d #1899: keep at least 0.5% of a cropped axis, so nothing shown is culled
With 0.1% the whole source of a 4000 pt frame spanned up to 4,000,000 pt,
past the ±1,000,000 pt limit Picture::place uses to leave out items far
off the page, so a metafile cropped that narrow lost what the crop shows.
At 0.5% it spans at most 800,000 pt (CodeRabbit on #3247).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:10:12 +02:00
MichaelandClaude Sonnet 5 9ea75bd921 Reload Russian and Czech dictionaries when the folder changes too
CodeRabbit (#3252): set_dictionaries_folder() reloaded every other
proofed language but not Russian or Czech, so after a folder change
installed_dictionaries() would report a path from the new folder
while spelling kept using the stale cached dictionary.

Also added Czech's test fixture to the shared dictionaries-folder
test's temp folder (the same content czech_proofing_through_the_
review_commands installs directly): since that folder-change test
now reloads Czech too, it was clearing a concurrently running Czech
test's installed dictionary with nothing to replace it, intermittently
emptying it mid-test. Same fixture content either way keeps the race
harmless, exactly as it already does for Polish/Portuguese/Indonesian.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 17:08:27 +08:00
Mikael Tollefsen 50a89f4fa5 #1549: a run fallback is cached under the face count it chose from
fallback_for_run cached its answer under the face count read after the
search. A face loaded in between by another thread (its own search, or a
family a document names) made a miss look current: later lookups at the
new count got the stale miss (review on #3078). The search now returns
the count of the loaded faces its last choice was made from, and the
answer is cached under that; anything loaded since makes the next lookup
search again. No lock is added, so cached lookups from parallel layout
stay contention-free.
2026-10-20 11:06:44 +02:00
MichaelandClaude Sonnet 5 16f36cdb5d Russian and Czech dictionaries are reported, not just checked (#3226)
installed_dictionaries() never included Russian or Czech at all,
because neither crates/proof/src/ru nor crates/proof/src/cs had an
installed_path() function for it to call (every other proofed
Hunspell language — Polish, Portuguese, Italian, Croatian, Turkish,
Indonesian — already did). File > Options > Proofing's dictionaries
list iterates installed_dictionaries() directly, so Russian and Czech
never showed up there at all, installed or not, matching the report.

Added installed_path() to both modules, mirroring the existing
pattern (crate::hunspell::find(NAMES).map(|(_, dic)| dic)), and added
both to installed_dictionaries().

The actual spell-checking dispatch (is_correct_in_with) already
routed Russian and Czech correctly — confirmed by installing the real
system ru_RU Hunspell dictionary and checking it directly
(ru::is_correct("молоко") true, ru::is_correct("малоко") false), so
that part of the pipeline isn't what's broken. The reporter's other
symptom (Russian text in the document not being checked at all) did
not reproduce with the real dictionary installed; I could not pin
that part down further without more specific repro details from
their setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 16:50:41 +08:00
NetterSilverandClaude Haiku 5.5 c55402452b Reject non-digit characters in numeric HTML entities
Found while testing crates/formats/src/html.rs:29-32 (decode_entity)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 08:43:48 +00:00
rabisandClaude Opus 5.5 0cca2f7d21 #1899: a picture cropped to a sliver is drawn at the size the file gives
A crop that kept under 5% of a picture's axis was widened to 5%, so a
header bar cropped to 2.7% of a page-sized WMF came out half as tall and
the text box over it fell below it. The rasteriser, the PDF writer and
the metafile placement now share wordcraft_metafile::crop_window, which
keeps down to 0.1% of an axis and keeps junk crops finite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:29:15 +02:00
MichaelandClaude Sonnet 5 ffeccc7148 Align test: compare Left/Center/Right pairwise, not just x > 0
CodeRabbit (#3246): x > 0.0 alone doesn't pin Align Right down, since
Align Center's x is positive too whenever the picture is narrower than
the text column — Right could silently dispatch Center and the test
would still pass. Capture Left's and Center's x as well and assert
left_x < center_x < right_x.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 16:20:38 +08:00
MichaelandClaude Sonnet 5 0a93c0edd4 Align opens a menu with Left, Center and Right, not just center (#3242)
The Picture Format / Shape Format Align button ran arrange.align with
no value, which defaults to "center" — there was no way to reach Left
or Right from the ribbon short of dragging the object manually. It now
opens a menu, like Word's own Align dropdown.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 16:14:32 +08:00
In MeanandClaude Sonnet 5.5 8f30acf112 Substitute a system Khmer font for a missing Khmer document font
A missing Khmer OS font drew in whichever loaded Khmer font covered the text, usually a
wider Khmer OS face, so lines wrapped and fixed-height text boxes hid their last line.
Word uses the system Khmer font; so do we now.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 15:00:33 +07:00
Diego GueriniandClaude Sonnet 5.5 ff77295db2 Merge upstream/main into altgr-keytips
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 09:54:15 +02:00
Mikael Tollefsen 835f5b5f5f Docs: layout-parity's status date follows its 2026-10-19 revision row 2026-10-20 09:40:49 +02:00
rabisandClaude Opus 5.5 79270d8314 #2038: the same for turned text in a vertical section
On a page of turned text (a vertical section) DocLayout::hit returned
through nearest_turned before the floating-table rule applied, so a click
outside a floating table there could still land in one of its cells.
nearest_turned now takes the same rule: the nearest turned line outside the
floating tables, else the nearest of any (as before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:28:50 +02:00
Mikael Tollefsen 43335e7c0e #1549: the run search goes past a collection and loads a family whole
Two holes in the whole-run fallback search (review on #3078):

- The coverage index keeps one coverage per file, so a font collection
  whose faces only together map a run matched it; the search loaded that
  family and stopped, returning no face even when a later font covered
  the run. It now goes on to the next file that maps the run.
- A single-character fallback may have loaded one file of a family (its
  Italic, say). The run search then treated the family as loaded and
  added only the file it reached first, so a regular run could be drawn
  in Bold although the Regular file was installed. It now loads whatever
  of the family isn't loaded yet.

Files loaded by family name are now recorded in the index too, so no
search loads a file a second time.

Tests: a split-coverage collection before a covering font; a family
whose Italic a character fallback loaded, reached at its Bold file first.
Both fail without the change (no face; Bold).
2026-10-20 09:11:35 +02:00
Mikael Tollefsen 8d16b26630 #1549: a Thai run with Latin finds one installed font covering it all
On Linux the run fallback loaded the script's preferred family (Noto Sans
Thai, which has no Latin letters) and stopped: every character had a face,
so an installed font mapping the whole run (IBM Plex Sans Thai) was never
tried, and a Thai + Latin run was split across two faces. Three Thai tests
failed on machines with Noto Sans Thai installed.

When no loaded face covers the run, the search now looks through the
coverage index for the first installed file mapping the whole run. It
loads that file's whole family, since path order can reach the Bold
file before the Regular. It also looks at the loaded faces again even
when it loaded nothing itself: a concurrent search may just have
loaded the font and taken its files off the index, and the miss would
otherwise be cached.

Regression test: a_run_no_preferred_face_covers_whole_loads_an_installed_font_that_does
(synthetic fonts, independent of what is installed).
2026-10-20 09:11:29 +02:00
Dominik StrebelandClaude Opus 5.5 714306fd31 Restrict Editing: forms protection greys out the content-control buttons
With Filling in forms enforced, Word greys out the Developer tab's
content-control buttons and Properties. WordCraft refused them when
clicked (#3112) but drew them live. The ribbon now asks the same check:
Session::why_disabled combines a command's own `enabled` with what
protection refuses outright, and the tooltip says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:57:11 +02:00
rabisandClaude Opus 5.5 fc36739622 #2038: a click below a floating table goes to the text, not its last row
A table dragged by its move handle floats and the empty paragraph after it
moves up beside it; a click below the table then landed in a cell of its last
row (the nearest line). Pages now list their floating body tables, and
DocLayout::hit leaves out a floating table's cell lines when the point is
outside the area its cells cover, as long as other lines are left. Checked
against Word 2019 for Windows: clicks below the table and Ctrl+End go to the
paragraph beside or after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:31:15 +02:00
luisvacaandClaude Opus 5.5 260014167d #2289: skip comments in header versions not in use and in linked text boxes' own text
A first-page or even-page header or footer is shown only while Different First Page or
Different Odd & Even is on, and a text box linked from another shows the first box's text
instead of its own. Comments in those stories were still listed and reached by Next and
Previous, and typing then went into text nobody sees. shown_stories now picks each
section's versions the way layout does and leaves out linked boxes' own text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:31:20 -06:00
Sazid Al Bayazid 862e7b212f Tests: a body row that fits under the header stays on that page (#1704)
The sweep pinned that a page never ends with header rows alone, but its
last check only asked whether *some* filler count put the header and a
body row together: a table that carried a body row over although the
page had room for it passed.

The check now measures the room left under the header on the first page
and requires the body row to be placed there whenever it fits, which is
the position the sweep could not see. The table is built once and read
two ways (rows per page, and the cells' frames).
2026-10-20 10:57:24 +06:00
midasdf a7f888055a #1681: HTML export and import keep furigana (ruby)
`InlineObject::Ruby` is written as `<ruby>base<rt>reading</rt></ruby>`, in the
base text's run formatting, and `<ruby>`/`<rb>`/`<rt>` are read back into one
ruby object, so docx/doc → HTML → doc keeps the annotation. `<rp>` fallback
parentheses and the tags are not text; unclosed or half-marked ruby (no `<rt>`,
empty `<rt>`, no base) is tolerated and keeps the text it held, with a block
boundary closing the element as a browser does. HTML states no alignment, size or
raise, so import uses the Phonetic Guide's defaults (centred, half the base size,
raised clear of the base text; the run's size, or 10.5 pt when none is stated).

The flow model gains `Inline::Ruby`; formats without ruby (RTF, ODT, Markdown,
LaTeX) write its base text as before. Tests: `html::tests::ruby_*` and
`tests::html_ruby_round_trip`. Docs: file-format-parity (HTML row) and
localization-parity (Japanese row).
2026-10-20 13:35:52 +09:00
midasdf 2d645dba03 #1653: RTF import decodes the East Asian multibyte code pages
`\ansicpg932`/`936`/`949`/`950`, a font's `\fcharset128`/`129`/`134`/`136`,
`\cpg932` and the OEM charset 255 were read one byte at a time, so Japanese,
Korean and Chinese text came out as mojibake. The reader now holds the 8-bit
text of a multibyte code page and decodes the run with encoding_rs (SHIFT_JIS,
GBK, EUC_KR/UHC, BIG5): a lead byte and its trail byte decode together, escaped
(`\'hh`) or raw. A code page switch (`\f`, `\fcharset`, `\cpg`, `\ansicpg`), a
group end and the end of the document flush what is pending; invalid sequences
read as U+FFFD, as Word writes them, and never lose the rest of the text. The
pending buffer is capped at 4096 bytes.

Single-byte code pages, `\uN?` escapes and their `\ucN` skips, the font table
and every existing test are unchanged. Tests cover Shift-JIS (escaped, raw and
through a `\fcharset128` font), CP949/CP936/CP950, a mixed Japanese/ASCII/
Cyrillic document whose code-page switch must flush, `\uN?` alongside
multibyte text, an invalid sequence and a Windows-1252 regression.
2026-10-20 13:35:52 +09:00
Sazid Al Bayazid 2c06a6b065 UI: the Full Screen button reports the command's own enabled state (#2864)
The button told assistive technology it was enabled whatever the command
could do, so the browser build -- which keeps full screen to itself and
says why -- announced a control that cannot be activated. It now reports
the command's own state, as every other ribbon control does.

Pinned by a test that makes the command unavailable the way the browser
does and checks the button is announced as disabled.
2026-10-20 10:28:56 +06:00
Магда М.Г. 66ca8745c0 Layout: a Symbol (PCL6) bullet drawn by an installed Symbol takes Symbol's own height
The fallback Symbol metrics applied whenever the raw family name wasn't
installed, so Symbol (PCL6) resolved to an installed Symbol still took
them. Use the resolution's substituted state instead.
2026-10-20 07:16:33 +03:00
PanSHIgg 91e4839060 Layout: bind the Format Page Numbers dialog to its section and keep starts to 100000 (#1113)
Review fixes: a section starting at 40,000 was lowered to 32,767 when the
dialog was opened and accepted (the engine takes starts to 100,000), and the
non-modal dialog kept writing the numbers read from the section it opened on
into whichever section the caret moved to (the form now follows the caret, as
the Size dialog follows its object). Tests cover both.
2026-10-20 12:04:42 +08:00
PanSHIgg d4192a0e98 Layout: Format Page Numbers dialog (#1113)
Insert › Header & Footer › Page Number ▸ Format Page Numbers… ran a
plain `layout.pageNumberFormat` call that changed nothing and cleared
the section's Start at. The item now opens Word's dialog: the number
format (1, 2, 3 / I, II, III / i, ii, iii / A, B, C / a, b, c) and
Page numbering — Continue from previous section or Start at: n.

`layout.pageNumberFormat` keeps the section's start when `start` is
left out and numbers on from the section before for `"start": null`;
the dialog reads the section at the caret and writes through the same
command, so scripts and MCP get the same result without it. The
dialog's strings are in every interface catalogue and it joins the
untranslated-text audit. ROADMAP, docs/ui-parity.md and docs/gaps.md
get their rows.

Tests: engine (format and start, keep and continue); ui-egui (the
dialog reads the section, OK sets the format and start, continue
clears it, and a plain command call keeps the start); the i18n audit
draws the dialog's text.
2026-10-20 12:04:42 +08:00
Cursor AgentandCRBBeesy 60e3ff8881 i18n: File › Home greeting in every interface language
`home_page` already passes "Good morning", "Good afternoon" and "Good evening"
through `tl!`, but nine catalogs had no entry, so File › Home opened in English
in German, Estonian, Japanese, Brazilian Portuguese, Serbian (Cyrillic and
Latin), Ukrainian and Chinese (Simplified and Traditional). Adds the three
greetings to each, and a test that every language has them.

Co-authored-by: CRBBeesy <CRBBeesy@users.noreply.github.com>
2026-10-20 03:57:48 +00:00
Sazid Al Bayazid 08d077d97a Tests: open the folded Window group to reach the Full Screen button (#2864)
The ribbon now shrinks its groups to fit the window (#662), and on the
View tab the Window group folds into a drop-down at 1440 pt: the Full
Screen button then lives in that menu, so the a11y query no longer finds
it and the test failed on the folded ribbon. Open the group when it is
folded, and click the button where it is.
2026-10-20 09:55:14 +06:00
MichaelandClaude Sonnet 5 a1d83853ec finish(): guard against a stale document identity and a prior job's error
Two real issues CodeRabbit caught in the first commit, both in
Recovery::finish:

- It didn't repeat tick's own guard against a stale self.id: if the
  session switched documents with no regular tick in between to
  notice and reset that identity, finish would discard the *previous*
  document's still-live recovery copy under the *new*, clean
  document's state. Added the same self.document == document_id()
  check tick already uses, and a test reproducing it (confirmed it
  fails without the guard).

- An in-flight job failing (e.g. a stray autosave) returned
  immediately via `?`, skipping the clean-document discard below it
  entirely — so a save that really did land cleanly right before exit
  could still leave a stale copy, for an unrelated reason. Keeps that
  error to return at the end instead, after still attempting the
  discard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 11:40:54 +08:00
MichaelandClaude Sonnet 5 2ce9518be8 Status bar names footnotes and endnotes, not just header/footer
The "editing a non-body story" status line only checked "not the
body, not a text box" and always said "Editing header/footer" —
including while editing a footnote or endnote, which isn't one.
Found while checking #3057 against current main (already fixed
elsewhere): a headless screenshot of editing a footnote showed this
label, which turned out to be an unrelated, still-live bug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 11:37:55 +08:00
MichaelandClaude Sonnet 5 f550ab7bea Wait for recovery cleanup before the app exits (#3140)
A save makes the document clean, which queues discarding this
session's now-redundant recovery copy — but that's only queued, on a
background worker thread, the same as a print job. Closing the app
right after Save (the very next frame, with no regular recovery_tick
in between to notice and queue the discard, or with the discard
queued but not yet finished by the worker) could race the process
exiting before the copy was actually removed, so the next launch
wrongly offered to recover a document that was in fact saved cleanly.

Added Recovery::finish and Session::finish_recovery, mirroring the
print spooler's own finish_pending_jobs, and call it from the same
places: on_exit and the macOS hard-quit path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 11:22:51 +08:00
MichaelandClaude Sonnet 5 ddfb47d861 Restore document focus when a successful print closes the Print page
Every other place that closes the backstage sets canvas.want_focus so
the document actually takes keyboard input back (open_recent and
others already do); this one didn't, so typing was ignored until the
user clicked the document even though the Print page was gone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 11:22:35 +08:00
Магда М.Г. 912441262e Layout tests: a Symbol bullet's line is the text's height plus its extra ascent 2026-10-20 06:11:47 +03:00
MichaelandClaude Sonnet 5 49204c5c8b Strengthen the print-returns-to-document test's failure assertions
Only checked that the Print page stayed open on a failed submission,
which could pass even if some other backstage page ended up showing
or the failure feedback itself wasn't retained. Also assert the Print
page specifically is still the one up, and that Feedback::Failed with
the actual error is still there to see.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 11:11:24 +08:00
MichaelandClaude Sonnet 5 579b2dc6ef A successful native print returns to the document (#3210)
poll_printing turned a terminal print message into a status-bar line
but never closed the Print backstage page, so it stayed open after a
successful submission (reported on macOS, where printing never shows
a separate OS dialog — WordCraft's own Print page is the whole flow)
until the user clicked Back, even though the job had already gone to
the spooler. Word returns to the document once the job is away, the
same as after Save.

A failed submission still leaves the Print page open, so the error
and the settings to fix it stay visible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 10:56:26 +08:00
Sazid Al Bayazid 213045230d Layout: a line can break after a hyphen before a digit, like Word (#2712) 2026-10-20 08:36:18 +06:00
Sazid Al Bayazid deea76838d Tests: a table that starts mid-page never leaves a repeated header row alone (#1704) 2026-10-20 08:33:51 +06:00
Sazid Al Bayazid 9992f47270 UI: the Full Screen button reports selected state, not enabled (#2864)
CodeRabbit review: WidgetInfo::labeled passed the fullscreen toggle state
as the enabled flag, so the button announced itself disabled while full
screen was off. A toggle reports its state through WidgetInfo::selected
(enabled, selected), like the other toggle-style button in the ribbon.
2026-10-20 08:31:16 +06:00
Sazid Al Bayazid 34f638ade1 UI: the Full Screen button leaves full screen on a second click (#2864)
The View tab's Full Screen button is a mode toggle, but it was drawn with
the one-shot big() helper: the fullscreen state was passed as the menu
flag, so while full screen was on the button swallowed its own clicks and
only Escape could leave. Draw it with big_toggle (checked state as
intended) and label it for assistive technology, pinned by a kittest
regression test that clicks the button in and out.
2026-10-20 08:31:15 +06:00
Магда М.Г. e6e007d018 Symbol (PCL6) keeps Symbol's line metrics; restore the STYLEREF row in layout-parity 2026-10-20 05:18:11 +03:00
MichaelandClaude Sonnet 5 25fb9b93f8 Use the rotated bounds for a floating picture's row-growing extent
rect.bottom() measures the unrotated frame; a rotated picture's actual
drawn bottom can extend further down (or less far), so use
Spin::bounds(rect).bottom() like wrap_area does for text wrapping.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 09:40:48 +08:00
MichaelandClaude Sonnet 5 1dd86b0d0a A floating picture in a table cell grows the row to hold it (#3108)
A row/cell's height was computed from the floating *tables* that grow it
(float_tables -> floats_bottom in crates/layout/src/lib.rs), but a
floating (anchored, non-inline wrap) picture or shape in a cell's
paragraph wasn't tracked the same way. The row sized to the cell's text
alone, so a picture taller than the text hung past the row's own bottom
edge and overlapped whatever came after the table — reproduced with the
reporter's own document and confirmed against their real Microsoft
Office 365 screenshot, which lays the same file out with the table
growing to fully contain the picture.

- crates/layout/src/lib.rs: layout_box now tracks the deepest point any
  floating (Wrap != Inline) picture or shape reaches in a box, the same
  way it already tracks float_tables, and folds it into floats_bottom.
- crates/layout/src/tests.rs:
  a_floating_picture_in_a_cell_grows_the_row_to_hold_it, mirroring the
  existing a_floating_table_in_a_cell_takes_no_room_and_the_cell_holds_it.

cargo xtask ci: fmt, clippy, assets, layers and wasm all pass. Full
workspace tests pass except the same 3 pre-existing, environment-only
Thai-font failures already noted on #3119 and #3184 (no
CRAFT_FONTS_DIR/TH Sarabun New in this build environment).

Closes #3108.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 09:23:17 +08:00
vangel76 7489ef9f8e SmartArt Text Pane: Tab demotes, Shift+Tab promotes the focused item (#2701)
Word's Text Pane demotes the focused item on Tab and promotes it on
Shift+Tab; our pane had only the Demote/Promote buttons, so Tab moved
the caret to the next row instead. WordApp::logic consumes Tab while a
Text Pane row is focused and runs smartArt.demote / smartArt.promote on
that item, keeping the row focused so the next Tab acts on it again. An
item that cannot go down a level is refused by the command, as the
button path already is.

A new flag records that a pane row has focus, so Tab from the canvas is
untouched. Regression test drives the harness: Tab on an item that
cannot demote changes nothing and keeps focus; Tab on one after a deeper
item demotes it and its sub-items, the same row takes the next Tab,
Shift+Tab promotes them back.
2026-10-20 01:33:43 +02:00
vangel76 9516e3977a M-parity: ROADMAP row for tracked table/section property changes 2026-10-20 01:26:35 +02:00
vangel76 12bb1039d1 Formatting revisions (gap 5, #41): a table, row, cell or section property change made while Track Changes is on now records as a tracked change (w:tblPrChange / w:trPrChange / w:tcPrChange / w:sectPrChange)
Word records formatting changes to table, row, cell and section properties while tracking; the model, the OOXML reading, the writing and Accept/Reject all existed — only the recorder compared characters and paragraphs. `Recorder.blocks` in `crates/engine/src/cmd/fmt_revisions.rs` now compares those properties too (structure stays out: a table's grid, a cell's merge span, a section's headers and footers), so Reject All puts them back and Accept All keeps them.

- regression test `tracked_table_changes_are_kept_as_formatting_changes` (fails without the recorder)
- docs/gaps.md gap 5 narrowed
2026-10-20 01:26:25 +02:00
rabis 9139162e0f #2802: Delete on whole table cells leaves one empty paragraph in each 2026-10-20 01:19:49 +02:00
Dominik StrebelandClaude Opus 5.5 9c3d39c597 DOCX: Punjabi in Pakistan takes the Arabic-script theme font
lang_script mapped every `pa` tag to Gurmukhi. Measured in Word for Mac:
with w:themeFontLang w:bidi="pa-PK" a majorBidi run takes the theme's
Arab font, with "pa-IN" its Guru font. The test matrix gains both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:19:14 +02:00
Dominik Strebel 1fa2533a52 DOCX: majorBidi/minorBidi theme fonts follow the theme font language 2026-10-20 01:19:14 +02:00
Viorel ToaderandClaude Sonnet 5.5 e375004cd6 G14: Romanian parity doc: entry range and citation-style note
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk
2026-10-20 01:13:28 +02:00
Viorel Toader 2d957c905b G14: Romanian (ro) interface catalog 2026-10-20 01:13:28 +02:00
Dominik Strebel 88d936c3c5 Layout: justified lines shrink their spaces by Word's measured limits 2026-10-20 01:11:36 +02:00
Joey 156817d6ba Word signature lines stay signable after saving a .docx 2026-10-20 01:10:22 +02:00
przemek-archandClaude Opus 5.5 5174973770 Go to Footer brings a new, empty footer into view
A caret in an empty story also "selects the whole story", the condition that keeps
the view still after Select All and drawing; so after Go to Footer into a footer just
made, the view stayed at the header. A collapsed caret is not a selection of the
story: the view follows it again.
Test: go_to_footer_into_an_empty_footer_scrolls_to_it (fails on main).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:04:46 +02:00
Matteo AndreuzzaandClaude Sonnet 5 05a3df36f2 #3099: Resolve a trailing slash on the formula object's href
CodeRabbit review on PR #3115: an xlink:href like "./Object 1/" (some
ODF producers write the object folder with a trailing slash) doubled
the slash before content.xml, so the zip lookup missed and the fix
silently fell back to the undecodable replacement image again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-20 00:56:59 +02:00
Matteo Andreuzza 59d9beaeec 3099: Read embedded ODT equations from their MathML object 2026-10-20 00:56:59 +02:00
rabisandClaude Opus 5.5 d1e7acfdf5 #2986: the focus test also takes the keyboard away with Tab
After Tab moves the keyboard to the dialog's next control, each box takes
it back with one input-method reset, then none, and the next key reaches it
(review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 00:51:55 +02:00
rabis fb92192fd2 2986: Search and three other boxes stop resetting the input method every frame 2026-10-20 00:51:55 +02:00
sanches 6f9b7a1803 da: translate the Insert Spike, Spike and Multilevel List Library entries
The Dutch reviewer's merge brought three new tab/command labels the
Danish catalog had not covered yet.
2026-10-20 00:30:28 +03:00
ratkoandClaude Opus 5.5 06a120b19f Drop a picture near the bottom of a page where it lands (#3169)
To find the offset from its anchor paragraph, a dropped object was
first laid out at the paragraph's top. Near the bottom of a page that
pushed the paragraph onto the next page, so the object ended up about
a page below the drop point, cut off at the page edge.

Now it is first placed where it was dropped, and the paragraph's
position is read from that layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:28:45 +02:00
NaxvogandClaude Opus 5.5 fec27d66d1 CLI: run without --file uses the region's paper, like convert (#3152)
A bare `run` built its document with Document::new() (Letter) and never went
through file.new, so it skipped the system region's paper. It now runs
file.new, as `run --template` already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:54:41 +02:00
Frédéric WeilandClaude Opus 5.5 912b98c833 Watermark: the text fills its shape's box, as Word's textpath does
Word's text watermark is a VML WordArt shape (`PowerPlusWaterMarkObject`)
whose `v:textpath fitshape` stretches the text over the shape's box (its
`style` width and height), centred on the margins and, when diagonal,
turned by `rotation:315`. WordCraft ignored the box: it drew the text
70 % of the page diagonal wide (half of it on screen), centred on the
page and along the page diagonal (55° on A4, not 45°). A "BROUILLON"
watermark in a 515 x 104 pt box came out about 1.4 times as large as in
Word.

- doc: `Watermark::size`, the shape's box (`None`: Word's default, half
  the page diagonal across, as high as the text's length makes it);
  `Watermark::frame` gives the box for a page, shared by the writer and
  the renderers.
- docx: the box is read from the shape's style and written back; a box
  equal to the default for the document's page reads as `None`, so a
  watermark made by Design > Watermark keeps following the page.
- pdf, render: the text's advance spans the box's width, its em the
  box's height, the em's foot (the font's win descent below the
  baseline) on the box's bottom; the box centred on the margins,
  turned 45° counterclockwise when diagonal.

Checked against Word for Microsoft 365 (ink bounds, pt, left top right
bottom; fonts here: Liberation Sans for Arial and Calibri, Liberation
Serif for Times New Roman):

  BROUILLON, Calibri, 515.4 x 104.1, 315°:
    Word 93.6 222.7 489.1 619.2; before 60.5 110.4 535.7 732.5;
    after 90.7 214.1 495.8 618.7
  SPECIMEN, Arial, 400 x 100, 315°:
    Word 136.3 261.6 455.0 580.3; before 66.7 108.5 539.0 727.7;
    after 137.8 255.4 454.6 573.1
  HXH, Arial, 300 x 100, flat: Word 158.9 378.2 436.3 449.8;
    before 105.1 353.8 490.1 489.6; after 158.9 380.2 435.8 449.3
  HXH, Times New Roman, 300 x 100, flat: Word 149.8 383.0 445.0 449.3;
    after 151.2 383.5 443.5 449.3
  COPIE, Calibri, 300 x 80, flat: Word 153.1 387.8 441.1 440.2;
    before 96.0 373.9 500.2 469.0; after 152.2 387.4 443.5 444.5

Tests: a watermark with a box of its own is written and read back with
it, Word's default box reads back as none; in the PDF, the text of a
300 x 80 pt watermark is 80 pt high, stretched across, starting at the
box's left edge on the margins' centre line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:31:10 +02:00
jvspierandClaude Opus 5.5 ed7f458560 #2722: Dutch interface — the 73 entries added since, and wording fixes
Fills every entry the Polish reference catalog has that Dutch was missing
(main moved on since the PR was written). Wording fixes, chosen by a native
Dutch speaker while using the interface:
- Review tab: Controleren; the Proofing group/options page: Taalcontrole
  (its button already reads "Spelling en grammatica")
- AutoSave: Automatisch opslaan
- Quick Access Toolbar: Sneltoegangslijst everywhere (was three different names)
- Hide Ink (two-line label): Inkt verbergen (word order)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:28:18 +02:00
jvspier bf1135c0d7 Merge main into #2722 (no conflicts) 2026-10-19 22:28:18 +02:00
Frédéric Weil | FSSPXandClaude Opus 5.5 a683260abf Borders: dotted, dashed and dash-dot borders take Word's dash patterns
Paragraph, cell and table borders were dashed, but not with Word's
patterns: dots were one width on and two off (Word: one and one),
dashes four on and two off (Word: three and one), and dash-dot was
drawn as plain dashes.

- layout: borders use their own strokes (`Stroke::BorderDotted`,
  `BorderDashed`, `BorderDotDash`); `Stroke::dashes` gives every
  pattern in line widths. Underlines and the other dotted lines keep
  theirs.
- pdf, render: lines are stroked with `Stroke::dashes`.

Checked against Word for Microsoft 365 (ink along each border, on
synthetic documents): borders 0.5, 1 and 2.25 pt wide, dotted / dashed
/ dotDash, are 1-1, 3-1 and 3-1-1-1 widths in Word; before 1-2, 4-2 and
4-2; after as Word (2.25 pt dashes: 6.72 on / 2.28 off in both).

Test: in the PDF, a dotted 1 pt border is dashed 1 / 1, a dashed 2 pt
border 6 / 2, a dash-dot 1 pt border 3 / 1 / 1 / 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:08:46 +02:00
dfgodvan e537dce6af Docs: tabs that wrap with their text in the pagination gap (#1840) 2026-10-19 23:08:19 +03:00
dfgodvan 10dff145c5 Docs: the tab-wrapping entries name the cases they cover (#1840) 2026-10-19 23:08:14 +03:00
dfgodvan 076eb68cd0 Layout: a tab wraps away only from text, not from markers alone
A left tab past the right indent, or a right, centre or decimal tab whose text
doesn't fit, wrapped whenever any cluster was before it on the line, so a
bookmark before the tab was left on a line of its own. It now wraps only when
something that takes room is before it.
Test: a_bookmark_before_a_left_tab_past_the_right_indent_is_not_a_line.
2026-10-19 23:07:57 +03:00
dfgodvan a216519d44 Docs: tabs that wrap with their text in layout-parity and the progress log (#1840) 2026-10-19 23:07:57 +03:00
dfgodvan c1d829d5c5 Layout: a left tab still past the right indent on the line it wrapped to takes no room
A left tab whose stop is past the right indent wraps to start the next line.
When that line can't reach the stop either, the tab went past the line's end,
its text didn't fit after it, and the tab was left on a line of its own. As
before the wrap, it now takes no room there and its text starts at the indent.
Test: left_tab_past_the_right_indent_does_not_add_a_line.
2026-10-19 23:07:36 +03:00
dfgodvanandClaude Opus 5.5 a22c2757b4 Layout: a tab whose text doesn't fit goes to the next line with it
A TOC entry whose text just fits showed its page number at the left of a line of its own,
without the leader. Two cases ended the line after the tab and started the next one with its
text at the indent:
- the text after a right, centre or decimal tab didn't fit;
- the text ended past the last stop, and the next default stop was past the right indent
  (the tab was left at the end of the line with no width).

Like Word and LibreOffice, the line now breaks before such a tab: the tab starts the next line
and goes to the first stop there, so a TOC page number ends at its stop after a leader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:07:36 +03:00
Frédéric WeilandClaude Opus 5.5 94d938fa63 Text: scaled letters (w:w) are stretched, not spaced out
Character scale (`w:w`, Format > Font > Advanced > Scale) widens or
narrows the letters themselves. WordCraft multiplied the advances but
drew each glyph at its natural width, so text at 200 % looked letter-
spaced (pdftotext even split it into words) and text at 50 % overlapped.

- layout: `Draw::Glyphs` carries `x_scale` (1 = 100 %), the run's scale
  for its text and its line-end hyphen (whose advance is scaled too);
  list labels, tab leaders, ruby, equations and shape text keep 1.
- pdf: a scaled run is drawn under a horizontal scale from its first pen
  position, the positions brought back so each glyph lands where the
  layout put it; the text and its extraction are unchanged.
- render: each glyph's outline is stretched across from its pen
  position.

Checked against Word for Microsoft 365, "HIHIH" in Arial 20 pt
(Liberation Sans here), right end of the word (pdftotext) and ink:

  200 %  Word 179.78, ink 73.92..176.52; before: split in three words,
         ink to 163.68; after 179.74, ink 74.04..176.52
  150 %  Word 152.19; after 152.52
   50 %  Word 97.76; after 98.07
  100 %  unchanged

Test: in the PDF, letters at 200 % are drawn twice as wide and as high,
at pen positions twice as far apart; at 100 % unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:02:49 +02:00
Frédéric WeilandClaude Opus 5.5 858cc838bf Layout: anchored objects thinner than a point keep their size
A floating object's frame was clamped to at least 1 pt on each side.
Word keeps the size: a 0.25 pt high rectangle (a cut mark, a rule drawn
as a filled shape) is 0.25 pt high, and a `line` shape 0 pt high is a
horizontal line. WordCraft drew the rectangle 1 pt high from its top
edge (its middle 0.375 pt too low) and the flat line from its start
down to 1 pt below its end, a slanted stroke.

- layout: `float_rect` keeps sizes from 0 pt (not finite: 0); the
  PDF and raster shape outlines already draw a flat line and skip an
  empty rectangle.

Checked against Word for Microsoft 365 (page-anchored, no outline
unless said; ink top..bottom in pt, 2400 dpi):

  0.25 pt rectangle   Word 99.99..100.26  before 99.99..101.01  after 99.99..100.26
  0.5 pt rectangle    Word 120.00..120.51 before 120.00..120.99 after 120.00..120.51
  0.75 pt rectangle   Word 140.01..140.76 before 140.01..141.00 after 140.01..140.76
  1 and 2 pt          unchanged, as Word
  flat line, 0.25 pt  Word 219.87..220.14 before 219.87..221.13 after 219.87..220.11
  flat line, 1 pt     Word 239.49..240.51 before 239.49..241.50 after 239.49..240.51
  upright line, 0 wide, 0.25 pt: Word 299.88..300.12 across, before
  299.88..301.14, after 299.88..300.12

Test: rectangles of 0.25, 0.5, 1 and 2 pt, a flat line and an upright
line keep their sizes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:02:49 +02:00
Fin Warman b42f1b8061 Refresh main without changing feature history (#2431) 2026-10-19 20:52:37 +01:00
Магда М.Г. af2eb4c6ad Merge remote-tracking branch 'up/main' into up-symbol-font-line-height
# Conflicts:
#	crates/layout/src/tests.rs
#	docs/layout-parity.md
2026-10-19 22:48:35 +03:00
Магда М.Г. bb4c579a4d Merge remote-tracking branch 'up/main' into up-fonts-roman-known-substitutes
# Conflicts:
#	crates/fonts/src/word.rs
#	crates/layout/src/para.rs
#	docs/file-format-parity.md
2026-10-19 22:46:07 +03:00
Hamza d78238345c Rename: split the name at its last dot, not at a byte offset
The extension check counted bytes back from the end of the name and
sliced there, which panics when that offset falls inside a character:

    (file.rename {"name": "abecde"} with a combining accent, .docm)
    byte index 3 is not a char boundary; it is inside 'e' (bytes 2..4)

Splitting at the name's own last dot asks the same question without an
arithmetic offset, and reads the same for every name. Covered for a name
with no extension and one with the document's, both multi-byte.
2026-10-20 00:39:52 +05:00
Hamza 2b43d06b90 Rename: a name given with the document's extension keeps it, once
`file.rename` appended the document's extension unconditionally, so
`{"name": "renamed.docx"}` produced `renamed.docx.docx`. The Rename field in
the ribbon edits the stem, which is why it was written that way, but a script
or an agent passes a whole file name.

A name that already ends in the document's extension is now taken as the whole
file name, whatever its case. Anything else still gets the extension: a name
that merely holds a dot (`Report.2026` -> `Report.2026.docm`), and the
extension on its own (`.docm` -> `.docm.docm`), which is a stem rather than a
file name.

Fix: #2983
2026-10-20 00:39:52 +05:00
SlavenDj 943fe2c8ae Crop any part of a picture: a side can take 95% of the source, not 45% (#3135) 2026-10-19 21:05:17 +02:00
SlavenDj 7360d50e72 #2958: the Print page's button leads the settings, in the primary accent
File › Print put "Print" between the printer settings and the page
settings, styled like every other button there, so the page's one action
was hard to find and didn't read as an immediate action.

The button now comes first in the settings column, above the printer and
the settings it prints with, as Word has it, and wears the accent the app
gives a dialog's primary button (the file browser's Open and Save, OK and
Save in dialogs): the first thing seen, and plainly the action. It still
prints through `ui.print`, and it is still disabled while a native print
job is busy.
2026-10-19 20:09:16 +02:00
Tyrel Haveman 452244fd19 Merge remote-tracking branch 'upstream/main' into table-of-figures-label
# Conflicts:
#	ROADMAP.md
2026-10-19 10:40:16 -07:00
justinjohn0306 0a5d0af4f0 Fix #3137: Cannot insert a tab inside table cells (missing Ctrl+Tab shortcut) 2026-10-19 23:02:15 +05:30
MyAIPluginsandClaude Opus 5.5 322b3d765c test(pdf): a described shape is exactly one Figure
The test checked that the description is there, so a Figure written twice
would still pass. It now also counts the alt text entries.

Suggested by the CodeRabbit review of this PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:36:37 +02:00
MyAIPluginsandClaude Opus 5.5 d16dd5cd72 fix(pdf): tag each picture in a rotated group on its own
A rotated inline group is drawn inside one rotation. Its tag was chosen for
the whole group: an artifact as soon as one member was decorative, so a
picture next to it lost its Figure and alt text; otherwise one Figure with
the first picture's alt text. The group is still turned as a whole, and each
picture in it is now tagged on its own: decorative ones as artifacts, the
others as Figures with their own alt text.

Found by the CodeRabbit review of this PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:36:37 +02:00
Fin Warman 7d80ba4caa M711: merge main and bind font geometry fixtures 2026-10-19 17:28:17 +01:00
MyAIPluginsandClaude Opus 5.5 73d159b6c9 fix(pdf): shapes with a description are figures in tagged PDF
A shape with a description (Alt Text) was exported as an artifact like any
other shape, so screen readers skipped it and the description was lost: an
arrow or an icon drawn with shapes that means something was invisible to them.

A shape that has a description, is not marked decorative and shows no text is
now a Figure with its description, inline (rotated too) and floating. Shapes
without a description and decorative shapes stay artifacts. A text box keeps
its frame as an artifact and its text read as paragraphs: its description would
replace the text, so it isn't used. The rule lives in `shape_figure_alt`.

Draw::Figure's `kind` becomes an Option (None for a shape), and Placed::Shape
carries the description for floating shapes.

Not covered: a group's own description (its members are placed one by one),
and shapes inside a Drawing Canvas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:52:55 +00:00
MyAIPluginsandClaude Opus 5.5 8664afe58f fix(export): pictures marked decorative are skipped by screen readers
A picture marked decorative (Alt Text > Mark as decorative, `adec:decorative`
in DOCX) was still exported as content:

- tagged PDF: a Figure with its alt text, or with "picture" when it had none,
  inline and floating alike. PDF/UA expects decorative content to be an
  artifact, so screen readers skip it. Draw::Image now carries the mark, and
  the exporter tags a decorative picture (inline, rotated or floating) as an
  artifact instead of a Figure.
- HTML and Markdown (and ODT, which reads the same model): the description,
  which the document keeps when a picture is marked decorative, was written
  as its alt text. A decorative picture now exports with an empty alt text
  (alt="" in HTML), the WCAG way to say "skip this".

Pictures not marked decorative are unchanged. Charts and diagrams are left as
figures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:52:55 +00:00
ratkoandClaude Opus 5.5 bbdd11f8ff #3120: test the Caption dialog through its OK button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:50:08 +02:00
fluorineandClaude Sonnet 5.5 9540d5ee4d Dark theme: the File page's side bar uses a deeper blue
The side bar used the light theme's bright blue in both themes. It now
reads a token, `nav`: the same blue in light mode and a muted indigo in
dark mode. White text on it stays above 4.5:1 (tested).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 10:43:19 -04:00
Tyrel HavemanandClaude Opus 5.5 5a5305a7b4 references: a hand-typed caption's label is dropped in any case for \a (#3013)
A hand-typed caption is matched to its label in any case, but `\a` (no
label and number) stripped the label case-sensitively, so "figure 4: Lower"
was listed whole. Found by a local CodeRabbit review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Nnsfu3QGqjD1aFZbBgUWP
2026-10-19 07:24:17 -07:00
ratkoandClaude Opus 5.5 4c0dbb429f Insert Caption picks the label used last, as Word does (#3120)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:17:23 +02:00
Tyrel HavemanandClaude Opus 5.5 47bcb64dbe references: a table of figures from Word replaces its saved list (#3013)
A Word table of figures whose saved list sits in the field's result, in
the field's own paragraph, was listed twice after Update Table: the
cached result stayed and the rebuilt entries followed it. The rebuild now
clears that result and keeps the paragraph's tab leader for the entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Nnsfu3QGqjD1aFZbBgUWP
2026-10-19 06:52:30 -07:00
Daniele MoriandClaude Opus 5.5 1ed20b8685 Stacking order test: two Behind Text objects, the higher one first in the XML
As reported on #2034 with a minimal document: Word keeps the object with
the higher relativeHeight on top even when it comes first in the XML.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:44:39 +02:00
Daniele MoriandClaude Opus 5.5 53296879db Floating objects are drawn in their stacking order (relativeHeight)
Word stacks floating objects by wp:anchor/@relativeHeight, not by the
order of their anchors in the paragraph. The reader ignored the
attribute, so a panel anchored after the text boxes it frames, but
lower in the stack, was drawn over them and hid their text.

- wordcraft-doc: Float gets a `z` (0 = none, drawn in document order).
- wordcraft-docx: it is read from relativeHeight and written back as
  read; an object without one gets the next order above, as before.
- wordcraft-layout: place_objects collects each object's items with
  its order and emits them lowest first (stable, so objects without an
  order keep the paragraph's).

Four round-trip tests compare whole floats after a save; saving now
gives objects without an order one, so those tests compare without it
(`unstacked`). The order itself is covered by its own round-trip test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:44:38 +02:00
Tyrel Haveman a923cf6dc5 Merge remote-tracking branch 'upstream/main' into table-of-figures-label
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-19 06:09:48 -07:00
dfgodvan 5813d01f73 Layout: paragraph border room in keep-together checks, after a kept break mark and in Outline view
- Keep lines together and keep with next count the paragraph's top and bottom
  border room, and the keep-with-next lookahead the next paragraph's top border
  room (unless the two join in one box): a kept paragraph that fits only
  without its borders moves on whole instead of splitting.
- A mark that stays with its closing page break leaves its bottom border's
  room on that page, as its space after: the next page no longer starts lower.
- Outline view makes room for paragraph borders above and below the lines.

Tests: a_kept_together_paragraph_counts_its_border_room,
a_bottom_border_stays_with_a_closing_page_break,
outline_view_makes_room_for_paragraph_borders (each fails before the change).
2026-10-19 14:40:53 +03:00
dfgodvanandClaude Opus 5.5 115ccd0e89 Layout: paragraph borders take their room and same-bordered paragraphs join in one shaded box
The top and bottom borders of a paragraph now take their distance plus width
in the text flow, as in Word, and the shading fills that room. Neighbouring
paragraphs with the same borders and indents make one box: the top border goes
above the first only, the bottom one below the last, and the shading and side
borders run on through the space between them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:08:09 +03:00
JoeyandClaude Opus 5.5 a7095086c3 Breaks menu tests pin the English interface, so they pass on a non-English machine
An integration test isn't built with cfg(test), so WordApp follows the system language and the
ribbon's "Breaks" button wasn't found on a French Mac. Set the app's language to English like
native_print.rs does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 13:07:23 +02:00
Fin Warman 85bbc821f0 Merge main117 into #2431 2026-10-19 12:06:00 +01:00
dfgodvan 3e5eeccfa2 Render: fade dimmed headers and footers as one layer
While the body is edited, headers and footers are drawn dimmed. Each draw
item got the dimming alpha on its own, so wherever two of them overlap the
alpha was applied twice: a header table's borders came out darker where
they meet and along shared cell edges, nearly black at crossings. The
PDF export and the undimmed view were fine.

page_layers gives a page's draws as two layers (headers/footers, body) at
full strength with the opacity to draw each at. The canvas draws a dimmed
layer inside one opacity layer, as Word dims it, and background_at blends
it the same way. page_display is unchanged for the PDF export and tests.
2026-10-19 14:05:15 +03:00
rabisandClaude Opus 5.5 fba2331bbf #3027: reset test checks the fill rule and paint transform for real
The test scene set even-odd itself before its only path that depends on
it and used solid paints only, so a fill rule or paint transform left
over from the last render could not change its pixels. It now fills
nested squares and a gradient on the defaults first, and checks that a
stale rule or paint transform does change the scene.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:00:20 +02:00
rabisandClaude Opus 5.5 955d166a69 #3027: renders reuse one render context instead of a thread pool each
Every page, area and icon render built its own multi-threaded vello_cpu
RenderContext, which starts and tears down a rayon thread pool: a few
threads per keystroke while typing. Renders now take one kept context,
reset to their size, and give it back; a render that overlaps another
builds its own, and one that panics drops its context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:35:17 +02:00
Fin Warman 328b36c9bd PR2453: merge main documentation 2026-10-19 07:28:58 +01:00
Tyrel HavemanandClaude Opus 5.5 52e119da5f references: table of figures review fixes (#3013)
- The leader asked for is carried through the rebuild by a placeholder
  entry, so it lands on the new list even when a stale list above it
  gains entries and moves it down.
- \c picks captions by their SEQ identifier, as Word does; the label text
  is used only for captions typed by hand.
- The dialog test drives the dialog through egui_kittest: page numbers
  off disables the right margin choice, OK closes it and the entries get
  the chosen leader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Nnsfu3QGqjD1aFZbBgUWP
2026-10-18 21:59:21 -07:00
vangel76 fec6872e68 Merge remote-tracking branch 'upstream/main' into tmp-pr2691
# Conflicts:
#	ROADMAP.md
2026-10-19 02:11:15 +02:00
vangel76andClaude Sonnet 5.5 21604d5fde Merge upstream/main into fix/float-in-cell-align-1887
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:08:49 +02:00
vangel76andClaude Sonnet 5.5 ffec4db77b cargo fmt after merging main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:08:25 +02:00
vangel76andClaude Sonnet 5.5 3de7f8e31e Merge upstream/main into feat/radar-chart-1890
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 02:08:06 +02:00
Tyrel HavemanandClaude Opus 5.5 0d48f236e5 references: Insert Table of Figures dialog with a caption label choice (#3013)
Insert Table of Figures now opens a Table of Figures dialog (caption label,
include label and number, page numbers, right margin, tab leader) instead of
always building a list of Figures. The label list offers the built-in labels,
the user's own and those the document's captions use, so a document can have
a list of figures beside a list of tables.

references.tableOfFigures takes includeLabel, pageNumbers, rightAlign and
tabLeader and writes the TOC switches for them (ECMA-376 17.16.5.68: \c or \a,
\n, \p). Captions are matched on their whole label (SEQ identifier), a Word
table of figures with its switches in any order (TOC \h \z \c "Figure") is
recognised, and Update Table keeps each list's leader. The table of contents
code treats any \c or \a TOC as a table of figures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:04:44 -07:00
ofdistress2 bedd8fe2b5 file.recover: merge main and cover pending discovery through UI 2026-10-19 00:30:43 +03:00
Diego Guerini 53e0dd3ce8 Merge remote-tracking branch 'upstream/main' into altgr-keytips
# Conflicts:
#	docs/typing-parity.md
#	docs/ui-parity.md
2026-10-18 23:15:27 +02:00
Nghiathan13 4c874d82a2 #2964: document break_lines 2026-10-19 03:10:48 +07:00
Nghiathan13 ec26a573be #2959: show the mini toolbar with text context menus 2026-10-19 02:43:16 +07:00
Nghiathan13 54d89d563b #2964: align list labels with text after leading page breaks 2026-10-19 02:36:39 +07:00
luisvacaandClaude Opus 5.5 80f6ba6a25 #2289: comment navigation follows the stories a reader sees, in reading order
Review of the first commit found, and ui_shot runs confirmed, three bugs in it:
- Next Comment moved the caret into a removed header or a deleted
  footnote's text: a story nothing shows, so typing went nowhere visible.
- Next visited headers and footers by part id (creation order), so a
  footer made first came before the header.
- Comments in footnotes were claimed but untested.

Document::shown_stories lists the stories a reader sees, in reading order:
the body with the notes and text boxes it shows, then the headers and
footers the sections use with theirs. It reuses the reachable walk that
counted_stories uses. comment_list scans those stories (and returns at
once when the document has no comments: the balloons ask every frame), and
nav_comment orders by a story's place in that list instead of by Pos.
tools::all_stories is private again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:50:15 -06:00
luisvacaandClaude Opus 5.5 487a7ccb86 #2289: comments in headers, footers, notes and text boxes are listed, get balloons and are reached by Next/Previous
comment_list scanned the body only, so a comment anchored anywhere else was
listed with no anchor: no balloon beside its text, Go to did nothing, and
Next/Previous Comment never reached it. It now scans every story except
comment text, body first and then each part in Pos order, which is the
order nav_comment relies on. Reuses tools::all_stories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:44:27 -06:00
Dmitrii KhlapovandClaude Sonnet 5.5 635137b769 gap: single cell border: progress docs after rebase
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 17:43:28 +02:00
Dmitrii KhlapovandClaude Sonnet 5.5 5aa87f27f3 gap: single cell border: only a triple-click selects the cell, not the ribbon's Select Paragraph
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 17:43:28 +02:00
Dmitrii KhlapovandClaude Sonnet 5.5 d9467df1c0 gap: single cell border: review round 1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 17:43:28 +02:00
Dmitrii KhlapovandClaude Sonnet 5.5 da99deb3aa gap: single selected cell's border drags in its row only
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 17:43:28 +02:00
Diego Guerini e0daa7bdea Merge remote-tracking branch 'upstream/main' into altgr-keytips
# Conflicts:
#	docs/ui-parity.md
2026-10-18 17:20:31 +02:00
Diego Guerini 2263941a6f Merge remote-tracking branch 'upstream/main' into altgr-keytips
# Conflicts:
#	docs/ui-parity.md
2026-10-18 16:08:51 +02:00
Fin Warman de86d323ee Merge frozen main and reconcile conflicts (#2431) 2026-10-18 14:30:22 +01:00
Fin Warman 8d259df48c Merge frozen main and reconcile conflicts (#2453) 2026-10-18 12:35:53 +01:00
NetterSilverandClaude Haiku 5.5 300164dde0 Double-click switches between header and footer editing
Fixes #1026

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 11:29:29 +00:00
Nghiathan13andClaude Opus 5.5 66b73105b2 Merge origin/main into feat/2738-underline-menu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:51:22 +07:00
Diego Guerini 70a4d7524c Merge remote-tracking branch 'upstream/main' into altgr-keytips
# Conflicts:
#	docs/typing-parity.md
#	docs/ui-parity.md
2026-10-18 11:33:12 +02:00
ofdistress2 5450419a39 file.recover: keep explicitly requested documents visible 2026-10-18 12:29:53 +03:00
Nghiathan13andClaude Opus 5.5 d342135bc7 #2738: parity checklist and catalog count after merging main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:21:25 +07:00
Nghiathan13andClaude Opus 5.5 763f9ca44e Merge origin/main into feat/2738-underline-menu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:45:48 +07:00
Diego Guerini b22f18467f Merge remote-tracking branch 'upstream/main' into altgr-keytips
# Conflicts:
#	docs/typing-parity.md
#	docs/ui-parity.md
2026-10-18 10:29:32 +02:00
Nghiathan13andClaude Opus 5.5 bbedcb73e5 Merge origin/main into feat/2738-underline-menu
Also: the underline-colour test picks a Standard Colors swatch through the
harness instead of running format.underlineColor directly (review nitpick).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:09:53 +07:00
Nghiathan13andClaude Opus 5.5 98942eb97e Merge origin/main into feat/2738-underline-menu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:08:51 +07:00
Nghiathan13andClaude Opus 5.5 aa84111205 Merge origin/main into feat/2738-underline-menu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 15:07:25 +07:00
NaxvogandClaude Opus 5.5 ffb3a2189f Hyphenation: exact German pattern count, a parser test for skipped commands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:48:38 +02:00
NaxvogandClaude Opus 5.5 de3db3f02c Hyphenation: German patterns in NOTICE; pattern files skip \message
Review follow-ups: the German patterns' MIT notice goes into NOTICE with
the other pattern sets; the pattern reader skips other commands'
arguments, so hyph-de-1996.tex's \message{} line no longer adds junk
patterns and exception words; a load test for the German set; the
status bar names a bare "de" tag; the docs list the languages with
their own hyphenation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:48:38 +02:00
NaxvogandClaude Opus 5.5 0fa438c7d1 Hyphenation: German text follows the German patterns
Runs in German (de, de-DE, de-AT, de-CH) hyphenated with the English
patterns, giving breaks such as "unterlieg-ende" and "rechtli-cher".
They now use hyph-utf8's German patterns for the reformed spelling
(MIT, bundled unmodified as assets/hyphenation/de.pat). German gets a
ProofLang of its own that, like Bulgarian, hyphenates only: no spelling
or grammar marks yet (#1564). Portuguese, Italian and German share one
pattern lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:48:37 +02:00
Nghiathan13andClaude Opus 5.5 657c37ddd3 Merge origin/main into feat/2738-underline-menu
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 14:47:27 +07:00
Fin Warman 8450147093 Merge main and resolve progress notes (#2453) 2026-10-18 07:31:17 +01:00
Nghiathan13 f695a53365 Merge remote-tracking branch 'origin/main' into feat/2738-underline-menu
# Conflicts:
#	ROADMAP.md
#	docs/ui-parity.md
2026-10-18 06:15:12 +07:00
sanches 7817c137a9 i18n: add Dutch and Danish interfaces
Two new interface languages, each a complete catalog of 2,585 entries:
every key the parity-class catalogs hold (the Polish-complete set — the
keys Italian, Hungarian, Bulgarian, Vietnamese, Swedish and Croatian
still carry beyond it are deprecated interface strings, so they are not
carried here). Written from the meaning of the English labels with each
language's standard software vocabulary: Dutch informal-neutral je,
Danish informal du with accented imperatives.

Registered in the language list (23 -> 25) with locale matching
(nl-NL/nl-BE, da-DK). The preferred-language test now uses the
private-use tag where it used nl as its unsupported example, and checks
that nl wins over da now that both are languages.

Supersedes #1070, whose Korean and Swedish were landed by other pull
requests; its Dutch and Danish catalogs are the ones carried here,
updated to today's key set.
2026-10-17 17:32:56 +03:00
Nghiathan13andClaude Opus 5.5 80b4b82419 #2738: Home › Underline drop-down shows its lines, None, More Underlines… and Underline Color
Each underline style in the drop-down is now a sample of its own line,
drawn in code in the theme's text colour (Single, Double, Thick, Dotted,
Dashed, Dot-dash, the missing Dot-dot-dash, Wave), named for tooltips and
assistive technology. Words only uses format.wordUnderline (its own
Ctrl+Shift+W); None removes the underline and shows no shortcut; More
Underlines… opens the Font dialog; Underline Color › offers Automatic and
the theme/standard colours with More Colors….

- engine: format.underlineColor {"color": "RRGGBB" | "auto"} sets or
  clears CharProps::underline_color and leaves the style alone;
  format.state reports underlineColor; catalog row Home|Font|Underline
  Color (parity checklist regenerated: 490/505).
- i18n: Dot-dot-dash, More Underlines… and Underline Color in all 22
  catalogs (Underline Color reuses each catalog's "Underline color").
- Tests: underline_color_sets_and_clears_the_underlines_own_colour (engine,
  DOCX round trip, undo, junk params), underline_menu_entries_apply_their_style
  (UI: every sample is a named entry that applies its style; Words only,
  None, Underline Color › Automatic).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:25:55 +07:00
vangel76 bea8ab8704 M-parity: ROADMAP progress row for the eqarr centring fix (#2617) 2026-10-17 12:54:00 +02:00
vangel76 4a9f24f642 eqarr rows without an align marker centre across the array; they no longer widen the alignment columns
A row of an m:eqArr with no `&` was right-aligned at the first column edge and counted into the
first column width, shifting the wider rows. Per Word, such a row is centred across the whole
array and takes no part in the column widths. Regression test eqarr_rows_without_an_align_marker_are_centred
fails without this. Closes #2617.
2026-10-17 12:51:07 +02:00
Martin BallandClaude Opus 5.5 0c9575230c #2625: recovery does not retry a snapshot that cannot be copied until the document changes
A failure caused by the snapshot itself (size limit, folder or disk budget,
a value the format cannot keep) now keeps the recorded revision, so tick
does not rewrite up to 256 MiB every interval while nothing changes.
Transient failures (locks, I/O) still retry at the next interval.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 11:39:44 +01:00
Магда М.Г. e60c699775 Layout: a Symbol bullet's line keeps Symbol's height without the font
Word sizes a line by the fonts in it, and a bullet in Symbol (Word's
default, U+F0B7) has a taller ascent than Calibri or Times New Roman.
Without Symbol (Linux, macOS without Office, the web build) WordCraft
draws the bullet from another face and used that face's height, so every
bulleted line was shorter than Word's: 14.5 pt against 15.1 pt in
Calibri 11 pt at 1.08 spacing, 14.9 against 15.7 in Times New Roman 12 pt.

symbol_font_metrics gives Symbol's and Wingdings' ascent and descent as
Word's PDFs state them, and a run in one of them that isn't installed
takes them. The label's ascent now comes from its style, so the existing
rule (the label's extra ascent added unscaled) sees them.

Measured in Word 2016: Calibri 11 / Times 12 bullets 15.08 / 15.72 pt,
WordCraft now 15.07 / 15.75 pt. On 251 real documents against Word PDFs,
page breaks at the same word 45.4% -> 50.1%.
2026-10-17 12:31:01 +02:00
Магда М.Г. 6a60779105 Fonts: Times New Roman keeps Liberation Serif under the roman-font rule (#1613)
#1613 draws a missing font in Cambria when the font table classes it as
roman, as Word does. Times New Roman is roman in every font table, so
where it isn't installed (Linux, the web build) every Times document came
out in Caladea: lines 2.1 pt taller at 12 pt and different line breaks.
Word always has Times New Roman, so it never applies that rule to it.

The rule now leaves alone a font whose substitutes are chosen by its name
(a metric-compatible clone or the same design under another name: Times,
Arial, Courier New, Palatino, the symbol, Hebrew and Persian fonts).
Fonts with no clone of their own (Cambria, Georgia, brand fonts) still
follow it.

Checked on 251 real documents against PDFs from Word: exact page counts
201 -> 229, page breaks in the same place 45.4% -> 51.9%.
2026-10-17 11:50:57 +02:00
Nghiathan13andClaude Opus 5.5 7c4f41270a #2583: the context menu offers the ribbon's paste choices
Right-clicking the page offered only Cut, Copy and Paste, even with
formatted text on the clipboard. Word's context menu lets you choose how
to paste there.

The context menu now has the same items as Home › Paste's menu, in the
same order and with the same shortcuts: Paste (the clipboard's
formatting kept, Word's Keep Source Formatting), Keep Text Only, Merge
Formatting and Paste Special…. They run the same commands (edit.paste,
edit.pasteText, edit.pasteMerge, edit.pasteSpecial), and
system_clipboard::fill already reads the system clipboard's text, HTML
and RTF for all of them. Every label was already translated in the 22
interface catalogs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 03:54:07 +07:00
Nghiathan13andClaude Opus 5.5 3c968aa782 #2233, #2234: inline pictures drag to a dashed insertion cursor and stay inline
Dragging an In Line with Text picture, shape or text box now moves it within
the text, as in Word: a dashed insertion cursor follows the pointer through
the text, and the drop puts the object there, still inline and selected, in
one undo step (Ctrl+drag drops a copy there). Before, the drop floated it with
Square wrapping where it was let go, and nothing showed where it would land.

arrange.bounds and arrange.copyTo take {"to": Pos} for inline objects; x/y
keep their old meaning for scripts. Floating objects drag as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:45:14 +07:00
Fin Warman 09f065c11f 286: resolve installed PostScript font names 2026-10-16 13:48:31 +01:00
midasmatterhorn e30e7fd132 engine: Update Field evaluates DOCPROPERTY against custom and built-in properties 2026-10-16 15:01:23 +03:00
Nghiathan13andClaude Opus 5.5 b407859f74 #2174: HYPERLINK fields read as links
Word for the web writes each table of contents entry as a HYPERLINK
complex field around the heading's text, a tab and a PAGEREF field.
The DOCX reader buffered the whole field into one field object with a
flat text result, so the tab was drawn as a box instead of reaching the
dotted right tab stop, the page number was no longer a field of its own,
and the link could not be followed. Rewriting those fields as
w:hyperlink elements makes WordCraft draw the TOC as Word does.

A HYPERLINK field, complex or simple, now reads the way a w:hyperlink
does: its result stays ordinary content carrying the link (url,
url#bookmark or #bookmark from \l), with nested fields kept. It is
written back as w:hyperlink; the tooltip (\o) and target frame (\t) are
dropped. A HYPERLINK field with no target stays a field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:52:15 +07:00
Nghiathan13andClaude Opus 5.5 0d1ede1291 #2376: a text file saved over itself closes without asking
Opening note.txt, typing, saving with Ctrl+S and closing still asked
"Do you want to save changes to note.txt?". Save in that prompt wrote
the file but left the window open, and only Don't Save closed it.

file.save marked the document saved only for formats that keep
everything (.docx, .odt, .rtf...). A save in any other format counted
as a copy, even over the file the document was opened from, so a .txt
or .md document could never become clean. The Save Changes prompt then
treated its own Save as "not saved" and cancelled the close.

A save over the document's own file now marks it saved in any format.
Save As to plain text, Markdown or a page image elsewhere is still a
copy that leaves the document unsaved, as before. AutoSave still
covers only the formats that keep everything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:27:15 +07:00
Nghiathan13andClaude Opus 5.5 da47503f1d #2360: a text frame in the paragraph defaults frames nothing
Apple Pages writes a sizeless w:framePr (xAlign left, y 0, hRule
exact) into w:pPrDefault. WordCraft read it as the default paragraph
frame, so every paragraph inherited it. The header's title of
traditional_skills_job_form was drawn from the page's left edge over
the first table, the footer's page number left the bottom of the page,
and table rows came out shorter than in Word.

Word ignores that frame: its PDF of the same file shows the title at
the left margin and "1/4" in the footer. Taking just that element out
of styles.xml gives WordCraft the same page. The reader now drops the
frame from the paragraph defaults and keeps the rest of them. Frames
from styles and direct formatting are unchanged, and the default frame
isn't written back on save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:39:37 +07:00
Fin Warman 3e2cf5edb4 DOCX: preserve text boxes containing pictures (task 256) 2026-10-16 11:25:51 +01:00
Fin Warman 425e01dec9 244: preserve list metrics in keep-next lookahead 2026-10-16 09:10:01 +01:00
Diego GueriniandClaude Opus 5.5 02fc9e6f9a #2388: AltGr types its characters instead of opening KeyTips on Linux
On Wayland, AltGr (Right Alt on Polish, German, Czech… layouts) arrives as
the Right Alt key followed by a modifiers event without Alt, since it turns
on Level 3 Shift (Mod5), not Alt. The KeyTips state machine read that event
as Alt let go with nothing pressed in between and opened KeyTips while AltGr
was still held, so the next letter went to the badges instead of typing ś,
ł, ż.

A modifiers event without Alt no longer ends the press while an Alt key is
still down by key events (its key-up does), and a release opens KeyTips only
if the Alt modifier was on during the press (Left Alt always counts), so a
bare AltGr tap doesn't open them either. Right Alt that is Alt (US layout)
still does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:10:22 +02:00
harshpsi9194 e5ffb15cb5 fix(ui): borrow fallback fonts and preserve visual order for recent files (#2067) 2026-10-16 11:22:02 +05:30
rabisandClaude Opus 5.5 2ffdf353a1 #2310: PDF: lam-alef drawn as two glyphs at the same x extracts in reading order
Some fonts (the static Noto Naskh Arabic builds) draw لا as a lam and a
zero-advance alef with their own clusters at one x. visual_order kept the
tie in logical order, so the right-to-left run's ranges didn't decrease,
krilla dropped /ReversedChars and السلام extracted as السالم. Ties between
clusters now go in reverse text order when right to left (text order
otherwise).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 06:56:17 +02:00
Nghiathan13 193b82d570 #2277: preview rotated object pixels while dragging 2026-10-16 10:35:52 +07:00
NetterSilverandClaude Haiku 5.5 0137a5157a Fix numbered list levels that start at 0 or are overridden to 1
Found while testing crates/doc/src/numbering.rs:319

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 04:15:02 +02:00
Divyanshu Gupta c9bebab058 fix(mathml): support linethickness=none and menclose notations 2026-10-16 07:04:27 +05:30
Divyanshu Gupta 48ff1e52be fix(formats): support bullet, top, bot, hslash, accents, bm, and mod in LaTeX math 2026-10-16 05:08:58 +05:30
Divyanshu Gupta 772cccf038 fix(formats): support xs:time, string-value, and case-insensitive cell types in spreadsheets 2026-10-16 04:39:59 +05:30
Divyanshu Gupta 51fc220092 fix(formats): support asian/complex scripts and underline/strike types in ODT 2026-10-16 04:03:21 +05:30
Divyanshu Gupta 5817874b6d fix(formats): parse leading plus and case-insensitive units in LaTeX lengths 2026-10-16 03:47:04 +05:30
Divyanshu Gupta 4818b7b8f2 fix(formats): parse case-insensitive and pica units in ODT lengths 2026-10-16 03:44:21 +05:30
Divyanshu Gupta f9808ae495 fix(docx): correctly parse emu and case-insensitive units in measure 2026-10-16 03:41:06 +05:30
Divyanshu Gupta 07ecf526a6 fix(formats): support case-insensitive and spaced !important in HTML CSS 2026-10-16 03:21:50 +05:30
Divyanshu Gupta 8711f5cfd1 fix(docx): inspect textbox style for v-text-anchor and handle case-insensitivity 2026-10-16 03:05:52 +05:30
Divyanshu Gupta 16e18d8492 fix(docx, doc): support VML wrap side and case-insensitive float anchors 2026-10-16 02:40:25 +05:30
vangel76andClaude Sonnet 5.5 efef885543 Merge upstream/main into fix/float-in-cell-align-1887
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 23:00:48 +02:00
Divyanshu Gupta a4e2cebc07 fix(formats): support borderless and bordered tables in LaTeX import and export 2026-10-16 02:05:49 +05:30
Mex 62bb20187d review (#1690): reject malformed explicit comment IDs 2026-10-15 21:48:27 +02:00
Divyanshu Gupta 9e477da0ee fix(formats): support small and big tags and relative/large font-size keywords in HTML 2026-10-15 23:45:12 +05:30
Divyanshu Gupta 68479ee33f fix(formats): support column break in RTF import and export 2026-10-15 23:38:07 +05:30
Filip Strajnar dfdce752dc i18n(sl): add the 155 strings main gained since the catalog was drafted
Rebased on main. sl.tsv now has 2,614 entries, every key of every other
catalog, including the native print settings (Tiskalnik, Odtenki sive,
Obojestransko tiskanje), mail merge filter and sort, Go To, bookmarks,
privacy options, interface size and dictionary folder strings. Three keys
main renamed were replaced.

- Slovenian added alongside Vietnamese, Swedish and Croatian in the
  language list, the audit list and the Polish coverage exclusions.
- New test: sl has no keys outside the other catalogs.
- localization-parity.md, localization-sl.md and ROADMAP.md counts,
  language lists and status lines updated.
2026-10-15 19:14:42 +02:00
Filip Strajnar 262310756d i18n: add Slovenian (sl) interface translation
A 2,463-entry Slovenian catalog: every key of every other catalog in the
repository (built-in style names, equation gallery, paper sizes, citation
styles included). Written from the meaning of the English labels in ordinary
Slovenian computing vocabulary; no other product's localisation was used.
Counts use count-neutral wording ("Besede: 21"), since the catalog has no
dual/plural forms.

- sl, sl-SI, sl_SI.UTF-8 select it automatically; "Slovenščina" in Options
  and ui.language with {"value":"sl"}.
- The interface audit checks Slovenian coverage the way it checks Russian.
- Tests: locales and saved preference, catalog coverage and key terms,
  count wording, and bundled fonts covering č, š, ž.
- docs/localization-sl.md with conventions and terminology; parity table,
  README, ROADMAP and ATTRIBUTION updated.
2026-10-15 18:57:13 +02:00
Divyanshu Gupta 147866bce5 fix(formats): support tab-separated list markers in markdown 2026-10-15 22:25:36 +05:30
Divyanshu Gupta 19109657d0 fix(formats): support distributed paragraph alignment in RTF 2026-10-15 22:01:39 +05:30
Divyanshu Gupta bdbfa62056 fix(formats): support left alignment in markdown tables 2026-10-15 21:49:08 +05:30
tydude001andClaude Opus 5.5 57a000e978 Linux: scan the Flatpak host font folders (#1937)
Inside a Flatpak, /usr/share/fonts and /usr/local/share/fonts are the runtime's and the home font
folders aren't exposed, so WordCraft listed only the runtime's fonts. Flatpak mounts the host's
system, local and user fonts at /run/host/fonts, /run/host/local-fonts and /run/host/user-fonts
for every app, and its main branch (after 1.19.2) also mounts ~/.fonts at
/run/host/user-fonts-legacy when the XDG user font folder exists too; scan those as well.
Folders that don't exist are skipped, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:11:34 -05:00
NetterSilverandClaude Haiku 5.5 9371cb0536 Return 0 for SIGN(0) in table formulas
Found while testing crates/engine/src/cmd/table_formula.rs:721

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 14:44:40 +00:00
asystemoffieldsandClaude Opus 5.5 5795259c45 File: recovery copies and .json files store pictures as base64 (#586 follow-up)
Native JSON wrote Document::media and Document::passthrough with serde's
default for bytes, an array of numbers. With a synthetic 27 MiB picture a
recovery copy (compact) was 96.4 MiB and a .json file (pretty-printed,
one number per line) 285.5 MiB.

Each part is now one standard base64 string (doc::byte_store, using the
workspace base64 crate), encoded straight into the writer. Reading accepts
base64 or the old array of numbers, so recovery copies and .json files
written before still open; anything else is an error, never a panic.

Measured (release, same synthetic picture): compact 96.4 -> 36.0 MiB,
pretty 285.5 -> 36.1 MiB, write 252/614 -> 52/57 ms, read 404/533 ->
46/37 ms. A recovery snapshot of a 6,000-paragraph document with the
picture: 99 -> 39 MB, worker time 2.3 -> 0.26 s, start-up scan 1.03 ->
0.14 s.

Tests: byte_store unit tests (round trip, old arrays, hostile values);
recovery_format.rs native_json_stores_bytes_as_base64,
native_json_with_byte_arrays_still_opens, native_json_with_bad_bytes_is_an_error,
ignored measurement native_json_size_with_a_large_picture; desktop.rs
copies_with_byte_arrays_written_before_still_restore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:10:23 -04:00
asystemoffields 9d22d69ee5 Contributors: add asystemoffields to people.toml 2026-10-15 09:24:09 -04:00
asystemoffieldsandClaude Opus 5.5 e500df7fb6 Opening a named pipe or a device no longer hangs or eats memory, whatever its name (#1444 follow-up)
The app read a file whose name isn't valid Unicode (#1440) with a plain
std::fs::read, and file.open on a path (also wordcraft-cli) checked only
the length before std::fs::read. Neither checked for a regular file: a
named pipe blocked the open (app start-up hung), and /dev/zero read until
memory ran out.

The new io::read_document reads a document path inside the automation
read root, only as a regular file of at most 2 GB, through the existing
read_regular_file. open_path_in and the app's open_non_unicode both use
it, so the two read a path the same way.

Regression tests: file.open on a FIFO and read_document on /dev/zero are
refused without blocking (engine), and the app's open of a FIFO or a
/dev/zero symlink under a non-Unicode name is a quick warning that leaves
the open document alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:24:08 -04:00
Kaan IlterandClaude Opus 5.5 624ce0bdc2 fix(fonts): emoji prefer a colour emoji font on Windows
Fallback put Segoe UI Symbol before Segoe UI Emoji, and Symbol has
monochrome glyphs for some emoji (✅), so they drew in black and white
and tests_color_glyphs failed on Windows. Characters with default emoji
presentation now rank the colour emoji fonts first, both when loading a
system fallback and when choosing among loaded faces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:44:48 +01:00
dfgodvanandClaude Opus 5.5 e4d9eb61b9 Tables: rows with gridBefore/gridAfter keep their grid columns (part of #1174)
A row that skips grid columns before its first cell (w:gridBefore) was laid
out from column 0, so its cells sat one or more columns to the left and
wrapped at the wrong width (a text cell landing on a 5 pt column sets one
letter per line). Row::first_grid() gives the column the row's first cell
starts at; Table::grid_col, cell_at_grid and cols(), the layout passes,
vertical-merge matching, cell selection and the insert-column spot count
from it, and cols() also counts gridAfter.

The skipped columns stay empty and borderless, as in Word: a row's own
first and last cell carry the table's outer side borders, and AutoFit gives
the skipped columns the row's wBefore/wAfter width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:54:55 +03:00
Guru Swarupa 70cfe06846 feat(navigation): implement collapsible headings in the navigation pane 2026-10-15 14:54:20 +05:30
fabiodalez-dev bdc7226fc7 i18n(it): translate the 15 strings other catalogs added since #1244
Paper and envelope sizes in the page-size list (decimal comma), the Go To and bookmark prompts, the print-job notice and the Letter template name the Croatian, Swedish and Vietnamese catalogs added. Italian again has every key the other catalogs have.
2026-10-15 11:20:32 +02:00
przemek-archandClaude Opus 5.5 7e33e5ae31 Ribbon: names for screen readers on the tab strip's drawn buttons (#1110)
Share and the » that holds the tabs that don't fit are painted, so a screen
reader had no name for them (» read as a glyph). They now say "Share" and
"More tabs", with "More tabs" translated in every catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:53:17 +02:00
rabisandClaude Opus 5.5 9066db8334 #1976: breaks_menu tests use the English interface
The Breaks menu tests look for English labels, but an integration test
links the library without cfg(test), so WordApp followed the machine's
interface language and both tests failed on a Polish Windows. Set the
app's language to English in layout_tab(), as native_print already does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:26:44 +02:00
Gert Pellin e9f1724f8a Merge branch 'main' into fix/1734-unit-suffix-translated 2026-10-15 09:06:42 +02:00
NetterSilverandClaude Haiku 5.5 8a0dcefb3f Read ^0nnn character codes 128-159 as Windows-1252 characters
Found while testing crates/engine/src/cmd/find_pattern.rs:97

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:53:50 +02:00
Dhruv 3ab6d287d1 Typing: Option+Shift+- types an em dash outside Outline view (#1933)
Alt+Shift+- is Word's Collapse shortcut, which only runs in Outline view, but
dispatch claimed the key and swallowed the typed character everywhere. An Alt
shortcut whose command is disabled now leaves the key to typing.
2026-10-14 22:42:37 -06:00
Patrik LermonandClaude Opus 5.5 8922ec6ff7 i18n(sv): translate 32 new strings
Brings the Swedish catalog up to date with upstream main (0aab569).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 06:42:25 +02:00
Nguyễn Đức a4b3a74655 ODT: read headers and footers from each master page (#863)
Headers and footers of an .odt live on its master pages in styles.xml
(style:header, -left, -first and the footer equivalents); import
skipped them, so a letterhead or logo at the top of every page was
lost.

They are now read with the body reader into header/footer parts and
named on their master page's section, so each section shows its own:
every page, left pages (turns on different odd and even pages) and
the first page (turns on a different first page). Hidden ones
(style:display="false") are left out. A master page without a header
or footer gets an empty one rather than continuing the previous
section's, as each ODF master page stands alone.
2026-10-15 09:11:33 +07:00
NetterSilverandClaude Haiku 5.5 6931b8db2a Draw EMF text records (EXTTEXTOUTW) in metafile pictures
Fixes #1764

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 02:50:12 +02:00
vangel76andClaude Sonnet 5.5 9acb1241b5 Charts: draw radar charts (standard, marker, filled) instead of a placeholder (#1890)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 01:31:37 +02:00
vangel76andClaude Sonnet 5.5 622df29a1e Layout: floats in table cells are not aligned in a zero-height area (#1887)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-15 01:25:34 +02:00
Gert Pellin 07479516dd Length fields show their unit in the interface language (#1734)
Centimetres, millimetres and picas took their suffix from Unit::suffix()
and never went through the catalog, so a Russian interface showed
"2,54 cm". The suffix now comes from the catalog like "pt" already did,
and a length field accepts the translated unit when typed ("3 см").
2026-10-15 00:55:59 +02:00
midasmatterhorn 16adb80b66 .doc import: symbol runs read their font and character, and paragraph borders are read 2026-10-15 00:11:03 +03:00
midasmatterhorn a643d4bec5 engine: Check spelling as you type is saved with the proofing options, so it survives a restart 2026-10-15 00:03:04 +03:00
asmodeus-oos 3fb47b910c test: txt export keeps list labels (verifies #1080) 2026-10-14 23:18:33 +03:00
asmodeus-oos 71d327163e test: end-of-document citation stays closed through typing and reload (probes #898, not reproducible) 2026-10-14 23:15:51 +03:00
asmodeus-oos 322454a8cb test: Farsi Yeh joins correctly (covers #824 shaping core) 2026-10-14 23:13:55 +03:00
midasmatterhorn 5b84776c05 formats: the Markdown code fence outgrows any backtick run in its content, so a round trip keeps it 2026-10-14 22:58:35 +03:00
midasmatterhorn 2d2e064ac6 engine: read_file reads only a bounded regular file, so a pipe or device path can't hang or exhaust memory 2026-10-14 22:55:50 +03:00
rabisandClaude Opus 5.5 9e608e85aa #1596: tables nest 24 deep at most; a paste that would go deeper keeps their text as paragraphs
Pasting a table into its own cell doubled the nesting each time, and about a
dozen pastes built a document that overflowed the stack when it was saved as
JSON, copied, closed or replaced by a new one.

Tables now nest at most 24 deep (wordcraft_doc::table::MAX_TABLE_DEPTH), the
depth the .docx reader and writer already keep. Document::insert_fragment
turns a table that would go deeper into its paragraphs, in document order;
Document::insert_block refuses it, so Insert > Table in a cell 24 tables deep
fails with a message and leaves the document as it was. The depth is measured
without recursion. Word has no limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:25:07 +02:00
MarekSpodBiedry be93f3b82d Tables: a table with its own width is not squeezed to the margins (#1721) 2026-10-14 19:32:58 +02:00
Saad Abdullah ad485db499 fix(geom): reject measurements that overflow to infinity in parse_measure (#1359) 2026-10-13 16:46:36 +00:00
234 changed files with 72445 additions and 2027 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \ pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libvulkan1 mesa-vulkan-drivers libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libvulkan1 mesa-vulkan-drivers
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with: with:
components: rustfmt, clippy components: rustfmt, clippy
targets: wasm32-unknown-unknown targets: wasm32-unknown-unknown
+1
View File
@@ -43,6 +43,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
- **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. - **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. - **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.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin and reads `Session` state; colours come from `theme::Tokens`. - **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin and reads `Session` state; colours come from `theme::Tokens`.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
## Quality gates ## Quality gates
+4
View File
@@ -11,10 +11,12 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `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.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/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-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/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/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/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/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 | | `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.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") | | `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") |
@@ -74,7 +76,9 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker | | `crates/ui-egui/src/i18n/ru.tsv` | Dmitrii Khlapov with Claude | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Russian interface catalog; reviewed by a native speaker |
| `crates/ui-egui/src/i18n/tr.tsv` | @PbyLoyth with Codex and Claude Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Turkish interface catalog | | `crates/ui-egui/src/i18n/tr.tsv` | @PbyLoyth with Codex and Claude Code | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Turkish interface catalog |
| `crates/ui-egui/src/i18n/bg.tsv` | Dimitar Klaturov (@bulgariamitko) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Bulgarian interface catalog | | `crates/ui-egui/src/i18n/bg.tsv` | Dimitar Klaturov (@bulgariamitko) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Bulgarian interface catalog |
| `crates/ui-egui/src/i18n/sl.tsv` | Filip Strajnar with Grok Bot | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Slovenian interface catalog |
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Hungarian interface catalog | | `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Hungarian interface catalog |
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | French interface catalog | | `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | French interface catalog |
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Swedish interface catalog; AI-assisted, terminology and the running app reviewed by a native speaker | | `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code | Original translation from the English interface labels in this repository; no other product localisation or other catalog used | MIT OR Apache-2.0 | Swedish interface catalog; AI-assisted, terminology and the running app reviewed by a native speaker |
| `crates/ui-egui/src/i18n/hr.tsv` | Andrej Škvorc (@andrejskvorc) with Claude Code (Sonnet 5.5); proofread with Gemini | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Croatian interface catalog; reviewed and corrected in the running interface, menu by menu, by the native-speaker author, who also checked the Gemini suggestions | | `crates/ui-egui/src/i18n/hr.tsv` | Andrej Škvorc (@andrejskvorc) with Claude Code (Sonnet 5.5); proofread with Gemini | Original translation from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 | Croatian interface catalog; reviewed and corrected in the running interface, menu by menu, by the native-speaker author, who also checked the Gemini suggestions |
| `crates/ui-egui/src/i18n/ro.tsv` | Viorel Toader (@vioreltus) with Claude Code (Opus 5.5) | Original translation from the English interface labels in this repository; no other product localisation copied (a few dozen short, generic labels were reused from the PdfCraft Romanian catalog written the same way) | MIT OR Apache-2.0 | Romanian interface catalog; AI-assisted draft, not yet reviewed line by line by a native speaker |
Generated
+38 -21
View File
@@ -704,6 +704,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "cipher" name = "cipher"
version = "0.5.2" version = "0.5.2"
@@ -2883,7 +2892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -5131,9 +5140,10 @@ dependencies = [
[[package]] [[package]]
name = "wordcraft" name = "wordcraft"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono",
"clipboard-win", "clipboard-win",
"craft-print", "craft-print",
"dispatch2", "dispatch2",
@@ -5164,7 +5174,7 @@ dependencies = [
[[package]] [[package]]
name = "wordcraft-cli" name = "wordcraft-cli"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"serde_json", "serde_json",
"sys-locale", "sys-locale",
@@ -5177,12 +5187,13 @@ dependencies = [
[[package]] [[package]]
name = "wordcraft-control-key" name = "wordcraft-control-key"
version = "0.9.0" version = "0.10.0"
[[package]] [[package]]
name = "wordcraft-doc" name = "wordcraft-doc"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"base64",
"proptest", "proptest",
"serde", "serde",
"serde_json", "serde_json",
@@ -5193,9 +5204,10 @@ dependencies = [
[[package]] [[package]]
name = "wordcraft-docbin" name = "wordcraft-docbin"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"cfb", "cfb",
"chrono",
"image", "image",
"log", "log",
"miniz_oxide 0.8.9", "miniz_oxide 0.8.9",
@@ -5209,11 +5221,12 @@ dependencies = [
[[package]] [[package]]
name = "wordcraft-docx" name = "wordcraft-docx"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"aes", "aes",
"base64", "base64",
"cfb", "cfb",
"chrono",
"getrandom 0.3.4", "getrandom 0.3.4",
"hmac", "hmac",
"image", "image",
@@ -5232,9 +5245,10 @@ dependencies = [
[[package]] [[package]]
name = "wordcraft-engine" name = "wordcraft-engine"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"cfb", "cfb",
"chrono",
"encoding_rs", "encoding_rs",
"getrandom 0.3.4", "getrandom 0.3.4",
"image", "image",
@@ -5261,7 +5275,7 @@ dependencies = [
[[package]] [[package]]
name = "wordcraft-fonts" name = "wordcraft-fonts"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"harfrust 0.13.3", "harfrust 0.13.3",
"kurbo", "kurbo",
@@ -5274,10 +5288,11 @@ dependencies = [
[[package]] [[package]]
name = "wordcraft-formats" name = "wordcraft-formats"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"cfb", "cfb",
"chardetng", "chardetng",
"chrono",
"encoding_rs", "encoding_rs",
"image", "image",
"js-sys", "js-sys",
@@ -5291,14 +5306,14 @@ dependencies = [
[[package]] [[package]]
name = "wordcraft-geom" name = "wordcraft-geom"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"serde", "serde",
] ]
[[package]] [[package]]
name = "wordcraft-layout" name = "wordcraft-layout"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"image", "image",
"js-sys", "js-sys",
@@ -5312,12 +5327,13 @@ dependencies = [
"wordcraft-doc", "wordcraft-doc",
"wordcraft-fonts", "wordcraft-fonts",
"wordcraft-geom", "wordcraft-geom",
"wordcraft-metafile",
"wordcraft-proof", "wordcraft-proof",
] ]
[[package]] [[package]]
name = "wordcraft-mcp" name = "wordcraft-mcp"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"log", "log",
"serde_json", "serde_json",
@@ -5329,7 +5345,7 @@ dependencies = [
[[package]] [[package]]
name = "wordcraft-metafile" name = "wordcraft-metafile"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"kurbo", "kurbo",
"thiserror 2.0.21", "thiserror 2.0.21",
@@ -5337,8 +5353,9 @@ dependencies = [
[[package]] [[package]]
name = "wordcraft-pdf" name = "wordcraft-pdf"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"chrono",
"craft-pdf", "craft-pdf",
"hayro-cmap", "hayro-cmap",
"hayro-interpret", "hayro-interpret",
@@ -5357,7 +5374,7 @@ dependencies = [
[[package]] [[package]]
name = "wordcraft-proof" name = "wordcraft-proof"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"log", "log",
"miniz_oxide 0.8.9", "miniz_oxide 0.8.9",
@@ -5366,7 +5383,7 @@ dependencies = [
[[package]] [[package]]
name = "wordcraft-render" name = "wordcraft-render"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"image", "image",
"log", "log",
@@ -5380,7 +5397,7 @@ dependencies = [
[[package]] [[package]]
name = "wordcraft-ui-egui" name = "wordcraft-ui-egui"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"cfb", "cfb",
"craft-ui", "craft-ui",
@@ -5407,7 +5424,7 @@ dependencies = [
[[package]] [[package]]
name = "wordcraft-web" name = "wordcraft-web"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"eframe", "eframe",
"egui", "egui",
@@ -5426,7 +5443,7 @@ dependencies = [
[[package]] [[package]]
name = "wordcraft-zotero" name = "wordcraft-zotero"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"log", "log",
"proptest", "proptest",
@@ -5539,7 +5556,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]] [[package]]
name = "xtask" name = "xtask"
version = "0.9.0" version = "0.10.0"
dependencies = [ dependencies = [
"serde_json", "serde_json",
] ]
+3 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"] default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package] [workspace.package]
version = "0.9.0" version = "0.10.0"
edition = "2024" edition = "2024"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
rust-version = "1.95" rust-version = "1.95"
@@ -35,6 +35,8 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] } serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] } serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4" log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1" proptest = "1"
rayon = "1" rayon = "1"
unicode-bidi = "0.3" unicode-bidi = "0.3"
+22
View File
@@ -52,6 +52,28 @@ list (author, source, license) is in ATTRIBUTION.md.
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 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 NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
OF THE POSSIBILITY OF SUCH DAMAGE. OF THE POSSIBILITY OF SUCH DAMAGE.
- German hyphenation patterns (assets/hyphenation/de.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:
Copyright (c) 2013-2024 Stephan Hennig, Werner Lemberg, Günter Milde, Sander van Geloven,
Georg Pfeiffer, Gisbert W. Selke, Tobias Wendorf, Keno Wehr
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.
- Italian hyphenation patterns (assets/hyphenation/it.pat) — hyph-it.tex from the hyph-utf8 - Italian hyphenation patterns (assets/hyphenation/it.pat) — hyph-it.tex from the hyph-utf8
package, Copyright (C) 2008-2011 Claudio Beccari, bundled unmodified and used under the MIT 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: License (offered under LPPL 1.3+ or MIT); the copyright and permission notice is in the file:
+5 -1
View File
@@ -7,6 +7,10 @@
</a> </a>
</p> </p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.wordcraft.png" width="128" height="128" alt="WordCraft icon: a document page and pen on a blue tile">
</p>
<h1 align="center">WordCraft</h1> <h1 align="center">WordCraft</h1>
<p align="center"> <p align="center">
@@ -120,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 | | **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 | | **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 | | **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, 한국어 or Svenska (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 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 | | **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 The honest picture, area by area, is in [ROADMAP.md](ROADMAP.md), the
+122 -7
View File
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -471,12 +471,13 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
} }
let mut s = match (from_file, from_template) { let mut s = match (from_file, from_template) {
(Some(f), _) => open(&f, password.as_deref())?, (Some(f), _) => open(&f, password.as_deref())?,
(None, Some(t)) => { // Both go through `file.new`, which puts a blank document on the region's paper (#3152).
(None, t) => {
let mut s = session(wordcraft_doc::Document::new()); let mut s = session(wordcraft_doc::Document::new());
s.run("file.new", &json!({"template": t})).map_err(|e| e.to_string())?; let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
s.run("file.new", &params).map_err(|e| e.to_string())?;
s s
} }
_ => session(wordcraft_doc::Document::new()),
}; };
if let Some(name) = arg_value(&rest, "--author") { if let Some(name) = arg_value(&rest, "--author") {
set_author(&mut s, &name)?; set_author(&mut s, &name)?;
+2 -1
View File
@@ -40,10 +40,11 @@ fn html_converts_onto_the_region_paper() {
#[test] #[test]
fn every_run_session_follows_the_region() { fn every_run_session_follows_the_region() {
// `run --file`, `run --template` and a bare `run` all start a session in the system region, // `run --file`, `run --template` and a bare `run` (with or without `file.new`) all start a session in the system region,
// so `file.new` gives the same paper whichever way the session began. // so `file.new` gives the same paper whichever way the session began.
for args in [ for args in [
&["run", "--cmd", "file.new", "--save", "{out}"][..], &["run", "--cmd", "file.new", "--save", "{out}"][..],
&["run", "--cmd", "text.insert={\"text\":\"x\"}", "--save", "{out}"][..],
&["run", "--template", "blank", "--save", "{out}"][..], &["run", "--template", "blank", "--save", "{out}"][..],
&["run", "--file", "{src}", "--cmd", "file.new", "--save", "{out}"][..], &["run", "--file", "{src}", "--cmd", "file.new", "--save", "{out}"][..],
] { ] {
+1
View File
@@ -23,6 +23,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
egui = { workspace = true } egui = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
log = { workspace = true } log = { workspace = true }
chrono = { workspace = true }
[build-dependencies] [build-dependencies]
# Icon + VERSIONINFO for wordcraft.exe; build.rs only uses it when the target is Windows. # Icon + VERSIONINFO for wordcraft.exe; build.rs only uses it when the target is Windows.
+5 -1
View File
@@ -93,8 +93,12 @@ fn picker(dialog: rfd::AsyncFileDialog, req: &FileDialogRequest) -> Picking {
match req.clone() { match req.clone() {
FileDialogRequest::Open { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_file().await.into_iter().collect() }), FileDialogRequest::Open { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_file().await.into_iter().collect() }),
FileDialogRequest::OpenMany { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_files().await.unwrap_or_default() }), FileDialogRequest::OpenMany { purpose } => Box::pin(async move { open_filters(dialog, &purpose).pick_files().await.unwrap_or_default() }),
FileDialogRequest::Save { name } => { FileDialogRequest::Save { name, dir } => {
let mut dialog = dialog.set_file_name(name.clone()); let mut dialog = dialog.set_file_name(name.clone());
// Save as Word Template starts in the user templates folder (#2852).
if let Some(dir) = dir.filter(|d| std::path::Path::new(d).is_dir()) {
dialog = dialog.set_directory(dir);
}
for (label, exts) in save_filters(&name) { for (label, exts) in save_filters(&name) {
dialog = dialog.add_filter(label, &exts); dialog = dialog.add_filter(label, &exts);
} }
+37 -16
View File
@@ -94,7 +94,10 @@ fn matches(name: &str, target: &str) -> bool {
} }
} }
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. /// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the
/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the
/// output never changes for a value the old formatter accepted and logging never panics.
pub fn timestamp(t: SystemTime) -> String { pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs(); let secs = since.as_secs();
@@ -103,20 +106,25 @@ pub fn timestamp(t: SystemTime) -> String {
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
} }
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's /// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock ///
/// value can overflow it. /// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
fn civil_from_days(days: u64) -> (u64, u64, u64) { /// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
let z = days.saturating_add(719_468); /// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
let era = z / 146_097; /// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
let doe = z % 146_097; /// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; /// is `num_days_from_ce` 719_163.
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); fn civil_from_days(days: u64) -> (i64, u32, u32) {
let mp = (5 * doy + 2) / 153; use chrono::Datelike;
let d = doy - (153 * mp + 2) / 5 + 1; let (cycles, rest) = (days / 146_097, days % 146_097);
let m = if mp < 10 { mp + 3 } else { mp - 9 }; // `rest + 719_163` is 719_163..865_259, always a valid CE day.
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
(y, m, d) Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
}
None => (1970, 1, 1), // unreachable
}
} }
fn numbered(dir: &Path, n: usize) -> PathBuf { fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -435,8 +443,21 @@ mod tests {
// 2000 is a leap year (divisible by 400), 2100 is not. // 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(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"); 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 instead of panicking. // 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"); 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] #[test]
+42 -4
View File
@@ -77,7 +77,7 @@ impl eframe::App for App {
{ {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
self.0.quit_requested = false; self.0.quit_requested = false;
schedule_macos_terminate(&self.0, self.2.as_ref()); schedule_macos_terminate(&mut self.0, self.2.as_ref());
} }
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
if self.0.quit_requested { if self.0.quit_requested {
@@ -94,6 +94,7 @@ impl eframe::App for App {
} }
fn on_exit(&mut self) { fn on_exit(&mut self) {
print::finish_pending_jobs(); print::finish_pending_jobs();
self.0.session.finish_recovery();
save_prefs(&self.0); save_prefs(&self.0);
if let Some(key_file) = &self.2 { if let Some(key_file) = &self.2 {
key_file.remove(); key_file.remove();
@@ -109,7 +110,7 @@ impl eframe::App for App {
/// removed first, as `on_exit` does, because a fallback [`std::process::exit`] /// removed first, as `on_exit` does, because a fallback [`std::process::exit`]
/// skips `on_exit`. A second close while a quit is already scheduled is ignored. /// skips `on_exit`. A second close while a quit is already scheduled is ignored.
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn schedule_macos_terminate(app: &WordApp, key_file: Option<&control_server::KeyFile>) { fn schedule_macos_terminate(app: &mut WordApp, key_file: Option<&control_server::KeyFile>) {
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
if app.printing.busy() { if app.printing.busy() {
return; return;
@@ -121,6 +122,7 @@ fn schedule_macos_terminate(app: &WordApp, key_file: Option<&control_server::Key
log::info!("window close: quitting"); log::info!("window close: quitting");
// This path can bypass on_exit. Join the owned workers before termination and cleanup. // This path can bypass on_exit. Join the owned workers before termination and cleanup.
print::finish_pending_jobs(); print::finish_pending_jobs();
app.session.finish_recovery();
save_prefs(app); save_prefs(app);
if let Some(key_file) = key_file { if let Some(key_file) = key_file {
key_file.remove(); key_file.remove();
@@ -244,6 +246,8 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
print_native: Some(print::submit()), print_native: Some(print::submit()),
clipboard_picture: Some(Box::new(clipboard::picture)), clipboard_picture: Some(Box::new(clipboard::picture)),
make_default_app: default_apps::hook(), make_default_app: default_apps::hook(),
// Personal templates live in `Templates` in the settings folder (#2852), made on first save.
templates_dir: wordcraft_control_key::settings_dir().map(|d| d.join("Templates")),
animations_enabled: Some(animations::hook(ctx)), animations_enabled: Some(animations::hook(ctx)),
clipboard_text: Some(Box::new(clipboard::text)), clipboard_text: Some(Box::new(clipboard::text)),
clipboard_html: Some(Box::new(clipboard::html)), clipboard_html: Some(Box::new(clipboard::html)),
@@ -303,10 +307,10 @@ fn text_args() -> (Vec<String>, Vec<std::path::PathBuf>) {
/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it /// 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. /// 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.
fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) { fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
let name = path.to_string_lossy(); let name = path.to_string_lossy();
let opened = std::fs::read(path) let opened = wordcraft_engine::io::read_document(path)
.map_err(|e| e.to_string())
.and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)}))); .and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})));
match opened { match opened {
// Not when it waits for a password: that open names the document itself. // Not when it waits for a password: that open names the document itself.
@@ -513,6 +517,40 @@ mod tests {
assert_eq!(app.session.path.as_deref(), Some(path.as_path())); assert_eq!(app.session.path.as_deref(), Some(path.as_path()));
let _ = std::fs::remove_dir_all(dir); let _ = std::fs::remove_dir_all(dir);
} }
/// A named pipe or a device under such a name is a warning, not a read: the pipe blocked
/// start-up until something wrote to it, and `/dev/zero` read until memory ran out.
#[test]
fn a_pipe_or_device_whose_name_isnt_unicode_is_not_read() {
let dir = std::env::temp_dir().join(format!("wc-app-non-utf8-dev-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let fifo = dir.join(std::ffi::OsString::from_vec(b"fif\xe9.txt".to_vec()));
if !std::process::Command::new("mkfifo").arg(&fifo).status().is_ok_and(|s| s.success()) {
let _ = std::fs::remove_dir_all(&dir);
eprintln!("mkfifo unavailable; skipping");
return;
}
let zero = dir.join(std::ffi::OsString::from_vec(b"z\xe9ro.txt".to_vec()));
std::os::unix::fs::symlink("/dev/zero", &zero).unwrap();
// If the open blocks on the pipe, a writer comes after 10 s, so the test fails rather than hangs.
let unblock = fifo.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(10));
let _ = std::fs::OpenOptions::new().write(true).open(unblock);
});
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
app.run("text.insert", serde_json::json!({"text": "kept"})).unwrap();
for p in [&fifo, &zero] {
let started = std::time::Instant::now();
open_non_unicode(&mut app, p);
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{p:?} took {:?}", started.elapsed());
assert_eq!(app.session.path, None, "{p:?}");
assert!(app.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("kept"), "{p:?}");
}
let _ = std::fs::remove_dir_all(dir);
}
} }
#[cfg(test)] #[cfg(test)]
File diff suppressed because it is too large Load Diff
+305
View File
@@ -0,0 +1,305 @@
# 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
+9
View File
@@ -166,3 +166,12 @@ real_name = "Max Murphy"
[people.SlavenDj] [people.SlavenDj]
sync_github_name = true sync_github_name = true
[people.asystemoffields]
display_name = "asystemoffields"
[people.dmim2502-commits]
display_name = "Mulai"
[people.Bortlesboat]
real_name = "Andrew Barnes"
+1
View File
@@ -10,6 +10,7 @@ workspace = true
[dependencies] [dependencies]
wordcraft-geom = { workspace = true } wordcraft-geom = { workspace = true }
base64 = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
+130
View File
@@ -0,0 +1,130 @@
//! How [`crate::Document::media`] and [`crate::Document::passthrough`] are written in the native
//! JSON form (`.json` files and crash-recovery copies): each part's bytes as one base64 string.
//! serde's default for bytes, an array of numbers, took about 3.6 times their size compact and
//! over six times pretty-printed (a 27 MB picture made a 99 MB recovery copy, #586).
//!
//! Reading takes either form, so `.json` files and recovery copies written before still open.
use std::collections::BTreeMap;
use std::fmt;
use std::sync::Arc;
use base64::Engine as _;
use base64::display::Base64Display;
use base64::engine::general_purpose::STANDARD;
use serde::de::{self, Deserializer, MapAccess, SeqAccess, Visitor};
use serde::ser::{Serialize, Serializer};
type Store = BTreeMap<String, Arc<Vec<u8>>>;
/// One part's bytes, encoded as it is written out: no second copy of a large picture is made.
struct Encoded<'a>(&'a [u8]);
impl Serialize for Encoded<'_> {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(&Base64Display::new(self.0, &STANDARD))
}
}
pub(crate) fn serialize<S: Serializer>(store: &Store, s: S) -> Result<S::Ok, S::Error> {
s.collect_map(store.iter().map(|(name, bytes)| (name, Encoded(bytes))))
}
pub(crate) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Store, D::Error> {
d.deserialize_map(StoreVisitor)
}
struct StoreVisitor;
impl<'de> Visitor<'de> for StoreVisitor {
type Value = Store;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a map of part names to base64 text or byte arrays")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Store, A::Error> {
let mut store = Store::new();
while let Some((name, Bytes(bytes))) = map.next_entry::<String, Bytes>()? {
store.insert(name, Arc::new(bytes));
}
Ok(store)
}
}
/// A part's bytes as either form.
struct Bytes(Vec<u8>);
impl<'de> de::Deserialize<'de> for Bytes {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
d.deserialize_any(BytesVisitor).map(Bytes)
}
}
struct BytesVisitor;
impl<'de> Visitor<'de> for BytesVisitor {
type Value = Vec<u8>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("base64 text or an array of bytes")
}
fn visit_str<E: de::Error>(self, text: &str) -> Result<Vec<u8>, E> {
STANDARD.decode(text).map_err(|e| E::custom(format!("bytes are not valid base64: {e}")))
}
fn visit_bytes<E: de::Error>(self, bytes: &[u8]) -> Result<Vec<u8>, E> {
Ok(bytes.to_vec())
}
/// The form written before: one number per byte.
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<u8>, A::Error> {
// The length hint comes from the file: trust it only so far.
let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0).min(1 << 20));
while let Some(b) = seq.next_element::<u8>()? {
out.push(b);
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug, Default)]
struct Parts {
#[serde(default, with = "super")]
media: Store,
}
fn parts(entries: &[(&str, &[u8])]) -> Parts {
Parts { media: entries.iter().map(|(n, b)| ((*n).to_string(), Arc::new(b.to_vec()))).collect() }
}
#[test]
fn bytes_are_written_as_base64_and_read_back() {
let all: Vec<u8> = (0..=255).collect();
for n in [0, 1, 2, 3, 4, 255, 256] {
let p = parts(&[("a.png", &all[..n.min(all.len())])]);
let text = serde_json::to_string(&p).unwrap();
assert_eq!(serde_json::from_str::<Parts>(&text).unwrap(), p, "{n}");
}
assert_eq!(serde_json::to_string(&parts(&[("m", b"Man"), ("ma", b"Ma")])).unwrap(), r#"{"media":{"m":"TWFu","ma":"TWE="}}"#);
}
#[test]
fn number_arrays_written_before_still_read() {
let old = r#"{"media":{"a.png":[0,1,127,128,254,255],"empty":[]}}"#;
assert_eq!(serde_json::from_str::<Parts>(old).unwrap(), parts(&[("a.png", &[0, 1, 127, 128, 254, 255]), ("empty", &[])]));
}
#[test]
fn hostile_values_are_errors_not_panics() {
for bad in [r#""T""#, r#""TWFu!""#, r#""-_""#, r#""TQ=x""#, "[256]", "[-1]", "[1.5]", "true", "{}", r#"["a"]"#] {
let text = format!(r#"{{"media":{{"a.png":{bad}}}}}"#);
assert!(serde_json::from_str::<Parts>(&text).is_err(), "{bad}");
}
assert!(serde_json::from_str::<Parts>(r#"{"media":"x"}"#).is_err());
}
}
+25 -1
View File
@@ -150,6 +150,8 @@ const HU: &[Tailor] = &[
const SV: &[Tailor] = &[t("å", 'z', 1), t("ä", 'z', 2), ts("æ", 'z', 2, 16), t("ö", 'z', 3), ts("ø", 'z', 3, 11)]; const SV: &[Tailor] = &[t("å", 'z', 1), t("ä", 'z', 2), ts("æ", 'z', 2, 16), t("ö", 'z', 3), ts("ø", 'z', 3, 11)];
const DA: &[Tailor] = &[t("æ", 'z', 1), ts("ä", 'z', 1, 4), t("ø", 'z', 2), ts("ö", 'z', 2, 4), t("å", 'z', 3)]; const DA: &[Tailor] = &[t("æ", 'z', 1), ts("ä", 'z', 1, 4), t("ø", 'z', 2), ts("ö", 'z', 2, 4), t("å", 'z', 3)];
const ES: &[Tailor] = &[t("ñ", 'n', 1)]; const ES: &[Tailor] = &[t("ñ", 'n', 1)];
// Russian ё shares е's primary weight; the diaeresis breaks ties (CLDR Russian standard).
const RU: &[Tailor] = &[ts("ё", 'е', 0, 4)];
const TR: &[Tailor] = &[t("ç", 'c', 1), t("ğ", 'g', 1), t("ı", 'h', 1), t("ö", 'o', 1), t("ş", 's', 1), t("ü", 'u', 1)]; const TR: &[Tailor] = &[t("ç", 'c', 1), t("ğ", 'g', 1), t("ı", 'h', 1), t("ö", 'o', 1), t("ş", 's', 1), t("ü", 'u', 1)];
const HR: &[Tailor] = const HR: &[Tailor] =
&[t("č", 'c', 1), t("ć", 'c', 2), t("dž", 'd', 1), t("đ", 'd', 2), t("lj", 'l', 1), t("nj", 'n', 1), t("š", 's', 1), t("ž", 'z', 1)]; &[t("č", 'c', 1), t("ć", 'c', 2), t("dž", 'd', 1), t("đ", 'd', 2), t("lj", 'l', 1), t("nj", 'n', 1), t("š", 's', 1), t("ž", 'z', 1)];
@@ -184,6 +186,7 @@ fn tailoring(lang: Option<&str>) -> &'static [Tailor] {
"sv" | "fi" => SV, "sv" | "fi" => SV,
"da" | "nb" | "nn" | "no" => DA, "da" | "nb" | "nn" | "no" => DA,
"es" | "gl" => ES, "es" | "gl" => ES,
"ru" => RU,
"tr" | "az" => TR, "tr" | "az" => TR,
"hr" | "bs" | "sr" => HR, "hr" | "bs" | "sr" => HR,
"ro" => RO, "ro" => RO,
@@ -196,7 +199,7 @@ fn tailoring(lang: Option<&str>) -> &'static [Tailor] {
} }
} }
/// A lowercase Latin letter followed by a combining mark, and the single letter Unicode /// A lowercase Latin or Russian letter followed by a combining mark, and the single letter Unicode
/// composes them to (NFC; generated from the Unicode Character Database). Text pasted from /// composes them to (NFC; generated from the Unicode Character Database). Text pasted from
/// decomposed sources (macOS file names: `z` + U+0307) collates like the composed letter. /// decomposed sources (macOS file names: `z` + U+0307) collates like the composed letter.
const COMPOSE: &[(char, char, char)] = &[ const COMPOSE: &[(char, char, char)] = &[
@@ -329,6 +332,8 @@ const COMPOSE: &[(char, char, char)] = &[
('z', '\u{302}', 'ẑ'), ('z', '\u{302}', 'ẑ'),
('z', '\u{307}', 'ż'), ('z', '\u{307}', 'ż'),
('z', '\u{30c}', 'ž'), ('z', '\u{30c}', 'ž'),
('е', '\u{308}', 'ё'),
('и', '\u{306}', 'й'),
]; ];
/// `s` lowercased, with each letter + combining mark that has a single-letter form composed. /// `s` lowercased, with each letter + combining mark that has a single-letter form composed.
@@ -476,6 +481,25 @@ mod tests {
assert_eq!(sorted(&["Straße", "Strasse", "Strand"], "de"), ["Strand", "Strasse", "Straße"]); assert_eq!(sorted(&["Straße", "Strasse", "Strand"], "de"), ["Strand", "Strasse", "Straße"]);
} }
#[test]
fn russian_yo_sorts_with_e_instead_of_after_ya() {
for lang in ["ru", "ru-RU", "RU_ru"] {
assert_eq!(sorted(&["яблоко", "ёлка", "жук", "ель", "ёж", "еж"], lang), ["еж", "ёж", "ёлка", "ель", "жук", "яблоко"]);
assert_eq!(collate("ЁЛКА", "ёлка", Some(lang)), Ordering::Equal);
assert_eq!(heading_letter("Ёлка", Some(lang)).as_deref(), Some("Ё"));
}
}
#[test]
fn russian_decomposed_letters_match_their_composed_forms() {
for (composed, decomposed) in [("ёлка", "е\u{308}лка"), ("Ёлка", "Е\u{308}лка"), ("йод", "и\u{306}од"), ("Йод", "И\u{306}од")]
{
assert_eq!(collate(composed, decomposed, Some("ru-RU")), Ordering::Equal);
assert_eq!(heading_letter(composed, Some("ru")), heading_letter(decomposed, Some("ru")));
}
assert_eq!(sorted(&["к", "и\u{306}", "и"], "ru"), ["и", "и\u{306}", "к"]);
}
#[test] #[test]
fn spanish_n_tilde_after_n() { fn spanish_n_tilde_after_n() {
assert_eq!(sorted(&["oso", "ñu", "nube"], "es-ES"), ["nube", "ñu", "oso"]); assert_eq!(sorted(&["oso", "ñu", "nube"], "es-ES"), ["nube", "ñu", "oso"]);
+4
View File
@@ -41,6 +41,10 @@ pub struct ShapeExtra {
/// The outline dash pattern; absent in older saved documents means solid. /// The outline dash pattern; absent in older saved documents means solid.
#[serde(skip_serializing_if = "crate::line_dash::LineDash::is_solid")] #[serde(skip_serializing_if = "crate::line_dash::LineDash::is_solid")]
pub dash: crate::line_dash::LineDash, pub dash: crate::line_dash::LineDash,
/// The outline's gradient (`a:ln/a:gradFill`), kept for saving; the outline is drawn in its
/// main colour (the shape's `stroke`).
#[serde(skip_serializing_if = "Option::is_none")]
pub outline_fill: Option<crate::fill::Fill>,
} }
fn is_zero(v: &u32) -> bool { fn is_zero(v: &u32) -> bool {
+128 -2
View File
@@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
use crate::para::InlineObject; use crate::para::InlineObject;
use crate::props::{CharProps, ParaProps}; use crate::props::{CharProps, ParaProps};
use crate::{Block, Blocks, DocError, Document, Paragraph, Part, PartKind, Path, Pos, Result, para_block}; use crate::{Block, Blocks, DocError, Document, Paragraph, Part, PartKind, Path, Pos, Result, para_block};
use crate::{Table, table::MAX_TABLE_DEPTH};
/// A copied piece of a document: blocks, where the first and last paragraphs may be partial. /// A copied piece of a document: blocks, where the first and last paragraphs may be partial.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
@@ -136,6 +137,53 @@ fn order(a: &Pos, b: &Pos) -> (Pos, Pos) {
if a <= b { (a.clone(), b.clone()) } else { (b.clone(), a.clone()) } if a <= b { (a.clone(), b.clone()) } else { (b.clone(), a.clone()) }
} }
/// The blocks in `t`'s cells, row by row.
fn cell_blocks(t: &Table) -> impl DoubleEndedIterator<Item = &Block> {
t.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.blocks.iter()).map(|b| &**b)
}
/// How many tables deep `t` goes, itself included: 1 when its cells hold no table (without
/// recursion: a pasted fragment may be deep).
fn table_height(t: &Table) -> usize {
let mut most = 1;
let mut stack: Vec<(&Block, usize)> = cell_blocks(t).map(|b| (b, 1)).collect();
while let Some((b, d)) = stack.pop() {
if let Block::Table(inner) = b {
let d = d.saturating_add(1);
most = most.max(d);
stack.extend(cell_blocks(inner).map(|cb| (cb, d)));
}
}
most
}
/// `b` as blocks nesting at most `room` tables deep: a table that would go deeper is replaced by
/// its paragraphs (tables in its cells too, document order), as the DOCX reader does.
fn fit_tables(b: Block, room: usize, out: &mut Vec<Block>) {
match b {
Block::Table(t) if room == 0 => {
let mut stack: Vec<&Block> = cell_blocks(&t).rev().collect();
while let Some(cb) = stack.pop() {
match cb {
Block::Para(p) => out.push(Block::Para(p.clone())),
Block::Table(inner) => stack.extend(cell_blocks(inner).rev()),
}
}
}
Block::Table(mut t) if table_height(&t) > room => {
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
let mut kept = Vec::new();
for cb in std::mem::take(&mut cell.blocks) {
fit_tables(Arc::unwrap_or_clone(cb), room.saturating_sub(1), &mut kept);
}
cell.blocks = kept.into_iter().map(Arc::new).collect();
}
out.push(Block::Table(t));
}
b => out.push(b),
}
}
/// Nesting limit when following text boxes inside text boxes. /// Nesting limit when following text boxes inside text boxes.
const MAX_BOX_DEPTH: usize = 8; const MAX_BOX_DEPTH: usize = 8;
/// Most text box stories one paste may create (hostile documents can fan out). /// Most text box stories one paste may create (hostile documents can fan out).
@@ -361,8 +409,14 @@ impl Document {
Ok(Pos { story: pos.story, path: pos.path.with_last(i + 1), off: 0 }) Ok(Pos { story: pos.story, path: pos.path.with_last(i + 1), off: 0 })
} }
/// Insert a block before the block at `path` (same container). /// Insert a block before the block at `path` (same container). A table that would nest
/// deeper than [`MAX_TABLE_DEPTH`] is refused.
pub fn insert_block(&mut self, story: crate::StoryRef, path: &Path, block: Block) -> Result<()> { pub fn insert_block(&mut self, story: crate::StoryRef, path: &Path, block: Block) -> Result<()> {
if let Block::Table(t) = &block
&& path.depth().saturating_add(table_height(t)) > MAX_TABLE_DEPTH
{
return Err(DocError::Invalid(format!("tables can't nest more than {MAX_TABLE_DEPTH} deep")));
}
let i = path.last(); let i = path.last();
let bl = self.container_mut(story, path)?; let bl = self.container_mut(story, path)?;
if i > bl.len() { if i > bl.len() {
@@ -736,7 +790,12 @@ impl Document {
/// Insert a fragment at `pos`; returns the position after the pasted content. /// Insert a fragment at `pos`; returns the position after the pasted content.
pub fn insert_fragment(&mut self, pos: &Pos, frag: &Fragment) -> Result<Pos> { pub fn insert_fragment(&mut self, pos: &Pos, frag: &Fragment) -> Result<Pos> {
let mut blocks = frag.blocks.clone(); // Tables that would nest deeper than the limit here become their paragraphs (#1596).
let room = MAX_TABLE_DEPTH.saturating_sub(pos.path.depth());
let mut blocks = Vec::with_capacity(frag.blocks.len());
for b in frag.blocks.iter().cloned() {
fit_tables(b, room, &mut blocks);
}
if blocks.is_empty() { if blocks.is_empty() {
return Ok(pos.clone()); return Ok(pos.clone());
} }
@@ -941,6 +1000,73 @@ mod tests {
assert!(d.plain_text(StoryRef::Body).starts_with("XYalpha")); 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 { 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()))]); 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(); d.insert_object(pos, box_shape(Some(id)), &CharProps::default()).unwrap();
+66 -6
View File
@@ -1,7 +1,8 @@
//! Windows-1252 (the byte encoding legacy Word text pieces use) and the other single-byte Windows //! Windows-1252 (the byte encoding legacy Word text pieces use) and the other single-byte Windows
//! code pages RTF files name with `\ansicpg` or a font's `\fcharset` (Cyrillic 1251, Central //! code pages RTF files name with `\ansicpg` or a font's `\fcharset` (Cyrillic 1251, Central
//! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai), and Mac OS Roman //! European 1250, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai), Mac OS Roman
//! (10000, RTF `\mac`). //! (10000, RTF `\mac`), and the East Asian multibyte code pages (932 Shift-JIS, 936 GBK, 949 UHC,
//! 950 Big5), whose bytes decode as runs rather than one at a time.
/// Windows-1252 code points for 0x80..=0x9F. /// Windows-1252 code points for 0x80..=0x9F.
const CP1252: [u16; 32] = [ const CP1252: [u16; 32] = [
@@ -84,7 +85,7 @@ const CP1255: [u16; 128] = [
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x00A1, 0x00A2, 0x00A3, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0xFFFD, 0x203A, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x00A0, 0x00A1, 0x00A2, 0x00A3,
0x20AA, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00D7, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5, 0x20AA, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x00D7, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00F7, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x05B0, 0x05B1, 0x05B2, 0x05B3, 0x05B4, 0x05B5, 0x05B6, 0x05B7, 0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x00F7, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x00BF, 0x05B0, 0x05B1, 0x05B2, 0x05B3, 0x05B4, 0x05B5, 0x05B6, 0x05B7,
0x05B8, 0x05B9, 0xFFFD, 0x05BB, 0x05BC, 0x05BD, 0x05BE, 0x05BF, 0x05C0, 0x05C1, 0x05C2, 0x05C3, 0x05F0, 0x05F1, 0x05F2, 0x05F3, 0x05F4, 0xFFFD, 0x05B8, 0x05B9, 0x05BA, 0x05BB, 0x05BC, 0x05BD, 0x05BE, 0x05BF, 0x05C0, 0x05C1, 0x05C2, 0x05C3, 0x05F0, 0x05F1, 0x05F2, 0x05F3, 0x05F4, 0xFFFD,
0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5, 0x05D6, 0x05D7, 0x05D8, 0x05D9, 0x05DA, 0x05DB, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0xFFFD, 0x05D0, 0x05D1, 0x05D2, 0x05D3, 0x05D4, 0x05D5, 0x05D6, 0x05D7, 0x05D8, 0x05D9, 0x05DA, 0x05DB,
0x05DC, 0x05DD, 0x05DE, 0x05DF, 0x05E0, 0x05E1, 0x05E2, 0x05E3, 0x05E4, 0x05E5, 0x05E6, 0x05E7, 0x05E8, 0x05E9, 0x05EA, 0xFFFD, 0xFFFD, 0x200E, 0x05DC, 0x05DD, 0x05DE, 0x05DF, 0x05E0, 0x05E1, 0x05E2, 0x05E3, 0x05E4, 0x05E5, 0x05E6, 0x05E7, 0x05E8, 0x05E9, 0x05EA, 0xFFFD, 0xFFFD, 0x200E,
0x200F, 0xFFFD, 0x200F, 0xFFFD,
@@ -286,13 +287,47 @@ pub fn code_page_byte(code_page: u16, b: u8) -> char {
if b >= 0x80 && OEM_CODE_PAGES.contains(&code_page) { oem_byte(code_page, b) } else { windows_byte(code_page, b) } if b >= 0x80 && OEM_CODE_PAGES.contains(&code_page) { oem_byte(code_page, b) } else { windows_byte(code_page, b) }
} }
/// The East Asian multibyte code pages a document format names with `\ansicpg` or a font's `\cpg`:
/// 932 Shift-JIS (Japanese), 936 GBK (Simplified Chinese), 949 UHC (Korean) and 950 Big5
/// (Traditional Chinese).
pub const MULTIBYTE_CODE_PAGES: [u16; 4] = [932, 936, 949, 950];
/// Whether `code_page` is one of the East Asian multibyte code pages ([`MULTIBYTE_CODE_PAGES`]):
/// its bytes decode as a run, a lead byte together with its trail byte.
pub fn is_multibyte_code_page(code_page: u16) -> bool {
multibyte_encoding_name(code_page).is_some()
}
/// The WHATWG encoding name of an East Asian multibyte code page (see [`MULTIBYTE_CODE_PAGES`]),
/// for `encoding_rs::Encoding::for_label`; `None` for the single-byte code pages, whose bytes
/// decode one at a time, and anything else.
pub fn multibyte_encoding_name(code_page: u16) -> Option<&'static str> {
Some(match code_page {
932 => "shift_jis",
936 => "gbk",
949 => "euc-kr",
950 => "big5",
_ => return None,
})
}
/// Whether a document format can name `code_page` for its text: a single-byte Windows, DOS or Mac
/// one ([`is_byte_code_page`]) or an East Asian multibyte one ([`is_multibyte_code_page`]).
pub fn is_text_code_page(code_page: u16) -> bool {
is_byte_code_page(code_page) || is_multibyte_code_page(code_page)
}
/// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern /// The code page of an RTF or Word font charset (`\fcharset`): 204 Russian → 1251, 238 Eastern
/// European → 1250, 77 Mac → 10000, 254 PC / 255 OEM → 437… `None` for the ANSI and default charsets (the /// European → 1250, 77 Mac → 10000, 254 PC / 255 OEM → 437, 128 Japanese → 932, 129 Korean → 949,
/// document's code page applies), symbol fonts and the double-byte East Asian charsets. /// 134 Simplified Chinese → 936, 136 Traditional Chinese → 950… `None` for the ANSI and default
/// charsets (the document's code page applies) and symbol fonts.
pub fn charset_code_page(charset: i32) -> Option<u16> { pub fn charset_code_page(charset: i32) -> Option<u16> {
Some(match charset { Some(match charset {
77 => MAC_ROMAN_CODE_PAGE, 77 => MAC_ROMAN_CODE_PAGE,
254 | 255 => 437, 128 => 932,
129 => 949,
134 => 936,
136 => 950,
161 => 1253, 161 => 1253,
162 => 1254, 162 => 1254,
163 => 1258, 163 => 1258,
@@ -302,6 +337,7 @@ pub fn charset_code_page(charset: i32) -> Option<u16> {
204 => 1251, 204 => 1251,
222 => 874, 222 => 874,
238 => 1250, 238 => 1250,
254 | 255 => 437,
_ => return None, _ => return None,
}) })
} }
@@ -333,6 +369,11 @@ mod tests {
} }
} }
#[test]
fn windows_1255_holam_haser_for_vav() {
assert_eq!(windows_byte(1255, 0xCA), '\u{05BA}');
}
/// #1653: one decoder for document text in any code page RTF names: Windows, DOS or Mac. /// #1653: one decoder for document text in any code page RTF names: Windows, DOS or Mac.
#[test] #[test]
fn document_text_in_windows_dos_and_mac_code_pages() { fn document_text_in_windows_dos_and_mac_code_pages() {
@@ -347,6 +388,25 @@ mod tests {
assert!(!is_byte_code_page(932) && !is_byte_code_page(0) && !SINGLE_BYTE_CODE_PAGES.contains(&10000)); assert!(!is_byte_code_page(932) && !is_byte_code_page(0) && !SINGLE_BYTE_CODE_PAGES.contains(&10000));
} }
/// #1653: the East Asian charsets and code pages RTF names are multibyte: their bytes decode
/// as runs (a lead byte and its trail byte together), not one at a time.
#[test]
fn east_asian_code_pages_are_multibyte() {
assert_eq!(charset_code_page(128), Some(932), "Japanese");
assert_eq!(charset_code_page(129), Some(949), "Korean");
assert_eq!(charset_code_page(134), Some(936), "Simplified Chinese");
assert_eq!(charset_code_page(136), Some(950), "Traditional Chinese");
assert_eq!(charset_code_page(255), Some(437), "OEM is single-byte");
assert_eq!(MULTIBYTE_CODE_PAGES, [932, 936, 949, 950]);
for cp in MULTIBYTE_CODE_PAGES {
assert!(is_multibyte_code_page(cp) && is_text_code_page(cp), "{cp}");
assert!(!is_byte_code_page(cp) && !SINGLE_BYTE_CODE_PAGES.contains(&cp), "{cp}");
assert!(multibyte_encoding_name(cp).is_some(), "{cp}");
}
assert!(is_text_code_page(1251) && is_text_code_page(437) && is_text_code_page(10000));
assert!(!is_text_code_page(0) && !is_text_code_page(9999));
}
#[test] #[test]
fn oem_code_pages() { fn oem_code_pages() {
let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| oem_byte(cp, b)).collect::<String>(); let decode = |cp: u16, bytes: &[u8]| bytes.iter().map(|&b| oem_byte(cp, b)).collect::<String>();
+237
View File
@@ -0,0 +1,237 @@
//! Shape fills: what paints a shape's, text box's or canvas's interior, and a shape outline's
//! gradient (DrawingML `a:solidFill` / `a:gradFill`, ECMA-376 §20.1.8.54 and §20.1.8.33; VML
//! `v:fill`).
//!
//! A gradient is kept as the file states it (stops, linear angle or path focus, tile rectangle,
//! `rotWithShape`, flip) so saving writes it back; drawing approximates rectangular and
//! shape-following paths as circular. Transparencies are percent (0 = opaque); rectangles are
//! percent insets (left, top, right, bottom) from the shape's edges.
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::sync::Arc;
use crate::props::Rgb;
#[cfg(test)]
use crate::wordart::MAX_STOPS;
use crate::wordart::{GradStop, clamp, sanitized_stops};
/// A shape's fill. In `.json` documents an opaque solid fill is written `[r, g, b]` as before
/// fills had transparency or gradients; the rest are tagged (`{"type": "gradient", ...}`).
#[derive(Clone, Debug, PartialEq)]
pub enum Fill {
Solid { color: Rgb, transparency: f32 },
Gradient(Arc<Gradient>),
}
/// A gradient fill.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Gradient {
pub stops: Vec<GradStop>,
pub kind: GradientKind,
/// `a:tileRect`: where the gradient's area sits relative to the shape (percent insets).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tile: Option<[f32; 4]>,
/// Turns with the shape (`rotWithShape`).
#[serde(default)]
pub rot_with_shape: bool,
#[serde(default, skip_serializing_if = "GradientFlip::is_none")]
pub flip: GradientFlip,
}
/// How a gradient runs.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum GradientKind {
/// Along a line at `angle` degrees (0 = left to right, 90 = top to bottom). `scaled`: the
/// angle is scaled with the shape's aspect ratio.
Linear {
angle: f32,
#[serde(default)]
scaled: bool,
},
/// Outwards from `focus` (percent insets from the shape's edges, `a:fillToRect`).
Path { shape: PathShape, focus: [f32; 4] },
}
/// A path gradient's outline (`a:path/@path`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PathShape {
Circle,
Rect,
Shape,
}
/// How the gradient's tile is mirrored when tiled (`a:gradFill/@flip`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GradientFlip {
#[default]
None,
X,
Y,
Xy,
}
impl GradientFlip {
pub fn is_none(&self) -> bool {
*self == GradientFlip::None
}
}
/// Largest rectangle inset kept, percent (hostile files).
const MAX_INSET: f32 = 1000.0;
fn rect(r: [f32; 4]) -> [f32; 4] {
r.map(|v| clamp(v, -MAX_INSET, MAX_INSET, 0.0))
}
impl Fill {
/// An opaque solid fill.
pub fn solid(color: Rgb) -> Fill {
Fill::Solid { color, transparency: 0.0 }
}
/// The one colour standing for the fill where only one fits: the solid colour, or a
/// gradient's middle stop.
pub fn main_color(&self) -> Rgb {
match self {
Fill::Solid { color, .. } => *color,
Fill::Gradient(g) => g.stops.get(g.stops.len() / 2).map_or(Rgb::BLACK, |s| s.color),
}
}
/// Finite, in range: at most [`MAX_STOPS`] stops, in order, positions and transparencies
/// 0–100%, angles 0–360°. A gradient with one stop is that stop's solid colour; with none,
/// no fill (`None`).
pub fn sanitized(&self) -> Option<Fill> {
Some(match self {
Fill::Solid { color, transparency } => Fill::Solid { color: *color, transparency: clamp(*transparency, 0.0, 100.0, 0.0) },
Fill::Gradient(g) => {
let stops = sanitized_stops(&g.stops);
match stops[..] {
[] => return None,
[s] => return Some(Fill::Solid { color: s.color, transparency: s.transparency }),
_ => {}
}
let kind = match g.kind {
GradientKind::Linear { angle, scaled } => {
GradientKind::Linear { angle: clamp(angle, -1e6, 1e6, 0.0).rem_euclid(360.0) % 360.0, scaled }
}
GradientKind::Path { shape, focus } => GradientKind::Path { shape, focus: rect(focus) },
};
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: g.tile.map(rect), rot_with_shape: g.rot_with_shape, flip: g.flip }))
}
})
}
}
/// The tagged `.json` form of fills other than opaque solid ones.
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
enum Tagged {
Solid {
color: Rgb,
#[serde(default)]
transparency: f32,
},
Gradient(Gradient),
}
#[derive(Deserialize)]
#[serde(untagged)]
enum AnyForm {
Plain(Rgb),
Tagged(Tagged),
}
impl Serialize for Fill {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
Fill::Solid { color, transparency: 0.0 } => color.serialize(s),
Fill::Solid { color, transparency } => Tagged::Solid { color: *color, transparency: *transparency }.serialize(s),
Fill::Gradient(g) => Tagged::Gradient(Gradient::clone(g)).serialize(s),
}
}
}
impl<'de> Deserialize<'de> for Fill {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Fill, D::Error> {
Ok(match AnyForm::deserialize(d)? {
AnyForm::Plain(color) => Fill::solid(color),
AnyForm::Tagged(Tagged::Solid { color, transparency }) => Fill::Solid { color, transparency },
AnyForm::Tagged(Tagged::Gradient(g)) => Fill::Gradient(Arc::new(g)),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stop(pos: f32, color: Rgb, transparency: f32) -> GradStop {
GradStop { pos, color, transparency }
}
fn grad(stops: Vec<GradStop>, kind: GradientKind) -> Fill {
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: None, rot_with_shape: true, flip: GradientFlip::None }))
}
#[test]
fn opaque_solid_fills_are_written_as_before_and_old_documents_load() {
let f = Fill::solid(Rgb(1, 2, 3));
assert_eq!(serde_json::to_string(&f).ok().as_deref(), Some("[1,2,3]"));
assert_eq!(serde_json::from_str::<Fill>("[1,2,3]").ok(), Some(f));
}
#[test]
fn transparent_and_gradient_fills_round_trip_tagged() {
let fills = [
Fill::Solid { color: Rgb::WHITE, transparency: 70.0 },
grad(vec![stop(0.0, Rgb(9, 9, 9), 0.0), stop(100.0, Rgb::WHITE, 50.0)], GradientKind::Linear { angle: 90.0, scaled: true }),
Fill::Gradient(Arc::new(Gradient {
stops: vec![stop(0.0, Rgb::BLACK, 0.0), stop(100.0, Rgb::WHITE, 0.0)],
kind: GradientKind::Path { shape: PathShape::Rect, focus: [50.0, 50.0, 50.0, 50.0] },
tile: Some([-10.0, 0.0, 0.0, 0.0]),
rot_with_shape: false,
flip: GradientFlip::Xy,
})),
];
for f in fills {
let json = serde_json::to_string(&f).unwrap_or_default();
assert!(json.contains("\"type\""), "{json}");
assert_eq!(serde_json::from_str::<Fill>(&json).ok(), Some(f), "{json}");
}
}
#[test]
fn hostile_fills_are_sanitized() {
let many: Vec<GradStop> = (0..30).map(|i| stop(100.0 - i as f32, Rgb::BLACK, f32::NAN)).collect();
let Some(Fill::Gradient(g)) = grad(many, GradientKind::Linear { angle: -450.0, scaled: false }).sanitized() else { panic!() };
assert_eq!(g.stops.len(), MAX_STOPS);
assert!(g.stops.windows(2).all(|w| w[0].pos <= w[1].pos) && g.stops.iter().all(|s| s.transparency == 0.0));
assert_eq!(g.kind, GradientKind::Linear { angle: 270.0, scaled: false });
let path = grad(
vec![stop(150.0, Rgb::BLACK, 200.0), stop(-5.0, Rgb::WHITE, 0.0)],
GradientKind::Path { shape: PathShape::Circle, focus: [f32::NAN, 1e9, -1e9, 5.0] },
);
let Some(Fill::Gradient(g)) = path.sanitized() else { panic!() };
assert_eq!((g.stops[0].pos, g.stops[1].pos, g.stops[1].transparency), (0.0, 100.0, 100.0));
assert_eq!(g.kind, GradientKind::Path { shape: PathShape::Circle, focus: [0.0, MAX_INSET, -MAX_INSET, 5.0] });
assert_eq!(
grad(vec![stop(0.0, Rgb::WHITE, 30.0)], GradientKind::Linear { angle: 0.0, scaled: false }).sanitized(),
Some(Fill::Solid { color: Rgb::WHITE, transparency: 30.0 })
);
assert_eq!(grad(vec![], GradientKind::Linear { angle: 0.0, scaled: false }).sanitized(), None);
assert_eq!(Fill::Solid { color: Rgb::BLACK, transparency: f32::NAN }.sanitized(), Some(Fill::solid(Rgb::BLACK)));
}
#[test]
fn main_colour_is_the_middle_stop() {
let g = grad(
vec![stop(0.0, Rgb(1, 0, 0), 0.0), stop(50.0, Rgb(2, 0, 0), 0.0), stop(100.0, Rgb(3, 0, 0), 0.0)],
GradientKind::Linear { angle: 0.0, scaled: false },
);
assert_eq!(g.main_color(), Rgb(2, 0, 0));
assert_eq!(Fill::solid(Rgb::WHITE).main_color(), Rgb::WHITE);
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ pub struct Graphic {
pub struct Embedded { pub struct Embedded {
/// The object's markup as a standalone XML element with its namespace declarations (DOCX: /// The object's markup as a standalone XML element with its namespace declarations (DOCX:
/// the `a:graphic` of a chart, SmartArt diagram or native ink, an ink `mc:AlternateContent`, /// the `a:graphic` of a chart, SmartArt diagram or native ink, an ink `mc:AlternateContent`,
/// or the `w:object` of an OLE object). /// the `w:object` of an OLE object, or the `w:pict` of a signature line).
pub xml: String, pub xml: String,
/// The relationships it uses, by the ids in `xml` (or in its parts, for a SmartArt drawing). /// The relationships it uses, by the ids in `xml` (or in its parts, for a SmartArt drawing).
pub refs: Vec<EmbeddedRef>, pub refs: Vec<EmbeddedRef>,
+85 -8
View File
@@ -11,6 +11,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod bidi; pub mod bidi;
mod byte_store;
pub mod chart; pub mod chart;
mod cjk_numbers; mod cjk_numbers;
pub mod cluster; pub mod cluster;
@@ -22,6 +23,7 @@ pub mod effects;
pub mod encoding; pub mod encoding;
pub mod eq; pub mod eq;
pub mod fields; pub mod fields;
pub mod fill;
pub mod freeform; pub mod freeform;
pub mod graphic; pub mod graphic;
pub mod legacy_form; pub mod legacy_form;
@@ -631,11 +633,40 @@ pub struct Watermark {
pub color: Rgb, pub color: Rgb,
pub diagonal: bool, pub diagonal: bool,
pub semitransparent: 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 { impl Default for Watermark {
fn default() -> Self { fn default() -> Self {
Watermark { text: "DRAFT".into(), font: styles::BODY_FONT.into(), color: Rgb(0xC0, 0xC0, 0xC0), diagonal: true, semitransparent: true } 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)]
} }
} }
@@ -722,11 +753,11 @@ pub struct Document {
/// The document's own endnote separators and continuation notice. /// The document's own endnote separators and continuation notice.
#[serde(skip_serializing_if = "NoteSeparators::is_empty")] #[serde(skip_serializing_if = "NoteSeparators::is_empty")]
pub endnote_separators: NoteSeparators, pub endnote_separators: NoteSeparators,
/// Embedded media (images) by key. /// Embedded media (images) by key. Native JSON writes each as base64 (`byte_store.rs`).
#[serde(skip_serializing_if = "BTreeMap::is_empty")] #[serde(skip_serializing_if = "BTreeMap::is_empty", with = "byte_store")]
pub media: BTreeMap<String, Arc<Vec<u8>>>, pub media: BTreeMap<String, Arc<Vec<u8>>>,
/// Source-format parts we keep verbatim for round-trip (e.g. custom XML), by part name. /// Source-format parts we keep verbatim for round-trip (e.g. custom XML), by part name.
#[serde(skip_serializing_if = "BTreeMap::is_empty")] #[serde(skip_serializing_if = "BTreeMap::is_empty", with = "byte_store")]
pub passthrough: BTreeMap<String, Arc<Vec<u8>>>, pub passthrough: BTreeMap<String, Arc<Vec<u8>>>,
/// The document's own building blocks (its glossary document), in file order. /// The document's own building blocks (its glossary document), in file order.
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
@@ -1079,10 +1110,48 @@ impl Document {
/// The body, then the text boxes, footnotes and endnotes it shows (nested ones too), in /// 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. /// document order. Headers, footers, comments and stories nothing points at aren't included.
pub fn counted_stories(&self) -> Vec<StoryRef> { pub fn counted_stories(&self) -> Vec<StoryRef> {
self.reachable(vec![StoryRef::Body], &|o| match o { self.reachable(vec![StoryRef::Body], &notes_and_boxes)
InlineObject::NoteRef { id, .. } => vec![(*id, &[PartKind::Footnote, PartKind::Endnote][..])], }
o => o.text_boxes().into_iter().map(|id| (id, &[PartKind::TextBox][..])).collect(),
}) /// 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 /// Drop the text box stories no box shows any more (a deleted or cut box's text), nested
@@ -1452,6 +1521,14 @@ pub fn count_words(s: &str) -> usize {
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped. /// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
pub(crate) const PARA_WALK_DEPTH: usize = 16; pub(crate) const PARA_WALK_DEPTH: usize = 16;
/// The note and text box stories an object shows, for `Document::reachable`.
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(),
}
}
fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) { fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
if depth > PARA_WALK_DEPTH { if depth > PARA_WALK_DEPTH {
return; return;
+12 -1
View File
@@ -8,7 +8,7 @@ pub fn decimal_separator(lang: Option<&str>) -> char {
let tag = lang.unwrap_or("").trim().to_ascii_lowercase().replace('_', "-"); let tag = lang.unwrap_or("").trim().to_ascii_lowercase().replace('_', "-");
// Regions that write a decimal point where the language usually writes a comma. // Regions that write a decimal point where the language usually writes a comma.
const POINT_REGIONS: &[&str] = const POINT_REGIONS: &[&str] =
&["de-ch", "de-li", "it-ch", "es-mx", "es-us", "es-419", "es-pr", "es-do", "es-gt", "es-hn", "es-ni", "es-pa", "es-sv"]; &["de-ch", "de-li", "it-ch", "es-mx", "es-us", "es-419", "es-pr", "es-do", "es-gt", "es-hn", "es-ni", "es-pa", "es-sv", "es-cu", "es-pe"];
if POINT_REGIONS.iter().any(|r| tag == *r || tag.starts_with(&format!("{r}-"))) { if POINT_REGIONS.iter().any(|r| tag == *r || tag.starts_with(&format!("{r}-"))) {
return '.'; return '.';
} }
@@ -151,6 +151,17 @@ mod tests {
assert_eq!(format_number(-0.001, pl), "0,00", "a rounded zero has no sign"); assert_eq!(format_number(-0.001, pl), "0,00", "a rounded zero has no sign");
} }
#[test]
fn cuban_and_peruvian_numbers_use_decimal_points() {
for lang in [Some("es-CU"), Some("es-PE")] {
assert_eq!(parse_number("1.234", lang), Some(1.234));
assert_eq!(parse_number("1,234.50", lang), Some(1234.5));
assert_eq!(format_number(5.75, lang), "5.75");
}
assert_eq!(parse_number("1.234,50", Some("es-ES")), Some(1234.5));
assert_eq!(format_number(5.75, Some("es-ES")), "5,75");
}
#[test] #[test]
fn english_numbers() { fn english_numbers() {
let en = Some("en-US"); let en = Some("en-US");
+90 -14
View File
@@ -288,8 +288,19 @@ impl Numbering {
pub struct Counters { pub struct Counters {
/// Per abstract list: current value per level. /// Per abstract list: current value per level.
state: HashMap<u32, [u32; 9]>, state: HashMap<u32, [u32; 9]>,
/// Which nums have started (to apply start overrides once). /// Which levels of which nums have started: a num's start override for a level applies
seen: HashMap<u32, bool>, /// once, when that level of that num is first used (ECMA-376 Part 1, 17.9.27 startOverride).
seen: HashMap<(u32, u8), bool>,
/// Per abstract list: whether each level has counted yet (a value of 0 can be a real count when a
/// level starts at 0).
flags: HashMap<u32, [LevelFlags; 9]>,
}
/// Whether a level has counted yet, and the value its next item takes if a start override says so.
#[derive(Default, Debug, Clone, Copy)]
struct LevelFlags {
started: bool,
first: Option<u32>,
} }
impl Counters { impl Counters {
@@ -312,29 +323,36 @@ impl Counters {
return None; return None;
} }
let st = self.state.entry(abs.id).or_insert([0; 9]); let st = self.state.entry(abs.id).or_insert([0; 9]);
if let std::collections::hash_map::Entry::Vacant(e) = self.seen.entry(num) { let fl = self.flags.entry(abs.id).or_default();
// Only this level's override, not the num's others: the counters are shared by every
// num of the abstract list, so restarting its other levels here would renumber
// paragraphs of the other nums. A heading style can use one num for levels 1-4 and
// another, with start overrides on every level, for 5-9; its first level-5 heading
// must not restart the chapter count.
if let std::collections::hash_map::Entry::Vacant(e) = self.seen.entry((num, lv as u8)) {
e.insert(true); e.insert(true);
for (l, start) in &n.start_overrides { if let Some((_, start)) = n.start_overrides.iter().find(|(l, _)| *l as usize == lv)
if let Some(s) = st.get_mut(*l as usize) { && let (Some(s), Some(f)) = (st.get_mut(lv), fl.get_mut(lv))
*s = start.saturating_sub(1); {
} *s = 0;
*f = LevelFlags { started: false, first: Some(*start) };
} }
} }
if let Some(v) = st.get_mut(lv) { if let (Some(v), Some(f)) = (st.get_mut(lv), fl.get_mut(lv)) {
*v = if *v == 0 { def.start.max(if def.start == 0 { 0 } else { 1 }) } else { v.saturating_add(1) }; *v = if f.started { v.saturating_add(1) } else { f.first.unwrap_or(def.start) };
if *v == 0 { *f = LevelFlags { started: true, first: None };
*v = def.start;
}
} }
// Deeper levels restart, unless they never restart or only restart after a level above // Deeper levels restart, unless they never restart or only restart after a level above
// this one (Word's lvlRestart). // this one (Word's lvlRestart).
for (d, deeper) in st.iter_mut().enumerate().skip(lv + 1) { for (d, (deeper, f)) in st.iter_mut().zip(fl.iter_mut()).enumerate().skip(lv + 1) {
let restarts = level_def(d).is_none_or(|l| l.restart && l.restart_after.is_none_or(|k| lv < k as usize)); let restarts = level_def(d).is_none_or(|l| l.restart && l.restart_after.is_none_or(|k| lv < k as usize));
if restarts { if restarts {
*deeper = 0; *deeper = 0;
*f = LevelFlags::default();
} }
} }
let vals = *st; let vals = *st;
let started = *fl;
if def.format == NumFormat::Bullet { if def.format == NumFormat::Bullet {
return Some((def.text.clone(), def)); return Some((def.text.clone(), def));
} }
@@ -347,7 +365,8 @@ impl Counters {
chars.next(); chars.next();
let k = (d as usize).saturating_sub(1).min(8); let k = (d as usize).saturating_sub(1).min(8);
let val = vals.get(k).copied().unwrap_or(0); let val = vals.get(k).copied().unwrap_or(0);
let val = if val == 0 { level_def(k).map(|l| l.start).unwrap_or(1) } else { val }; let counted = started.get(k).is_some_and(|f| f.started);
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own // Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
// format (二 of a Chinese outline becomes 2 in "2.1"). // format (二 of a Chinese outline becomes 2 in "2.1").
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) { let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
@@ -389,6 +408,29 @@ mod tests {
assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero)); assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero));
} }
#[test]
fn a_start_override_restarts_only_its_own_level_of_a_shared_list() {
// One abstract list numbers the headings through two nums: one for levels 1-4, and one
// restarting every level (startOverride 1 on 0..8) for levels 5-9, first used deep in
// chapter 2. Chapter 3 is still chapter 3, and the deep heading counts from 1.
let mut n = Numbering::default();
let chapters = n.add_list(ListKind::Legal);
let abs = n.abstract_of(chapters).map(|a| a.id).unwrap();
let deep = chapters + 1;
n.nums.push(Num { id: deep, abstract_id: abs, start_overrides: (0..9).map(|l| (l, 1)).collect(), ..Num::default() });
let mut c = Counters::default();
let mut l = |num, lv| c.next_label(&n, num, lv).unwrap().0;
assert_eq!(l(chapters, 0), "1.");
assert_eq!(l(chapters, 0), "2.");
assert_eq!(l(chapters, 1), "2.1.");
assert_eq!(l(chapters, 2), "2.1.1.");
assert_eq!(l(chapters, 3), "2.1.1.1.");
assert_eq!(l(deep, 4), "2.1.1.1.1.", "the deep num restarts only level 5");
assert_eq!(l(deep, 4), "2.1.1.1.2.");
assert_eq!(l(chapters, 0), "3.", "and not the chapter count");
assert_eq!(l(chapters, 1), "3.1.");
}
#[test] #[test]
fn levels_restart_only_after_their_restart_level() { fn levels_restart_only_after_their_restart_level() {
let mut n = Numbering::default(); let mut n = Numbering::default();
@@ -411,6 +453,40 @@ mod tests {
assert_eq!(l(2), "(1)", "level 1 restarts level 3"); assert_eq!(l(2), "(1)", "level 1 restarts level 3");
} }
#[test]
fn a_level_starting_at_zero_counts_up() {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
if let Some(a) = n.abstracts.first_mut() {
a.levels[0].start = 0;
}
let mut c = Counters::default();
let labels: Vec<String> = (0..4).map(|_| c.next_label(&n, id, 0).unwrap().0).collect();
assert_eq!(labels, ["0.", "1.", "2.", "3."]);
}
#[test]
fn a_start_override_of_one_or_zero_is_honoured() {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
if let Some(a) = n.abstracts.first_mut() {
a.levels[0].start = 5;
}
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.start_overrides = vec![(0, 1)];
}
let mut c = Counters::default();
let labels: Vec<String> = (0..2).map(|_| c.next_label(&n, id, 0).unwrap().0).collect();
assert_eq!(labels, ["1.", "2."]);
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.start_overrides = vec![(0, 0)];
}
let mut c = Counters::default();
let labels: Vec<String> = (0..3).map(|_| c.next_label(&n, id, 0).unwrap().0).collect();
assert_eq!(labels, ["0.", "1.", "2."]);
}
#[test] #[test]
fn numbered_labels() { fn numbered_labels() {
let mut n = Numbering::default(); let mut n = Numbering::default();
+24 -11
View File
@@ -77,12 +77,17 @@ pub enum WrapText {
impl WrapText { impl WrapText {
/// From the OOXML value (`bothSides`, `left`, `right`, `largest`). /// From the OOXML value (`bothSides`, `left`, `right`, `largest`).
pub fn from_ooxml(v: &str) -> Option<WrapText> { pub fn from_ooxml(v: &str) -> Option<WrapText> {
match v { let v = v.trim();
"bothSides" => Some(WrapText::BothSides), if v.eq_ignore_ascii_case("bothSides") || v.eq_ignore_ascii_case("both") {
"left" => Some(WrapText::Left), Some(WrapText::BothSides)
"right" => Some(WrapText::Right), } else if v.eq_ignore_ascii_case("left") {
"largest" => Some(WrapText::Largest), Some(WrapText::Left)
_ => None, } else if v.eq_ignore_ascii_case("right") {
Some(WrapText::Right)
} else if v.eq_ignore_ascii_case("largest") {
Some(WrapText::Largest)
} else {
None
} }
} }
@@ -220,6 +225,10 @@ pub struct Float {
/// off, any VML shape in a header or footer): a picture watermark in a header that starts with a table. /// 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")] #[serde(skip_serializing_if = "is_false")]
pub outside_cell: bool, 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. /// Longest alternative text kept (characters): hostile files and commands are capped.
@@ -259,6 +268,9 @@ fn is_zero(v: &f32) -> bool {
fn is_false(v: &bool) -> bool { fn is_false(v: &bool) -> bool {
!*v !*v
} }
fn is_zero_u32(v: &u32) -> bool {
*v == 0
}
impl Float { impl Float {
/// The rotation and flips, the angle normalised to `0..360` (a NaN angle is no rotation). /// The rotation and flips, the angle normalised to `0..360` (a NaN angle is no rotation).
@@ -440,8 +452,9 @@ pub enum InlineObject {
/// Crop (left, top, right, bottom) fractions 0..1. /// Crop (left, top, right, bottom) fractions 0..1.
#[serde(default)] #[serde(default)]
crop: [f32; 4], crop: [f32; 4],
/// The OLE object (embedded or linked file) this picture shows, as read from a file: /// The OLE object (embedded or linked file) or Word signature line this picture shows, as
/// saving writes the object back, not only its picture. See [`crate::graphic::Embedded`]. /// 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")] #[serde(default, skip_serializing_if = "Option::is_none")]
ole: Option<std::sync::Arc<crate::graphic::Embedded>>, ole: Option<std::sync::Arc<crate::graphic::Embedded>>,
}, },
@@ -459,7 +472,7 @@ pub enum InlineObject {
kind: ShapeKind, kind: ShapeKind,
w: f32, w: f32,
h: f32, h: f32,
fill: Option<crate::props::Rgb>, fill: Option<crate::fill::Fill>,
stroke: Option<crate::props::Rgb>, stroke: Option<crate::props::Rgb>,
#[serde(default)] #[serde(default)]
stroke_width: f32, stroke_width: f32,
@@ -641,10 +654,10 @@ pub struct GroupChild {
pub const MAX_GROUP_CHILDREN: usize = 1000; pub const MAX_GROUP_CHILDREN: usize = 1000;
/// The background fill and outline of a Drawing Canvas (an [`InlineObject::Group`] with `canvas`). /// The background fill and outline of a Drawing Canvas (an [`InlineObject::Group`] with `canvas`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")] #[serde(default, rename_all = "camelCase")]
pub struct CanvasStyle { pub struct CanvasStyle {
pub fill: Option<crate::props::Rgb>, pub fill: Option<crate::fill::Fill>,
pub stroke: Option<crate::props::Rgb>, pub stroke: Option<crate::props::Rgb>,
/// Outline width, points. /// Outline width, points.
pub stroke_width: f32, pub stroke_width: f32,
+96 -4
View File
@@ -47,13 +47,38 @@ impl Rgb {
} }
/// Parse `RRGGBB` or `#RRGGBB`. /// Parse `RRGGBB` or `#RRGGBB`.
pub fn parse(s: &str) -> Option<Rgb> { pub fn parse(s: &str) -> Option<Rgb> {
let s = s.trim().trim_start_matches('#'); let s = s.trim();
if s.len() != 6 || !s.is_ascii() { let s = s.strip_prefix('#').unwrap_or(s);
if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None; return None;
} }
let p = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok()); 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)?)) 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 { pub fn luma(self) -> f32 {
0.299 * self.0 as f32 + 0.587 * self.1 as f32 + 0.114 * self.2 as f32 0.299 * self.0 as f32 + 0.587 * self.1 as f32 + 0.114 * self.2 as f32
} }
@@ -421,6 +446,9 @@ pub struct CharProps {
/// BCP 47 language of complex-script text (`w:lang/@w:bidi`, e.g. `fa-IR`). /// BCP 47 language of complex-script text (`w:lang/@w:bidi`, e.g. `fa-IR`).
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub lang_bidi: Option<String>, 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`). /// East Asian typography: two lines in one and horizontal in vertical (`w:eastAsianLayout`).
/// Kept for round trip; layout doesn't apply it yet. /// Kept for round trip; layout doesn't apply it yet.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -549,7 +577,7 @@ impl CharProps {
pub fn minus(mut self, base: &CharProps) -> CharProps { pub fn minus(mut self, base: &CharProps) -> CharProps {
drop_same_fields!(self, base; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, 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, shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
cs, font_cs, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del, ligatures); cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, ligatures);
self self
} }
@@ -560,7 +588,7 @@ impl CharProps {
pub fn overlay(&mut self, patch: &CharProps) { pub fn overlay(&mut self, patch: &CharProps) {
overlay_fields!(self, patch; style, font, sym_font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight, 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, shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, east_asian_layout, link, ins, del, fmt_change, ligatures); cs, font_cs, font_ea, size_cs, bold_cs, italic_cs, lang_bidi, lang_ea, east_asian_layout, link, ins, del, fmt_change, ligatures);
if let Some(p) = &patch.text_effects { if let Some(p) = &patch.text_effects {
self.text_effects.get_or_insert_with(Default::default).overlay(p); self.text_effects.get_or_insert_with(Default::default).overlay(p);
} }
@@ -1298,6 +1326,60 @@ impl CellProps {
} }
} }
/// `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))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1310,6 +1392,16 @@ mod tests {
assert_eq!(Rgb::parse("é1234"), None); assert_eq!(Rgb::parse("é1234"), None);
} }
#[test]
fn rgb_parse_rejects_non_hex() {
assert_eq!(Rgb::parse("+1+2+3"), None);
assert_eq!(Rgb::parse("-1-2-3"), None);
assert_eq!(Rgb::parse("##ff0000"), None);
assert_eq!(Rgb::parse("ff00 0"), None);
assert_eq!(Rgb::parse("#ff0000"), Some(Rgb(255, 0, 0)));
assert_eq!(Rgb::parse("ff0000"), Some(Rgb(255, 0, 0)));
}
#[test] #[test]
fn overlay_keeps_unset() { fn overlay_keeps_unset() {
let base = CharProps { bold: Some(true), size: Some(12.0), ..Default::default() }; let base = CharProps { bold: Some(true), size: Some(12.0), ..Default::default() };
+42 -9
View File
@@ -73,8 +73,13 @@ pub struct TableStyleParts {
pub band_chr: CharProps, pub band_chr: CharProps,
/// Cell borders of the odd row bands (`band1Horz` region's `w:tcBorders`). /// Cell borders of the odd row bands (`band1Horz` region's `w:tcBorders`).
pub band_borders: Option<Borders>, pub band_borders: Option<Borders>,
/// Rows per band (`w:tblStyleRowBandSize`); one when unset. /// Rows per band (`w:tblStyleRowBandSize`). Unset (in the whole based-on chain) or zero, the
/// row bands are off: Word reads a missing size as zero, where ECMA-376 says one (MS-OE376
/// §2.7.5.7, #3415). Our own banded styles set one.
pub band_size: Option<u32>, pub band_size: Option<u32>,
/// Columns per band (`w:tblStyleColBandSize`); unset or zero, the column bands are off, as
/// in Word (MS-OI29500 §17.7.6.5).
pub col_band_size: Option<u32>,
pub first_col_chr: CharProps, pub first_col_chr: CharProps,
pub total_chr: CharProps, pub total_chr: CharProps,
pub total_border_top: Option<Border>, pub total_border_top: Option<Border>,
@@ -179,17 +184,24 @@ pub struct PartSet(u16);
impl PartSet { impl PartSet {
/// The parts of the cell at row `ri` of `nrows`, grid column `g` spanning `span` of `ncols`, /// The parts of the cell at row `ri` of `nrows`, grid column `g` spanning `span` of `ncols`,
/// in a table with options `look` and row bands of `band_size` rows. /// in a table with options `look`, row bands of `row_size` rows and column bands of
pub fn of_cell(look: &TableLook, band_size: u32, (ri, nrows): (usize, usize), (g, span, ncols): (usize, usize, usize)) -> PartSet { /// `col_size` columns (a size of zero turns those bands off, as in Word).
pub fn of_cell(
look: &TableLook,
(row_size, col_size): (u32, u32),
(ri, nrows): (usize, usize),
(g, span, ncols): (usize, usize, usize),
) -> PartSet {
let header = look.header_row && ri == 0; let header = look.header_row && ri == 0;
let total = look.total_row && ri + 1 == nrows && nrows > 1; let total = look.total_row && ri + 1 == nrows && nrows > 1;
let first_col = look.first_column && g == 0; let first_col = look.first_column && g == 0;
let last_col = look.last_column && g.saturating_add(span) >= ncols && ncols > 1; let last_col = look.last_column && g.saturating_add(span) >= ncols && ncols > 1;
let size = band_size.clamp(1, 1000) as usize; let rows = row_size.min(1000) as usize;
let row_band = look.banded_rows && !header; let cols = col_size.min(1000) as usize;
let odd_row = (ri.saturating_sub(usize::from(look.header_row)) / size).is_multiple_of(2); let row_band = look.banded_rows && !header && rows > 0;
let col_band = look.banded_columns && !first_col; let odd_row = (ri.saturating_sub(usize::from(look.header_row)) / rows.max(1)).is_multiple_of(2);
let odd_col = g.saturating_sub(usize::from(look.first_column)).is_multiple_of(2); let col_band = look.banded_columns && !first_col && cols > 0;
let odd_col = (g.saturating_sub(usize::from(look.first_column)) / cols.max(1)).is_multiple_of(2);
let mut bits = TablePart::WholeTable.bit(); let mut bits = TablePart::WholeTable.bit();
for (on, p) in [ for (on, p) in [
(col_band && odd_col, TablePart::Band1Vert), (col_band && odd_col, TablePart::Band1Vert),
@@ -270,6 +282,7 @@ impl TableStyleParts {
self.header_borders = merge(self.header_borders, patch.header_borders); self.header_borders = merge(self.header_borders, patch.header_borders);
self.band_borders = merge(self.band_borders, patch.band_borders); self.band_borders = merge(self.band_borders, patch.band_borders);
self.band_size = patch.band_size.or(self.band_size); self.band_size = patch.band_size.or(self.band_size);
self.col_band_size = patch.col_band_size.or(self.col_band_size);
self.band_chr.overlay(&patch.band_chr); self.band_chr.overlay(&patch.band_chr);
self.fill = patch.fill.or(self.fill); self.fill = patch.fill.or(self.fill);
self.cell_margins = patch.cell_margins.or(self.cell_margins); self.cell_margins = patch.cell_margins.or(self.cell_margins);
@@ -846,6 +859,7 @@ fn p_style() -> Style {
/// A few table styles of our own (grid, plain, light/medium accents). /// A few table styles of our own (grid, plain, light/medium accents).
fn table_styles() -> Vec<Style> { fn table_styles() -> Vec<Style> {
let thin = |c: Rgb| Border { style: BorderStyle::Single, width: 0.5, color: Some(c), space: 0.0 }; let thin = |c: Rgb| Border { style: BorderStyle::Single, width: 0.5, color: Some(c), space: 0.0 };
// Bands of one row and one column, written out so Word bands them too (#3415).
let mk = |id: &str, name: &str, parts: TableStyleParts| Style { let mk = |id: &str, name: &str, parts: TableStyleParts| Style {
id: id.into(), id: id.into(),
name: name.into(), name: name.into(),
@@ -854,7 +868,7 @@ fn table_styles() -> Vec<Style> {
para: ParaProps { space_after: Some(0.0), line_spacing: Some(LineSpacing::Multiple(1.0)), ..ParaProps::default() }, para: ParaProps { space_after: Some(0.0), line_spacing: Some(LineSpacing::Multiple(1.0)), ..ParaProps::default() },
priority: Some(60), priority: Some(60),
builtin: true, builtin: true,
table: Some(parts), table: Some(TableStyleParts { band_size: Some(1), col_band_size: Some(1), ..parts }),
..Default::default() ..Default::default()
}; };
let accents = [ let accents = [
@@ -979,6 +993,25 @@ mod tests {
assert!(c.len() <= 2); assert!(c.len() <= 2);
} }
/// #3415: a band size of zero (Word's reading of a style without one) turns those bands off.
#[test]
fn band_size_zero_turns_bands_off() {
let look = crate::props::TableLook { banded_columns: true, ..Default::default() };
let parts = |sizes, ri, g| PartSet::of_cell(&look, sizes, (ri, 5), (g, 1, 4));
assert!(parts((1, 1), 1, 1).contains(TablePart::Band1Horz) && parts((1, 1), 1, 1).contains(TablePart::Band1Vert));
assert!(parts((1, 1), 2, 2).contains(TablePart::Band2Horz) && parts((1, 1), 2, 2).contains(TablePart::Band2Vert));
for (ri, g) in [(1, 1), (2, 2)] {
let p = parts((0, 0), ri, g);
for band in [TablePart::Band1Horz, TablePart::Band2Horz, TablePart::Band1Vert, TablePart::Band2Vert] {
assert!(!p.contains(band), "{band:?} at row {ri}, column {g}");
}
}
// Our banded styles say so, so Word bands them too.
let s = StyleSheet::builtin();
let t = s.table_style("GridTable4AccentBlue").map(|t| t.parts);
assert_eq!(t.map(|t| (t.band_size, t.col_band_size)), Some((Some(1), Some(1))));
}
/// A table style based on another inherits what it doesn't set itself; borders merge edge by /// A table style based on another inherits what it doesn't set itself; borders merge edge by
/// edge. /// edge.
#[test] #[test]
+30 -3
View File
@@ -43,6 +43,13 @@ pub struct Row {
pub controls: ControlWrap, pub controls: ControlWrap,
} }
impl Row {
/// Grid column where the row's first cell starts: the columns `w:gridBefore` skips.
pub fn first_grid(&self) -> usize {
(self.props.grid_before as usize).min(MAX_COLS)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
pub struct Table { pub struct Table {
#[serde(default)] #[serde(default)]
@@ -58,6 +65,10 @@ pub struct Table {
/// Hard limits that keep hostile input from exhausting memory. /// Hard limits that keep hostile input from exhausting memory.
pub const MAX_ROWS: usize = 32_767; pub const MAX_ROWS: usize = 32_767;
pub const MAX_COLS: usize = 63; pub const MAX_COLS: usize = 63;
/// Most tables nested one inside another: the depth the DOCX reader and writer keep. Pasting
/// and inserting stop there too, so no edit builds a document deep enough to overflow the stack
/// when it is saved, copied or dropped (#1596).
pub const MAX_TABLE_DEPTH: usize = 24;
impl Table { impl Table {
/// A `rows × cols` table spanning `width` points, "Table Grid" style. /// A `rows × cols` table spanning `width` points, "Table Grid" style.
@@ -86,18 +97,22 @@ impl Table {
} }
pub fn cols(&self) -> usize { pub fn cols(&self) -> usize {
self.grid.len().max(self.rows.iter().map(|r| r.cells.iter().map(Cell::span).sum::<usize>()).max().unwrap_or(0)) let row_cols = |r: &Row| r.first_grid() + r.cells.iter().map(Cell::span).sum::<usize>() + r.props.grid_after as usize;
self.grid.len().max(self.rows.iter().map(row_cols).max().unwrap_or(0))
} }
/// Grid column where cell `c` of row `r` starts. /// Grid column where cell `c` of row `r` starts.
pub fn grid_col(&self, r: usize, c: usize) -> usize { pub fn grid_col(&self, r: usize, c: usize) -> usize {
self.rows.get(r).map(|row| row.cells.iter().take(c).map(Cell::span).sum()).unwrap_or(0) self.rows.get(r).map(|row| row.first_grid() + row.cells.iter().take(c).map(Cell::span).sum::<usize>()).unwrap_or(0)
} }
/// Cell index in row `r` that covers grid column `g`. /// Cell index in row `r` that covers grid column `g`.
pub fn cell_at_grid(&self, r: usize, g: usize) -> Option<usize> { pub fn cell_at_grid(&self, r: usize, g: usize) -> Option<usize> {
let row = self.rows.get(r)?; let row = self.rows.get(r)?;
let mut x = 0; let mut x = row.first_grid();
if g < x {
return None;
}
for (i, c) in row.cells.iter().enumerate() { for (i, c) in row.cells.iter().enumerate() {
if g < x + c.span() { if g < x + c.span() {
return Some(i); return Some(i);
@@ -495,6 +510,18 @@ impl Table {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn grid_before_shifts_the_row() {
let mut t = Table::new(2, 3, 300.0);
t.rows[1].cells.remove(0);
t.rows[1].props.grid_before = 1;
assert_eq!(t.grid_col(1, 0), 1);
assert_eq!(t.grid_col(1, 1), 2);
assert_eq!(t.cell_at_grid(1, 0), None);
assert_eq!(t.cell_at_grid(1, 2), Some(1));
assert_eq!(t.cols(), 3);
}
#[test] #[test]
fn new_table_shape() { fn new_table_shape() {
let t = Table::new(3, 4, 468.0); let t = Table::new(3, 4, 468.0);
+14 -7
View File
@@ -100,10 +100,22 @@ impl Default for Reflection {
} }
} }
fn clamp(v: f32, lo: f32, hi: f32, dflt: f32) -> f32 { pub(crate) fn clamp(v: f32, lo: f32, hi: f32, dflt: f32) -> f32 {
if v.is_finite() { v.clamp(lo, hi) } else { dflt } if v.is_finite() { v.clamp(lo, hi) } else { dflt }
} }
/// At most [`MAX_STOPS`] stops, positions and transparencies 0–100%, in order (text and shape
/// gradients).
pub(crate) fn sanitized_stops(stops: &[GradStop]) -> Vec<GradStop> {
let mut s: Vec<GradStop> = stops
.iter()
.take(MAX_STOPS)
.map(|g| GradStop { pos: clamp(g.pos, 0.0, 100.0, 0.0), color: g.color, transparency: clamp(g.transparency, 0.0, 100.0, 0.0) })
.collect();
s.sort_by(|a, b| a.pos.total_cmp(&b.pos));
s
}
impl TextFill { impl TextFill {
/// Finite, in range: at most [`MAX_STOPS`] stops, in order; a gradient without stops is no /// Finite, in range: at most [`MAX_STOPS`] stops, in order; a gradient without stops is no
/// gradient (`None` back). /// gradient (`None` back).
@@ -112,15 +124,10 @@ impl TextFill {
TextFill::None => TextFill::None, TextFill::None => TextFill::None,
TextFill::Solid { color, transparency } => TextFill::Solid { color: *color, transparency: clamp(*transparency, 0.0, 100.0, 0.0) }, TextFill::Solid { color, transparency } => TextFill::Solid { color: *color, transparency: clamp(*transparency, 0.0, 100.0, 0.0) },
TextFill::Gradient { stops, angle } => { TextFill::Gradient { stops, angle } => {
let mut s: Vec<GradStop> = stops let s = sanitized_stops(stops);
.iter()
.take(MAX_STOPS)
.map(|g| GradStop { pos: clamp(g.pos, 0.0, 100.0, 0.0), color: g.color, transparency: clamp(g.transparency, 0.0, 100.0, 0.0) })
.collect();
if s.is_empty() { if s.is_empty() {
return None; return None;
} }
s.sort_by(|a, b| a.pos.total_cmp(&b.pos));
TextFill::Gradient { stops: s, angle: clamp(*angle, -1e6, 1e6, 0.0).rem_euclid(360.0) } TextFill::Gradient { stops: s, angle: clamp(*angle, -1e6, 1e6, 0.0).rem_euclid(360.0) }
} }
}) })
+1
View File
@@ -17,6 +17,7 @@ miniz_oxide = { workspace = true }
sha1 = { workspace = true } sha1 = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
log = { workspace = true } log = { workspace = true }
chrono = { workspace = true }
[dev-dependencies] [dev-dependencies]
proptest = { workspace = true } proptest = { workspace = true }
+2 -1
View File
@@ -4,6 +4,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use wordcraft_doc::fill::Fill;
use wordcraft_doc::para::{Anchor, Float, FloatAlign, GroupChild, InlineObject, ShapeKind, Wrap}; use wordcraft_doc::para::{Anchor, Float, FloatAlign, GroupChild, InlineObject, ShapeKind, Wrap};
use wordcraft_doc::props::Rgb; use wordcraft_doc::props::Rgb;
@@ -201,7 +202,7 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
kind = ShapeKind::TextBox; kind = ShapeKind::TextBox;
} }
let line = kind == ShapeKind::Line; let line = kind == ShapeKind::Line;
let fill = if line || p.filled == Some(false) { None } else { Some(p.fill_color.unwrap_or(Rgb::WHITE)) }; let fill = if line || p.filled == Some(false) { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) }; let stroke = if p.line == Some(false) { None } else { Some(p.line_color.unwrap_or(Rgb::BLACK)) };
// Line widths are EMUs; Word's default is 0.75 pt. // Line widths are EMUs; Word's default is 0.75 pt.
let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0)); let stroke_width = p.line_width.map_or(0.75, |e| (e as f32 / 12_700.0).clamp(0.0, 100.0));
+88 -1
View File
@@ -1,7 +1,9 @@
//! Sprms → model properties, and style-chain resolution. //! Sprms → model properties, and style-chain resolution.
use wordcraft_doc::Rgb; use wordcraft_doc::Rgb;
use wordcraft_doc::props::{Align, CharProps, Highlight, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop, TextColor, Underline, VertAlign}; use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CharProps, Highlight, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop, TextColor, Underline, VertAlign,
};
use wordcraft_doc::styles::{Style, StyleKind, StyleSheet}; use wordcraft_doc::styles::{Style, StyleKind, StyleSheet};
use crate::sprm::{self, Prl, Toggle}; use crate::sprm::{self, Prl, Toggle};
@@ -42,6 +44,18 @@ const P_KEEP: u16 = 0x2405; // keep lines together
const P_KEEP_FOLLOW: u16 = 0x2406; // keep with next const P_KEEP_FOLLOW: u16 = 0x2406; // keep with next
const P_PAGE_BREAK_BEFORE: u16 = 0x2407; const P_PAGE_BREAK_BEFORE: u16 = 0x2407;
const P_OUTLVL: u16 = 0x2640; const P_OUTLVL: u16 = 0x2640;
// Paragraph borders ([MS-DOC] §2.6.2). The 80 forms carry a Brc80 (palette colour), the
// newer ones a BrcOperand: cb(1) + Brc(8, with a full COLORREF).
const P_BRC_TOP80: u16 = 0x6424;
const P_BRC_LEFT80: u16 = 0x6425;
const P_BRC_BOTTOM80: u16 = 0x6426;
const P_BRC_RIGHT80: u16 = 0x6427;
const P_BRC_BETWEEN80: u16 = 0x6428;
const P_BRC_TOP: u16 = 0xC64E;
const P_BRC_LEFT: u16 = 0xC64F;
const P_BRC_BOTTOM: u16 = 0xC650;
const P_BRC_RIGHT: u16 = 0xC651;
const P_BRC_BETWEEN: u16 = 0xC652;
/// Word's 16-colour `Ico` palette ([MS-DOC] §2.9.135), 0 = automatic. /// Word's 16-colour `Ico` palette ([MS-DOC] §2.9.135), 0 = automatic.
const ICO: [Rgb; 17] = [ const ICO: [Rgb; 17] = [
@@ -93,6 +107,69 @@ pub(crate) fn ico(i: u8) -> Option<Rgb> {
ICO.get(i as usize).copied() ICO.get(i as usize).copied()
} }
/// The model border style a `brcType` stands for ([MS-DOC] §2.9.22 BrcType). Types the model
/// doesn't carry (the gap variants, 3-D, inset/outset) draw as single lines, as the DOCX
/// reader maps them.
fn brc_style(t: u8) -> BorderStyle {
match t {
0x00 => BorderStyle::None,
0x03 => BorderStyle::Double,
0x06 => BorderStyle::Dotted,
0x07 | 0x16 => BorderStyle::Dashed,
0x08 | 0x09 => BorderStyle::DotDash,
0x0A => BorderStyle::Triple,
0x14 | 0x15 => BorderStyle::Wave,
_ => BorderStyle::Single,
}
}
/// The border width in points: `dptLineWidth` in 1/8-point steps (values below 2 count as 2;
/// 1-point steps for page borders, which never reach a paragraph property).
fn brc_width(t: u8, dpt: u8) -> f32 {
if t >= 0x40 { f32::from(dpt) } else { f32::from(dpt.max(2)) / 8.0 }
}
/// One border from a Brc80 operand ([MS-DOC] §2.9.18). All bits set means "no border"; a
/// truncated operand is ignored.
fn brc80(op: &[u8]) -> Option<Border> {
let [dpt, ty, ico_index, tail, ..] = op else { return None };
if [*dpt, *ty, *ico_index, *tail] == [0xFF; 4] {
return Some(Border::default());
}
Some(Border { style: brc_style(*ty), width: brc_width(*ty, *dpt), color: ico(*ico_index), space: f32::from(tail & 0x1F) })
}
/// One border from a `BrcOperand` ([MS-DOC] §2.9.21): cb(1, MUST be 8) + Brc(8).
fn brc(op: &[u8]) -> Option<Border> {
if op.first() != Some(&8) {
return None;
}
let b = op.get(1..9)?;
if b.iter().all(|x| *x == 0xFF) {
return Some(Border::default());
}
let color = match b.get(..4) {
Some([0, 0, 0, 0]) => None, // automatic
Some(px) => Some(Rgb(px[0], px[1], px[2])),
None => None,
};
Some(Border { style: brc_style(b[5]), width: brc_width(b[5], b[4]), color, space: f32::from(b[6] & 0x1F) })
}
/// Put a decoded border on the side its sprm names (`between` = the border between this
/// paragraph and the next).
fn set_border(p: &mut ParaProps, op: u16, b: Border) {
let borders = p.borders.get_or_insert_with(Borders::default);
let slot = match op {
P_BRC_TOP80 | P_BRC_TOP => &mut borders.top,
P_BRC_LEFT80 | P_BRC_LEFT => &mut borders.left,
P_BRC_BOTTOM80 | P_BRC_BOTTOM => &mut borders.bottom,
P_BRC_RIGHT80 | P_BRC_RIGHT => &mut borders.right,
_ => &mut borders.between,
};
*slot = Some(b);
}
fn u16_of(p: &Prl) -> Option<u16> { fn u16_of(p: &Prl) -> Option<u16> {
match p.operand { match p.operand {
[b0, b1, ..] => Some(u16::from_le_bytes([*b0, *b1])), [b0, b1, ..] => Some(u16::from_le_bytes([*b0, *b1])),
@@ -324,6 +401,16 @@ pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) {
p.outline_level = Some(l.min(8)); p.outline_level = Some(l.min(8));
} }
} }
P_BRC_TOP80 | P_BRC_LEFT80 | P_BRC_BOTTOM80 | P_BRC_RIGHT80 | P_BRC_BETWEEN80 => {
if let Some(b) = brc80(prl.operand) {
set_border(p, prl.op, b);
}
}
P_BRC_TOP | P_BRC_LEFT | P_BRC_BOTTOM | P_BRC_RIGHT | P_BRC_BETWEEN => {
if let Some(b) = brc(prl.operand) {
set_border(p, prl.op, b);
}
}
_ => {} _ => {}
} }
} }
+96 -17
View File
@@ -51,6 +51,9 @@ const MAX_PARTS: usize = 50_000;
const MAX_TOTAL_CHARS: u64 = 64 << 20; const MAX_TOTAL_CHARS: u64 = 64 << 20;
/// `sprmCPicLocation`: the fc of a picture in the Data stream. /// `sprmCPicLocation`: the fc of a picture in the Data stream.
const C_PIC_LOCATION: u16 = 0x6A03; const C_PIC_LOCATION: u16 = 0x6A03;
/// `sprmCSymbol`: the record font and character of a symbol character ([MS-DOC] §2.6.1); the
/// text stream holds the placeholder U+0028 at its position.
const C_SYMBOL: u16 = 0x6A09;
/// `sprmPIlvl` / `sprmPIlfo`: the paragraph's list level and list. /// `sprmPIlvl` / `sprmPIlfo`: the paragraph's list level and list.
const P_ILVL: u16 = 0x260A; const P_ILVL: u16 = 0x260A;
const P_ILFO: u16 = 0x460B; const P_ILFO: u16 = 0x460B;
@@ -581,30 +584,80 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild) {
/// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target /// `HYPERLINK "url"` / `HYPERLINK url \l "bookmark"` → the model link target
/// (`#bookmark` for internal links); `None` when not a hyperlink. /// (`#bookmark` for internal links); `None` when not a hyperlink.
fn hyperlink_target(instr: &str) -> Option<String> { fn hyperlink_target(instr: &str) -> Option<String> {
let rest = instr.strip_prefix("HYPERLINK").or_else(|| instr.strip_prefix("hyperlink"))?; let t = instr.trim();
let rest = rest.strip_prefix(' ').unwrap_or(rest); let rest = if t.get(..9).is_some_and(|h| h.eq_ignore_ascii_case("hyperlink")) {
t.get(9..).filter(|r| r.is_empty() || r.starts_with(char::is_whitespace))?.trim()
} else {
return None;
};
if rest.is_empty() { if rest.is_empty() {
return None; return None;
} }
let mut quoted = None;
let mut local = None; let mut url: Option<String> = None;
let mut tokens = rest.split_whitespace().peekable(); let mut local: Option<String> = None;
while let Some(tok) = tokens.next() { let mut switch: Option<&str> = None;
match tok {
"\\l" => { let mut i = 0;
local = Some(tokens.next().unwrap_or_default().trim_matches('"').to_string()); let bytes = rest.as_bytes();
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
break;
}
if bytes[i] == b'\\' {
let start = i;
i += 1;
while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'*') {
i += 1;
} }
"\\o" | "\\m" | "\\n" | "\\t" => { switch = Some(rest.get(start..i).unwrap_or(""));
let _ = tokens.next(); continue;
}
let arg = if bytes[i] == b'"' {
i += 1;
let start = i;
while i < bytes.len() && bytes[i] != b'"' {
i += 1;
}
let val = rest.get(start..i).unwrap_or("");
if i < bytes.len() && bytes[i] == b'"' {
i += 1;
}
val.to_string()
} else {
let start = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
i += 1;
}
rest.get(start..i).unwrap_or("").to_string()
};
let clean_arg = arg.replace("%22", "\"");
match switch.take() {
Some("\\l") => local = Some(clean_arg),
Some(_) => {}
None => {
if url.is_none() {
url = Some(clean_arg);
}
} }
_ if quoted.is_none() => quoted = Some(tok.trim_matches('"').to_string()),
_ => {}
} }
} }
Some(match local {
Some(name) if !name.is_empty() => format!("#{name}"), match (url, local) {
_ => quoted.unwrap_or_default(), (Some(u), Some(l)) if !u.is_empty() && !l.is_empty() => {
}) let frag = l.trim_start_matches('#');
if u.contains('#') { Some(u) } else { Some(format!("{u}#{frag}")) }
}
(Some(u), _) if !u.is_empty() => Some(u),
(None, Some(l)) if !l.is_empty() => {
let frag = l.trim_start_matches('#');
Some(format!("#{frag}"))
}
_ => None,
}
} }
/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream. /// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream.
@@ -620,6 +673,19 @@ fn pic_location(chpx: &[u8]) -> Option<u32> {
None None
} }
/// `sprmCSymbol` from a CHPX grpprl: the font table index and the character code of a symbol
/// character ([MS-DOC] §2.6.1, CSymbolOperand).
fn symbol_operand(chpx: &[u8]) -> Option<(u16, u16)> {
for prl in sprm::iter(chpx) {
if prl.op == C_SYMBOL
&& let [f0, f1, c0, c1, ..] = prl.operand
{
return Some((u16::from_le_bytes([*f0, *f1]), u16::from_le_bytes([*c0, *c1])));
}
}
None
}
/// `sprmPIlfo` operand (a signed 16-bit value, [MS-DOC] §2.6.2) → the num id, if the /// `sprmPIlfo` operand (a signed 16-bit value, [MS-DOC] §2.6.2) → the num id, if the
/// paragraph is in a list. 0x0001-0x07FE is a 1-based LFO index; 0xF802-0xFFFF (-2046..=-1) /// paragraph is in a list. 0x0001-0x07FE is a 1-based LFO index; 0xF802-0xFFFF (-2046..=-1)
/// is the negation of one and keeps the paragraph's own indents, which we do by leaving the /// is the negation of one and keeps the paragraph's own indents, which we do by leaving the
@@ -649,6 +715,19 @@ fn push_formatted(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx, field: Op
}; };
let mut props = char_props(grpprl, ctx.fonts, base); let mut props = char_props(grpprl, ctx.fonts, base);
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base); apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base);
// A symbol character (Insert › Symbol) is stored as the placeholder U+0028 with
// sprmCSymbol carrying the font and the character code ([MS-DOC] §2.6.1).
let mut c = c;
if c == '('
&& let Some((ftc, xchar)) = symbol_operand(grpprl)
{
if let Some(font) = ctx.fonts.get(usize::from(ftc)) {
props.font = Some(font.clone());
}
if let Some(sym) = char::from_u32(u32::from(xchar)) {
c = sym;
}
}
match field { match field {
Some(f) => match f.sep.as_mut() { Some(f) => match f.sep.as_mut() {
// Inside a field, characters are recorded for the field object instead of shown; // Inside a field, characters are recorded for the field object instead of shown;
+58 -18
View File
@@ -87,20 +87,8 @@ fn parse(bytes: &[u8]) -> Option<CoreProps> {
}) })
} }
fn civil(days: i64) -> (i64, u32, u32) { /// A `VT_FILETIME` property value as an ISO-8601 UTC string (`YYYY-MM-DDTHH:MM:SSZ`), or `None`
let z = days + 719_468; /// if it is not a FILETIME, is zero, or predates the 1601 FILETIME epoch.
let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
let doe = (z - era * 146_097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = y + i64::from(m <= 2);
(y, m, d)
}
fn filetime_iso(value: &[u8]) -> Option<String> { fn filetime_iso(value: &[u8]) -> Option<String> {
let kind = u32_at(value, 0)?; let kind = u32_at(value, 0)?;
if kind != 64 { if kind != 64 {
@@ -114,14 +102,34 @@ fn filetime_iso(value: &[u8]) -> Option<String> {
} }
const EPOCH_DIFFERENCE: u64 = 116_444_736_000_000_000; const EPOCH_DIFFERENCE: u64 = 116_444_736_000_000_000;
let unix_100ns = ft.checked_sub(EPOCH_DIFFERENCE)?; let unix_100ns = ft.checked_sub(EPOCH_DIFFERENCE)?;
let unix_secs = unix_100ns / 10_000_000; let unix_secs = i64::try_from(unix_100ns / 10_000_000).ok()?;
let days = (unix_secs / 86_400) as i64; let (days, sod) = (unix_secs.div_euclid(86_400), unix_secs.rem_euclid(86_400));
let secs_of_day = (unix_secs % 86_400) as u32; let (h, mi, s) = (sod / 3600, sod / 60 % 60, sod % 60);
let (h, mi, s) = (secs_of_day / 3600, (secs_of_day / 60) % 60, secs_of_day % 60);
let (y, m, d) = civil(days); let (y, m, d) = civil(days);
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
Some(format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z")) Some(format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"))
} }
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
///
/// A FILETIME can land past `NaiveDate`'s ±262143-year range, where the old hand-rolled converter
/// still produced a date; dropping to `None` there would change the output. The Gregorian calendar
/// repeats every 400 years (146_097 days), so chrono converts the day within one cycle and whole
/// cycles go back into the year (an `i128` so the extremes can't overflow), keeping chrono as the
/// only calendar logic and reproducing the old result exactly. 1970-01-01 is `num_days_from_ce` 719_163.
fn civil(days: i64) -> (i64, u32, u32) {
use chrono::Datelike;
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
}
None => (1970, 1, 1), // unreachable
}
}
fn text(value: &[u8], codepage: u16) -> Option<String> { fn text(value: &[u8], codepage: u16) -> Option<String> {
let kind = u32_at(value, 0)?; let kind = u32_at(value, 0)?;
let count = u32_at(value, 4)? as usize; let count = u32_at(value, 4)? as usize;
@@ -151,3 +159,35 @@ fn text(value: &[u8], codepage: u16) -> Option<String> {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::filetime_iso;
/// A `FILETIME` property (VT_FILETIME = 64) as `YYYY-MM-DDTHH:MM:SSZ`. The values are pinned
/// to what the old hand-rolled `civil` produced.
fn value(ft: u64) -> Vec<u8> {
let mut v = 64u32.to_le_bytes().to_vec();
v.extend_from_slice(&((ft & 0xffff_ffff) as u32).to_le_bytes());
v.extend_from_slice(&((ft >> 32) as u32).to_le_bytes());
v
}
#[test]
fn filetime_to_iso() {
// 2020-01-01T00:00:00Z and the same day at 13:45:09.
assert_eq!(filetime_iso(&value(132_223_104_000_000_000)).as_deref(), Some("2020-01-01T00:00:00Z"));
assert_eq!(filetime_iso(&value(132_223_599_090_000_000)).as_deref(), Some("2020-01-01T13:45:09Z"));
// Year 10000 keeps the plain zero-padded year (no leading `+` that chrono's `%Y` would add).
assert_eq!(filetime_iso(&value(2_650_467_744_000_000_000)).as_deref(), Some("10000-01-01T00:00:00Z"));
// The largest FILETIME (u64::MAX) lands in year 60056: past year 9999 (so the plain year
// matters) and the old `civil` converted it without ever dropping to the epoch. chrono's
// own range (year 262143) isn't reachable here — a u64 FILETIME caps at unix seconds
// ~1.83e12 — but the 400-year cycle keeps the result exact regardless.
assert_eq!(filetime_iso(&value(u64::MAX)).as_deref(), Some("60056-05-28T05:36:10Z"));
// Zero, a non-FILETIME kind and times before the 1601 epoch are rejected, not a panic.
assert_eq!(filetime_iso(&value(0)), None);
assert_eq!(filetime_iso(&[2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), None);
assert_eq!(filetime_iso(&value(1)), None);
}
}
+63 -1
View File
@@ -856,7 +856,7 @@ fn struct_doc(spec: &StructSpec) -> Vec<u8> {
let stsh = stsh_bytes(&[std_bytes(1, 0x0FFF, "Normal", &Vec::new(), &Vec::new())]); let stsh = stsh_bytes(&[std_bytes(1, 0x0FFF, "Normal", &Vec::new(), &Vec::new())]);
let stsh_at = table.len(); let stsh_at = table.len();
table.extend_from_slice(&stsh); table.extend_from_slice(&stsh);
let ffn = ffn_bytes(&["Times New Roman"]); let ffn = ffn_bytes(&["Times New Roman", "Wingdings"]);
let ffn_at = table.len(); let ffn_at = table.len();
table.extend_from_slice(&ffn); table.extend_from_slice(&ffn);
let lst_at = table.len(); let lst_at = table.len();
@@ -1354,6 +1354,22 @@ fn fields_and_hyperlinks() {
assert_eq!(result, "3"); assert_eq!(result, "3");
} }
#[test]
fn hyperlink_target_sublocation_and_quotes() {
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com" \l "heading""#), Some("http://example.com#heading".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK \l "heading""#), Some("#heading".into()));
assert_eq!(super::hyperlink_target(r#"Hyperlink "http://example.com/a path""#), Some("http://example.com/a path".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com" \o "Help text""#), Some("http://example.com".into()));
assert_eq!(super::hyperlink_target(r#"HYPERLINK \l "bm" \o "tip""#), Some("#bm".into()));
}
/// A field whose name only starts with HYPERLINK is not a hyperlink.
#[test]
fn hyperlink_target_needs_the_whole_field_name() {
assert_eq!(super::hyperlink_target(r#"HYPERLINKS "x""#), None);
assert_eq!(super::hyperlink_target(r#"HYPERLINK "http://example.com""#), Some("http://example.com".into()));
}
#[test] #[test]
fn bookmarks_become_objects() { fn bookmarks_become_objects() {
let text = "Hello world\r"; let text = "Hello world\r";
@@ -2330,3 +2346,49 @@ fn sprm_t_cell_width_twips_and_percentage() {
assert_eq!(table.rows[0].cells[1].props.width, Some(72.0)); assert_eq!(table.rows[0].cells[1].props.width, Some(72.0));
assert_eq!(table.rows[0].cells[2].props.width_pct, Some(50.0)); assert_eq!(table.rows[0].cells[2].props.width_pct, Some(50.0));
} }
// ---------------------------------------------------------------------------------------------
// #1767: symbol runs and paragraph borders.
/// Word stores a symbol character (Insert › Symbol) as the placeholder U+0028 plus
/// `sprmCSymbol` (0x6A09), which carries the font table index and the character code
/// ([MS-DOC] §2.6.1 CSymbolOperand); the text stream alone would read "(".
#[test]
fn symbol_runs_read_the_character_and_font_from_sprm_c_symbol() {
let text = "(\r";
// ftc = 1 (Wingdings), xchar = F071: the box Word draws for Wingdings 0x71.
let sym = [1u16.to_le_bytes(), 0xF071u16.to_le_bytes()].concat();
let spec = StructSpec { text, chpx_runs: vec![(0, grpprl(&[(0x6A09, &sym)]))], ..structured_spec(text, text.chars().count() as u32) };
let doc = read(&struct_doc(&spec)).expect("opens");
let p = doc.body.first().and_then(|b| b.as_para()).expect("paragraph");
assert_eq!(p.text, "\u{F071}", "the symbol, not the placeholder");
assert_eq!(p.props_of_char(0).font.as_deref(), Some("Wingdings"), "its font from the font table");
}
/// Paragraph borders around a block are read from both storage forms: the Word 97 Brc80
/// (palette colour) and the Word 2000+ BrcOperand (cb + Brc with a full colour).
#[test]
fn paragraph_borders_read_from_both_forms() {
let b80 = [8u8, 0x01, 0x06, 0x01]; // 1 pt single line, palette red (6), 1 pt from the text
let brc = [8u8, 0xFF, 0x00, 0x00, 0x00, 8, 0x01, 0x02, 0x00]; // cb 8, red COLORREF, 1 pt single, space 2
let text = "boxed\r\r";
let spec = StructSpec {
text,
papx_entries: vec![
(0, 0, grpprl(&[(0x6424, &b80), (0x6425, &b80), (0x6426, &b80), (0x6427, &b80), (0x6428, &b80)])),
(6, 0, grpprl(&[(0xC64E, &brc)])),
],
..structured_spec(text, text.chars().count() as u32)
};
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("first paragraph");
let b = p0.props.borders.expect("borders");
let top = b.top.expect("top border");
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 1.0));
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
assert!(b.left.is_some() && b.bottom.is_some() && b.right.is_some() && b.between.is_some());
let p1 = doc.body.get(1).and_then(|b| b.as_para()).expect("second paragraph");
let top = p1.props.borders.expect("borders").top.expect("top border");
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 2.0));
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
}
+3 -2
View File
@@ -2,6 +2,7 @@
//! specification, with tiny pictures generated in code. No file produced by Word is used. //! specification, with tiny pictures generated in code. No file produced by Word is used.
use proptest::prelude::*; use proptest::prelude::*;
use wordcraft_doc::fill::Fill;
use wordcraft_doc::para::{Anchor, InlineObject, ShapeKind, Wrap}; use wordcraft_doc::para::{Anchor, InlineObject, ShapeKind, Wrap};
use wordcraft_doc::props::Rgb; use wordcraft_doc::props::Rgb;
@@ -173,10 +174,10 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() {
assert_eq!(p0.objects.len(), 2, "{:?}", p0.objects); assert_eq!(p0.objects.len(), 2, "{:?}", p0.objects);
let Some(InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, .. }) = p0.objects.first() else { panic!("{:?}", p0.objects) }; let Some(InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, .. }) = p0.objects.first() else { panic!("{:?}", p0.objects) };
assert_eq!((*kind, *w, *h), (ShapeKind::Ellipse, 72.0, 72.0)); assert_eq!((*kind, *w, *h), (ShapeKind::Ellipse, 72.0, 72.0));
assert_eq!((*fill, *stroke, *stroke_width), (Some(Rgb(255, 0, 0)), Some(Rgb(0, 0, 255)), 2.0)); assert_eq!((fill.clone(), *stroke, *stroke_width), (Some(Fill::solid(Rgb(255, 0, 0))), Some(Rgb(0, 0, 255)), 2.0));
assert_eq!((float.wrap, float.h_rel, float.v_rel), (Wrap::BehindText, Anchor::Column, Anchor::Paragraph)); assert_eq!((float.wrap, float.h_rel, float.v_rel), (Wrap::BehindText, Anchor::Column, Anchor::Paragraph));
let Some(InlineObject::Shape { kind, fill, float, story, .. }) = p0.objects.get(1) else { panic!("{:?}", p0.objects) }; let Some(InlineObject::Shape { kind, fill, float, story, .. }) = p0.objects.get(1) else { panic!("{:?}", p0.objects) };
assert_eq!((*kind, *fill, float.wrap), (ShapeKind::TextBox, None, Wrap::TopAndBottom)); assert_eq!((*kind, fill.clone(), float.wrap), (ShapeKind::TextBox, None, Wrap::TopAndBottom));
let part = story.and_then(|id| doc.parts.get(&id)).expect("text box story part"); let part = story.and_then(|id| doc.parts.get(&id)).expect("text box story part");
assert_eq!(part.kind, wordcraft_doc::PartKind::TextBox); assert_eq!(part.kind, wordcraft_doc::PartKind::TextBox);
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside")); assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
+1
View File
@@ -15,6 +15,7 @@ quick-xml = { workspace = true }
zip = { workspace = true } zip = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
log = { workspace = true } log = { workspace = true }
chrono = { workspace = true }
unicode-bidi = { workspace = true } unicode-bidi = { workspace = true }
cfb = { workspace = true } cfb = { workspace = true }
aes = { workspace = true } aes = { workspace = true }
+16
View File
@@ -52,6 +52,22 @@ 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> {
use std::io::Read;
let mut zip = zip::ZipArchive::new(package).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()?;
let root = xml::parse(&xml).ok()?;
let title = root.child("dc:title")?.text();
let title = title.trim();
(!title.is_empty()).then(|| title.to_string())
}
/// What [`DocxError::PasswordRequired`] says. /// What [`DocxError::PasswordRequired`] says.
pub const PASSWORD_REQUIRED: &str = "this document is protected with a password"; pub const PASSWORD_REQUIRED: &str = "this document is protected with a password";
/// What [`DocxError::WrongPassword`] says. /// What [`DocxError::WrongPassword`] says.
+233 -22
View File
@@ -2,7 +2,8 @@
//! //!
//! Bar and column (clustered, stacked or 100% stacked), line, area, pie, doughnut and scatter //! Bar and column (clustered, stacked or 100% stacked), line, area, pie, doughnut and scatter
//! charts are drawn from the first supported chart type of the plot area, with their values as //! charts are drawn from the first supported chart type of the plot area, with their values as
//! data labels where `c:dLbls` shows them (`c:showVal`). A chart of only other types (radar, stock…) //! data labels where `c:dLbls` shows them (`c:showVal`). Radar charts (standard, with markers or
//! filled) draw as spokes, concentric grid polygons and one polygon per series. A chart of only other types (stock, bubble…)
//! draws a placeholder: its frame, its title and the type it is. Text widths are estimated ([`CHAR_W`] em a character), so label places are approximate. //! draws a placeholder: its frame, its title and the type it is. Text widths are estimated ([`CHAR_W`] em a character), so label places are approximate.
use std::f64::consts::{FRAC_PI_2, TAU}; use std::f64::consts::{FRAC_PI_2, TAU};
@@ -23,6 +24,10 @@ const MAX_POINTS: usize = 10_000;
const MAX_CELLS: usize = 100_000; const MAX_CELLS: usize = 100_000;
/// Longest series name or category label kept. /// Longest series name or category label kept.
const MAX_LABEL: usize = 200; const MAX_LABEL: usize = 200;
/// Most categories (spokes) of a radar chart.
const MAX_SPOKES: usize = 100;
/// Most series of a radar chart.
const MAX_RADAR_SERIES: usize = 50;
/// Most axis ticks drawn. /// Most axis ticks drawn.
const MAX_TICKS: usize = 20; const MAX_TICKS: usize = 20;
/// Default text size, points. /// Default text size, points.
@@ -75,6 +80,7 @@ pub(crate) fn chart_items(space: &El, theme: &[Rgb], w: f32, h: f32) -> Vec<Grap
} }
match chart.kind { match chart.kind {
Kind::Pie | Kind::Doughnut => pie(&mut out, &chart, area), Kind::Pie | Kind::Doughnut => pie(&mut out, &chart, area),
Kind::Radar => radar(&mut out, &chart, area),
_ => cartesian(&mut out, &chart, area), _ => cartesian(&mut out, &chart, area),
} }
out out
@@ -87,7 +93,7 @@ fn title_block(out: &mut Vec<GraphicItem>, t: &str, area: Rect) -> Rect {
Rect { y: area.y + lh + PAD, h: (area.h - lh - PAD).max(1.0), ..area } Rect { y: area.y + lh + PAD, h: (area.h - lh - PAD).max(1.0), ..area }
} }
/// A chart whose plot area holds only chart types WordCraft doesn't draw (radar, stock…): its /// A chart whose plot area holds only chart types WordCraft doesn't draw (stock, bubble…): its
/// frame with a light border, its title and a line saying which type it is, so it isn't blank /// frame with a light border, its title and a line saying which type it is, so it isn't blank
/// space. Nothing when the plot area has no chart type at all, or a supported one with nothing /// space. Nothing when the plot area has no chart type at all, or a supported one with nothing
/// to draw. /// to draw.
@@ -114,7 +120,6 @@ fn placeholder(out: &mut Vec<GraphicItem>, space: &El, theme: &[Rgb], w: f32, h:
/// What a placeholder calls a chart type element WordCraft doesn't draw. /// What a placeholder calls a chart type element WordCraft doesn't draw.
fn unsupported_name(name: &str) -> &'static str { fn unsupported_name(name: &str) -> &'static str {
match name { match name {
"c:radarChart" => "Radar chart",
"c:stockChart" => "Stock chart", "c:stockChart" => "Stock chart",
"c:bubbleChart" => "Bubble chart", "c:bubbleChart" => "Bubble chart",
"c:surfaceChart" | "c:surface3DChart" => "Surface chart", "c:surfaceChart" | "c:surface3DChart" => "Surface chart",
@@ -144,6 +149,7 @@ enum Kind {
Pie, Pie,
Doughnut, Doughnut,
Scatter, Scatter,
Radar,
} }
impl Kind { impl Kind {
@@ -155,6 +161,7 @@ impl Kind {
"c:pieChart" => Kind::Pie, "c:pieChart" => Kind::Pie,
"c:doughnutChart" => Kind::Doughnut, "c:doughnutChart" => Kind::Doughnut,
"c:scatterChart" => Kind::Scatter, "c:scatterChart" => Kind::Scatter,
"c:radarChart" => Kind::Radar,
_ => return None, _ => return None,
}) })
} }
@@ -221,6 +228,10 @@ struct Chart {
/// Doughnut hole, fraction of the outer radius. /// Doughnut hole, fraction of the outer radius.
hole: f32, hole: f32,
first_angle: f64, first_angle: f64,
/// Radar: series polygons are filled (`c:radarStyle` = `filled`).
radar_fill: bool,
/// Radar: points carry markers (`c:radarStyle` = `marker`).
radar_marks: bool,
vary: bool, vary: bool,
/// Number of categories (scatter: points per series at most). /// Number of categories (scatter: points per series at most).
n: usize, n: usize,
@@ -253,7 +264,9 @@ impl Chart {
return None; 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 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 matches!(kind, Kind::Pie | Kind::Doughnut) { 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()) series.first().map_or(0, |s| s.values.len()).max(cats.len())
} else { } else {
series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len()) series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len())
@@ -262,6 +275,9 @@ impl Chart {
if let Some(keep) = MAX_CELLS.checked_div(n) { if let Some(keep) = MAX_CELLS.checked_div(n) {
series.truncate(keep.max(1)); 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")) { 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("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, Some("percentStacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Percent,
@@ -269,6 +285,7 @@ impl Chart {
}; };
let mut cats = cats; let mut cats = cats;
cats.resize(n, String::new()); 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")); let type_labels = show_values(ty.child("c:dLbls"));
for s in &mut series { for s in &mut series {
s.values.resize(n, None); s.values.resize(n, None);
@@ -296,6 +313,8 @@ impl Chart {
overlap: num("c:overlap").unwrap_or(0).clamp(-100, 100) 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, 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, 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), vary: matches!(kind, Kind::Pie | Kind::Doughnut) && ty.child("c:varyColors").and_then(|v| v.attr("val")).is_none_or(truthy),
n, n,
cats, cats,
@@ -803,18 +822,27 @@ fn excel_stamp(serial: f64, date1904: bool) -> Option<Stamp> {
Some(Stamp { y, m, d, weekday: (day - 1).rem_euclid(7) as u32, secs }) 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 (Howard Hinnant's /// Calendar date (year, month, day) of day `n` counted from 30 December 1899 (an Excel serial
/// civil-from-days algorithm). /// 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`.
fn civil(n: i64) -> (i64, u32, u32) { fn civil(n: i64) -> (i64, u32, u32) {
let z = n - 25_569 + 719_468; use chrono::Datelike;
let era = z.div_euclid(146_097); // CE day of serial `n`, in i128 so no `n` overflows.
let doe = z.rem_euclid(146_097); let from_ce = i128::from(n) - 25_569 + 719_163;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let (cycles, rest) = (from_ce.div_euclid(146_097), from_ce.rem_euclid(146_097));
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); match i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
let mp = (5 * doy + 2) / 153; Some(date) => {
let d = (doy - (153 * mp + 2) / 5 + 1) as u32; let y = i128::from(date.year()) + cycles * 400;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day())
(yoe + era * 400 + i64::from(m <= 2), m, d) }
// 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 MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
@@ -1324,7 +1352,7 @@ fn cartesian(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
Kind::Line => lines(out, c, &g, &mut labels), Kind::Line => lines(out, c, &g, &mut labels),
Kind::Area => areas(out, c, &g, &mut labels), Kind::Area => areas(out, c, &g, &mut labels),
Kind::Scatter => draw_scatter(out, c, &g, &mut labels), Kind::Scatter => draw_scatter(out, c, &g, &mut labels),
Kind::Pie | Kind::Doughnut => {} Kind::Pie | Kind::Doughnut | Kind::Radar => {}
} }
draw_axes(out, c, &g); draw_axes(out, c, &g);
draw_data_labels(out, &labels, c.text_size, g.horiz && c.kind == Kind::Bar); draw_data_labels(out, &labels, c.text_size, g.horiz && c.kind == Kind::Bar);
@@ -1621,6 +1649,94 @@ fn pie(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
draw_data_labels(out, &labels, c.text_size, false); 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.
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, /// 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. /// in the series' number format.
fn label_of(s: &Series, i: usize) -> Option<String> { fn label_of(s: &Series, i: usize) -> Option<String> {
@@ -1849,21 +1965,104 @@ mod tests {
fn unsupported_chart_types_draw_a_placeholder() { 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 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 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:radarChart>{ser}</c:radarChart></c:plotArea></c:chart></c:chartSpace>"#); let x =
let radar = chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), 300.0, 200.0); format!(r#"<c:chartSpace {NS}><c:chart>{title}<c:plotArea><c:bubbleChart>{ser}</c:bubbleChart></c:plotArea></c:chart></c:chartSpace>"#);
assert_eq!(texts(&radar), ["Skills", "Radar chart (not supported yet)"]); let bubble = chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), 300.0, 200.0);
let framed = radar.iter().any(|it| matches!(it, GraphicItem::Shape { rect, stroke: Some(_), .. } if *rect == [0.0, 0.0, 300.0, 200.0])); assert_eq!(texts(&bubble), ["Skills", "Bubble chart (not supported yet)"]);
assert!(framed, "a light border marks the chart's frame: {radar:?}"); 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); 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)"]); 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); let other = chart_items(&space(&format!("<c:fancyChart>{ser}</c:fancyChart>")), &theme(), 300.0, 200.0);
assert_eq!(texts(&other), ["Chart (not supported yet)"]); assert_eq!(texts(&other), ["Chart (not supported yet)"]);
// A supported type in the plot area is drawn, an unsupported one beside it is skipped. // A supported type in the plot area is drawn, an unsupported one beside it is skipped.
let combo = chart_items(&space(&format!("<c:radarChart>{ser}</c:radarChart><c:lineChart>{ser}</c:lineChart>")), &theme(), 300.0, 200.0); 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:?}"); 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`…). /// 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> { fn with_legend(plot: &str, pos: &str, w: f32, h: f32) -> Vec<GraphicItem> {
let x = format!( let x = format!(
@@ -1975,6 +2174,18 @@ mod tests {
assert_eq!(ymd(45292.0), Some((2024, 1, 1))); assert_eq!(ymd(45292.0), Some((2024, 1, 1)));
} }
#[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] #[test]
fn number_formats_keep_literal_text_and_currency() { 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(1234.5, "#,##0.00 [$€-407]", 0.25), "1,234.50 €");
+82 -55
View File
@@ -1,8 +1,12 @@
//! DrawingML colours shared by charts and SmartArt: a colour element with its modifiers, and the //! DrawingML colours shared by charts and SmartArt: a colour element with its modifiers, and the
//! paint of a fill or line (solid, the first stop of a gradient, a pattern's foreground). //! paint of a fill or line (solid, the first stop of a gradient, a pattern's foreground).
use std::sync::Arc;
use wordcraft_doc::THEME_COLORS; use wordcraft_doc::THEME_COLORS;
use wordcraft_doc::fill::{Fill, Gradient, GradientFlip, GradientKind, PathShape};
use wordcraft_doc::props::Rgb; use wordcraft_doc::props::Rgb;
use wordcraft_doc::wordart::{GradStop, MAX_STOPS};
use crate::units::int; use crate::units::int;
use crate::xml::El; use crate::xml::El;
@@ -35,11 +39,24 @@ fn is_color(e: &El) -> bool {
} }
/// A colour element (`a:srgbClr`, `a:schemeClr`, `a:sysClr`), or the one inside `e`, with its /// A colour element (`a:srgbClr`, `a:schemeClr`, `a:sysClr`), or the one inside `e`, with its
/// `lumMod` / `lumOff` / `tint` / `shade` applied in document order. /// `lumMod` / `lumOff` / `tint` / `shade` / `satMod` applied in document order.
pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> { pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
color_alpha(e, theme, None).map(|(c, _)| c)
}
/// [`color`] and its transparency, percent (`a:alpha` is opacity, so 30% opaque is 70%
/// transparent). `ph` is the colour and transparency `phClr` stands for (in a theme's fill
/// styles: the colour of the shape's style reference).
pub(crate) fn color_alpha(e: &El, theme: &[Rgb], ph: Option<(Rgb, f32)>) -> Option<(Rgb, f32)> {
let c = if is_color(e) { e } else { e.els().find(|c| is_color(c))? }; let c = if is_color(e) { e } else { e.els().find(|c| is_color(c))? };
let mut opacity = 100.0;
let base = match c.name.as_str() { let base = match c.name.as_str() {
"a:srgbClr" => Rgb::parse(c.attr("val")?)?, "a:srgbClr" => Rgb::parse(c.attr("val")?)?,
"a:schemeClr" if c.attr("val") == Some("phClr") => {
let (rgb, t) = ph?;
opacity -= t;
rgb
}
"a:schemeClr" => *theme.get(scheme_slot(c.attr("val")?)?)?, "a:schemeClr" => *theme.get(scheme_slot(c.attr("val")?)?)?,
"a:sysClr" => match c.attr("lastClr").and_then(Rgb::parse) { "a:sysClr" => match c.attr("lastClr").and_then(Rgb::parse) {
Some(rgb) => rgb, Some(rgb) => rgb,
@@ -58,7 +75,7 @@ pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
"a:hslClr" => { "a:hslClr" => {
let v = |n: &str| c.attr(n).and_then(int).unwrap_or(0) as f64; let v = |n: &str| c.attr(n).and_then(int).unwrap_or(0) as f64;
let hue = (v("hue") / 60_000.0).rem_euclid(360.0) / 360.0; let hue = (v("hue") / 60_000.0).rem_euclid(360.0) / 360.0;
from_hsl(hue, (v("sat") / 100_000.0).clamp(0.0, 1.0), (v("lum") / 100_000.0).clamp(0.0, 1.0)) Rgb::from_hsl(hue, (v("sat") / 100_000.0).clamp(0.0, 1.0), (v("lum") / 100_000.0).clamp(0.0, 1.0))
} }
_ => return None, _ => return None,
}; };
@@ -66,14 +83,68 @@ pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option<Rgb> {
let mut rgb = base; let mut rgb = base;
for m in c.els() { for m in c.els() {
rgb = match m.name.as_str() { rgb = match m.name.as_str() {
"a:lumMod" => adjust_lum(rgb, |l| l * val(m)), n @ ("a:lumMod" | "a:lumOff" | "a:tint" | "a:shade" | "a:satMod") => modified(rgb, n.trim_start_matches("a:"), val(m)),
"a:lumOff" => adjust_lum(rgb, |l| l + val(m)), "a:alpha" => {
"a:tint" => mix(rgb, 255.0, val(m)), opacity = val(m) as f32 * 100.0;
"a:shade" => mix(rgb, 0.0, val(m)), rgb
}
"a:alphaMod" => {
opacity *= val(m) as f32;
rgb
}
"a:alphaOff" => {
opacity += val(m) as f32 * 100.0;
rgb
}
_ => rgb, _ => rgb,
}; };
} }
Some(rgb) Some((rgb, (100.0 - opacity).clamp(0.0, 100.0)))
}
/// Fill element `e` (`a:solidFill`, `a:gradFill`; a pattern as its foreground colour) as a
/// shape's fill; `None` for no fill, picture and group fills. `ph` as in [`color_alpha`].
pub(crate) fn fill(e: &El, theme: &[Rgb], ph: Option<(Rgb, f32)>) -> Option<Fill> {
let solid = |c: Option<&El>| c.and_then(|c| color_alpha(c, theme, ph)).map(|(color, transparency)| Fill::Solid { color, transparency });
match e.name.as_str() {
"a:solidFill" => solid(Some(e)),
"a:pattFill" => solid(e.child("a:fgClr")),
"a:gradFill" => {
let pct = |v: Option<&str>| v.and_then(int).map_or(0.0, |v| v.clamp(-100_000_000, 100_000_000) as f32 / 1000.0);
let rect = |r: Option<&El>| r.map_or([0.0; 4], |r| ["l", "t", "r", "b"].map(|k| pct(r.attr(k))));
let stops = e
.child("a:gsLst")
.into_iter()
.flat_map(|l| l.children("a:gs"))
.take(MAX_STOPS * 4)
.filter_map(|g| color_alpha(g, theme, ph).map(|(color, transparency)| GradStop { pos: pct(g.attr("pos")), color, transparency }))
.collect();
let on = |v: Option<&str>| v.is_some_and(|v| v == "1" || v == "true");
let kind = match (e.child("a:path"), e.child("a:lin")) {
(Some(p), _) => {
let shape = match p.attr("path") {
Some("rect") => PathShape::Rect,
Some("shape") => PathShape::Shape,
_ => PathShape::Circle,
};
GradientKind::Path { shape, focus: rect(p.child("a:fillToRect")) }
}
(None, l) => GradientKind::Linear {
angle: l.and_then(|l| l.attr("ang")).and_then(int).map_or(0.0, |a| (a % 21_600_000) as f32 / 60_000.0),
scaled: on(l.and_then(|l| l.attr("scaled"))),
},
};
let flip = match e.attr("flip") {
Some("x") => GradientFlip::X,
Some("y") => GradientFlip::Y,
Some("xy") => GradientFlip::Xy,
_ => GradientFlip::None,
};
let tile = e.child("a:tileRect").map(|r| rect(Some(r)));
Fill::Gradient(Arc::new(Gradient { stops, kind, tile, rot_with_shape: on(e.attr("rotWithShape")), flip })).sanitized()
}
_ => None,
}
} }
/// A preset colour (`a:prstClr/@val`, ECMA-376 Part 1 §20.1.10.48): the common ones; others are /// A preset colour (`a:prstClr/@val`, ECMA-376 Part 1 §20.1.10.48): the common ones; others are
@@ -159,58 +230,14 @@ pub(crate) fn lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb {
adjust_lum(c, f) adjust_lum(c, f)
} }
/// `tint` (towards `to` = 255) and `shade` (towards 0): `v` is the share of the original colour kept. /// `c` with the DrawingML colour modifier `name` at `v` (see [`Rgb::modified`]).
fn mix(c: Rgb, to: f64, v: f64) -> Rgb { fn modified(c: Rgb, name: &str, v: f64) -> Rgb {
let f = |x: u8| (x as f64 * v + to * (1.0 - v)).round().clamp(0.0, 255.0) as u8; c.modified(name, v)
Rgb(f(c.0), f(c.1), f(c.2))
} }
/// `c` with its HSL luminance changed by `f`. /// `c` with its HSL luminance changed by `f`.
pub(crate) fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb { pub(crate) fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb {
let (h, s, l) = to_hsl(c); c.with_lum(f)
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))
} }
#[cfg(test)] #[cfg(test)]
+156 -40
View File
@@ -16,6 +16,7 @@ mod wordart;
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
use wordcraft_doc::control::{ControlEvent, ControlKind, control_events, pair_events};
use wordcraft_doc::graphic::{Graphic, GraphicKind}; use wordcraft_doc::graphic::{Graphic, GraphicKind};
use wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, Num}; use wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, Num};
use wordcraft_doc::para::NoteKind; use wordcraft_doc::para::NoteKind;
@@ -96,6 +97,10 @@ pub(crate) struct Reader<'p> {
/// Linked text boxes as read: (`wps:txbx/@id` or `wps:linkedTxbx/@id`, `seq` (0 for the box /// 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`]. /// holding the text), text box part). See [`link_text_boxes`].
pub txbx_links: Vec<(u32, u32, u32)>, 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` /// The package path that internal relationship `id` points at, if it has type `kind` (an `rt`
@@ -159,6 +164,8 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
content_types: None, content_types: None,
embedded: EmbeddedManifest::default(), embedded: EmbeddedManifest::default(),
txbx_links: Vec::new(), txbx_links: Vec::new(),
theme_fills: Default::default(),
group_fill: None,
}; };
r.pc.major_font = r.doc.settings.major_font.clone(); r.pc.major_font = r.doc.settings.major_font.clone();
r.pc.minor_font = r.doc.settings.minor_font.clone(); r.pc.minor_font = r.doc.settings.minor_font.clone();
@@ -178,7 +185,8 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
log::warn!("docx: ignoring unreadable {what}: {e}"); log::warn!("docx: ignoring unreadable {what}: {e}");
} }
}; };
// Styles come before settings, but their theme colours already follow its colour mapping. // 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) { if let Some(p) = part(rt::SETTINGS) {
r.read_color_mapping(&p); r.read_color_mapping(&p);
} }
@@ -252,11 +260,23 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
r.flush_pending(&mut sc, &mut blocks); r.flush_pending(&mut sc, &mut blocks);
r.doc.body = blocks; r.doc.body = blocks;
unstyle_toc_links(&mut r.doc); unstyle_toc_links(&mut r.doc);
toc_control_to_toc(&mut r.doc.body);
toc_field_to_heading(&mut r.doc.body); toc_field_to_heading(&mut r.doc.body);
if let Some(s) = body.child("w:sectPr") { if let Some(s) = body.child("w:sectPr") {
r.doc.last_section = r.read_section(s, &rels); 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()); let core = root_rels.by_type(rt::CORE).map(|x| x.target.clone()).unwrap_or_else(|| "docProps/core.xml".into());
r.read_core(&core); r.read_core(&core);
@@ -416,12 +436,21 @@ fn keep_glued_ids(doc: &mut Document, glued: &HashSet<u32>) {
/// no underline) though the entries carry it (measured in Word for Windows: a Hyperlink-styled run /// no underline) though the entries carry it (measured in Word for Windows: a Hyperlink-styled run
/// in a hyperlink inside a TOC field's result is plain; one outside a hyperlink, another style, /// in a hyperlink inside a TOC field's result is plain; one outside a hyperlink, another style,
/// direct formatting, or a hyperlink in another field keep their look). The style is dropped from /// direct formatting, or a hyperlink in another field keep their look). The style is dropped from
/// those runs so they look the same here; the links stay. /// those runs so they look the same here; the links stay. The style is found by its built-in
/// name: localized Word and older files give it another id (`Hipervnculo`, `Hyperlnk`).
fn unstyle_toc_links(doc: &mut Document) { fn unstyle_toc_links(doc: &mut Document) {
let ids: HashSet<String> = doc
.styles
.styles
.iter()
.filter(|s| s.kind == StyleKind::Character && s.name.eq_ignore_ascii_case("Hyperlink"))
.map(|s| s.id.clone())
.chain(["Hyperlink".to_string()])
.collect();
for f in doc.field_ranges(wordcraft_doc::StoryRef::Body) { for f in doc.field_ranges(wordcraft_doc::StoryRef::Body) {
if is_toc(&f.instr) { if is_toc(&f.instr) {
let _ = doc.format_range(&f.start, &f.end, &|c| { let _ = doc.format_range(&f.start, &f.end, &|c| {
if c.link.is_some() && c.style.as_deref() == Some("Hyperlink") { if c.link.is_some() && c.style.as_deref().is_some_and(|s| ids.contains(s)) {
c.style = None; c.style = None;
} }
}); });
@@ -429,47 +458,113 @@ fn unstyle_toc_links(doc: &mut Document) {
} }
} }
/// Word's Table of Contents content control belongs to the TOC: the writer puts it back around
/// the TOC heading and its entries (`toc_span`), which References › Update Table rebuilds. It is
/// handed to the TOC only when the writer would put it back around the same paragraphs; any other
/// is kept as read, or saving would drop it (#2427).
fn toc_control_to_toc(body: &mut Blocks) {
let span = {
let events = control_events(body);
let (pairs, _) = pair_events(&events);
pairs.iter().find_map(|&(o, c, depth)| {
let (ControlEvent::Open { block: s, off: Some(0), control }, ControlEvent::Close { block: e, off: Some(off) }) =
(events.get(o)?, events.get(c)?)
else {
return None;
};
let toc = matches!(&control.kind, ControlKind::Gallery { list: false, gallery, .. } if gallery == "Table of Contents");
let at_end = body.get(*e)?.as_para()?.len() == off.checked_add(OBJ.len_utf8())?;
(depth == 0 && toc && at_end).then_some((*s, *e, *off))
})
};
let Some((s, e, off)) = span else { return };
let mut toc = body.clone();
// The end first: both markers may be in the same paragraph.
if let Some(Block::Para(p)) = toc.get_mut(e).map(Arc::make_mut) {
let _ = p.delete(off, p.len());
}
if let Some(Block::Para(p)) = toc.get_mut(s).map(Arc::make_mut) {
let _ = p.delete(0, OBJ.len_utf8());
}
toc_field_to_heading(&mut toc);
if crate::write::written_toc_span(&toc) == Some((s, e)) {
*body = toc;
return;
}
// TOCs the writer rebuilds in the same paragraphs once Update Table has turned them into the
// engine's TOC (`range_toc_to_field`) are handed over as the range read, as without the control:
// - Word's layout, the field's end alone in a paragraph after the entries (it goes with them);
// - ours with a heading that isn't a `TOCHeading`, written empty and the field opening in the
// first entry. Kept in the control, that paragraph wouldn't be empty and each save and update
// would add a blank line above the entries (#2182).
let mut engine = toc.clone();
let mut last = e;
// The entry before Word's paragraph holding nothing but the field's end, and that end's format.
let end_only = e.checked_sub(1).filter(|&l| l > s).and_then(|l| {
let p = engine.get(e)?.as_para()?;
let only = p.section.is_none() && p.len() == OBJ.len_utf8() && matches!(p.object_at(0), Some(InlineObject::FieldEnd));
only.then(|| (l, p.props_of_char(0).clone()))
});
if let Some((l, props)) = end_only {
engine.remove(e);
last = l;
if let Some(Block::Para(p)) = engine.get_mut(l).map(Arc::make_mut) {
let len = p.len();
let _ = p.insert_object(len, InlineObject::FieldEnd, &props);
}
}
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty() && p.section.is_none()) {
field_to_heading(&mut engine, s + 1);
} else {
toc_field_to_heading(&mut engine);
}
if crate::write::written_toc_span(&engine) == Some((s, last)) {
*body = toc;
}
}
/// Normalize only a complete TOC range that the engine's heading/entry representation can /// Normalize only a complete TOC range that the engine's heading/entry representation can
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles. /// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
fn toc_field_to_heading(body: &mut Blocks) { fn toc_field_to_heading(body: &mut Blocks) {
for i in 1..body.len() { for i in 1..body.len() {
let heading = body[i - 1].as_para().filter(|p| p.props.style.as_deref() == Some("TOCHeading")); if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
let has_toc = |o: &InlineObject| matches!(o, InlineObject::Field { instr, .. } if is_toc(instr)); field_to_heading(body, i);
if heading.is_none_or(|h| h.objects.iter().any(has_toc)) {
continue;
} }
let Some((field, props, end)) = body[i].as_para().and_then(|p| { }
let (field, end) = match p.object_at(0) { }
Some(InlineObject::FieldStart { instr, locked, dirty }) if is_toc(instr) => (
InlineObject::Field { /// Moves the TOC field that opens paragraph `i` (a complete range over the entries, or an empty
instr: instr.clone(), /// field) to the end of paragraph `i - 1`, the engine's TOC heading, unless that one has a TOC
result: String::new(), /// field already.
locked: *locked, fn field_to_heading(body: &mut Blocks, i: usize) {
dirty: *dirty, let has_toc = |o: &InlineObject| matches!(o, InlineObject::Field { instr, .. } if is_toc(instr));
code: false, if i == 0 || body.get(i - 1).and_then(|b| b.as_para()).is_none_or(|h| h.objects.iter().any(has_toc)) {
form: String::new(), return;
}, }
Some(toc_range_end(body, i)?), let Some((field, props, end)) = body.get(i).and_then(|b| b.as_para()).and_then(|p| {
), let (field, end) = match p.object_at(0) {
Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None), Some(InlineObject::FieldStart { instr, locked, dirty }) if is_toc(instr) => (
_ => return None, InlineObject::Field { instr: instr.clone(), result: String::new(), locked: *locked, dirty: *dirty, code: false, form: String::new() },
}; Some(toc_range_end(body, i)?),
Some((field, p.props_of_char(0).clone(), end)) ),
}) else { Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None),
continue; _ => return None,
}; };
// Delete the end first: the two markers may occupy the same paragraph. Some((field, p.props_of_char(0).clone(), end))
if let Some(Block::Para(p)) = end.and_then(|end| body.get_mut(end)).map(Arc::make_mut) { }) else {
let off = p.len().saturating_sub(OBJ.len_utf8()); return;
let _ = p.delete(off, p.len()); };
} // Delete the end first: the two markers may occupy the same paragraph.
if let Some(Block::Para(p)) = body.get_mut(i).map(Arc::make_mut) { if let Some(Block::Para(p)) = end.and_then(|end| body.get_mut(end)).map(Arc::make_mut) {
let _ = p.delete(0, OBJ.len_utf8()); let off = p.len().saturating_sub(OBJ.len_utf8());
} let _ = p.delete(off, p.len());
if let Some(Block::Para(h)) = body.get_mut(i - 1).map(Arc::make_mut) { }
let end = h.len(); if let Some(Block::Para(p)) = body.get_mut(i).map(Arc::make_mut) {
let _ = h.insert_object(end, field, &props); let _ = p.delete(0, OBJ.len_utf8());
} }
if let Some(Block::Para(h)) = body.get_mut(i - 1).map(Arc::make_mut) {
let end = h.len();
let _ = h.insert_object(end, field, &props);
} }
} }
@@ -717,6 +812,7 @@ impl Reader<'_> {
self.pc.theme_colors = colors.clone(); self.pc.theme_colors = colors.clone();
self.doc.settings.theme_colors = colors; 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") { if let Some(fs) = el.child("a:fontScheme") {
let face = 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); |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);
@@ -729,15 +825,30 @@ impl Reader<'_> {
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(); 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.major_cs = cs("a:majorFont");
self.pc.minor_cs = cs("a:minorFont"); 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.major_font = self.doc.settings.major_font.clone();
self.pc.minor_font = self.doc.settings.minor_font.clone(); self.pc.minor_font = self.doc.settings.minor_font.clone();
self.pc.theme = self.doc.settings.theme_colors.clone(); self.pc.theme = self.doc.settings.theme_colors.clone();
} }
/// `w:settings/w:clrSchemeMapping`: which scheme colour text1, background1… stand for. /// `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.
fn read_color_mapping(&mut self, path: &str) { fn read_color_mapping(&mut self, path: &str) {
let Ok(Some(root)) = self.xml(path) else { return }; 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 Some(m) = root.child("w:clrSchemeMapping") else { return };
let names = [ let names = [
"dark1", "dark1",
@@ -798,6 +909,10 @@ impl Reader<'_> {
} }
if let Some(p) = dd.child("w:pPrDefault").and_then(|d| d.child("w:pPr")) { if let Some(p) = dd.child("w:pPrDefault").and_then(|d| d.child("w:pPr")) {
sheet.default_para = self.pc.ppr(p).0; sheet.default_para = self.pc.ppr(p).0;
// Word makes no paragraph a text frame from the document defaults: the sizeless
// top-left `w:framePr` Apple Pages writes there would put every paragraph, the
// headers' and footers' included, at the page's corner (#2360).
sheet.default_para.frame = None;
} }
} }
if sheet.default_chr.size.is_none() { if sheet.default_chr.size.is_none() {
@@ -894,7 +1009,8 @@ impl Reader<'_> {
} }
t.fill = tp.child("w:shd").and_then(|e| self.pc.shd_fill(e)); t.fill = tp.child("w:shd").and_then(|e| self.pc.shd_fill(e));
t.cell_margins = tp.child("w:tblCellMar").map(props::margins); t.cell_margins = tp.child("w:tblCellMar").map(props::margins);
t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).filter(|n| *n > 0).map(|n| n.min(1000)); t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).map(|n| n.min(1000));
t.col_band_size = tp.child_val("w:tblStyleColBandSize").and_then(u32_of).map(|n| n.min(1000));
} }
for c in s.children("w:tblStylePr").take(64) { for c in s.children("w:tblStylePr").take(64) {
let Some(part) = c.attr("w:type").and_then(TablePart::from_ooxml) else { continue }; let Some(part) = c.attr("w:type").and_then(TablePart::from_ooxml) else { continue };
+87 -8
View File
@@ -44,9 +44,15 @@ use crate::xml::El;
pub struct PropCtx { pub struct PropCtx {
pub major_font: String, pub major_font: String,
pub minor_font: String, pub minor_font: String,
/// The theme's complex-script fonts (`a:cs`), which the `…Bidi` theme slots name. /// The theme's complex-script fonts (`a:cs`), which the `…Bidi` theme slots fall back on.
pub major_cs: String, pub major_cs: String,
pub minor_cs: String, pub minor_cs: String,
/// The theme's per-script fonts (`a:font script="Arab" typeface="…"`).
pub major_scripts: Vec<(String, String)>,
pub minor_scripts: Vec<(String, String)>,
/// The script (ISO 15924) of `w:themeFontLang/@w:bidi`, which picks the font a `…Bidi` theme
/// slot names. `None` = the settings name no bidi language.
pub bidi_script: Option<&'static str>,
/// The theme's colour scheme (dk1, lt1, dk2, lt2, accent1..6, hlink, folHlink); empty when the /// The theme's colour scheme (dk1, lt1, dk2, lt2, accent1..6, hlink, folHlink); empty when the
/// package has none, and then `w:themeColor` falls back to the fixed colour. /// package has none, and then `w:themeColor` falls back to the fixed colour.
pub theme_colors: Vec<Rgb>, pub theme_colors: Vec<Rgb>,
@@ -64,6 +70,64 @@ pub struct PropCtx {
/// a document (`resolve_list_levels`). /// a document (`resolve_list_levels`).
pub(crate) const LEVEL_ONLY: u32 = u32::MAX; pub(crate) const LEVEL_ONLY: u32 = u32::MAX;
/// The font Word draws a `minorBidi`/`majorBidi` theme slot in when the settings name no bidi
/// language, or the theme has no font for it.
const BIDI_THEME_FALLBACK_FONT: &str = "Times New Roman";
/// The script a language tag is written in, as a theme's `a:font script` names it (ISO 15924, and
/// Office's own `Uigh` for Uyghur and `Viet` for Vietnamese): an explicit script subtag
/// (`ku-Arab`), else the language's usual script. Languages not listed are taken as Latin.
pub(crate) fn lang_script(tag: &str) -> &'static str {
const SCRIPTS: &[&str] = &[
"Arab", "Armn", "Beng", "Cans", "Cher", "Cyrl", "Deva", "Ethi", "Geor", "Grek", "Gujr", "Guru", "Hang", "Hans", "Hant", "Hebr", "Jpan",
"Khmr", "Knda", "Laoo", "Latn", "Mlym", "Mong", "Mymr", "Orya", "Sinh", "Syrc", "Taml", "Telu", "Tfng", "Thaa", "Thai", "Tibt", "Uigh",
"Viet", "Yiii",
];
let mut parts = tag.split(['-', '_']).filter(|p| !p.is_empty());
let lang = parts.next().unwrap_or_default().to_ascii_lowercase();
let rest: Vec<&str> = parts.take(8).collect();
if let Some(s) = rest.iter().find_map(|p| SCRIPTS.iter().find(|s| s.eq_ignore_ascii_case(p))) {
return s;
}
match lang.as_str() {
"ar" | "fa" | "ur" | "ps" | "sd" | "ckb" | "prs" => "Arab",
"ug" => "Uigh",
"vi" => "Viet",
"he" | "yi" => "Hebr",
"syr" => "Syrc",
"dv" => "Thaa",
"th" => "Thai",
"hi" | "mr" | "ne" | "sa" | "kok" => "Deva",
"bn" | "as" => "Beng",
// Measured in Word: Punjabi takes the Arabic-script font in Pakistan, Gurmukhi elsewhere.
"pa" if rest.iter().any(|p| p.eq_ignore_ascii_case("pk")) => "Arab",
"pa" => "Guru",
"gu" => "Gujr",
"or" => "Orya",
"ta" => "Taml",
"te" => "Telu",
"kn" => "Knda",
"ml" => "Mlym",
"si" => "Sinh",
"lo" => "Laoo",
"km" => "Khmr",
"bo" => "Tibt",
"my" => "Mymr",
"am" | "ti" => "Ethi",
"chr" => "Cher",
"iu" => "Cans",
"ii" => "Yiii",
"ka" => "Geor",
"hy" => "Armn",
"el" => "Grek",
"ja" => "Jpan",
"ko" => "Hang",
"zh" if rest.iter().any(|p| ["tw", "hk", "mo"].iter().any(|r| r.eq_ignore_ascii_case(p))) => "Hant",
"zh" => "Hans",
_ => "Latn",
}
}
/// The list a level-only `numPr` refers to: the nearest numbering in style `style`'s based-on chain /// The list a level-only `numPr` refers to: the nearest numbering in style `style`'s based-on chain
/// (its own included) that names a list. `None` when no style in the chain has one. /// (its own included) that names a list. `None` when no style in the chain has one.
pub(crate) fn chain_list(sheet: &wordcraft_doc::styles::StyleSheet, style: &str) -> Option<u32> { pub(crate) fn chain_list(sheet: &wordcraft_doc::styles::StyleSheet, style: &str) -> Option<u32> {
@@ -86,11 +150,25 @@ impl PropCtx {
} }
fn theme_font(&self, t: &str) -> Option<String> { fn theme_font(&self, t: &str) -> Option<String> {
let (latin, cs) = if t.starts_with("major") { (&self.major_font, &self.major_cs) } else { (&self.minor_font, &self.minor_cs) }; let major = t.starts_with("major");
// Measured in Word: `minorBidi`/`majorBidi` name the complex-script font, and an empty one let latin = if major { &self.major_font } else { &self.minor_font };
// names nothing, so the font beside it or the inherited one applies. if t.ends_with("Bidi") {
let f = if t.ends_with("Bidi") { cs } else { latin }; return Some(self.bidi_theme_font(major));
(!f.is_empty()).then(|| f.clone()) }
(!latin.is_empty()).then(|| latin.clone())
}
/// The font a `majorBidi`/`minorBidi` theme slot names. Measured in Word for Mac: the theme's
/// font for the script of the settings' `w:themeFontLang/@w:bidi` (its `a:latin` for a Latin
/// language), else the theme's `a:cs`, else Times New Roman; with no bidi language there,
/// Times New Roman whatever the theme says. The run's own `w:lang` plays no part.
fn bidi_theme_font(&self, major: bool) -> String {
let (latin, cs, scripts) =
if major { (&self.major_font, &self.major_cs, &self.major_scripts) } else { (&self.minor_font, &self.minor_cs, &self.minor_scripts) };
let named = |f: &String| (!f.is_empty()).then(|| f.clone());
let Some(script) = self.bidi_script else { return BIDI_THEME_FALLBACK_FONT.to_string() };
let by_script = if script == "Latn" { named(latin) } else { scripts.iter().find(|(s, _)| s == script).and_then(|(_, f)| named(f)) };
by_script.or_else(|| named(cs)).unwrap_or_else(|| BIDI_THEME_FALLBACK_FONT.to_string())
} }
/// The theme colour `e` names in `attr` (`w:themeColor`, `w:themeFill`), through the colour /// The theme colour `e` names in `attr` (`w:themeColor`, `w:themeFill`), through the colour
@@ -227,8 +305,9 @@ impl PropCtx {
c.east_asian_layout = (l != EastAsianLayout::default()).then_some(l); c.east_asian_layout = (l != EastAsianLayout::default()).then_some(l);
} }
"w:lang" => { "w:lang" => {
c.lang = k.attr("w:val").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string); c.lang = k.attr("w:val").map(str::trim).filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
c.lang_bidi = k.attr("w:bidi").filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string); c.lang_bidi = k.attr("w:bidi").map(str::trim).filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
c.lang_ea = k.attr("w:eastAsia").map(str::trim).filter(|v| !v.is_empty() && v.len() < 64).map(str::to_string);
} }
"w14:ligatures" => c.ligatures = k.attr("w14:val").and_then(wordcraft_doc::props::Ligatures::from_ooxml), "w14:ligatures" => c.ligatures = k.attr("w14:val").and_then(wordcraft_doc::props::Ligatures::from_ooxml),
n if n.starts_with("w14:") => { n if n.starts_with("w14:") => {
+241 -18
View File
@@ -1,10 +1,14 @@
//! How a Word shape (`wps:wsp`) looks: its preset geometry, and the fill and outline it gets from its own properties (`wps:spPr`) or, failing those, from the theme through its //! How a Word shape (`wps:wsp`) looks: its preset geometry, and the fill and outline it gets from its own properties (`wps:spPr`) or, failing those, from the theme through its
//! style references (`wps:style`: `a:fillRef`, `a:lnRef`, ECMA-376 Part 1 §20.1.4.2). //! style references (`wps:style`: `a:fillRef`, `a:lnRef`, ECMA-376 Part 1 §20.1.4.2).
use std::sync::Arc;
use wordcraft_doc::fill::{Fill, Gradient, GradientFlip, GradientKind, PathShape};
use wordcraft_doc::para::ShapeKind; use wordcraft_doc::para::ShapeKind;
use wordcraft_doc::props::Rgb; use wordcraft_doc::props::Rgb;
use wordcraft_doc::wordart::{GradStop, MAX_STOPS};
use super::drawing_color::{color, fill_colour}; use super::drawing_color::{color, color_alpha, fill as read_fill, fill_colour};
use crate::units::{int, measure}; use crate::units::{int, measure};
use crate::xml::El; use crate::xml::El;
@@ -108,29 +112,76 @@ pub(crate) fn vml_preset_kind(spt: u32) -> Option<ShapeKind> {
/// A shape's fill and outline. /// A shape's fill and outline.
#[derive(Debug, Default, PartialEq)] #[derive(Debug, Default, PartialEq)]
pub(crate) struct ShapePaint { pub(crate) struct ShapePaint {
pub fill: Option<Rgb>, pub fill: Option<Fill>,
pub stroke: Option<Rgb>, pub stroke: Option<Rgb>,
/// Points. /// Points.
pub stroke_width: f32, pub stroke_width: f32,
/// The outline's gradient (drawn in its main colour, `stroke`).
pub outline_fill: Option<Fill>,
}
/// The theme's fill styles (`a:fmtScheme`): `a:fillStyleLst` and `a:bgFillStyleLst`, which a
/// shape's `a:fillRef` 1–999 and 1001+ point to.
#[derive(Debug, Default)]
pub(crate) struct ThemeFills {
pub fill: Vec<El>,
pub bg: Vec<El>,
}
impl ThemeFills {
pub fn read(fmt: Option<&El>) -> ThemeFills {
let list = |n: &str| fmt.and_then(|f| f.child(n)).map(|l| l.els().take(MAX_THEME_FILLS).cloned().collect()).unwrap_or_default();
ThemeFills { fill: list("a:fillStyleLst"), bg: list("a:bgFillStyleLst") }
}
/// Style `idx` (1-based; 1001+ the background styles; 1000 names none).
fn get(&self, idx: i64) -> Option<&El> {
let (list, i) = if idx > 1000 { (&self.bg, idx - 1001) } else { (&self.fill, idx - 1) };
list.get(usize::try_from(i).ok()?)
}
}
/// Most fill styles kept per theme list: every one a `a:fillRef` can reach (1–999 into
/// `a:fillStyleLst`, 1001–1999 into `a:bgFillStyleLst`, ECMA-376 Part 1 §20.1.4.2.10), and no
/// more, so a hostile theme with millions of entries keeps a bounded list.
const MAX_THEME_FILLS: usize = 999;
/// The fill a `…spPr` element states itself: `Some(None)` for none (`a:noFill`, picture fills),
/// `group` for `a:grpFill`, `None` when it leaves the fill to the shape's style.
pub(crate) fn own_fill(sppr: Option<&El>, theme: &[Rgb], group: Option<&Fill>) -> Option<Option<Fill>> {
let f = sppr?.els().find(|c| matches!(c.name.as_str(), "a:noFill" | "a:blipFill" | "a:grpFill" | "a:solidFill" | "a:gradFill" | "a:pattFill"))?;
Some(match f.name.as_str() {
"a:grpFill" => group.cloned(),
_ => read_fill(f, theme, None),
})
} }
/// The paint of a shape from its properties `sppr`, falling back on its style `style` (theme /// The paint of a shape from its properties `sppr`, falling back on its style `style` (theme
/// colours `theme`) for what they leave out: an explicit `a:noFill` is no fill or outline, an /// colours `theme`, fill styles `styles`) for what they leave out: an explicit `a:noFill` is no
/// explicit colour wins, and a style reference with index 0 is none. /// fill or outline, an explicit colour wins, `a:grpFill` is the enclosing group's fill `group`,
pub(crate) fn shape_paint(sppr: Option<&El>, style: Option<&El>, theme: &[Rgb]) -> ShapePaint { /// and a style reference with index 0 is none.
pub(crate) fn shape_paint(sppr: Option<&El>, style: Option<&El>, theme: &[Rgb], styles: &ThemeFills, group: Option<&Fill>) -> ShapePaint {
let style_ref = |name: &str| -> Option<(i64, &El)> { let style_ref = |name: &str| -> Option<(i64, &El)> {
let r = style?.child(name)?; let r = style?.child(name)?;
Some((r.attr("idx").and_then(int).unwrap_or(0), r)) Some((r.attr("idx").and_then(int).unwrap_or(0), r))
}; };
let fill = match sppr { let fill = own_fill(sppr, theme, group).unwrap_or_else(|| {
Some(s) if s.child("a:noFill").is_some() || s.child("a:blipFill").is_some() => None, let (idx, r) = style_ref("a:fillRef").filter(|(idx, _)| *idx > 0)?;
Some(s) if s.els().any(|c| matches!(c.name.as_str(), "a:solidFill" | "a:gradFill" | "a:pattFill")) => fill_colour(s, theme), // The theme style in the reference's colour (`phClr`), else that colour: also for a style
_ => style_ref("a:fillRef").filter(|(idx, _)| *idx > 0).and_then(|(_, r)| color(r, theme)), // the model can't hold (a picture fill), so the shape doesn't vanish.
}; let ph = color_alpha(r, theme, None)?;
let solid = Fill::Solid { color: ph.0, transparency: ph.1 };
match styles.get(idx) {
Some(st) if st.name == "a:noFill" => None,
Some(st) => read_fill(st, theme, Some(ph)).or(Some(solid)),
None => Some(solid),
}
});
let ln = sppr.and_then(|s| s.child("a:ln")); let ln = sppr.and_then(|s| s.child("a:ln"));
let line_ref = style_ref("a:lnRef").filter(|(idx, _)| *idx > 0); let line_ref = style_ref("a:lnRef").filter(|(idx, _)| *idx > 0);
let outline_fill = ln.and_then(|l| l.child("a:gradFill")).and_then(|g| read_fill(g, theme, None)).filter(|f| matches!(f, Fill::Gradient(_)));
let stroke = match ln { let stroke = match ln {
Some(l) if l.child("a:noFill").is_some() => None, Some(l) if l.child("a:noFill").is_some() => None,
_ if outline_fill.is_some() => outline_fill.as_ref().map(Fill::main_color),
Some(l) if l.els().any(|c| matches!(c.name.as_str(), "a:solidFill" | "a:gradFill" | "a:pattFill")) => fill_colour(l, theme), Some(l) if l.els().any(|c| matches!(c.name.as_str(), "a:solidFill" | "a:gradFill" | "a:pattFill")) => fill_colour(l, theme),
_ => line_ref.and_then(|(_, r)| color(r, theme)), _ => line_ref.and_then(|(_, r)| color(r, theme)),
}; };
@@ -142,7 +193,86 @@ pub(crate) fn shape_paint(sppr: Option<&El>, style: Option<&El>, theme: &[Rgb])
// Word's default outline when the file names none (no outline: no width). // Word's default outline when the file names none (no outline: no width).
.unwrap_or(if stroke.is_some() { 0.75 } else { 0.0 }) .unwrap_or(if stroke.is_some() { 0.75 } else { 0.0 })
.clamp(0.0, 100.0); .clamp(0.0, 100.0);
ShapePaint { fill, stroke, stroke_width } ShapePaint { fill, stroke, stroke_width, outline_fill }
}
/// A VML shape's fill: `fillcolor` (white by default) and its `v:fill`: opacity, and a linear
/// (`type="gradient"`) or circular (`gradientRadial`) gradient to `color2`, or through `colors`.
/// A gradient's `focus` (−100%–100%) and `angle` are approximated: 0 and 100% run `color` to
/// `color2`, −100% the other way, and a focus between mirrors the gradient there (`color2`
/// there for a positive focus, `color` for a negative one). `None` when not filled.
pub(crate) fn vml_fill(shape: &El, shapetype: Option<&El>) -> Option<Fill> {
// What the shape leaves out comes from its shapetype (#1377).
let own = |n: &str| shape.attr(n).or_else(|| shapetype.and_then(|t| t.attr(n)));
let f = shape.child("v:fill").or_else(|| shapetype.and_then(|t| t.child("v:fill")));
let attr = |n: &str| f.and_then(|f| f.attr(n));
let off = |v: Option<&str>| {
v.is_some_and(|v| {
let t = v.trim();
t.eq_ignore_ascii_case("f") || t.eq_ignore_ascii_case("false") || t == "0"
})
};
if off(own("filled")) || off(attr("on")) {
return None;
}
let c1 = attr("color").or(own("fillcolor")).map_or(Some(Rgb::WHITE), vml_color)?;
let t1 = 100.0 - attr("opacity").and_then(vml_fraction).unwrap_or(1.0) * 100.0;
let radial = match attr("type") {
Some("gradient") => false,
Some("gradientRadial") => true,
_ => return Fill::Solid { color: c1, transparency: t1 }.sanitized(),
};
let c2 = attr("color2").and_then(vml_color).unwrap_or(Rgb::WHITE);
let t2 = 100.0 - attr("o:opacity2").and_then(vml_fraction).unwrap_or(1.0) * 100.0;
let stop = |pos: f32, (color, transparency): (Rgb, f32)| GradStop { pos, color, transparency };
let (a, b) = ((c1, t1), (c2, t2));
let focus = attr("focus").and_then(vml_fraction).unwrap_or(0.0).clamp(-1.0, 1.0) * 100.0;
let colors: Vec<GradStop> = attr("colors")
.into_iter()
.flat_map(|v| v.split(';'))
.take(MAX_STOPS)
.filter_map(|s| {
let (pos, c) = s.trim().split_once(' ')?;
Some(stop(vml_fraction(pos)? * 100.0, (vml_color(c)?, t1)))
})
.collect();
let stops = if colors.len() >= 2 {
colors
} else if focus <= -100.0 {
vec![stop(0.0, b), stop(100.0, a)]
} else if focus >= 100.0 || focus == 0.0 {
vec![stop(0.0, a), stop(100.0, b)]
} else if focus > 0.0 {
vec![stop(0.0, a), stop(focus, b), stop(100.0, a)]
} else {
vec![stop(0.0, b), stop(-focus, a), stop(100.0, b)]
};
let kind = if radial {
// `focusposition` "x,y" (fractions of the shape) is the centre.
let fp = attr("focusposition").unwrap_or("0.5,0.5");
let (x, y) = fp.split_once(',').unwrap_or((fp, fp));
let (x, y) = (vml_fraction(x).unwrap_or(0.5) * 100.0, vml_fraction(y).unwrap_or(0.5) * 100.0);
GradientKind::Path { shape: PathShape::Circle, focus: [x, y, 100.0 - x, 100.0 - y] }
} else {
// VML's angle is counter-clockwise from a top-to-bottom gradient.
let ang = attr("angle").and_then(|v| v.trim().parse::<f32>().ok()).filter(|v| v.is_finite()).unwrap_or(0.0);
GradientKind::Linear { angle: 90.0 - ang, scaled: false }
};
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: None, rot_with_shape: true, flip: GradientFlip::None })).sanitized()
}
/// A VML fraction (`opacity`, `focus`, a `colors` position): `0.5`, `50%`, or 65536ths
/// (`32768f`). Not clamped: a focus runs from -1 to 1.
pub(crate) fn vml_fraction(v: &str) -> Option<f32> {
let v = v.trim();
let n = if let Some(f) = v.strip_suffix('f') {
f.trim().parse::<f32>().ok()? / 65_536.0
} else if let Some(p) = v.strip_suffix('%') {
p.trim().parse::<f32>().ok()? / 100.0
} else {
v.parse::<f32>().ok()?
};
n.is_finite().then_some(n)
} }
/// Explicit DrawingML dash stops override the solid default. /// Explicit DrawingML dash stops override the solid default.
@@ -222,7 +352,7 @@ mod tests {
let st = el( let st = el(
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="1"><a:schemeClr val="accent4"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent4"/></a:fillRef></wps:style>"#, r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="1"><a:schemeClr val="accent4"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent4"/></a:fillRef></wps:style>"#,
); );
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS); let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS, &ThemeFills::default(), None);
assert_eq!(p.stroke, Some(THEME_COLORS[7])); assert_eq!(p.stroke, Some(THEME_COLORS[7]));
assert_eq!(p.stroke_width, 0.5); assert_eq!(p.stroke_width, 0.5);
assert_eq!(p.fill, None); assert_eq!(p.fill, None);
@@ -239,32 +369,94 @@ mod tests {
let st = el( let st = el(
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="2"><a:schemeClr val="accent1"><a:shade val="15000"/></a:schemeClr></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef></wps:style>"#, r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="2"><a:schemeClr val="accent1"><a:shade val="15000"/></a:schemeClr></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef></wps:style>"#,
); );
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS); let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS, &ThemeFills::default(), None);
assert_eq!((p.fill, p.stroke, p.stroke_width), (None, Some(Rgb(0xEE, 0, 0)), 1.0)); assert_eq!((p.fill, p.stroke, p.stroke_width), (None, Some(Rgb(0xEE, 0, 0)), 1.0));
// Preset and scheme colours as the box's own fill and outline. // Preset and scheme colours as the box's own fill and outline.
let sp = el( let sp = el(
r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:ln w="6350"><a:solidFill><a:prstClr val="black"/></a:solidFill></a:ln></wps:spPr>"#, r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:ln w="6350"><a:solidFill><a:prstClr val="black"/></a:solidFill></a:ln></wps:spPr>"#,
); );
let p = shape_paint(Some(&sp), None, &THEME_COLORS); let p = shape_paint(Some(&sp), None, &THEME_COLORS, &ThemeFills::default(), None);
assert_eq!((p.fill, p.stroke, p.stroke_width), (Some(THEME_COLORS[1]), Some(Rgb(0, 0, 0)), 0.5)); assert_eq!((p.fill, p.stroke, p.stroke_width), (Some(Fill::solid(THEME_COLORS[1])), Some(Rgb(0, 0, 0)), 0.5));
// A style fill with index 0 is none; an outline set to no fill is none. // A style fill with index 0 is none; an outline set to no fill is none.
let sp = el(r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:ln><a:noFill/></a:ln></wps:spPr>"#); let sp = el(r#"<wps:spPr xmlns:wps="w" xmlns:a="a"><a:ln><a:noFill/></a:ln></wps:spPr>"#);
let st = el( let st = el(
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="2"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent1"/></a:fillRef></wps:style>"#, r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="2"><a:schemeClr val="accent1"/></a:lnRef><a:fillRef idx="0"><a:schemeClr val="accent1"/></a:fillRef></wps:style>"#,
); );
let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS); let p = shape_paint(Some(&sp), Some(&st), &THEME_COLORS, &ThemeFills::default(), None);
assert_eq!((p.fill, p.stroke), (None, None)); assert_eq!((p.fill, p.stroke), (None, None));
} }
#[test]
fn a_theme_picture_fill_falls_back_on_the_reference_colour() {
let fmt = el(
r#"<a:fmtScheme xmlns:wps="w" xmlns:a="a"><a:fillStyleLst><a:noFill/><a:blipFill><a:blip/></a:blipFill></a:fillStyleLst></a:fmtScheme>"#,
);
let styles = ThemeFills::read(Some(&fmt));
let st = |idx: u8| {
el(&format!(r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:fillRef idx="{idx}"><a:srgbClr val="112233"/></a:fillRef></wps:style>"#))
};
assert_eq!(shape_paint(None, Some(&st(2)), &THEME_COLORS, &styles, None).fill, Some(Fill::solid(Rgb(0x11, 0x22, 0x33))));
// An explicit theme `a:noFill` stays none.
assert_eq!(shape_paint(None, Some(&st(1)), &THEME_COLORS, &styles, None).fill, None);
}
#[test]
fn every_reachable_theme_fill_style_is_kept() {
// 20 fill styles and 20 background styles: the 17th of each a gradient / none, the rest
// solid, so a reference past Word's usual three still finds its style.
let entries = |odd: &str| {
(1..=20)
.map(|i| if i == 17 { odd.to_string() } else { r#"<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>"#.to_string() })
.collect::<String>()
};
let grad = r#"<a:gradFill><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"/></a:gs><a:gs pos="100000"><a:srgbClr val="FFFFFF"/></a:gs></a:gsLst><a:lin ang="0"/></a:gradFill>"#;
let fmt = el(&format!(
r#"<a:fmtScheme xmlns:wps="w" xmlns:a="a"><a:fillStyleLst>{}</a:fillStyleLst><a:bgFillStyleLst>{}</a:bgFillStyleLst></a:fmtScheme>"#,
entries(grad),
entries("<a:noFill/>")
));
let styles = ThemeFills::read(Some(&fmt));
let st = |idx: u32| {
el(&format!(r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:fillRef idx="{idx}"><a:srgbClr val="112233"/></a:fillRef></wps:style>"#))
};
let fill = |idx| shape_paint(None, Some(&st(idx)), &THEME_COLORS, &styles, None).fill;
assert!(matches!(fill(17), Some(Fill::Gradient(_))), "{:?}", fill(17));
assert_eq!(fill(1017), None);
assert_eq!(fill(16), Some(Fill::solid(Rgb(0x11, 0x22, 0x33))));
}
#[test]
fn a_theme_with_absurdly_many_fill_styles_stays_bounded() {
let many = "<a:noFill/>".repeat(100_000);
let fmt = el(&format!(
r#"<a:fmtScheme xmlns:wps="w" xmlns:a="a"><a:fillStyleLst>{many}</a:fillStyleLst><a:bgFillStyleLst>{many}</a:bgFillStyleLst></a:fmtScheme>"#
));
let styles = ThemeFills::read(Some(&fmt));
assert_eq!((styles.fill.len(), styles.bg.len()), (MAX_THEME_FILLS, MAX_THEME_FILLS));
let st = |idx: &str| {
el(&format!(r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:fillRef idx="{idx}"><a:srgbClr val="112233"/></a:fillRef></wps:style>"#))
};
// The last reachable style of each list is kept; past them (and 1000) the reference colour.
for (idx, want) in [
("999", None),
("1999", None),
("1000", Some(Fill::solid(Rgb(0x11, 0x22, 0x33)))),
("2000", Some(Fill::solid(Rgb(0x11, 0x22, 0x33)))),
("99999999999", Some(Fill::solid(Rgb(0x11, 0x22, 0x33)))),
] {
assert_eq!(shape_paint(None, Some(&st(idx)), &THEME_COLORS, &styles, None).fill, want, "idx {idx}");
}
}
#[test] #[test]
fn hostile_style_references_do_not_panic() { fn hostile_style_references_do_not_panic() {
let st = el( let st = el(
r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="-99999999999"><a:schemeClr val="nope"/></a:lnRef><a:fillRef idx="99999999999"/></wps:style>"#, r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="-99999999999"><a:schemeClr val="nope"/></a:lnRef><a:fillRef idx="99999999999"/></wps:style>"#,
); );
let p = shape_paint(None, Some(&st), &[]); let p = shape_paint(None, Some(&st), &[], &ThemeFills::default(), None);
assert_eq!((p.fill, p.stroke), (None, None)); assert_eq!((p.fill, p.stroke), (None, None));
let st = el(r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="77"><a:srgbClr val="010203"/></a:lnRef></wps:style>"#); let st = el(r#"<wps:style xmlns:wps="w" xmlns:a="a"><a:lnRef idx="77"><a:srgbClr val="010203"/></a:lnRef></wps:style>"#);
assert_eq!(shape_paint(None, Some(&st), &[]).stroke_width, 1.5); assert_eq!(shape_paint(None, Some(&st), &[], &ThemeFills::default(), None).stroke_width, 1.5);
} }
#[test] #[test]
@@ -297,6 +489,37 @@ mod tests {
} }
} }
#[test]
fn vml_gradients_focus_and_colours() {
let v = |attrs: &str, fill: &str| {
let ns = r#"xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office""#;
vml_fill(&crate::xml::parse(format!(r#"<v:rect {ns} {attrs}>{fill}</v:rect>"#).as_bytes()).unwrap(), None)
};
let stops = |f: Option<Fill>| match f {
Some(Fill::Gradient(g)) => g.stops.iter().map(|s| (s.pos, s.color)).collect::<Vec<_>>(),
o => panic!("{o:?}"),
};
let (red, blue) = (Rgb(255, 0, 0), Rgb(0, 0, 255));
// A focus of 50% mirrors the gradient in the middle; 100% runs as 0%, −100% reverses it.
assert_eq!(
stops(v(r##"fillcolor="red""##, r##"<v:fill type="gradient" color2="blue" focus="50%"/>"##)),
[(0.0, red), (50.0, blue), (100.0, red)]
);
assert_eq!(stops(v(r##"fillcolor="red""##, r##"<v:fill type="gradient" color2="blue" focus="100%"/>"##)), [(0.0, red), (100.0, blue)]);
assert_eq!(stops(v(r##"fillcolor="red""##, r##"<v:fill type="gradient" color2="blue" focus="-100%"/>"##)), [(0.0, blue), (100.0, red)]);
assert_eq!(v("", r##"<v:fill opacity="-2"/>"##), Some(Fill::Solid { color: Rgb::WHITE, transparency: 100.0 }));
// `colors` lists the stops (fractions or 65536ths).
assert_eq!(stops(v("", r##"<v:fill type="gradient" colors="0 red;32768f blue;1 red"/>"##)), [(0.0, red), (50.0, blue), (100.0, red)]);
let Some(Fill::Gradient(g)) = v(r##"fillcolor="red""##, r##"<v:fill type="gradientRadial" color2="blue" focusposition=".25,.75"/>"##) else {
panic!()
};
assert_eq!(g.kind, GradientKind::Path { shape: PathShape::Circle, focus: [25.0, 75.0, 75.0, 25.0] });
assert_eq!(v(r##"filled="f""##, ""), None);
assert_eq!(v("", r##"<v:fill opacity="32768f"/>"##), Some(Fill::Solid { color: Rgb::WHITE, transparency: 50.0 }));
// Hostile values don't panic.
assert!(v("", r##"<v:fill type="gradient" colors="x;;1 #zz;NaN red" focus="NaN" angle="inf" opacity="1e99f"/>"##).is_some());
}
#[test] #[test]
fn vml_colours() { fn vml_colours() {
assert_eq!(vml_color("#ffc000 [3207]"), Some(Rgb(0xFF, 0xC0, 0))); assert_eq!(vml_color("#ffc000 [3207]"), Some(Rgb(0xFF, 0xC0, 0)));
+180 -56
View File
@@ -3,7 +3,7 @@
use std::sync::Arc; use std::sync::Arc;
use wordcraft_doc::connector::{ConnEnd, ShapeExtra}; use wordcraft_doc::connector::{ConnEnd, ShapeExtra};
use wordcraft_doc::control::{ContentControl, ControlKind, ControlWrap}; use wordcraft_doc::control::{ContentControl, ControlWrap};
use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects}; use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects};
use wordcraft_doc::graphic::{Embedded, Graphic, GraphicItem, GraphicKind}; use wordcraft_doc::graphic::{Embedded, Graphic, GraphicItem, GraphicKind};
use wordcraft_doc::para::{AltText, Anchor, Float, FloatAlign, NoteKind, PTabAlign, PTabBase, ShapeKind, Wrap, WrapText, cap_alt}; use wordcraft_doc::para::{AltText, Anchor, Float, FloatAlign, NoteKind, PTabAlign, PTabBase, ShapeKind, Wrap, WrapText, cap_alt};
@@ -13,7 +13,7 @@ use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKi
use super::Reader; use super::Reader;
use super::props::{sectpr, tcpr, trpr}; use super::props::{sectpr, tcpr, trpr};
use super::shape_style::{preset_kind, shape_paint, vml_color, vml_preset_kind}; use super::shape_style::{own_fill, preset_kind, shape_paint, vml_color, vml_fill, vml_fraction, vml_preset_kind};
use crate::package::{ContentTypes, Rels, rt}; use crate::package::{ContentTypes, Rels, rt};
use crate::units::{int, measure, u32_of}; use crate::units::{int, measure, u32_of};
use crate::xml::{El, Node}; use crate::xml::{El, Node};
@@ -101,6 +101,69 @@ fn is_range_instr(instr: &str) -> bool {
super::is_toc(instr) || instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN")) super::is_toc(instr) || instr.trim_start().get(..5).is_some_and(|k| k.eq_ignore_ascii_case("ADDIN"))
} }
/// Where a `HYPERLINK` field goes, as a `w:hyperlink` reads: `url`, `url#bookmark` (`\l`) or
/// `#bookmark`. None for any other field, or one with no target.
fn hyperlink_target(instr: &str) -> Option<String> {
// Only a `HYPERLINK` code is split up (a field's code may run to megabytes).
if !instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("HYPERLINK")) {
return None;
}
let args = field_args(instr);
let mut args = args.iter();
if !args.next().is_some_and(|(k, quoted)| !quoted && k.eq_ignore_ascii_case("HYPERLINK")) {
return None;
}
let (mut url, mut anchor) = (None, None);
while let Some((a, quoted)) = args.next() {
if *quoted || !a.starts_with('\\') {
url = url.or(Some(a.as_str()));
continue;
}
// `\l` names a bookmark, `\o` a tooltip and `\t` a target frame; `\m` and `\n` take none.
let value = matches!(a.to_ascii_lowercase().as_str(), "\\l" | "\\o" | "\\t").then(|| args.next()).flatten();
if a.eq_ignore_ascii_case("\\l") {
anchor = value.map(|(v, _)| v.as_str());
}
}
let (url, anchor) = (url.map(str::trim).filter(|u| !u.is_empty()), anchor.map(str::trim).filter(|a| !a.is_empty()));
match (url, anchor) {
(Some(u), Some(a)) => Some(format!("{u}#{a}")),
(Some(u), None) => Some(u.to_string()),
(None, Some(a)) => Some(format!("#{a}")),
(None, None) => None,
}
}
/// A field code's words: the field's name, its switches and its arguments, each with whether
/// it was quoted. In quotes, `\"` is a quote and `\\` a backslash.
fn field_args(instr: &str) -> Vec<(String, bool)> {
let mut out = Vec::new();
let mut chars = instr.chars().peekable();
while let Some(c) = chars.next() {
if c.is_whitespace() {
continue;
}
let mut word = String::new();
if c == '"' {
while let Some(c) = chars.next() {
match c {
'"' => break,
'\\' if matches!(chars.peek(), Some('"' | '\\')) => word.extend(chars.next()),
_ => word.push(c),
}
}
out.push((word, true));
} else {
word.push(c);
while let Some(c) = chars.next_if(|c| !c.is_whitespace() && *c != '"') {
word.push(c);
}
out.push((word, false));
}
}
out
}
/// The last paragraph of a block list: its last block, or inside a final table the last /// The last paragraph of a block list: its last block, or inside a final table the last
/// paragraph of its last cell (nested tables to [`MAX_TABLE_DEPTH`]). /// paragraph of its last cell (nested tables to [`MAX_TABLE_DEPTH`]).
fn last_para_mut(out: &mut Blocks, depth: usize) -> Option<&mut Paragraph> { fn last_para_mut(out: &mut Blocks, depth: usize) -> Option<&mut Paragraph> {
@@ -265,10 +328,9 @@ impl Reader<'_> {
ctl.run_props = e.child("w:sdtPr").and_then(|pr| pr.child("w:rPr")).map(|r| self.pc.rpr(r)); ctl.run_props = e.child("w:sdtPr").and_then(|pr| pr.child("w:rPr")).map(|r| self.pc.rpr(r));
let mut inner = Blocks::new(); let mut inner = Blocks::new();
self.read_blocks(sc, content, rels, &mut inner, depth + 1); self.read_blocks(sc, content, rels, &mut inner, depth + 1);
// A table of contents' control belongs to the TOC: the writer puts it back around the // A table of contents' control is handed to the TOC later, when the writer puts it back
// TOC field and its entries, which References › Update Table rebuilds. // (`toc_control_to_toc`).
let toc = matches!(&ctl.kind, ControlKind::Gallery { list: false, gallery, .. } if gallery == "Table of Contents"); if inner.is_empty() {
if inner.is_empty() || toc {
out.extend(inner); out.extend(inner);
return; return;
} }
@@ -702,8 +764,12 @@ impl Reader<'_> {
} }
} }
"w:pict" => { "w:pict" => {
for o in self.read_vml_all(sc, k, rels) { if let Some(o) = self.read_signature_line(sc, k, rels) {
self.emit_obj(sc, pb, o, props); self.emit_obj(sc, pb, o, props);
} else {
for o in self.read_vml_all(sc, k, rels) {
self.emit_obj(sc, pb, o, props);
}
} }
} }
"w:object" => { "w:object" => {
@@ -889,6 +955,19 @@ impl Reader<'_> {
self.emit_obj(sc, pb, InlineObject::FieldEnd, &f.props); self.emit_obj(sc, pb, InlineObject::FieldEnd, &f.props);
return; return;
} }
// A `HYPERLINK` field is a link, as a `w:hyperlink` is: its result stays ordinary content
// carrying the link, so a tab in it reaches its tab stop and the `PAGEREF` in a table of
// contents entry (Word for the web writes them this way) stays a field of its own (#2174).
if !nested_in_instr && let Some(target) = hyperlink_target(&f.instr) {
for (it, mut p) in f.buf {
p.link.get_or_insert_with(|| target.clone());
match it {
Item::Text(t) => self.emit_text(sc, pb, &t, &p),
Item::Obj(o) => self.emit_obj(sc, pb, *o, &p),
}
}
return;
}
// A cached result holding a picture, chart or shape (`INCLUDEPICTURE`'s embedded // A cached result holding a picture, chart or shape (`INCLUDEPICTURE`'s embedded
// picture) can't be a text result: keep the field as a range around it so the picture // picture) can't be a text result: keep the field as a range around it so the picture
// stays in the document and is saved again. The linked source is never read. // stays in the document and is saved again. The linked source is never read.
@@ -1048,14 +1127,19 @@ impl Reader<'_> {
float.effect = float.effect.map(|e| e.max(0.0)); float.effect = float.effect.map(|e| e.max(0.0));
return Some(InlineObject::Graphic { w, h, alt, float, graphic }); return Some(InlineObject::Graphic { w, h, alt, float, graphic });
} }
// A textbox may contain a picture; its descendant blip is not the outer payload.
let direct_shape = gd.child("wps:wsp").filter(|shape| {
// Picture fills still use the image fallback; the shape model has no image fill.
shape.child("wps:spPr").and_then(|props| props.child("a:blipFill")).is_none()
});
let mut obj = if let Some(c) = gd.child("wpc:wpc") { let mut obj = if let Some(c) = gd.child("wpc:wpc") {
self.read_canvas(sc, c, rels, w, h, float) self.read_canvas(sc, c, rels, w, h, float)
} else if let Some(g) = gd.child("wpg:wgp") { } else if let Some(g) = gd.child("wpg:wgp") {
self.read_group(sc, g, rels, w, h, float)? self.read_group(sc, g, rels, w, h, float)?
} else if gd.find("a:blip").is_some() { } else if direct_shape.is_none() && gd.find("a:blip").is_some() {
self.read_pic(gd, rels, w, h, alt.clone(), float)? self.read_pic(gd, rels, w, h, alt.clone(), float)?
} else { } else {
let wsp = gd.find("wps:wsp")?; let wsp = direct_shape.or_else(|| gd.find("wps:wsp"))?;
let mut obj = self.read_wsp(sc, wsp, rels, w, h, float); let mut obj = self.read_wsp(sc, wsp, rels, w, h, float);
// A freeform: whether it's ink WordCraft wrote. // A freeform: whether it's ink WordCraft wrote.
if let InlineObject::Shape { freeform: Some(f), .. } = &mut obj if let InlineObject::Shape { freeform: Some(f), .. } = &mut obj
@@ -1246,7 +1330,10 @@ impl Reader<'_> {
}; };
let (cx, cy) = x.ch_off.unwrap_or((0.0, 0.0)); let (cx, cy) = x.ch_off.unwrap_or((0.0, 0.0));
let mut children = Vec::new(); let mut children = Vec::new();
let outer = self.group_fill.take();
self.group_fill = own_fill(g.child("wpg:grpSpPr"), &self.doc.settings.theme_colors, None).flatten();
self.group_members(sc, g, rels, (-cx, -cy, 1.0, 1.0), 0, &mut children); self.group_members(sc, g, rels, (-cx, -cy, 1.0, 1.0), 0, &mut children);
self.group_fill = outer;
if children.is_empty() { if children.is_empty() {
return None; return None;
} }
@@ -1266,7 +1353,7 @@ impl Reader<'_> {
}; };
let ln = c.child("wpc:whole").and_then(|e| e.child("a:ln")); let ln = c.child("wpc:whole").and_then(|e| e.child("a:ln"));
let style = wordcraft_doc::para::CanvasStyle { let style = wordcraft_doc::para::CanvasStyle {
fill: solid(c.child("wpc:bg")), fill: own_fill(c.child("wpc:bg"), &self.doc.settings.theme_colors, None).flatten(),
stroke: solid(ln), stroke: solid(ln),
stroke_width: ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0).clamp(0.0, 100.0), stroke_width: ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.0).clamp(0.0, 100.0),
}; };
@@ -1332,7 +1419,12 @@ impl Reader<'_> {
let (cw, ch) = x.ch_ext.filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((ew.max(1.0), eh.max(1.0))); let (cw, ch) = x.ch_ext.filter(|(cw, ch)| *cw > 0.0 && *ch > 0.0).unwrap_or((ew.max(1.0), eh.max(1.0)));
let (kx, ky) = (ew / cw, eh / ch); let (kx, ky) = (ew / cw, eh / ch);
let nt = (x0 + (ox - cox * kx) * sx, y0 + (oy - coy * ky) * sy, sx * kx, sy * ky); let nt = (x0 + (ox - cox * kx) * sx, y0 + (oy - coy * ky) * sy, sx * kx, sy * ky);
// Its own fill, for its members' `a:grpFill` (#1500).
// One without a fill of its own passes its group's on.
let own = own_fill(sppr, &self.doc.settings.theme_colors, self.group_fill.as_ref()).unwrap_or_else(|| self.group_fill.clone());
let outer = std::mem::replace(&mut self.group_fill, own);
self.group_members(sc, e, rels, nt, depth + 1, out); self.group_members(sc, e, rels, nt, depth + 1, out);
self.group_fill = outer;
continue; continue;
} }
let max = crate::units::MAX_LEN_PT; let max = crate::units::MAX_LEN_PT;
@@ -1380,7 +1472,7 @@ impl Reader<'_> {
kind = ShapeKind::TextBox; kind = ShapeKind::TextBox;
} }
// Explicit paint, else the theme's through the style references (#611). // Explicit paint, else the theme's through the style references (#611).
let paint = shape_paint(sppr, wsp.child("wps:style"), &self.doc.settings.theme_colors); let paint = shape_paint(sppr, wsp.child("wps:style"), &self.doc.settings.theme_colors, &self.theme_fills, self.group_fill.as_ref());
let (stroke, stroke_width) = (paint.stroke, paint.stroke_width); let (stroke, stroke_width) = (paint.stroke, paint.stroke_width);
let fill = if kind.is_open() { None } else { paint.fill }; let fill = if kind.is_open() { None } else { paint.fill };
let ln = sppr.and_then(|s| s.child("a:ln")); let ln = sppr.and_then(|s| s.child("a:ln"));
@@ -1441,7 +1533,7 @@ impl Reader<'_> {
{ {
part.body.auto_color = Some(rgb); part.body.auto_color = Some(rgb);
} }
let mut extra = ShapeExtra { dash: super::shape_style::line_dash(ln), ..Default::default() }; let mut extra = ShapeExtra { dash: super::shape_style::line_dash(ln), outline_fill: paint.outline_fill, ..ShapeExtra::default() };
// A rounded rectangle's own corner (`adj`, ECMA-376 §20.1.10.56); other presets drawn as // A rounded rectangle's own corner (`adj`, ECMA-376 §20.1.10.56); other presets drawn as
// rounded rectangles keep the default. // rounded rectangles keep the default.
if prst == "roundRect" { if prst == "roundRect" {
@@ -1489,7 +1581,10 @@ impl Reader<'_> {
fn read_textbox(&mut self, sc: &StoryCtx, content: &El, rels: &Rels) -> u32 { fn read_textbox(&mut self, sc: &StoryCtx, content: &El, rels: &Rels) -> u32 {
let mut inner = StoryCtx { story_depth: sc.story_depth + 1, ..Default::default() }; let mut inner = StoryCtx { story_depth: sc.story_depth + 1, ..Default::default() };
let mut blocks = Blocks::new(); let mut blocks = Blocks::new();
// Shapes in the text aren't members of a group the box may be in.
let group = self.group_fill.take();
self.read_blocks(&mut inner, content, rels, &mut blocks, 0); self.read_blocks(&mut inner, content, rels, &mut blocks, 0);
self.group_fill = group;
self.flush_pending(&mut inner, &mut blocks); self.flush_pending(&mut inner, &mut blocks);
self.doc.add_part(PartKind::TextBox, blocks) self.doc.add_part(PartKind::TextBox, blocks)
} }
@@ -1624,10 +1719,15 @@ impl Reader<'_> {
let type_id = shape.attr("type").map(|t| t.trim().trim_start_matches('#')); let type_id = shape.attr("type").map(|t| t.trim().trim_start_matches('#'));
let shapetype = type_id.and_then(|id| pict.els().find(|e| e.name == "v:shapetype" && e.attr("id") == Some(id))); let shapetype = type_id.and_then(|id| pict.els().find(|e| e.name == "v:shapetype" && e.attr("id") == Some(id)));
let attr = |name: &str| shape.attr(name).or_else(|| shapetype.and_then(|t| t.attr(name))); let attr = |name: &str| shape.attr(name).or_else(|| shapetype.and_then(|t| t.attr(name)));
let on = |name: &str| attr(name).is_none_or(|v| !matches!(v, "f" | "false" | "0")); let on = |name: &str| {
attr(name).is_none_or(|v| {
let t = v.trim();
!t.eq_ignore_ascii_case("f") && !t.eq_ignore_ascii_case("false") && t != "0"
})
};
let stroke = on("stroked").then(|| attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten(); let stroke = on("stroked").then(|| attr("strokecolor").map_or(Some(Rgb::BLACK), vml_color)).flatten();
let stroke_width = attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0); let stroke_width = attr("strokeweight").and_then(|v| measure(v, 1.0)).unwrap_or(0.75).clamp(0.0, 100.0);
let fill = on("filled").then(|| attr("fillcolor").map_or(Some(Rgb::WHITE), vml_color)).flatten(); let fill = vml_fill(shape, shapetype);
let kind = match shape.local() { let kind = match shape.local() {
"line" => Some(ShapeKind::Line), "line" => Some(ShapeKind::Line),
"rect" => Some(ShapeKind::Rectangle), "rect" => Some(ShapeKind::Rectangle),
@@ -1702,14 +1802,17 @@ impl Reader<'_> {
} }
part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset); part.body.inset = (inset != wordcraft_doc::TEXT_BOX_INSET).then_some(inset);
// Resize shape to fit text (DrawingML's `a:spAutoFit`). // Resize shape to fit text (DrawingML's `a:spAutoFit`).
let fit = vml_style(tb.attr("style").unwrap_or(""), "mso-fit-shape-to-text"); let fit = vml_style(tb.attr("style").unwrap_or(""), "mso-fit-shape-to-text").or_else(|| vml_style(style, "mso-fit-shape-to-text"));
part.body.auto_fit = fit.is_some_and(|v| v.eq_ignore_ascii_case("t") || v.eq_ignore_ascii_case("true")); part.body.auto_fit = fit.is_some_and(|v| v.eq_ignore_ascii_case("t") || v.eq_ignore_ascii_case("true"));
// Where the text sits between the box's top and bottom (DrawingML's `anchor`). // Where the text sits between the box's top and bottom (DrawingML's `anchor`).
if let Some(a) = vml_style(style, "v-text-anchor") { if let Some(a) = vml_style(tb.attr("style").unwrap_or(""), "v-text-anchor").or_else(|| vml_style(style, "v-text-anchor")) {
part.body.anchor = match a { let al = a.trim().to_ascii_lowercase();
a if a.starts_with("middle") => wordcraft_doc::props::VAlign::Center, part.body.anchor = if al.starts_with("middle") || al == "center" {
a if a.starts_with("bottom") => wordcraft_doc::props::VAlign::Bottom, wordcraft_doc::props::VAlign::Center
_ => wordcraft_doc::props::VAlign::Top, } else if al.starts_with("bottom") {
wordcraft_doc::props::VAlign::Bottom
} else {
wordcraft_doc::props::VAlign::Top
}; };
} }
} }
@@ -1737,7 +1840,7 @@ impl Reader<'_> {
&& let Some(path) = shape.attr("path") && let Some(path) = shape.attr("path")
&& let Some(vp) = super::freeform::vml_path(path, shape.attr("coordsize"), shape.attr("coordorigin"), w, h) && let Some(vp) = super::freeform::vml_path(path, shape.attr("coordsize"), shape.attr("coordorigin"), w, h)
{ {
let opacity = |el: &str| shape.child(el).and_then(|e| e.attr("opacity")).and_then(vml_fraction).unwrap_or(1.0); let opacity = |el: &str| shape.child(el).and_then(|e| e.attr("opacity")).and_then(vml_opacity).unwrap_or(1.0);
let fill = fill.filter(|_| !vp.no_fill && opacity("v:fill") > 0.0); let fill = fill.filter(|_| !vp.no_fill && opacity("v:fill") > 0.0);
let stroke = stroke.filter(|_| !vp.no_stroke); let stroke = stroke.filter(|_| !vp.no_stroke);
if stroke.is_none() && fill.is_none() { if stroke.is_none() && fill.is_none() {
@@ -1819,6 +1922,23 @@ impl Reader<'_> {
Some(o) Some(o)
} }
/// A Word signature line (Insert › Signature Line): a `w:pict` whose one VML picture carries
/// an `o:signatureline` (the suggested signer, title, e-mail and instructions). Shown as its
/// picture, and kept whole like an OLE object so saving writes the signature line back,
/// still signable in Word, not a plain picture. `None` for any other `w:pict`.
fn read_signature_line(&mut self, sc: &mut StoryCtx, pict: &El, rels: &Rels) -> Option<InlineObject> {
let mut drawings = pict.els().filter(|e| e.name.starts_with("v:") && e.local() != "shapetype");
let shape = drawings.next().filter(|s| s.name == "v:shape")?;
if drawings.next().is_some() || shape.child("o:signatureline").is_none() {
return None;
}
let mut o = self.read_vml_shape(sc, pict, shape, rels, None)?;
if let InlineObject::Image { ole, .. } = &mut o {
*ole = self.keep_embedded(pict, rels, &[]);
}
Some(o)
}
// ---- tables ---- // ---- tables ----
fn read_table(&mut self, sc: &mut StoryCtx, t: &El, rels: &Rels, depth: usize) -> Option<Table> { fn read_table(&mut self, sc: &mut StoryCtx, t: &El, rels: &Rels, depth: usize) -> Option<Table> {
@@ -1940,7 +2060,10 @@ fn nv_alt(p: Option<&El>) -> (String, AltText) {
} }
fn anchor_float(c: &El) -> Float { fn anchor_float(c: &El) -> Float {
let mut f = Float { wrap: Wrap::Square, ..Float::default() }; // `relativeHeight`: the stacking order Word draws floating objects in (higher on top), which
// need not be their order in the file.
let z = c.attr("relativeHeight").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(0);
let mut f = Float { wrap: Wrap::Square, z, ..Float::default() };
let behind = on_off_attr(c, "behindDoc"); let behind = on_off_attr(c, "behindDoc");
// Word wraps a position relative to the page's size (`wp14:pctPosVOffset`) in // Word wraps a position relative to the page's size (`wp14:pctPosVOffset`) in
// `mc:AlternateContent`; its Fallback gives the same position as an offset (#743). // `mc:AlternateContent`; its Fallback gives the same position as an offset (#743).
@@ -2126,37 +2249,43 @@ fn vml_float(shape: &El, style: &str, in_hf: bool) -> Float {
let off = |k: &str| get(k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0); let off = |k: &str| get(k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0);
let max = crate::units::MAX_LEN_PT; let max = crate::units::MAX_LEN_PT;
let behind = get("z-index").and_then(int).is_some_and(|z| z < 0); let behind = get("z-index").and_then(int).is_some_and(|z| z < 0);
let wrap = match shape.child("w10:wrap").and_then(|w| w.attr("type")).unwrap_or("none") { let wrap_el = shape.child("w10:wrap");
"square" => Wrap::Square, let wrap_type = wrap_el.and_then(|w| w.attr("type")).unwrap_or("none");
"tight" => Wrap::Tight, let wrap = match wrap_type {
"through" => Wrap::Through, t if t.eq_ignore_ascii_case("square") => Wrap::Square,
"topAndBottom" => Wrap::TopAndBottom, t if t.eq_ignore_ascii_case("tight") => Wrap::Tight,
t if t.eq_ignore_ascii_case("through") => Wrap::Through,
t if t.eq_ignore_ascii_case("topAndBottom") || t.eq_ignore_ascii_case("top-and-bottom") => Wrap::TopAndBottom,
_ if behind => Wrap::BehindText, _ if behind => Wrap::BehindText,
_ => Wrap::InFrontOfText, _ => Wrap::InFrontOfText,
}; };
let h_rel = match get("mso-position-horizontal-relative").unwrap_or("text") { let wrap_text = wrap_el.and_then(|w| w.attr("side")).and_then(wordcraft_doc::para::WrapText::from_ooxml).unwrap_or_default();
"page" => Anchor::Page, let h_rel_str = get("mso-position-horizontal-relative").unwrap_or("text");
"margin" => Anchor::Margin, let h_rel = match h_rel_str {
"char" => Anchor::Character, s if s.eq_ignore_ascii_case("page") => Anchor::Page,
"left-margin-area" => Anchor::LeftMargin, s if s.eq_ignore_ascii_case("margin") => Anchor::Margin,
"right-margin-area" => Anchor::RightMargin, s if s.eq_ignore_ascii_case("char") => Anchor::Character,
"inner-margin-area" => Anchor::InsideMargin, s if s.eq_ignore_ascii_case("left-margin-area") => Anchor::LeftMargin,
"outer-margin-area" => Anchor::OutsideMargin, s if s.eq_ignore_ascii_case("right-margin-area") => Anchor::RightMargin,
s if s.eq_ignore_ascii_case("inner-margin-area") => Anchor::InsideMargin,
s if s.eq_ignore_ascii_case("outer-margin-area") => Anchor::OutsideMargin,
_ => Anchor::Column, _ => Anchor::Column,
}; };
let v_rel = match get("mso-position-vertical-relative").unwrap_or("text") { let v_rel_str = get("mso-position-vertical-relative").unwrap_or("text");
"page" => Anchor::Page, let v_rel = match v_rel_str {
"margin" => Anchor::Margin, s if s.eq_ignore_ascii_case("page") => Anchor::Page,
"line" => Anchor::Line, s if s.eq_ignore_ascii_case("margin") => Anchor::Margin,
"top-margin-area" => Anchor::TopMargin, s if s.eq_ignore_ascii_case("line") => Anchor::Line,
"bottom-margin-area" => Anchor::BottomMargin, s if s.eq_ignore_ascii_case("top-margin-area") => Anchor::TopMargin,
"inner-margin-area" => Anchor::InsideMargin, s if s.eq_ignore_ascii_case("bottom-margin-area") => Anchor::BottomMargin,
"outer-margin-area" => Anchor::OutsideMargin, s if s.eq_ignore_ascii_case("inner-margin-area") => Anchor::InsideMargin,
s if s.eq_ignore_ascii_case("outer-margin-area") => Anchor::OutsideMargin,
_ => Anchor::Paragraph, _ => Anchor::Paragraph,
}; };
let dist = |k: &str| get(k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 1584.0); let dist = |k: &str| get(k).and_then(|v| measure(v, 1.0)).unwrap_or(0.0).clamp(0.0, 1584.0);
let mut f = Float { let mut f = Float {
wrap, wrap,
wrap_text,
h_rel, h_rel,
v_rel, v_rel,
x: (off("left") + off("margin-left")).clamp(-max, max), x: (off("left") + off("margin-left")).clamp(-max, max),
@@ -2207,21 +2336,16 @@ fn vml_watermark(shape: &El) -> Option<wordcraft_doc::Watermark> {
.filter(|f| !f.is_empty()) .filter(|f| !f.is_empty())
.unwrap_or(default.font); .unwrap_or(default.font);
let color = shape.attr("fillcolor").and_then(vml_color).unwrap_or(default.color); let color = shape.attr("fillcolor").and_then(vml_color).unwrap_or(default.color);
let opacity = shape.child("v:fill").and_then(|f| f.attr("opacity")).and_then(vml_fraction).unwrap_or(1.0); let opacity = shape.child("v:fill").and_then(|f| f.attr("opacity")).and_then(vml_opacity).unwrap_or(1.0);
Some(wordcraft_doc::Watermark { text, font, color, diagonal: vml_spin(style).deg != 0.0, semitransparent: opacity < 1.0 }) // The box the text is stretched to (`fitshape`).
let len = |k: &str| vml_style(style, k).and_then(|v| measure(v, 1.0)).filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(4000.0));
let size = len("width").zip(len("height")).map(|(w, h)| [w, h]);
Some(wordcraft_doc::Watermark { text, font, color, diagonal: vml_spin(style).deg != 0.0, semitransparent: opacity < 1.0, size })
} }
/// A VML fraction (`opacity`): a number, a percentage, or 65536ths with an `f` suffix; 0..=1. /// A VML `opacity`, 0..=1.
fn vml_fraction(v: &str) -> Option<f32> { fn vml_opacity(v: &str) -> Option<f32> {
let v = v.trim(); vml_fraction(v).map(|f| f.clamp(0.0, 1.0))
let f = if let Some(n) = v.strip_suffix('f') {
n.trim().parse::<f32>().ok()? / 65_536.0
} else if let Some(n) = v.strip_suffix('%') {
n.trim().parse::<f32>().ok()? / 100.0
} else {
v.parse::<f32>().ok()?
};
f.is_finite().then(|| f.clamp(0.0, 1.0))
} }
/// A VML shape's turn: `rotation` in degrees, or 65536ths of one with an `fd` suffix; `flip`: `x` /// A VML shape's turn: `rotation` in degrees, or 65536ths of one with an `fd` suffix; `flip`: `x`
+37 -2
View File
@@ -13,13 +13,39 @@ pub fn measure(s: &str, per_pt: f32) -> Option<f32> {
if t.is_empty() || t.len() > 40 { if t.is_empty() || t.len() > 40 {
return None; return None;
} }
let split = t.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-' || c == '+' || c == 'e' || c == 'E')).unwrap_or(t.len()); let bytes = t.as_bytes();
let mut split = t.len();
let mut in_exp = false;
for (i, &b) in bytes.iter().enumerate() {
if b.is_ascii_digit() || b == b'.' {
in_exp = false;
continue;
}
if (b == b'-' || b == b'+') && (i == 0 || in_exp) {
in_exp = false;
continue;
}
if b == b'e' || b == b'E' {
let rest = &bytes[i + 1..];
let after_sign = match rest.first() {
Some(&s) if s == b'+' || s == b'-' => &rest[1..],
_ => rest,
};
if after_sign.first().is_some_and(|c| c.is_ascii_digit()) {
in_exp = true;
continue;
}
}
split = i;
break;
}
let (num, unit) = t.split_at(split); let (num, unit) = t.split_at(split);
let v: f64 = num.parse().ok()?; let v: f64 = num.parse().ok()?;
if !v.is_finite() { if !v.is_finite() {
return None; return None;
} }
let pt = match unit.trim() { let unit_lower = unit.trim().to_ascii_lowercase();
let pt = match unit_lower.as_str() {
"" => v / per_pt as f64, "" => v / per_pt as f64,
"pt" => v, "pt" => v,
"in" => v * 72.0, "in" => v * 72.0,
@@ -119,6 +145,15 @@ mod tests {
assert!((inches - 7.2).abs() < 1e-5, "{inches}"); assert!((inches - 7.2).abs() < 1e-5, "{inches}");
assert_eq!(line_multiple("junk"), None); assert_eq!(line_multiple("junk"), None);
assert_eq!(line_multiple(""), None); assert_eq!(line_multiple(""), None);
assert_eq!(measure("12700emu", 20.0), Some(1.0));
assert_eq!(measure("25400EMU", 20.0), Some(2.0));
assert_eq!(measure("1IN", 20.0), Some(72.0));
assert_eq!(measure("12PT", 2.0), Some(12.0));
assert_eq!(measure("2.54CM", 20.0), Some(72.0));
assert_eq!(measure("25.4MM", 20.0), Some(72.0));
assert_eq!(measure("10PX", 20.0), Some(7.5));
assert_eq!(measure("1e2pt", 20.0), Some(100.0));
assert_eq!(measure("1e+2pt", 20.0), Some(100.0));
assert_eq!(int("12.6"), Some(13)); assert_eq!(int("12.6"), Some(13));
assert_eq!(u32_of("-5"), Some(0)); assert_eq!(u32_of("-5"), Some(0));
} }
+6 -2
View File
@@ -9,6 +9,7 @@ mod math;
mod props; mod props;
mod sdt; mod sdt;
mod story; mod story;
pub(crate) use story::written_toc_span;
mod watermark; mod watermark;
mod wordart; mod wordart;
@@ -795,10 +796,13 @@ fn style_xml(w: &mut W, st: &Style, flagged: &[StyleKind]) {
w.close("w:rPr"); w.close("w:rPr");
} }
if let Some(t) = &st.table { if let Some(t) = &st.table {
if t.borders.is_some() || t.cell_margins.is_some() || t.band_size.is_some() { if t.borders.is_some() || t.cell_margins.is_some() || t.band_size.is_some() || t.col_band_size.is_some() {
w.open("w:tblPr", &[]); w.open("w:tblPr", &[]);
if let Some(n) = t.band_size { if let Some(n) = t.band_size {
w.val("w:tblStyleRowBandSize", &n.clamp(1, 1000).to_string()); w.val("w:tblStyleRowBandSize", &n.min(1000).to_string());
}
if let Some(n) = t.col_band_size {
w.val("w:tblStyleColBandSize", &n.min(1000).to_string());
} }
if let Some(b) = &t.borders { if let Some(b) = &t.borders {
props::borders(w, "w:tblBorders", b, Some("w:insideH"), &[]); props::borders(w, "w:tblBorders", b, Some("w:insideH"), &[]);
+12 -5
View File
@@ -106,11 +106,18 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
} }
toggle(w, "w:rtl", c.rtl); toggle(w, "w:rtl", c.rtl);
toggle(w, "w:cs", c.cs); toggle(w, "w:cs", c.cs);
match (&c.lang, &c.lang_bidi) { let mut lang_attrs: Vec<(&str, &str)> = Vec::with_capacity(3);
(Some(l), Some(b)) => w.empty("w:lang", &[("w:val", l), ("w:bidi", b)]), if let Some(l) = &c.lang {
(Some(l), None) => w.val("w:lang", l), lang_attrs.push(("w:val", l));
(None, Some(b)) => w.empty("w:lang", &[("w:bidi", b)]), }
(None, None) => {} if let Some(ea) = &c.lang_ea {
lang_attrs.push(("w:eastAsia", ea));
}
if let Some(b) = &c.lang_bidi {
lang_attrs.push(("w:bidi", b));
}
if !lang_attrs.is_empty() {
w.empty("w:lang", &lang_attrs);
} }
// After w:lang (CT_RPr's sequence). // After w:lang (CT_RPr's sequence).
if let Some(l) = &c.east_asian_layout { if let Some(l) = &c.east_asian_layout {
+40 -15
View File
@@ -5,8 +5,6 @@
//! elements around runs (inline) or around whole paragraphs and tables (block level). [`plan`] //! elements around runs (inline) or around whole paragraphs and tables (block level). [`plan`]
//! decides, per block list, which controls are written where so that the XML always nests. //! decides, per block list, which controls are written where so that the XML always nests.
use std::collections::HashSet;
use wordcraft_doc::Blocks; use wordcraft_doc::Blocks;
use wordcraft_doc::control::{ContentControl, ControlEvent, ControlKind, control_events, pair_events}; use wordcraft_doc::control::{ContentControl, ControlEvent, ControlKind, control_events, pair_events};
use wordcraft_doc::para::OBJ; use wordcraft_doc::para::OBJ;
@@ -20,8 +18,11 @@ pub struct Plan<'a> {
pub opens: Vec<Vec<&'a ContentControl>>, pub opens: Vec<Vec<&'a ContentControl>>,
/// Controls closing after block `i`. /// Controls closing after block `i`.
pub closes: Vec<u32>, pub closes: Vec<u32>,
/// Paragraph markers written inline, as (block, byte offset). /// The paragraph markers written inline, by block: the byte offsets in block `i`, in order.
pub inline: HashSet<(usize, usize)>, ///
/// Grouped by block rather than one set of (block, offset) pairs, so that asking for a
/// paragraph's markers is a lookup and not a search of every marker in the document (#13).
pub inline: Vec<Vec<usize>>,
} }
impl Plan<'_> { impl Plan<'_> {
@@ -38,9 +39,7 @@ impl Plan<'_> {
} }
/// The byte offsets of paragraph `i`'s markers that are written inline. /// The byte offsets of paragraph `i`'s markers that are written inline.
pub fn inline_in(&self, i: usize) -> Vec<usize> { pub fn inline_in(&self, i: usize) -> Vec<usize> {
let mut v: Vec<usize> = self.inline.iter().filter(|(b, _)| *b == i).map(|(_, o)| *o).collect(); self.inline.get(i).cloned().unwrap_or_default()
v.sort_unstable();
v
} }
} }
@@ -54,7 +53,7 @@ enum Mode {
/// Markers without a partner are not written. /// Markers without a partner are not written.
pub fn plan(bl: &Blocks) -> Plan<'_> { pub fn plan(bl: &Blocks) -> Plan<'_> {
let n = bl.len(); let n = bl.len();
let mut out = Plan { opens: vec![Vec::new(); n], closes: vec![0; n], inline: HashSet::new() }; let mut out = Plan { opens: vec![Vec::new(); n], closes: vec![0; n], inline: vec![Vec::new(); n] };
let events = control_events(bl); let events = control_events(bl);
if events.is_empty() { if events.is_empty() {
return out; return out;
@@ -115,17 +114,37 @@ pub fn plan(bl: &Blocks) -> Plan<'_> {
} }
// Block-level controls nest by block: one that opens in the block another (not its // Block-level controls nest by block: one that opens in the block another (not its
// ancestor) closes after starts with the next block instead. // ancestor) closes after starts with the next block instead.
//
// Such a clash is with a control that closes before this one opens and ends in the block
// this one starts in. The block-level controls are grouped by the block they end in, each
// group keeping the two earliest closes in it: the earliest close that is not this
// control's own is the one the test is about (#13).
let mut first_close: Vec<usize> = vec![usize::MAX; n + 1];
let mut second_close: Vec<usize> = vec![usize::MAX; n + 1];
let mut first_owner: Vec<usize> = vec![usize::MAX; n + 1];
for (q, (_, qc)) in ranges.iter().enumerate() {
if mode.get(q) != Some(&Mode::Block) {
continue;
}
let Some(&(_, qe)) = span.get(q) else { continue };
let e = qe.min(n);
if *qc < first_close[e] {
second_close[e] = first_close[e];
first_close[e] = *qc;
first_owner[e] = q;
} else if *qc < second_close[e] {
second_close[e] = *qc;
}
}
for k in 0..ranges.len() { for k in 0..ranges.len() {
if mode.get(k) != Some(&Mode::Block) { if mode.get(k) != Some(&Mode::Block) {
continue; continue;
} }
let Some(&(o, _)) = ranges.get(k) else { continue }; let Some(&(o, _)) = ranges.get(k) else { continue };
let Some(&(sb, eb)) = span.get(k) else { continue }; let Some(&(sb, eb)) = span.get(k) else { continue };
let clash = ranges let e = sb.min(n);
.iter() let close = if first_owner[e] == k { second_close[e] } else { first_close[e] };
.enumerate() if close < o {
.any(|(q, (_, qc))| q != k && *qc < o && mode.get(q) == Some(&Mode::Block) && span.get(q).is_some_and(|s| s.1 == sb));
if clash {
let to = (sb + 1).min(eb); let to = (sb + 1).min(eb);
if let Some(s) = span.get_mut(k) { if let Some(s) = span.get_mut(k) {
s.0 = to; s.0 = to;
@@ -146,13 +165,19 @@ pub fn plan(bl: &Blocks) -> Plan<'_> {
} }
_ => { _ => {
for e in [events.get(*o), events.get(*c)].into_iter().flatten() { for e in [events.get(*o), events.get(*c)].into_iter().flatten() {
if let Some(off) = e.off() { if let Some(off) = e.off()
out.inline.insert((e.block(), off)); && let Some(v) = out.inline.get_mut(e.block())
{
v.push(off);
} }
} }
} }
} }
} }
for v in out.inline.iter_mut() {
v.sort_unstable();
v.dedup();
}
out out
} }
+86 -26
View File
@@ -1,6 +1,7 @@
//! Blocks, paragraphs, runs, objects and tables → WordprocessingML. //! Blocks, paragraphs, runs, objects and tables → WordprocessingML.
use wordcraft_doc::effects::ShapeEffects; use wordcraft_doc::effects::ShapeEffects;
use wordcraft_doc::fill::{Fill, GradientFlip, GradientKind, PathShape};
use wordcraft_doc::para::{AltText, Anchor, Float, NoteKind, ShapeKind, Wrap}; use wordcraft_doc::para::{AltText, Anchor, Float, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{CharProps, PropChange, Rgb, TextColor}; use wordcraft_doc::props::{CharProps, PropChange, Rgb, TextColor};
use wordcraft_doc::section::{LineNumberRestart, NoteProps, SectionProps, SectionStart}; use wordcraft_doc::section::{LineNumberRestart, NoteProps, SectionProps, SectionStart};
@@ -35,8 +36,7 @@ impl Writer<'_> {
return; return;
} }
let plan = super::sdt::plan(bl); let plan = super::sdt::plan(bl);
// A TOC inside a content control already has one (read from a file). let toc = if top { written_toc_span(bl) } else { None };
let toc = if top { toc_span(bl).filter(|(start, _)| !plan.covers(*start)) } else { None };
// Whether the heading's TOC field was written and still needs its end. // Whether the heading's TOC field was written and still needs its end.
let mut toc_field_open = false; let mut toc_field_open = false;
for (i, b) in bl.iter().enumerate() { for (i, b) in bl.iter().enumerate() {
@@ -720,14 +720,14 @@ impl Writer<'_> {
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas")]); w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas")]);
w.open("wpc:wpc", &[]); w.open("wpc:wpc", &[]);
w.open("wpc:bg", &[]); w.open("wpc:bg", &[]);
solid_fill(w, c.fill); write_fill(w, c.fill.as_ref());
w.close("wpc:bg"); w.close("wpc:bg");
w.open("wpc:whole", &[]); w.open("wpc:whole", &[]);
match c.stroke { match c.stroke {
Some(_) => w.open("a:ln", &[("w", &emu(c.stroke_width.clamp(0.0, 100.0)))]), Some(_) => w.open("a:ln", &[("w", &emu(c.stroke_width.clamp(0.0, 100.0)))]),
None => w.open("a:ln", &[]), None => w.open("a:ln", &[]),
} }
solid_fill(w, c.stroke); write_fill(w, c.stroke.map(Fill::solid).as_ref());
w.close("a:ln"); w.close("a:ln");
w.close("wpc:whole"); w.close("wpc:whole");
"wpc:wpc" "wpc:wpc"
@@ -1074,28 +1074,27 @@ impl Writer<'_> {
} }
let alpha = geom.map_or(1.0, |f| f.alpha); let alpha = geom.map_or(1.0, |f| f.alpha);
let ink = geom.is_some_and(|f| f.is_ink()); let ink = geom.is_some_and(|f| f.is_ink());
match fill.filter(|_| !ink && !kind.is_open()) { write_fill(w, fill.as_ref().filter(|_| !ink && !kind.is_open()));
Some(c) => {
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
w.close("a:solidFill");
}
None => w.empty("a:noFill", &[]),
}
match stroke { match stroke {
Some(c) => { Some(c) => {
let width = emu(stroke_width.clamp(0.0, 100.0)); let width = emu(stroke_width.clamp(0.0, 100.0));
let ln: &[(&str, &str)] = if ink { &[("w", width.as_str()), ("cap", "rnd")] } else { &[("w", width.as_str())] }; let ln: &[(&str, &str)] = if ink { &[("w", width.as_str()), ("cap", "rnd")] } else { &[("w", width.as_str())] };
w.open("a:ln", ln); w.open("a:ln", ln);
w.open("a:solidFill", &[]); // An outline gradient read from a file, while the outline keeps its colour (#1500).
if alpha < 1.0 { let grad = extra.outline_fill.as_ref().filter(|f| !ink && f.main_color() == *c);
w.open("a:srgbClr", &[("val", &c.hex())]); if grad.is_some() {
w.empty("a:alpha", &[("val", &n((alpha.clamp(0.0, 1.0) * 100_000.0).round() as i64))]); write_fill(w, grad);
w.close("a:srgbClr");
} else { } else {
w.empty("a:srgbClr", &[("val", &c.hex())]); w.open("a:solidFill", &[]);
if alpha < 1.0 {
w.open("a:srgbClr", &[("val", &c.hex())]);
w.empty("a:alpha", &[("val", &n((alpha.clamp(0.0, 1.0) * 100_000.0).round() as i64))]);
w.close("a:srgbClr");
} else {
w.empty("a:srgbClr", &[("val", &c.hex())]);
}
w.close("a:solidFill");
} }
w.close("a:solidFill");
line_dash(w, &extra.dash); line_dash(w, &extra.dash);
if ink { if ink {
w.empty("a:round", &[]); w.empty("a:round", &[]);
@@ -1184,7 +1183,9 @@ impl Writer<'_> {
} else { } else {
let d = emu(float.dist.clamp(0.0, 1584.0)); let d = emu(float.dist.clamp(0.0, 1584.0));
let (dt, db) = (emu(float.dist_top.clamp(0.0, 1584.0)), emu(float.dist_bottom.clamp(0.0, 1584.0))); let (dt, db) = (emu(float.dist_top.clamp(0.0, 1584.0)), emu(float.dist_bottom.clamp(0.0, 1584.0)));
self.z += 1; // The stacking order read from the file is kept; objects without one stack in
// document order above it.
self.z = if float.z > 0 { float.z } else { self.z.saturating_add(1) };
let behind = if float.wrap == Wrap::BehindText { "1" } else { "0" }; let behind = if float.wrap == Wrap::BehindText { "1" } else { "0" };
w.open( w.open(
"wp:anchor", "wp:anchor",
@@ -1688,6 +1689,12 @@ fn is_toc(instr: &str) -> bool {
instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("TOC")) instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
} }
/// The body TOC the writer wraps in Word's Table of Contents control (`toc_span`): not one whose
/// heading is inside a content control already (read from a file).
pub(crate) fn written_toc_span(bl: &Blocks) -> Option<(usize, usize)> {
toc_span(bl).filter(|(start, _)| !super::sdt::plan(bl).covers(*start))
}
/// The engine's TOC: a paragraph holding an empty `TOC` field, then its TOC-styled entries. /// The engine's TOC: a paragraph holding an empty `TOC` field, then its TOC-styled entries.
/// Returns (heading, last entry) so the field can be written spanning the entries, as Word does. /// Returns (heading, last entry) so the field can be written spanning the entries, as Word does.
fn toc_span(bl: &Blocks) -> Option<(usize, usize)> { fn toc_span(bl: &Blocks) -> Option<(usize, usize)> {
@@ -1734,15 +1741,68 @@ pub(super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str,
w.close(tag); w.close(tag);
} }
/// A DrawingML `a:solidFill` in `c`, or `a:noFill`. /// A shape's fill: `a:solidFill` (with `a:alpha` when transparent), `a:gradFill` with everything
fn solid_fill(w: &mut W, c: Option<wordcraft_doc::props::Rgb>) { /// read from the file, or `a:noFill`.
match c { fn write_fill(w: &mut W, f: Option<&Fill>) {
Some(c) => { let pct = |v: f32| n((v * 1000.0).round() as i64);
w.open("a:solidFill", &[]); let color = |w: &mut W, c: Rgb, transparency: f32| {
if transparency > 0.0 {
w.open("a:srgbClr", &[("val", &c.hex())]);
w.empty("a:alpha", &[("val", &pct(100.0 - transparency))]);
w.close("a:srgbClr");
} else {
w.empty("a:srgbClr", &[("val", &c.hex())]); w.empty("a:srgbClr", &[("val", &c.hex())]);
}
};
let rect = |r: [f32; 4]| [("l", pct(r[0])), ("t", pct(r[1])), ("r", pct(r[2])), ("b", pct(r[3]))];
match f.and_then(Fill::sanitized) {
None => w.empty("a:noFill", &[]),
Some(Fill::Solid { color: c, transparency }) => {
w.open("a:solidFill", &[]);
color(w, c, transparency);
w.close("a:solidFill"); w.close("a:solidFill");
} }
None => w.empty("a:noFill", &[]), Some(Fill::Gradient(g)) => {
// `rotWithShape` and `scaled` are written only when set: an absent one reads back off.
let mut attrs = if g.rot_with_shape { vec![("rotWithShape", "1")] } else { vec![] };
match g.flip {
GradientFlip::None => {}
GradientFlip::X => attrs.insert(0, ("flip", "x")),
GradientFlip::Y => attrs.insert(0, ("flip", "y")),
GradientFlip::Xy => attrs.insert(0, ("flip", "xy")),
}
w.open("a:gradFill", &attrs);
w.open("a:gsLst", &[]);
for s in &g.stops {
w.open("a:gs", &[("pos", &pct(s.pos))]);
color(w, s.color, s.transparency);
w.close("a:gs");
}
w.close("a:gsLst");
match g.kind {
GradientKind::Linear { angle, scaled } => {
let ang = n((angle * 60_000.0).round() as i64);
let attrs: &[(&str, &str)] = if scaled { &[("ang", &ang), ("scaled", "1")] } else { &[("ang", &ang)] };
w.empty("a:lin", attrs)
}
GradientKind::Path { shape, focus } => {
let path = match shape {
PathShape::Circle => "circle",
PathShape::Rect => "rect",
PathShape::Shape => "shape",
};
w.open("a:path", &[("path", path)]);
let r = rect(focus);
w.empty("a:fillToRect", &r.iter().map(|(k, v)| (*k, v.as_str())).collect::<Vec<_>>());
w.close("a:path");
}
}
if let Some(t) = g.tile {
let r = rect(t);
w.empty("a:tileRect", &r.iter().map(|(k, v)| (*k, v.as_str())).collect::<Vec<_>>());
}
w.close("a:gradFill");
}
} }
} }
+2 -7
View File
@@ -19,15 +19,10 @@ pub(super) fn text(wm: &Watermark) -> Option<String> {
} }
/// A `w:r` holding the watermark shape (and its shape type), for the first paragraph of header /// A `w:r` holding the watermark shape (and its shape type), for the first paragraph of header
/// part number `n`. Sized like the renderers draw it: half the page width across, or half the /// part number `n`, in its box ([`Watermark::frame`]: its own, or Word's default for the page).
/// page diagonal when diagonal; the height follows the text's length.
pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<String> { pub(super) fn run(wm: &Watermark, n: usize, page: &SectionProps) -> Option<String> {
let text = text(wm)?; let text = text(wm)?;
let (pw, ph) = (page.page_w.clamp(72.0, 4000.0), page.page_h.clamp(72.0, 4000.0)); let [width, height] = wm.frame(page.page_w, page.page_h);
let across = if wm.diagonal { (pw * pw + ph * ph).sqrt() } else { pw };
let width = (across * 0.5).clamp(36.0, 2000.0);
let chars = text.chars().count().clamp(1, 200) as f32;
let height = (width / (0.55 * chars)).clamp(12.0, 400.0);
// A font name can't break out of the textpath's CSS-like style. // A font name can't break out of the textpath's CSS-like style.
let font: String = wm.font.chars().filter(|c| !matches!(c, '"' | '\'' | ';' | ':')).take(100).collect(); let font: String = wm.font.chars().filter(|c| !matches!(c, '"' | '\'' | ';' | ':')).take(100).collect();
let font = if font.trim().is_empty() { Watermark::default().font } else { font }; let font = if font.trim().is_empty() { Watermark::default().font } else { font };
@@ -0,0 +1,228 @@
//! What saving a document with many content controls costs (#13).
//!
//! A form-shaped document: a control per paragraph, inline, or a control around two paragraphs.
//! The writer used to look for each paragraph's markers in the whole set of markers, once per
//! paragraph, and to compare every control with every other one, so the save was quadratic in the
//! number of controls.
use std::time::Instant;
use wordcraft_doc::control::{ContentControl, ControlKind};
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, Paragraph, para_block};
use wordcraft_docx::write;
/// One paragraph per control, the control inline in it.
fn inline_controls(n: usize) -> Document {
let mut doc = Document::new();
doc.body.clear();
let props = CharProps::default();
for _ in 0..n {
let mut p = Paragraph::with_text("value: ", props.clone());
let off = p.len();
p.insert_object(off, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::RichText)) }, &props)
.expect("insert the start marker");
let off = p.len();
p.insert_object(off, InlineObject::ControlEnd, &props).expect("insert the end marker");
doc.body.push(para_block(p));
}
doc
}
/// Two paragraphs per control, the control written around both of them.
fn block_controls(n: usize) -> Document {
let mut doc = Document::new();
doc.body.clear();
let props = CharProps::default();
for _ in 0..n {
let mut open = Paragraph::new();
let off = open.len();
open.insert_object(off, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::RichText)) }, &props)
.expect("insert the start marker");
doc.body.push(para_block(open));
let mut close = Paragraph::with_text("value", props.clone());
let off = close.len();
close.insert_object(off, InlineObject::ControlEnd, &props).expect("insert the end marker");
doc.body.push(para_block(close));
}
doc
}
/// How much more saving the bigger document costs than the smaller one, for the bigger one
/// `scale` times the size of the smaller one's: ~`scale` when linear, ~`scale`² when quadratic.
/// Each is the cheapest of several runs after a warm-up, the runs alternating so a busy spell
/// slows both sizes alike; the smaller document is saved `scale` times over per run, so both
/// timings are long against the clock's resolution.
fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> (f64, f64, f64) {
small();
big();
let mut small_scaled = || (0..scale).for_each(|_| small());
let (mut t1, mut tk) = (f64::INFINITY, f64::INFINITY);
for _ in 0..5 {
let a = Instant::now();
small_scaled();
t1 = t1.min(a.elapsed().as_secs_f64() / scale as f64);
let b = Instant::now();
big();
tk = tk.min(b.elapsed().as_secs_f64());
}
(t1, tk, tk / t1.max(1e-9))
}
/// A document with many inline content controls saves in the time the controls take: the writer
/// looked for each paragraph's markers in the whole set of markers, once per paragraph, so a
/// document with 32,000 controls took four seconds to save where it takes a tenth of one now.
#[test]
fn a_document_with_many_inline_content_controls_saves_in_linear_time() {
let scale = 4;
let small = inline_controls(8_000);
let big = inline_controls(8_000 * scale);
let (t1, tk, ratio) = cost_ratio(
scale,
|| {
write(&small).expect("write");
},
|| {
write(&big).expect("write");
},
);
eprintln!("t(n) {t1:.4} s, t(4n) {tk:.4} s, ratio {ratio:.1}");
assert!(ratio < 8.0, "four times the controls took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear");
}
/// The same for the controls written around whole paragraphs: the writer compared every control
/// with every other one to find the ones that clash, so the save was quadratic in their number.
#[test]
fn a_document_with_many_block_content_controls_saves_in_linear_time() {
let scale = 4;
let small = block_controls(8_000);
let big = block_controls(8_000 * scale);
let (t1, tk, ratio) = cost_ratio(
scale,
|| {
write(&small).expect("write");
},
|| {
write(&big).expect("write");
},
);
eprintln!("t(n) {t1:.4} s, t(4n) {tk:.4} s, ratio {ratio:.1}");
assert!(ratio < 8.0, "four times the controls took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear");
}
/// The controls are written where they were: the plan that decides whether a control is written
/// inline or around whole paragraphs gives the same answer as it did before it was made faster.
#[test]
fn the_controls_are_written_where_they_were() {
// Inline controls: both markers in the same paragraph, so the control is written in it.
let bytes = write(&inline_controls(50)).expect("write");
let back = wordcraft_docx::read(&bytes).expect("read back");
let ranges = back.control_ranges(wordcraft_doc::StoryRef::Body);
assert_eq!(ranges.len(), 50, "every control survives the round trip");
for r in &ranges {
assert_eq!(r.start.path, r.end.path, "the control is written inline: {:?}", r.start.path);
}
// Block controls: the markers in different paragraphs, so the control is written around them.
let bytes = write(&block_controls(50)).expect("write");
let back = wordcraft_docx::read(&bytes).expect("read back");
let ranges = back.control_ranges(wordcraft_doc::StoryRef::Body);
assert_eq!(ranges.len(), 50, "every control survives the round trip");
for r in &ranges {
assert_ne!(r.start.path, r.end.path, "the control is written around the paragraphs");
}
}
/// The names of the parts of a package, in the order they are in it.
fn part_names(bytes: &[u8]) -> Vec<String> {
let z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
z.file_names().map(str::to_string).collect()
}
/// The body of the document part of a package.
fn document_body(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
let mut xml = String::new();
{
use std::io::Read;
z.by_name("word/document.xml").expect("the document part").read_to_string(&mut xml).expect("read");
}
let rest = xml.split_once("<w:body>").expect("the body").1;
let body = rest.split_once("</w:body>").expect("the end of the body").0;
format!("<w:body>{body}</w:body>")
}
/// A document whose controls are written in an order and a place worth pinning: two inline
/// controls in one paragraph, and a control around a whole paragraph.
fn pinned_doc() -> Document {
let props = CharProps::default();
let mut doc = Document::new();
doc.body.clear();
// "before [a] between [b] after", the two controls inline in one paragraph.
let mut p = Paragraph::with_text("before ", props.clone());
let mut at = p.len();
p.insert_object(at, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::Text { multi_line: false })) }, &props)
.expect("the first marker");
at = p.len();
p.insert_text(at, "inside a", &props).expect("text");
at = p.len();
p.insert_object(at, InlineObject::ControlEnd, &props).expect("the first end marker");
at = p.len();
p.insert_text(at, " between ", &props).expect("text");
at = p.len();
p.insert_object(at, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::RichText)) }, &props)
.expect("the second marker");
at = p.len();
p.insert_text(at, "inside b", &props).expect("text");
at = p.len();
p.insert_object(at, InlineObject::ControlEnd, &props).expect("the second end marker");
at = p.len();
p.insert_text(at, " after", &props).expect("text");
doc.body.push(para_block(p));
// A control around a whole paragraph.
let mut open = Paragraph::new();
let at = open.len();
open.insert_object(at, InlineObject::ControlStart { control: Box::new(ContentControl::new(ControlKind::RichText)) }, &props)
.expect("the block marker");
doc.body.push(para_block(open));
let mut inside = Paragraph::with_text("in the block", props.clone());
let at = inside.len();
inside.insert_object(at, InlineObject::ControlEnd, &props).expect("the block end marker");
doc.body.push(para_block(inside));
doc
}
/// The body of the document part the writer writes for [`pinned_doc`], byte for byte: what the
/// writer wrote before it was made faster. The counts and the paragraphs in the test above say
/// that the controls are where they belong; this says that the rest is where it was too, down to
/// the order and the place of the markers inside a paragraph.
///
/// The body is what the plan decides: which controls are written inline and which around whole
/// paragraphs, and where. Nothing outside it can change with the plan, so it is what is pinned.
///
/// Regenerate it when the writer's output changes on purpose: write [`pinned_doc`] and take the
/// body of the document part.
#[test]
fn the_document_body_is_written_as_it_was() {
let bytes = write(&pinned_doc()).expect("write");
assert_eq!(document_body(&bytes), PINNED_BODY);
// The parts are the same ones, so nothing is added to the package and nothing is lost.
assert_eq!(part_names(&bytes), PINNED_PARTS);
}
/// The body of the document part of [`pinned_doc`], as the writer wrote it before it was made
/// faster.
const PINNED_PARTS: &[&str] = &[
"[Content_Types].xml",
"_rels/.rels",
"word/document.xml",
"word/_rels/document.xml.rels",
"word/styles.xml",
"word/settings.xml",
"word/theme/theme1.xml",
"docProps/core.xml",
"docProps/app.xml",
];
const PINNED_BODY: &str = r#"<w:body><w:p><w:r><w:t xml:space="preserve">before </w:t></w:r><w:sdt><w:sdtPr><w:text/></w:sdtPr><w:sdtContent><w:r><w:t xml:space="preserve">inside a</w:t></w:r></w:sdtContent></w:sdt><w:r><w:t xml:space="preserve"> between </w:t></w:r><w:sdt><w:sdtPr></w:sdtPr><w:sdtContent><w:r><w:t xml:space="preserve">inside b</w:t></w:r></w:sdtContent></w:sdt><w:r><w:t xml:space="preserve"> after</w:t></w:r></w:p><w:sdt><w:sdtPr></w:sdtPr><w:sdtContent><w:p></w:p><w:p><w:r><w:t xml:space="preserve">in the block</w:t></w:r></w:p></w:sdtContent></w:sdt><w:sectPr><w:type w:val="nextPage"/><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/><w:cols w:space="720" w:num="1"/></w:sectPr></w:body>"#;
+86
View File
@@ -245,3 +245,89 @@ fn a_controls_run_properties_are_read_and_kept() {
let out = document_xml(&write(&d).unwrap()); let out = document_xml(&write(&d).unwrap());
assert!(out.contains(r#"<w:rPr><w:rStyle w:val="Eingabe"/><w:sz w:val="20"/></w:rPr>"#), "{out}"); assert!(out.contains(r#"<w:rPr><w:rStyle w:val="Eingabe"/><w:sz w:val="20"/></w:rPr>"#), "{out}");
} }
/// Word's Table of Contents control (`w:docPartObj`, #2427) around a TOC the engine's
/// heading/entry form doesn't cover (no `TOCHeading`, unstyled entries, the field ending in a
/// paragraph of its own) is kept as read: nothing rebuilds it on save.
#[test]
fn toc_gallery_around_a_free_form_toc_round_trips() {
let pr = r#"<w:sdtPr><w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtEndPr/><w:sdtContent>"#;
let field = format!(
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{}<w:r><w:tab/></w:r>{}"#,
t("Chapter One"),
t("1")
);
let end = r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#;
let body = format!("<w:sdt>{pr}{}{}{}</w:sdtContent></w:sdt>{}", p(&t("Contents")), p(&field), p(end), p(&t("Chapter One")));
let mut bytes = package(&body);
for pass in 0..2 {
let d = read(&bytes).unwrap();
bytes = write(&d).unwrap();
let xml = document_xml(&bytes);
assert_eq!(xml.matches("<w:sdt>").count(), 1, "pass {pass}: {xml}");
let open = xml.find(pr).unwrap_or_else(|| panic!("pass {pass}: {xml}"));
let close = xml.find("</w:sdtContent></w:sdt>").unwrap();
let (contents, fend) = (xml.find(">Contents<").unwrap(), xml.find(r#"w:fldCharType="end""#).unwrap());
assert!(open < contents && fend < close, "pass {pass}: {xml}");
assert!(close < xml.rfind(">Chapter One<").unwrap(), "the body paragraph stays outside: {xml}");
}
}
/// The control around a TOC in the engine's form (a `TOCHeading`, then TOC-styled entries the
/// field spans) still goes to the TOC: written once, around the heading and the entries, and the
/// field opens in the heading as References › Update Table expects.
#[test]
fn toc_gallery_around_the_engine_form_goes_to_the_toc() {
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
let end = r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#;
let content = format!(
"{}{}{}",
style("TOCHeading", &t("Contents")),
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
style("TOC2", &format!("{}{end}", t("Section\t2")))
);
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One"))));
for pass in 0..2 {
let d = read(&bytes).unwrap();
assert!(controls(&d).is_empty(), "pass {pass}");
let heading = d.body[0].as_para().unwrap();
assert!(heading.objects.iter().any(|o| matches!(o, wordcraft_doc::InlineObject::Field { instr, .. } if instr.trim().starts_with("TOC"))));
bytes = write(&d).unwrap();
let xml = document_xml(&bytes);
assert_eq!(xml.matches("<w:sdt>").count(), 1, "pass {pass}: {xml}");
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "pass {pass}: {xml}");
assert!(xml.find("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
}
}
/// A rich text control around the heading and the first entry of a TOC in the engine's form,
/// inside Word's Table of Contents control: the writer doesn't put a TOC control around a heading
/// already in a block control, so the outer one is kept as read rather than lost on save.
#[test]
fn toc_gallery_around_a_toc_whose_heading_is_in_a_block_control_round_trips() {
let style = |s: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
let end = r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#;
let content = format!(
"{}{}",
sdt(
r#"<w:id w:val="31"/><w:richText/>"#,
&format!("{}{}", style("TOCHeading", &t("Contents")), style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))))
),
style("TOC2", &format!("{}{end}", t("Section\t2")))
);
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One"))));
for pass in 0..2 {
let d = read(&bytes).unwrap();
bytes = write(&d).unwrap();
let xml = document_xml(&bytes);
assert_eq!(xml.matches("<w:sdt>").count(), 2, "pass {pass}: {xml}");
let (gallery, rich) =
(xml.find(&format!("<w:sdtPr>{pr}")).unwrap_or_else(|| panic!("pass {pass}: {xml}")), xml.find(r#"<w:id w:val="31"/>"#).unwrap());
assert!(gallery < rich && rich < xml.find(">Contents<").unwrap(), "pass {pass}: {xml}");
assert!(xml.rfind("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
}
}
File diff suppressed because one or more lines are too long
+85
View File
@@ -0,0 +1,85 @@
//! East Asian language attribute of a run (`w:lang/@w:eastAsia`, ISO/IEC 29500-1 §17.3.2.20).
//! Verifies that saving DOCX documents preserves the East Asian language of `w:lang`.
use std::io::{Read, Write};
fn docx(body: &str, styles: Option<&str>) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc_rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut parts = vec![("_rels/.rels", root.to_string()), ("word/document.xml", doc)];
if let Some(s) = styles {
parts.push(("word/_rels/document.xml.rels", doc_rels.to_string()));
parts.push((
"word/styles.xml",
format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">{s}</w:styles>"#),
));
}
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, xml) in &parts {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(xml.as_bytes()).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn part(bytes: &[u8], name: &str) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name(name).unwrap().read_to_string(&mut s).unwrap();
s
}
fn run(text: &str, rpr: &str) -> String {
format!(r#"<w:r><w:rPr>{rpr}</w:rPr><w:t>{text}</w:t></w:r>"#)
}
#[test]
fn east_asian_lang_survives_a_save() {
let body = format!(
r#"<w:p>{}{}</w:p>"#,
run("Hello", r#"<w:lang w:val="en-US" w:eastAsia="ja-JP" w:bidi="ar-SA"/>"#),
run("中文", r#"<w:lang w:eastAsia="zh-CN"/>"#)
);
let d = wordcraft_docx::read(&docx(&body, None)).unwrap();
let p = d.body[0].as_para().unwrap();
let r0 = &p.runs[0];
assert_eq!(r0.props.lang.as_deref(), Some("en-US"));
assert_eq!(r0.props.lang_ea.as_deref(), Some("ja-JP"));
assert_eq!(r0.props.lang_bidi.as_deref(), Some("ar-SA"));
let r1 = &p.runs[1];
assert_eq!(r1.props.lang, None);
assert_eq!(r1.props.lang_ea.as_deref(), Some("zh-CN"));
assert_eq!(r1.props.lang_bidi, None);
let out = wordcraft_docx::write(&d).unwrap();
let xml = part(&out, "word/document.xml");
assert!(xml.contains(r#"w:eastAsia="ja-JP""#), "{xml}");
assert!(xml.contains(r#"w:eastAsia="zh-CN""#), "{xml}");
let back = wordcraft_docx::read(&out).unwrap();
let back_p = back.body[0].as_para().unwrap();
assert_eq!(back_p.runs[0].props.lang.as_deref(), Some("en-US"));
assert_eq!(back_p.runs[0].props.lang_ea.as_deref(), Some("ja-JP"));
assert_eq!(back_p.runs[0].props.lang_bidi.as_deref(), Some("ar-SA"));
assert_eq!(back_p.runs[1].props.lang_ea.as_deref(), Some("zh-CN"));
}
#[test]
fn east_asian_lang_style_and_whitespace_trim() {
let styles =
r#"<w:style w:type="character" w:styleId="CJKLang"><w:name w:val="CJK Language"/><w:rPr><w:lang w:eastAsia=" ko-KR "/></w:rPr></w:style>"#;
let body = format!(r#"<w:p>{}</w:p>"#, run("한국어", r#"<w:rStyle w:val="CJKLang"/>"#));
let d = wordcraft_docx::read(&docx(&body, Some(styles))).unwrap();
let style = d.styles.get("CJKLang").unwrap();
assert_eq!(style.chr.lang_ea.as_deref(), Some("ko-KR"));
let out = wordcraft_docx::write(&d).unwrap();
let styles_xml = part(&out, "word/styles.xml");
assert!(styles_xml.contains(r#"w:eastAsia="ko-KR""#), "{styles_xml}");
}
+395 -21
View File
@@ -2,6 +2,7 @@
use std::io::Write; use std::io::Write;
use wordcraft_doc::fill::Fill;
use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap}; use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{Align, Border, BorderStyle, Rgb, TextColor, Underline, VMerge}; use wordcraft_doc::props::{Align, Border, BorderStyle, Rgb, TextColor, Underline, VMerge};
use wordcraft_doc::{Block, Document, InlineObject, Paragraph, RevisionKind}; use wordcraft_doc::{Block, Document, InlineObject, Paragraph, RevisionKind};
@@ -1012,6 +1013,25 @@ fn anchor_alignment_reference_areas_and_distances() {
assert_eq!((b.dist, b.dist_top, b.dist_bottom), (0.0, 0.0, 0.0)); assert_eq!((b.dist, b.dist_top, b.dist_bottom), (0.0, 0.0, 0.0));
} }
#[test]
fn anchor_stacking_order_is_read_and_kept() {
let shape = |z: &str| {
format!(
r#"<w:r><w:drawing><wp:anchor behindDoc="0" {z}><wp:positionH relativeFrom="column"><wp:posOffset>0</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV><wp:extent cx="127000" cy="127000"/><wp:wrapNone/><wp:docPr id="1" name="S"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="rect"/></wps:spPr></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#
)
};
let body = format!("<w:p>{}{}{}</w:p>", shape(r#"relativeHeight="251660288""#), shape(r#"relativeHeight="251657214""#), shape(""));
let zs = |d: &Document| -> Vec<u32> {
paras(d)[0].objects.iter().filter_map(|o| if let InlineObject::Shape { float, .. } = o { Some(float.z) } else { None }).collect()
};
let d = read_body(&body);
assert_eq!(zs(&d), [251660288, 251657214, 0]);
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
let z = zs(&again);
assert_eq!(&z[..2], &[251660288, 251657214]);
assert!(z[2] > 0, "an object without one gets an order on saving: {z:?}");
}
#[test] #[test]
fn documents_without_a_compatibility_mode_are_laid_out_as_word_2007() { fn documents_without_a_compatibility_mode_are_laid_out_as_word_2007() {
assert_eq!(read_body("<w:p/>").settings.compat_mode, wordcraft_doc::LEGACY_COMPAT_MODE); assert_eq!(read_body("<w:p/>").settings.compat_mode, wordcraft_doc::LEGACY_COMPAT_MODE);
@@ -1623,6 +1643,46 @@ fn ole_object_round_trips() {
assert!(matches!(paras(&back)[0].objects.first(), Some(InlineObject::Image { w: 144.0, ole: Some(_), .. }))); assert!(matches!(paras(&back)[0].objects.first(), Some(InlineObject::Image { w: 144.0, ole: Some(_), .. })));
} }
#[test]
fn signature_line_round_trips() {
// Insert › Signature Line: a VML picture (the line, its X and the signer drawn in a metafile)
// with an `o:signatureline` that makes it signable in Word. Saving keeps it a signature line,
// not a plain picture; a VML picture without one still becomes a DrawingML picture.
let body = r##"<w:p><w:r><w:pict><v:shapetype id="_x0000_t75" coordsize="21600,21600" o:spt="75" o:preferrelative="t" path="m@4@5l@4@11@9@11@9@5xe" filled="f" stroked="f"><v:path o:extrusionok="f" gradientshapeok="t" o:connecttype="rect"/><o:lock v:ext="edit" aspectratio="t"/></v:shapetype><v:shape id="_x0000_i1025" type="#_x0000_t75" alt="Signature line" style="width:192pt;height:96pt"><v:imagedata r:id="rId8" o:title=""/><o:lock v:ext="edit" ungrouping="t" rotation="t" cropping="t" verticies="t" text="t" grouping="t"/><o:signatureline v:ext="edit" id="{00000000-0000-0000-0000-000000000001}" provid="{00000000-0000-0000-0000-000000000000}" o:suggestedsigner="A. Example" o:suggestedsigner2="Director" showsigndate="f" issignatureline="t"/></v:shape></w:pict></w:r><w:r><w:pict><v:shape style="width:72pt;height:36pt" alt="plain"><v:imagedata r:id="rId8" o:title=""/></v:shape></w:pict></w:r></w:p>"##;
let picture = "\u{89}PNG stand-in";
let types =
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="png" ContentType="image/png"/></Types>"#;
let bytes = docx(body, &[("rId8", "image", "media/image1.png")], &[("[Content_Types].xml", types), ("word/media/image1.png", picture)]);
let d = wordcraft_docx::read(&bytes).unwrap();
let objects = &paras(&d)[0].objects;
let [InlineObject::Image { w, alt, ole: Some(_), .. }, InlineObject::Image { ole: None, .. }] = objects.as_slice() else { panic!("{objects:?}") };
assert_eq!((*w, alt.as_str()), (192.0, "Signature line"));
// Resized in WordCraft.
let mut doc = d.clone();
if let Some(wordcraft_doc::Block::Para(p)) = doc.body.first_mut().map(std::sync::Arc::make_mut)
&& let Some(InlineObject::Image { w, h, .. }) = p.objects.first_mut()
{
(*w, *h) = (144.0, 72.0);
}
let out = wordcraft_docx::write(&doc).unwrap();
let files = unzip(&out);
let body = text(&files, "word/document.xml");
let rels = text(&files, "word/_rels/document.xml.rels");
assert_eq!(attr_values(&body, "o:signatureline", "o:suggestedsigner"), ["A. Example"]);
assert_eq!(attr_values(&body, "o:signatureline", "o:suggestedsigner2"), ["Director"]);
assert_eq!(attr_values(&body, "v:shape", "style"), ["width:144pt;height:72pt"]);
assert_eq!(attr_values(&body, "v:shapetype", "id"), ["_x0000_t75"]);
let [pic_id] = attr_values(&body, "v:imagedata", "r:id").try_into().unwrap();
let (kind, target) = rel(&rels, &pic_id);
assert!(kind.ends_with("/image"), "{kind}");
assert_eq!(files.get(&part_at("word", &target)).map(Vec::as_slice), Some(picture.as_bytes()));
assert_eq!(body.matches("<w:drawing>").count(), 1, "only the plain picture becomes a DrawingML picture");
let back = wordcraft_docx::read(&out).unwrap();
assert!(matches!(paras(&back)[0].objects.first(), Some(InlineObject::Image { w: 144.0, ole: Some(_), .. })));
}
#[test] #[test]
fn hostile_embedded_relationships_stay_bounded() { fn hostile_embedded_relationships_stay_bounded() {
// The chart relates to itself, the main document, a relationships part and a missing part; // The chart relates to itself, the main document, a relationships part and a missing part;
@@ -2138,7 +2198,7 @@ fn theme_styled_connectors_and_callout_boxes_keep_their_paint() {
assert_eq!(objs.len(), 5); assert_eq!(objs.len(), 5);
let shape = |i: usize| match &objs[i] { let shape = |i: usize| match &objs[i] {
InlineObject::Shape { kind, fill, stroke, stroke_width, extra, story, .. } => { InlineObject::Shape { kind, fill, stroke, stroke_width, extra, story, .. } => {
(*kind, *fill, *stroke, *stroke_width, extra.arrow_end, story.is_some()) (*kind, fill.as_ref().map(Fill::main_color), *stroke, *stroke_width, extra.arrow_end, story.is_some())
} }
o => panic!("{o:?}"), o => panic!("{o:?}"),
}; };
@@ -2211,7 +2271,10 @@ fn vml_lines_and_rectangles() {
} }
o => panic!("{o:?}"), o => panic!("{o:?}"),
} }
assert!(matches!(&objs[1], InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Rgb(255, 0, 0)), stroke: None, w: 30.0, h: 20.0, .. })); assert!(matches!(
&objs[1],
InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Fill::Solid { color: Rgb(255, 0, 0), .. }), stroke: None, w: 30.0, h: 20.0, .. }
));
} }
/// The right arrow's shapetype as Word writes it (ECMA-376 Part 4 `v:shapetype`, `o:spt` 13). /// The right arrow's shapetype as Word writes it (ECMA-376 Part 4 `v:shapetype`, `o:spt` 13).
@@ -2263,7 +2326,7 @@ fn vml_preset_shapes_are_drawn() {
let ps = paras(&d); let ps = paras(&d);
let shape = |i: usize| match ps[i].objects.first() { let shape = |i: usize| match ps[i].objects.first() {
Some(InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, .. }) => { Some(InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, .. }) => {
Some((*kind, *w, *h, *fill, *stroke, *stroke_width, float.wrap == Wrap::Inline, story.is_some())) Some((*kind, *w, *h, fill.as_ref().map(Fill::main_color), *stroke, *stroke_width, float.wrap == Wrap::Inline, story.is_some()))
} }
_ => None, _ => None,
}; };
@@ -2339,7 +2402,14 @@ fn vml_preset_shape_survives_saving() {
assert!( assert!(
matches!( matches!(
&paras(&again)[0].objects[..], &paras(&again)[0].objects[..],
[InlineObject::Shape { kind: ShapeKind::Arrow, w: 120.0, h: 40.0, fill: Some(Rgb(0xFF, 0xA5, 0x00)), stroke: Some(Rgb::BLACK), .. }] [InlineObject::Shape {
kind: ShapeKind::Arrow,
w: 120.0,
h: 40.0,
fill: Some(Fill::Solid { color: Rgb(0xFF, 0xA5, 0x00), .. }),
stroke: Some(Rgb::BLACK),
..
}]
), ),
"{:?}", "{:?}",
paras(&again)[0].objects paras(&again)[0].objects
@@ -2360,7 +2430,7 @@ fn vml_nearest_preset_shape_survives_saving() {
assert!( assert!(
matches!( matches!(
&paras(&again)[0].objects[..], &paras(&again)[0].objects[..],
[InlineObject::Shape { kind: ShapeKind::Arrow, w: 60.0, h: 20.0, fill: Some(Rgb(0xFF, 0xA5, 0x00)), .. }] [InlineObject::Shape { kind: ShapeKind::Arrow, w: 60.0, h: 20.0, fill: Some(Fill::Solid { color: Rgb(0xFF, 0xA5, 0x00), .. }), .. }]
), ),
"{:?}", "{:?}",
paras(&again)[0].objects paras(&again)[0].objects
@@ -2417,7 +2487,7 @@ fn cover_page_group_fills_freeforms_and_text_box_margins() {
let InlineObject::Group { children, .. } = &objs[0] else { panic!("{:?}", objs[0]) }; let InlineObject::Group { children, .. } = &objs[0] else { panic!("{:?}", objs[0]) };
assert_eq!(children.len(), 3); assert_eq!(children.len(), 3);
let shape = |i: usize| match &children[i].obj { let shape = |i: usize| match &children[i].obj {
InlineObject::Shape { kind, fill, freeform, story, .. } => (*kind, *fill, freeform.is_some(), *story), InlineObject::Shape { kind, fill, freeform, story, .. } => (*kind, fill.as_ref().map(Fill::main_color), freeform.is_some(), *story),
o => panic!("{o:?}"), o => panic!("{o:?}"),
}; };
let (kind, fill, free, _) = shape(0); let (kind, fill, free, _) = shape(0);
@@ -2469,7 +2539,7 @@ fn drawingml_shapes_survive_saving() {
.iter() .iter()
.map(|o| match o { .map(|o| match o {
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, extra, .. } => { InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, extra, .. } => {
(*kind, *w, *h, *fill, *stroke, *stroke_width, float.x, float.y, float.wrap, extra.arrow_end) (*kind, *w, *h, fill.clone(), *stroke, *stroke_width, float.x, float.y, float.wrap, extra.arrow_end)
} }
o => panic!("{o:?}"), o => panic!("{o:?}"),
}) })
@@ -2492,7 +2562,7 @@ fn drawingml_shapes_survive_saving() {
] ]
); );
for s in &read[..5] { for s in &read[..5] {
assert_eq!(s.3, Some(accent), "{:?} is filled from the theme", s.0); assert_eq!(s.3, Some(wordcraft_doc::fill::Fill::solid(accent)), "{:?} is filled from the theme", s.0);
assert!(s.4.is_some() && s.5 > 0.0, "{:?} has its outline", s.0); assert!(s.4.is_some() && s.5 > 0.0, "{:?} has its outline", s.0);
assert_eq!((s.6, s.7, s.8), (1.0, 2.0, Wrap::InFrontOfText), "{:?} keeps its place", s.0); assert_eq!((s.6, s.7, s.8), (1.0, 2.0, Wrap::InFrontOfText), "{:?} keeps its place", s.0);
} }
@@ -3109,6 +3179,39 @@ fn toc_entry_links_lose_the_hyperlink_style() {
assert_eq!(run("plain").style.as_deref(), Some("Hyperlink"), "outside fields: kept"); assert_eq!(run("plain").style.as_deref(), Some("Hyperlink"), "outside fields: kept");
} }
/// Localized Word (Spanish: `Hipervnculo`) and older files (`Hyperlnk`) give the built-in
/// Hyperlink character style another id; its name stays "Hyperlink". TOC entries carrying it are
/// plain too (#3239: entries showed blue).
#[test]
fn toc_entry_links_lose_a_localized_hyperlink_style() {
for id in ["Hipervnculo", "Hyperlnk"] {
let styles = format!(
r#"<w:styles {W_NS}><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style>
<w:style w:type="character" w:styleId="{id}"><w:name w:val="Hyperlink"/><w:rPr><w:color w:val="0563C1"/><w:u w:val="single"/></w:rPr></w:style></w:styles>"#
);
let link = |t: &str| {
format!(
r#"<w:hyperlink w:anchor="_Toc1"><w:r><w:rPr><w:rStyle w:val="{id}"/></w:rPr><w:t>{t}</w:t></w:r><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> PAGEREF _Toc1 \h </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>1</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:hyperlink>"#
)
};
let body = format!(
r#"<w:sdt><w:sdtPr><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent><w:p><w:pPr><w:pStyle w:val="TOC1"/></w:pPr><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{}</w:p><w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p></w:sdtContent></w:sdt><w:p>{}</w:p><w:p><w:bookmarkStart w:id="1" w:name="_Toc1"/><w:r><w:t>Target</w:t></w:r><w:bookmarkEnd w:id="1"/></w:p>"#,
link("entry"),
link("plain")
);
let d = wordcraft_docx::read(&docx(&body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
let run = |word: &str| {
paras(&d)
.into_iter()
.find_map(|p| p.text.find(word).map(|at| p.props_of_char(at).clone()))
.unwrap_or_else(|| panic!("no run with {word}"))
};
let entry = run("entry");
assert_eq!((entry.style.as_deref(), entry.link.as_deref()), (None, Some("#_Toc1")), "{id}: a TOC entry: plain, still a link");
assert_eq!(run("plain").style.as_deref(), Some(id), "{id}: outside fields: kept");
}
}
/// A style or paragraph whose `w:numPr` gives only a level (`w:ilvl`) takes the list from its /// A style or paragraph whose `w:numPr` gives only a level (`w:ilvl`) takes the list from its
/// style chain (ECMA-376 §17.3.1.19): a Heading 3 based on a numbered Heading 2 is level 3 of the /// style chain (ECMA-376 §17.3.1.19): a Heading 3 based on a numbered Heading 2 is level 3 of the
/// same list. WordCraft dropped the level, so Heading 3s numbered as Heading 2s (6.15 where Word /// same list. WordCraft dropped the level, so Heading 3s numbered as Heading 2s (6.15 where Word
@@ -3190,6 +3293,45 @@ fn text_frames_read_and_round_trip() {
assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none())); assert!(q.iter().any(|x| x.props.drop_cap == Some(2) && x.props.frame.is_none()));
} }
/// #2360: a `w:framePr` in the document defaults, the sizeless top-left one Apple Pages writes,
/// frames no paragraph, as in Word: a header's title and the body's text stay in the text instead
/// of moving to the page's corner, and the default isn't written back. The defaults' other
/// properties are kept, and a style's own frame still applies.
#[test]
fn a_frame_in_the_paragraph_defaults_frames_nothing() {
let styles = format!(
r#"<w:styles {W_NS}><w:docDefaults><w:pPrDefault><w:pPr><w:framePr w:anchorLock="0" w:w="0" w:h="0" w:vSpace="0" w:hSpace="0" w:xAlign="left" w:y="0" w:hRule="exact" w:vAnchor="margin"/><w:spacing w:after="120"/></w:pPr></w:pPrDefault></w:docDefaults><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="Framed"><w:name w:val="Framed"/><w:pPr><w:framePr w:wrap="around" w:hAnchor="margin" w:vAnchor="text" w:xAlign="right" w:y="1"/></w:pPr></w:style></w:styles>"#
);
let header = format!(r#"<w:hdr {W_NS}><w:p><w:r><w:t>Title</w:t></w:r></w:p></w:hdr>"#);
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:p><w:pPr><w:pStyle w:val="Framed"/></w:pPr><w:r><w:t>Boxed</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr>"#;
let bytes = docx(
body,
&[("rIdS", "styles", "styles.xml"), ("rIdH", "header", "header1.xml")],
&[("word/styles.xml", &styles), ("word/header1.xml", &header)],
);
let check = |d: &Document| {
assert!(d.styles.default_para.frame.is_none(), "{:?}", d.styles.default_para.frame);
assert_eq!(d.styles.default_para.space_after, Some(6.0), "the defaults' spacing is kept");
let frame = |p: &Paragraph| d.styles.resolve_para(&p.props).frame;
let body = paras(d);
assert_eq!(body[0].text, "Body");
assert!(frame(body[0]).is_none(), "body text is not a frame");
let boxed = frame(body[1]).expect("a style's frame still applies");
assert_eq!((boxed.h_anchor.as_str(), boxed.x_align.as_deref()), ("margin", Some("right")));
let hid = d.last_section.headers.default.expect("a header");
let title = d.parts.get(&hid).unwrap().blocks.iter().find_map(|b| b.as_para()).expect("the title");
assert_eq!(title.text, "Title");
assert!(frame(title).is_none(), "the header's title is not a frame");
};
let d = wordcraft_docx::read(&bytes).unwrap();
check(&d);
let saved = wordcraft_docx::write(&d).unwrap();
let xml = text(&unzip(&saved), "word/styles.xml");
let defaults = xml.split("</w:docDefaults>").next().unwrap_or_default();
assert!(!defaults.contains("w:framePr"), "the default frame isn't written back: {defaults}");
check(&wordcraft_docx::read(&saved).unwrap());
}
/// A legacy VML freeform (`v:shape` with a `path`, ECMA-376 Part 4): a rule across a header, /// A legacy VML freeform (`v:shape` with a `path`, ECMA-376 Part 4): a rule across a header,
/// drawn in a 1000 × 1000 coordinate space, outlined, its fill transparent. It is read as a /// drawn in a 1000 × 1000 coordinate space, outlined, its fill transparent. It is read as a
/// freeform of the shape's size and kept when saved (as DrawingML custom geometry) and read back. /// freeform of the shape's size and kept when saved (as DrawingML custom geometry) and read back.
@@ -3207,7 +3349,7 @@ fn vml_freeform_rule_in_header() {
let shapes: Vec<&InlineObject> = d.parts.get(&hid).unwrap().blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.objects).collect(); let shapes: Vec<&InlineObject> = d.parts.get(&hid).unwrap().blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.objects).collect();
let [rule, tri] = shapes.as_slice() else { panic!("{shapes:?}") }; let [rule, tri] = shapes.as_slice() else { panic!("{shapes:?}") };
let InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, freeform: Some(f), .. } = rule else { panic!("{rule:?}") }; let InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, freeform: Some(f), .. } = rule else { panic!("{rule:?}") };
assert_eq!((*kind, *fill, *stroke), (ShapeKind::Freeform, None, Some(Rgb(0x33, 0x66, 0xCC)))); assert_eq!((*kind, fill.as_ref().map(Fill::main_color), *stroke), (ShapeKind::Freeform, None, Some(Rgb(0x33, 0x66, 0xCC))));
assert!(near(*w, 360.0) && near(*h, 12.0) && near(*stroke_width, 1.5), "{w} x {h}, {stroke_width}"); assert!(near(*w, 360.0) && near(*h, 12.0) && near(*stroke_width, 1.5), "{w} x {h}, {stroke_width}");
assert_eq!((float.h_rel, float.v_rel, float.wrap), (Anchor::Margin, Anchor::Margin, Wrap::InFrontOfText)); assert_eq!((float.h_rel, float.v_rel, float.wrap), (Anchor::Margin, Anchor::Margin, Wrap::InFrontOfText));
assert!(near(float.y, 30.0), "{float:?}"); assert!(near(float.y, 30.0), "{float:?}");
@@ -3277,21 +3419,56 @@ fn hostile_vml_paths_stay_finite() {
} }
} }
/// Measured in Word: `w:asciiTheme="minorBidi"` names the theme's complex-script font (`a:cs`); /// Measured in Word for Mac: a `majorBidi`/`minorBidi` theme slot names the theme's font for the
/// when that is empty it names nothing, and the run keeps the font it inherits. /// script of the settings' `w:themeFontLang/@w:bidi` (the Latin font for a Latin language), else
/// the theme's `a:cs`, else Times New Roman; with no bidi language there, Times New Roman whatever
/// the theme says. The run's own `w:lang` and the inherited fonts play no part. Papers set in
/// `majorBidi` with Office's usual theme (`Arab` → Times New Roman) came out in Calibri Light.
#[test] #[test]
fn bidi_theme_slots_name_the_complex_script_font() { fn bidi_theme_slots_follow_the_theme_font_language() {
let font = |cs: &str| { let tnr = "Times New Roman";
// (themeFontLang, a:cs, Arab entries present, run lang) → (majorBidi, minorBidi)
let cases: &[(Option<&str>, &str, bool, &str, [&str; 2])] = &[
(None, "", true, "", [tnr, tnr]),
(None, "Arial", true, r#"<w:lang w:bidi="ar-SA"/>"#, [tnr, tnr]),
(Some(r#"w:val="en-US""#), "Arial", true, "", [tnr, tnr]),
(Some(r#"w:bidi="ar-SA""#), "", true, r#"<w:lang w:bidi="he-IL"/>"#, ["Georgia", "Trebuchet MS"]),
(Some(r#"w:bidi="ar-SA""#), "Arial", true, "", ["Georgia", "Trebuchet MS"]),
(Some(r#"w:bidi="he-IL""#), "", true, "", ["Courier New", "Comic Sans MS"]),
(Some(r#"w:bidi="ur-PK""#), "Arial", true, "", ["Georgia", "Trebuchet MS"]),
(Some(r#"w:bidi="pa-PK""#), "Arial", true, "", ["Georgia", "Trebuchet MS"]),
(Some(r#"w:bidi="pa-IN""#), "Arial", true, "", ["Arial", "Arial"]),
(Some(r#"w:bidi="fr-FR""#), "Arial", true, "", ["Verdana", "Impact"]),
(Some(r#"w:bidi="th-TH""#), "Arial", true, "", ["Arial", "Arial"]),
(Some(r#"w:bidi="th-TH""#), "", true, "", [tnr, tnr]),
(Some(r#"w:bidi="ar-SA""#), "Arial", false, "", ["Arial", "Arial"]),
];
for (i, (lang, cs, arab, run_lang, want)) in cases.iter().enumerate() {
let script = |f: &str| if *arab { format!(r#"<a:font script="Arab" typeface="{f}"/>"#) } else { String::new() };
let theme = format!( let theme = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="T"><a:themeElements><a:fontScheme name="F"><a:majorFont><a:latin typeface="Cambria"/><a:ea typeface=""/><a:cs typeface="{cs}"/></a:majorFont><a:minorFont><a:latin typeface="Calibri"/><a:ea typeface=""/><a:cs typeface="{cs}"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"# r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="T"><a:themeElements><a:fontScheme name="F"><a:majorFont><a:latin typeface="Verdana"/><a:ea typeface=""/><a:cs typeface="{cs}"/>{}<a:font script="Hebr" typeface="Courier New"/></a:majorFont><a:minorFont><a:latin typeface="Impact"/><a:ea typeface=""/><a:cs typeface="{cs}"/>{}<a:font script="Hebr" typeface="Comic Sans MS"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#,
script("Georgia"),
script("Trebuchet MS")
); );
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme", "theme/theme1.xml")]; let font_lang = lang.map(|l| format!("<w:themeFontLang {l}/>")).unwrap_or_default();
let body = r#"<w:p><w:r><w:rPr><w:rFonts w:asciiTheme="minorBidi" w:hAnsiTheme="minorBidi"/></w:rPr><w:t>x</w:t></w:r></w:p>"#; let settings = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings {W_NS}>{font_lang}</w:settings>"#);
let d = wordcraft_docx::read(&docx(body, &rels, &[("word/theme/theme1.xml", &theme)])).unwrap(); let styles = format!(
paras(&d)[0].runs[0].props.font.clone() r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles {W_NS}><w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Andale Mono" w:hAnsi="Andale Mono" w:cs="Andale Mono"/><w:lang w:val="en-US" w:bidi="ar-SA"/></w:rPr></w:rPrDefault></w:docDefaults></w:styles>"#
}; );
assert_eq!(font(""), None); let run = |slot: &str| {
assert_eq!(font("Times New Roman").as_deref(), Some("Times New Roman")); format!(r#"<w:p><w:r><w:rPr><w:rFonts w:asciiTheme="{slot}" w:hAnsiTheme="{slot}"/>{run_lang}</w:rPr><w:t>x</w:t></w:r></w:p>"#)
};
let body = [run("majorBidi"), run("minorBidi"), run("majorHAnsi")].concat();
let rels = [
("rId7", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings", "settings.xml"),
("rId8", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles", "styles.xml"),
("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme", "theme/theme1.xml"),
];
let parts = [("word/theme/theme1.xml", theme.as_str()), ("word/settings.xml", settings.as_str()), ("word/styles.xml", styles.as_str())];
let d = wordcraft_docx::read(&docx(&body, &rels, &parts)).unwrap();
let fonts: Vec<_> = paras(&d).iter().map(|p| p.runs[0].props.font.clone().unwrap_or_default()).collect();
assert_eq!(fonts, [want[0], want[1], "Verdana"], "case {i}");
}
} }
/// The font table's family classes: Word draws a missing roman font in a serif face. /// The font table's family classes: Word draws a missing roman font in a serif face.
@@ -3540,3 +3717,200 @@ fn equation_in_alternate_content_is_read() {
assert_eq!(equations(p), vec![("x+1".to_string(), false)]); assert_eq!(equations(p), vec![("x+1".to_string(), false)]);
assert!(!p.plain_text().contains("[picture]")); assert!(!p.plain_text().contains("[picture]"));
} }
/// #1500: gradient fills and transparency on shapes (the cases in the issue), from the shape's
/// own properties, the theme's fill styles (`a:fillRef` 3 and 1003, as Word's cover pages use),
/// a group's fill (`a:grpFill`), an outline gradient and VML's `v:fill`. Each is read as the
/// file states it and survives saving.
#[test]
fn shape_gradients_and_transparency_are_read_and_survive_saving() {
use std::sync::Arc;
use wordcraft_doc::fill::{Gradient, GradientFlip, GradientKind, PathShape};
use wordcraft_doc::wordart::GradStop;
let theme = r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="T"><a:themeElements><a:fmtScheme name="T"><a:fillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"/></a:gs><a:gs pos="100000"><a:schemeClr val="phClr"><a:shade val="50000"/></a:schemeClr></a:gs></a:gsLst><a:lin ang="5400000" scaled="0"/></a:gradFill></a:fillStyleLst><a:bgFillStyleLst><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:schemeClr val="phClr"/></a:gs><a:gs pos="100000"><a:srgbClr val="000000"/></a:gs></a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path></a:gradFill></a:bgFillStyleLst></a:fmtScheme></a:themeElements></a:theme>"#;
let rect = r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#;
let wsp = |sppr: &str, style: &str| anchored_wsp("S", 914400, 914400, &format!("{rect}{sppr}"), style, "<wps:cNvSpPr/>");
let fill_ref = |idx: u32| {
format!(
r#"<wps:style><a:lnRef idx="0"><a:srgbClr val="4080C0"/></a:lnRef><a:fillRef idx="{idx}"><a:srgbClr val="4080C0"/></a:fillRef><a:effectRef idx="0"><a:srgbClr val="4080C0"/></a:effectRef><a:fontRef idx="minor"/></wps:style>"#
)
};
let gs = |pos: u32, c: &str| format!(r#"<a:gs pos="{pos}"><a:srgbClr val="{c}"/></a:gs>"#);
let shapes = [
// A: 30%-opaque white.
wsp(r#"<a:solidFill><a:srgbClr val="FFFFFF"><a:alpha val="30000"/></a:srgbClr></a:solidFill>"#, ""),
// B: two stops top to bottom, the second 40% opaque.
wsp(&format!(r#"<a:gradFill rotWithShape="1"><a:gsLst>{}<a:gs pos="100000"><a:srgbClr val="0000FF"><a:alpha val="40000"/></a:srgbClr></a:gs></a:gsLst><a:lin ang="5400000" scaled="1"/></a:gradFill>"#, gs(0, "FF0000")), ""),
// C: three stops at 45°.
wsp(&format!(r#"<a:gradFill><a:gsLst>{}{}{}</a:gsLst><a:lin ang="2700000" scaled="0"/></a:gradFill>"#, gs(0, "FF0000"), gs(50000, "00FF00"), gs(100000, "0000FF")), ""),
// D: circular from the centre.
wsp(&format!(r#"<a:gradFill rotWithShape="1"><a:gsLst>{}{}</a:gsLst><a:path path="circle"><a:fillToRect l="50000" t="50000" r="50000" b="50000"/></a:path></a:gradFill>"#, gs(0, "FFFFFF"), gs(100000, "000000")), ""),
// E: rectangular from a corner, flipped and tiled, not turning with the shape.
wsp(&format!(r#"<a:gradFill flip="xy" rotWithShape="0"><a:gsLst>{}{}</a:gsLst><a:path path="rect"><a:fillToRect l="100000" t="100000" r="0" b="0"/></a:path><a:tileRect l="-100000" t="-100000" r="0" b="0"/></a:gradFill>"#, gs(0, "FFFFFF"), gs(100000, "000000")), ""),
// F, G: the theme's fill style 3 and background style 3 in the reference's colour.
wsp("", &fill_ref(3)),
wsp("", &fill_ref(1003)),
// H: an outline gradient.
wsp(&format!(r#"<a:noFill/><a:ln w="12700"><a:gradFill><a:gsLst>{}{}{}</a:gsLst><a:lin ang="0" scaled="0"/></a:gradFill></a:ln>"#, gs(0, "FF0000"), gs(50000, "00FF00"), gs(100000, "0000FF")), ""),
]
.concat();
let member = |id: u32, paint: &str| {
format!(
r#"<wps:wsp><wps:cNvPr id="{id}" name="M{id}"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></a:xfrm>{rect}{paint}</wps:spPr><wps:bodyPr/></wps:wsp>"#
)
};
// I: a group with a gradient fill; one member takes it (`a:grpFill`), one has its own, and
// one in a nested group without a fill of its own takes the outer group's.
let group = format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="914400" cy="914400"/><wp:docPr id="9" name="G"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/><a:chOff x="0" y="0"/><a:chExt cx="914400" cy="914400"/></a:xfrm><a:gradFill><a:gsLst>{}{}</a:gsLst><a:lin ang="0" scaled="0"/></a:gradFill></wpg:grpSpPr>{}{}<wpg:grpSp><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/><a:chOff x="0" y="0"/><a:chExt cx="914400" cy="914400"/></a:xfrm></wpg:grpSpPr>{}</wpg:grpSp></wpg:wgp></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#,
gs(0, "112233"),
gs(100000, "445566"),
member(10, "<a:grpFill/>"),
member(11, r#"<a:solidFill><a:srgbClr val="00FF00"/></a:solidFill>"#),
member(12, "<a:grpFill/>")
);
// VML: a linear gradient to `color2`, half opaque.
let vml = r##"<w:r><w:pict><v:rect style="width:72pt;height:72pt" fillcolor="#ff0000" stroked="f"><v:fill type="gradient" color2="#0000ff" opacity="0.5"/></v:rect></w:pict></w:r>"##;
let bytes = docx(&format!("<w:p>{shapes}{group}{vml}</w:p>"), &[("rId2", "theme", "theme/theme1.xml")], &[("word/theme/theme1.xml", theme)]);
let stop = |pos: f32, c: Rgb, transparency: f32| GradStop { pos, color: c, transparency };
let (red, green, blue, white, black) = (Rgb(255, 0, 0), Rgb(0, 255, 0), Rgb(0, 0, 255), Rgb::WHITE, Rgb::BLACK);
let grad = |stops: Vec<GradStop>, kind: GradientKind, rot: bool| {
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: None, rot_with_shape: rot, flip: GradientFlip::None }))
};
let lin = |angle: f32, scaled: bool| GradientKind::Linear { angle, scaled };
let centre = GradientKind::Path { shape: PathShape::Circle, focus: [50.0; 4] };
let ph = Rgb(0x40, 0x80, 0xC0);
let expected = [
Some(Fill::Solid { color: white, transparency: 70.0 }),
Some(grad(vec![stop(0.0, red, 0.0), stop(100.0, blue, 60.0)], lin(90.0, true), true)),
Some(grad(vec![stop(0.0, red, 0.0), stop(50.0, green, 0.0), stop(100.0, blue, 0.0)], lin(45.0, false), false)),
Some(grad(vec![stop(0.0, white, 0.0), stop(100.0, black, 0.0)], centre, true)),
Some(Fill::Gradient(Arc::new(Gradient {
stops: vec![stop(0.0, white, 0.0), stop(100.0, black, 0.0)],
kind: GradientKind::Path { shape: PathShape::Rect, focus: [100.0, 100.0, 0.0, 0.0] },
tile: Some([-100.0, -100.0, 0.0, 0.0]),
rot_with_shape: false,
flip: GradientFlip::Xy,
}))),
Some(grad(vec![stop(0.0, ph, 0.0), stop(100.0, Rgb(0x20, 0x40, 0x60), 0.0)], lin(90.0, false), true)),
Some(grad(vec![stop(0.0, ph, 0.0), stop(100.0, black, 0.0)], centre, true)),
None,
];
let check = |d: &Document| {
let objs = &paras(d)[0].objects;
assert_eq!(objs.len(), expected.len() + 2);
for (i, want) in expected.iter().enumerate() {
let InlineObject::Shape { fill, .. } = &objs[i] else { panic!("{:?}", objs[i]) };
assert_eq!(fill, want, "shape {i}");
}
let InlineObject::Shape { stroke, extra, .. } = &objs[7] else { panic!() };
let outline = grad(vec![stop(0.0, red, 0.0), stop(50.0, green, 0.0), stop(100.0, blue, 0.0)], lin(0.0, false), false);
assert_eq!((*stroke, extra.outline_fill.as_ref()), (Some(green), Some(&outline)), "outline drawn in its middle stop");
let InlineObject::Group { children, .. } = &objs[8] else { panic!("{:?}", objs[8]) };
let fills: Vec<_> = children.iter().map(|c| if let InlineObject::Shape { fill, .. } = &c.obj { fill.clone() } else { None }).collect();
let group_fill = grad(vec![stop(0.0, Rgb(0x11, 0x22, 0x33), 0.0), stop(100.0, Rgb(0x44, 0x55, 0x66), 0.0)], lin(0.0, false), false);
assert_eq!(fills, [Some(group_fill.clone()), Some(Fill::solid(green)), Some(group_fill)], "a:grpFill takes the group's fill");
let InlineObject::Shape { fill, .. } = &objs[9] else { panic!("{:?}", objs[9]) };
assert_eq!(fill.as_ref(), Some(&grad(vec![stop(0.0, red, 50.0), stop(100.0, blue, 0.0)], lin(90.0, false), true)), "VML gradient");
};
let d = wordcraft_docx::read(&bytes).unwrap();
check(&d);
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
}
/// #3415: a table style's row and column band sizes (`w:tblStyleRowBandSize`,
/// `w:tblStyleColBandSize`, ECMA-376 §17.7.6.5 and §17.7.6.7) are read, zero included, and left
/// unset when the file has none, which is how Word reads it: no bands. A style based on one with
/// sizes inherits them.
#[test]
fn table_style_band_sizes_read_as_word_reads_them() {
let band = r#"<w:tblStylePr w:type="band1Horz"><w:tcPr><w:shd w:val="clear" w:color="auto" w:fill="C1E4F5"/></w:tcPr></w:tblStylePr>"#;
let styles = format!(
r#"<w:styles {W_NS}>
<w:style w:type="table" w:default="1" w:styleId="TableNormal"><w:name w:val="Normal Table"/></w:style>
<w:style w:type="table" w:styleId="Missing"><w:name w:val="Missing"/><w:basedOn w:val="TableNormal"/>{band}</w:style>
<w:style w:type="table" w:styleId="One"><w:name w:val="One"/><w:basedOn w:val="TableNormal"/><w:tblPr><w:tblStyleRowBandSize w:val="1"/><w:tblStyleColBandSize w:val="1"/></w:tblPr>{band}</w:style>
<w:style w:type="table" w:styleId="Zero"><w:name w:val="Zero"/><w:basedOn w:val="One"/><w:tblPr><w:tblStyleRowBandSize w:val="0"/></w:tblPr></w:style>
<w:style w:type="table" w:styleId="Child"><w:name w:val="Child"/><w:basedOn w:val="One"/>{band}</w:style>
</w:styles>"#
);
let d = wordcraft_docx::read(&docx("<w:p/>", &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
let sizes = |id: &str| d.styles.table_style(id).map(|t| (t.parts.band_size, t.parts.col_band_size));
assert_eq!(sizes("Missing"), Some((None, None)), "no sizes: no bands, as in Word");
assert_eq!(sizes("One"), Some((Some(1), Some(1))));
assert_eq!(sizes("Zero"), Some((Some(0), Some(1))));
assert_eq!(sizes("Child"), Some((Some(1), Some(1))), "inherited");
// Written back as read: still no sizes where there were none.
let xml = text(&unzip(&wordcraft_docx::write(&d).unwrap()), "word/styles.xml");
let style = |id: &str| {
let start = xml.find(&format!(r#"w:styleId="{id}""#)).unwrap();
xml[start..start + xml[start..].find("</w:style>").unwrap()].to_string()
};
assert!(!style("Missing").contains("BandSize"), "{}", style("Missing"));
assert!(style("One").contains(r#"<w:tblStyleRowBandSize w:val="1"/><w:tblStyleColBandSize w:val="1"/>"#), "{}", style("One"));
assert!(style("Zero").contains(r#"<w:tblStyleRowBandSize w:val="0"/>"#), "{}", style("Zero"));
}
/// #3415: a document saved with one of our banded table styles tells Word its band sizes, so Word
/// shades the same rows we do (it reads a style without them as having no bands).
#[test]
fn our_banded_table_styles_write_their_band_sizes() {
let d = Document::new();
let xml = text(&unzip(&wordcraft_docx::write(&d).unwrap()), "word/styles.xml");
let start = xml.find(r#"w:styleId="GridTable4AccentBlue""#).expect("Grid Table 4 – Blue is written");
let style = &xml[start..start + xml[start..].find("</w:style>").unwrap()];
assert!(style.contains(r#"<w:tblPr><w:tblStyleRowBandSize w:val="1"/><w:tblStyleColBandSize w:val="1"/>"#), "{style}");
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(back.styles.table_style("GridTable4AccentBlue").map(|t| t.parts.band_size), Some(Some(1)));
}
#[test]
fn vml_shape_wrap_side_and_anchor_case_insensitivity() {
let body = r##"<w:p>
<w:r>
<w:pict xmlns:w10="urn:schemas-microsoft-com:office:word">
<v:rect style="position:absolute;width:100pt;height:50pt;mso-position-horizontal-relative:Page;mso-position-vertical-relative:Margin" filled="False" stroked="t">
<w10:wrap type="Square" side="left"/>
</v:rect>
</w:pict>
</w:r>
</w:p>"##;
let doc = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
let p = doc.body.iter().find_map(|b| b.as_para()).unwrap();
let shape = match p.objects.first() {
Some(InlineObject::Shape { fill, float, .. }) => (fill, float),
o => panic!("expected shape, got {o:?}"),
};
assert_eq!(*shape.0, None);
assert_eq!(shape.1.wrap, wordcraft_doc::para::Wrap::Square);
assert_eq!(shape.1.wrap_text, wordcraft_doc::para::WrapText::Left);
assert_eq!(shape.1.h_rel, wordcraft_doc::para::Anchor::Page);
assert_eq!(shape.1.v_rel, wordcraft_doc::para::Anchor::Margin);
}
#[test]
fn vml_textbox_style_anchor_and_fit() {
let body = r#"<w:p>
<w:r>
<w:pict>
<v:rect style="width:100pt;height:50pt">
<v:textbox style="v-text-anchor:Middle;mso-fit-shape-to-text:True">
<w:txbxContent>
<w:p><w:r><w:t>Centered text</w:t></w:r></w:p>
</w:txbxContent>
</v:textbox>
</v:rect>
</w:pict>
</w:r>
</w:p>"#;
let doc = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
let p = doc.body.first().unwrap().as_para().unwrap();
let story_id = match p.objects.first() {
Some(InlineObject::Shape { story: Some(id), .. }) => *id,
o => panic!("expected shape with story, got {o:?}"),
};
let tb = doc.parts.get(&story_id).unwrap();
assert_eq!(tb.body.anchor, wordcraft_doc::props::VAlign::Center);
assert!(tb.body.auto_fit);
}
+46 -22
View File
@@ -1,6 +1,7 @@
//! Round-trip tests: build documents programmatically → write → read → compare. //! Round-trip tests: build documents programmatically → write → read → compare.
use std::sync::Arc; use std::sync::Arc;
use wordcraft_doc::fill::Fill;
use wordcraft_doc::numbering::ListKind; use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap}; use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
@@ -22,6 +23,21 @@ fn paras(doc: &Document) -> Vec<&Paragraph> {
doc.body.iter().filter_map(|b| b.as_para()).collect() doc.body.iter().filter_map(|b| b.as_para()).collect()
} }
/// `doc` with the stacking order of its body's floating objects cleared: saving gives objects
/// without one an order (`relativeHeight`), so tests about other properties compare without it.
fn unstacked(mut doc: Document) -> Document {
for b in &mut doc.body {
if let Block::Para(p) = std::sync::Arc::make_mut(b) {
for o in &mut p.objects {
if let Some(f) = o.float_mut() {
f.z = 0;
}
}
}
}
doc
}
fn part_text(doc: &Document, id: Option<u32>) -> String { fn part_text(doc: &Document, id: Option<u32>) -> String {
id.and_then(|i| doc.parts.get(&i)) id.and_then(|i| doc.parts.get(&i))
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<Vec<_>>().join("\n")) .map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<Vec<_>>().join("\n"))
@@ -113,6 +129,7 @@ fn every_char_prop_round_trips() {
bold_cs: Some(false), bold_cs: Some(false),
italic_cs: Some(true), italic_cs: Some(true),
lang_bidi: Some("fa-IR".into()), lang_bidi: Some("fa-IR".into()),
lang_ea: Some("ja-JP".into()),
east_asian_layout: Some(wordcraft_doc::props::EastAsianLayout { east_asian_layout: Some(wordcraft_doc::props::EastAsianLayout {
id: Some(3), id: Some(3),
combine: Some(true), combine: Some(true),
@@ -457,7 +474,7 @@ fn images_round_trip() {
p.insert_object(end, f.clone(), &CharProps::default()).unwrap(); p.insert_object(end, f.clone(), &CharProps::default()).unwrap();
} }
d.body = vec![para_block(p)]; d.body = vec![para_block(p)];
let r = rt(&d); let r = unstacked(rt(&d));
let got = paras(&r)[0]; let got = paras(&r)[0];
let mut expect = vec![inline]; let mut expect = vec![inline];
expect.extend(floats); expect.extend(floats);
@@ -1101,7 +1118,7 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
kind: ShapeKind::TextBox, kind: ShapeKind::TextBox,
w: 144.0, w: 144.0,
h: 72.0, h: 72.0,
fill: Some(Rgb(255, 255, 200)), fill: Some(Fill::solid(Rgb(255, 255, 200))),
stroke: Some(Rgb(0, 0, 0)), stroke: Some(Rgb(0, 0, 0)),
stroke_width: 1.0, stroke_width: 1.0,
float: Float { wrap: Wrap::Square, h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, x: 10.0, y: 20.0, dist: 0.0, ..Default::default() }, float: Float { wrap: Wrap::Square, h_rel: Anchor::Margin, v_rel: Anchor::Paragraph, x: 10.0, y: 20.0, dist: 0.0, ..Default::default() },
@@ -1139,8 +1156,8 @@ fn shapes_textboxes_equations_dropcaps_round_trip() {
match &got[0].objects[0] { match &got[0].objects[0] {
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, .. } => { InlineObject::Shape { kind, w, h, fill, stroke, stroke_width, float, story, .. } => {
assert_eq!( assert_eq!(
(*kind, *w, *h, *fill, *stroke, *stroke_width), (*kind, *w, *h, fill.clone(), *stroke, *stroke_width),
(ShapeKind::TextBox, 144.0, 72.0, Some(Rgb(255, 255, 200)), Some(Rgb(0, 0, 0)), 1.0) (ShapeKind::TextBox, 144.0, 72.0, Some(Fill::solid(Rgb(255, 255, 200))), Some(Rgb(0, 0, 0)), 1.0)
); );
assert_eq!(float.wrap, Wrap::Square); assert_eq!(float.wrap, Wrap::Square);
assert_eq!(part_text(&r, *story), "Inside the box"); assert_eq!(part_text(&r, *story), "Inside the box");
@@ -1174,7 +1191,7 @@ fn groups_round_trip() {
kind: ShapeKind::Ellipse, kind: ShapeKind::Ellipse,
w: 50.0, w: 50.0,
h: 30.0, h: 30.0,
fill: Some(Rgb(200, 0, 0)), fill: Some(Fill::solid(Rgb(200, 0, 0))),
stroke: None, stroke: None,
stroke_width: 0.0, stroke_width: 0.0,
float: Float::default(), float: Float::default(),
@@ -1199,7 +1216,7 @@ fn groups_round_trip() {
kind: ShapeKind::TextBox, kind: ShapeKind::TextBox,
w: 100.0, w: 100.0,
h: 40.0, h: 40.0,
fill: Some(Rgb(255, 255, 255)), fill: Some(Fill::solid(Rgb(255, 255, 255))),
stroke: Some(Rgb(0, 0, 0)), stroke: Some(Rgb(0, 0, 0)),
stroke_width: 0.75, stroke_width: 0.75,
float: Float::default(), float: Float::default(),
@@ -1226,7 +1243,7 @@ fn groups_round_trip() {
let end = p.len(); let end = p.len();
p.insert_object(end, group, &CharProps::default()).unwrap(); p.insert_object(end, group, &CharProps::default()).unwrap();
d.body = vec![para_block(p)]; d.body = vec![para_block(p)];
let r = rt(&d); let r = unstacked(rt(&d));
let got = paras(&r); let got = paras(&r);
let InlineObject::Group { w, h, float: f, ch_w, ch_h, children, .. } = &got[0].objects[0] else { panic!("{:?}", got[0].objects) }; let InlineObject::Group { w, h, float: f, ch_w, ch_h, children, .. } = &got[0].objects[0] else { panic!("{:?}", got[0].objects) };
assert_eq!((*w, *h, *ch_w, *ch_h), (240.0, 120.0, 200.0, 100.0)); assert_eq!((*w, *h, *ch_w, *ch_h), (240.0, 120.0, 200.0, 100.0));
@@ -1261,7 +1278,7 @@ fn drawing_canvas_round_trips() {
kind: ShapeKind::Star, kind: ShapeKind::Star,
w: 72.0, w: 72.0,
h: 72.0, h: 72.0,
fill: Some(Rgb(0xE0, 0xA0, 0x20)), fill: Some(Fill::solid(Rgb(0xE0, 0xA0, 0x20))),
stroke: Some(Rgb(0x40, 0x40, 0x40)), stroke: Some(Rgb(0x40, 0x40, 0x40)),
stroke_width: 1.0, stroke_width: 1.0,
float: Float::default(), float: Float::default(),
@@ -1270,7 +1287,11 @@ fn drawing_canvas_round_trips() {
freeform: None, freeform: None,
extra: Default::default(), extra: Default::default(),
}; };
let style = CanvasStyle { fill: Some(Rgb(0xF0, 0xF4, 0xFF)), stroke: Some(Rgb(0x20, 0x40, 0x80)), stroke_width: 1.5 }; let style = CanvasStyle {
fill: Some(Fill::Solid { color: Rgb(0xF0, 0xF4, 0xFF), transparency: 25.0 }),
stroke: Some(Rgb(0x20, 0x40, 0x80)),
stroke_width: 1.5,
};
let canvas = InlineObject::Group { let canvas = InlineObject::Group {
w: 432.0, w: 432.0,
h: 216.0, h: 216.0,
@@ -1278,7 +1299,7 @@ fn drawing_canvas_round_trips() {
ch_w: 432.0, ch_w: 432.0,
ch_h: 216.0, ch_h: 216.0,
children: vec![GroupChild { x: 12.0, y: 18.0, obj: pic }, GroupChild { x: 300.0, y: 100.0, obj: star.clone() }], children: vec![GroupChild { x: 12.0, y: 18.0, obj: pic }, GroupChild { x: 300.0, y: 100.0, obj: star.clone() }],
canvas: Some(style), canvas: Some(style.clone()),
}; };
let mut p = Paragraph::with_text("drawn ", CharProps::default()); let mut p = Paragraph::with_text("drawn ", CharProps::default());
let end = p.len(); let end = p.len();
@@ -1309,7 +1330,7 @@ fn rotated_drawing_canvas_round_trips() {
kind: ShapeKind::Rectangle, kind: ShapeKind::Rectangle,
w: 60.0, w: 60.0,
h: 40.0, h: 40.0,
fill: Some(Rgb(1, 2, 3)), fill: Some(Fill::solid(Rgb(1, 2, 3))),
stroke: None, stroke: None,
stroke_width: 0.0, stroke_width: 0.0,
float: Float { rot: 15.0, ..Default::default() }, float: Float { rot: 15.0, ..Default::default() },
@@ -1325,7 +1346,7 @@ fn rotated_drawing_canvas_round_trips() {
ch_w: 200.0, ch_w: 200.0,
ch_h: 100.0, ch_h: 100.0,
children: vec![GroupChild { x: 10.0, y: 20.0, obj: member.clone() }], children: vec![GroupChild { x: 10.0, y: 20.0, obj: member.clone() }],
canvas: Some(CanvasStyle { fill: Some(Rgb(0xF0, 0xF4, 0xFF)), stroke: None, stroke_width: 0.0 }), canvas: Some(CanvasStyle { fill: Some(Fill::solid(Rgb(0xF0, 0xF4, 0xFF))), stroke: None, stroke_width: 0.0 }),
}; };
let square = Float { wrap: Wrap::Square, ..Default::default() }; let square = Float { wrap: Wrap::Square, ..Default::default() };
let mut p = Paragraph::with_text("c", CharProps::default()); let mut p = Paragraph::with_text("c", CharProps::default());
@@ -1511,7 +1532,10 @@ fn watermark_roundtrips() {
color: Rgb(0x80, 0x10, 0x20), color: Rgb(0x80, 0x10, 0x20),
diagonal: false, diagonal: false,
semitransparent: false, semitransparent: false,
size: None,
}, },
// A box of its own (not Word's default for the page) is kept.
Watermark { text: "COPY".into(), size: Some([300.0, 80.0]), ..Default::default() },
] { ] {
let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]); let mut d = doc_with(vec![Paragraph::with_text("Body", CharProps::default())]);
d.settings.watermark = Some(wm.clone()); d.settings.watermark = Some(wm.clone());
@@ -1911,11 +1935,11 @@ fn list_level_overrides_round_trip() {
#[test] #[test]
fn ink_and_freeforms_round_trip_as_custom_geometry() { fn ink_and_freeforms_round_trip_as_custom_geometry() {
use wordcraft_doc::freeform::{FreePath, Freeform, InkTool}; use wordcraft_doc::freeform::{FreePath, Freeform, InkTool};
let shape = |fill, stroke_width, wrap, f: Freeform| InlineObject::Shape { let shape = |fill: Option<Rgb>, stroke_width, wrap, f: Freeform| InlineObject::Shape {
kind: ShapeKind::Freeform, kind: ShapeKind::Freeform,
w: f.w, w: f.w,
h: f.h, h: f.h,
fill, fill: fill.map(Fill::solid),
stroke: Some(Rgb(0xC0, 0, 0)), stroke: Some(Rgb(0xC0, 0, 0)),
stroke_width, stroke_width,
float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 30.0, y: 12.0, ..Default::default() }, float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 30.0, y: 12.0, ..Default::default() },
@@ -1951,7 +1975,7 @@ fn ink_and_freeforms_round_trip_as_custom_geometry() {
assert_eq!(xml.matches("<a:custGeom>").count(), 3, "{xml}"); assert_eq!(xml.matches("<a:custGeom>").count(), 3, "{xml}");
assert!(xml.contains(r#"<a:path w="762000" h="254000" fill="none"><a:moveTo><a:pt x="12700" y="12700"/></a:moveTo><a:lnTo>"#), "{xml}"); assert!(xml.contains(r#"<a:path w="762000" h="254000" fill="none"><a:moveTo><a:pt x="12700" y="12700"/></a:moveTo><a:lnTo>"#), "{xml}");
assert!(xml.contains(r#"<a:alpha val="50000"/>"#) && xml.contains(r#"cap="rnd""#), "{xml}"); assert!(xml.contains(r#"<a:alpha val="50000"/>"#) && xml.contains(r#"cap="rnd""#), "{xml}");
let r = rt(&d); let r = unstacked(rt(&d));
let objs = &paras(&r)[0].objects; let objs = &paras(&r)[0].objects;
assert_eq!(objs.len(), 3); assert_eq!(objs.len(), 3);
// Written in reverse (each inserted at the start): the triangle, the highlighter, the pen. // Written in reverse (each inserted at the start): the triangle, the highlighter, the pen.
@@ -1975,7 +1999,7 @@ fn shape_effects_round_trip() {
kind: ShapeKind::RoundedRectangle, kind: ShapeKind::RoundedRectangle,
w: 100.0, w: 100.0,
h: 50.0, h: 50.0,
fill: Some(Rgb(0x15, 0x60, 0x82)), fill: Some(Fill::solid(Rgb(0x15, 0x60, 0x82))),
stroke: None, stroke: None,
stroke_width: 0.0, stroke_width: 0.0,
float: Float::default(), float: Float::default(),
@@ -2051,7 +2075,7 @@ fn rotation_and_flips_round_trip() {
kind, kind,
w: 80.0, w: 80.0,
h: 40.0, h: 40.0,
fill: Some(Rgb(1, 2, 3)), fill: Some(Fill::solid(Rgb(1, 2, 3))),
stroke: None, stroke: None,
stroke_width: 0.0, stroke_width: 0.0,
float, float,
@@ -2108,7 +2132,7 @@ fn rotated_group_and_shadow_rotation_round_trip() {
kind: ShapeKind::Rectangle, kind: ShapeKind::Rectangle,
w: 60.0, w: 60.0,
h: 40.0, h: 40.0,
fill: Some(Rgb(1, 2, 3)), fill: Some(Fill::solid(Rgb(1, 2, 3))),
stroke: None, stroke: None,
stroke_width: 0.0, stroke_width: 0.0,
float: Float { rot: 20.0, ..Default::default() }, float: Float { rot: 20.0, ..Default::default() },
@@ -2306,7 +2330,7 @@ fn wrap_polygons_round_trip() {
kind, kind,
w: 80.0, w: 80.0,
h: 40.0, h: 40.0,
fill: Some(Rgb(1, 2, 3)), fill: Some(Fill::solid(Rgb(1, 2, 3))),
stroke: None, stroke: None,
stroke_width: 0.0, stroke_width: 0.0,
float, float,
@@ -2332,7 +2356,7 @@ fn wrap_polygons_round_trip() {
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap(); std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
assert!(xml.contains(r#"<wp:wrapPolygon edited="1"><wp:start x="-300" y="0"/><wp:lineTo x="21600" y="1200"/>"#), "{xml}"); assert!(xml.contains(r#"<wp:wrapPolygon edited="1"><wp:start x="-300" y="0"/><wp:lineTo x="21600" y="1200"/>"#), "{xml}");
assert!(xml.contains(r#"<wp:lineTo x="-300" y="0"/></wp:wrapPolygon>"#), "closed: {xml}"); assert!(xml.contains(r#"<wp:lineTo x="-300" y="0"/></wp:wrapPolygon>"#), "closed: {xml}");
let r = wordcraft_docx::read(&bytes).expect("read"); let r = unstacked(wordcraft_docx::read(&bytes).expect("read"));
let got = paras(&r)[0]; let got = paras(&r)[0];
let floats: Vec<Float> = got.objects.iter().filter_map(|o| o.frame().map(|(_, _, f)| f.clone())).collect(); let floats: Vec<Float> = got.objects.iter().filter_map(|o| o.frame().map(|(_, _, f)| f.clone())).collect();
assert_eq!(floats[0], edited); assert_eq!(floats[0], edited);
@@ -2430,7 +2454,7 @@ fn wrap_text_sides_round_trip() {
kind: ShapeKind::Rectangle, kind: ShapeKind::Rectangle,
w: 80.0, w: 80.0,
h: 40.0, h: 40.0,
fill: Some(Rgb(1, 2, 3)), fill: Some(Fill::solid(Rgb(1, 2, 3))),
stroke: None, stroke: None,
stroke_width: 0.0, stroke_width: 0.0,
float, float,
@@ -2484,7 +2508,7 @@ fn alt_text_title_and_decorative_round_trip_for_every_object() {
kind: ShapeKind::Ellipse, kind: ShapeKind::Ellipse,
w: 80.0, w: 80.0,
h: 40.0, h: 40.0,
fill: Some(Rgb(1, 2, 3)), fill: Some(Fill::solid(Rgb(1, 2, 3))),
stroke: None, stroke: None,
stroke_width: 0.0, stroke_width: 0.0,
float: Float { alt, ..Default::default() }, float: Float { alt, ..Default::default() },
+38
View File
@@ -196,3 +196,41 @@ fn hidden_drawings_stay_hidden() {
assert_eq!((marked("wp:docPr"), marked("wps:cNvPr")), (3, 1), "{xml}"); assert_eq!((marked("wp:docPr"), marked("wps:cNvPr")), (3, 1), "{xml}");
assert_eq!(hidden(&wordcraft_docx::read(&saved).unwrap()), want); assert_eq!(hidden(&wordcraft_docx::read(&saved).unwrap()), want);
} }
#[test]
fn picture_in_box_keeps_its_frame() {
use wordcraft_doc::{Paragraph, Pos, StoryRef};
let mut d = Document::from_text("Anchor");
let body = TextBody { anchor: VAlign::Center, inset: Some([0; 4]), ..Default::default() };
let outer = text_box(&mut d, "Label", body);
let id = outer.text_box().unwrap();
let mut bytes = std::io::Cursor::new(Vec::new());
image::RgbaImage::from_pixel(10, 4, image::Rgba([20, 40, 60, 255])).write_to(&mut bytes, image::ImageFormat::Png).unwrap();
let media = d.add_media(bytes.into_inner(), "png");
let image = InlineObject::Image {
media: media.clone(),
w: 90.0,
h: 36.0,
alt: "Synthetic marker".into(),
float: Default::default(),
crop: [0.0; 4],
ole: None,
};
let mut p = Paragraph::new();
p.insert_object(0, image, &Default::default()).unwrap();
d.parts.get_mut(&id).unwrap().blocks.push(para_block(p));
d.insert_object(&Pos::body(0, 0), outer, &Default::default()).unwrap();
let read = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
let ids = boxes(&read);
assert_eq!(ids.len(), 1, "a nested picture must not replace its text box");
let id = ids[0];
assert_eq!(read.parts[&id].body, body);
assert!(read.plain_text(StoryRef::Part(id)).starts_with("Label"));
let p = read.para(StoryRef::Part(id), &wordcraft_doc::Path::top(1)).unwrap();
assert!(matches!(&p.objects[0], InlineObject::Image { w, h, alt, .. }
if *w == 90.0 && *h == 36.0 && alt == "Synthetic marker"));
let p = read.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
assert!(matches!(&p.objects[0], InlineObject::Shape { w, h, .. } if *w == 144.0 && *h == 72.0));
}
+68
View File
@@ -175,3 +175,71 @@ fn section_break_keeps_an_explicit_range_and_toc_prefix_is_not_a_toc() {
); );
} }
} }
/// The paragraph's characters other than objects, each with the link it carries.
fn linked_chars(p: &wordcraft_doc::Paragraph) -> Vec<(char, Option<String>)> {
p.text.char_indices().filter(|(_, c)| *c != wordcraft_doc::para::OBJ).map(|(i, c)| (c, p.props_of_char(i).link.clone())).collect()
}
/// #2174: Word for the web writes each table of contents entry as a `HYPERLINK` complex field
/// around the heading's text, a tab and a `PAGEREF` field. The entry reads as linked text, as a
/// `w:hyperlink` would: the tab is a tab (it reaches the dotted right tab stop) and the page
/// number stays a field of its own, through a save.
#[test]
fn hyperlink_field_entries_read_as_linked_text_with_their_page_field() {
let entry = |anchor: &str, text: &str, page: &str| {
format!(
r#"{}<w:r><w:t>{text}</w:t></w:r><w:r><w:tab/></w:r>{}<w:r><w:t>{page}</w:t></w:r>{END}{END}"#,
start(&format!(r#"HYPERLINK \l "{anchor}" "#)),
start(&format!(r#"PAGEREF {anchor} \h"#))
)
};
let ppr = r#"<w:pPr><w:pStyle w:val="TOC1"/><w:tabs><w:tab w:val="right" w:leader="dot" w:pos="9015"/></w:tabs></w:pPr>"#;
let body = format!(
r#"<w:p>{ppr}{}{}</w:p><w:p>{ppr}{}{END}</w:p><w:p><w:r><w:t>After.</w:t></w:r></w:p>"#,
start(r#"TOC \o \z \u \h"#),
entry("_Toc1", "Intro", "1"),
entry("_Toc2", "Title 2", "2")
);
let doc = wordcraft_docx::read(&package(&body)).unwrap();
let saved = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap();
for d in [&doc, &saved] {
for (i, anchor, text, page) in [(0, "_Toc1", "Intro", "1"), (1, "_Toc2", "Title 2", "2")] {
let p = d.body[i].as_para().unwrap();
assert_eq!(p.plain_text(), format!("{text}\t{page}"));
let link = Some(format!("#{anchor}"));
assert_eq!(linked_chars(p), format!("{text}\t").chars().map(|c| (c, link.clone())).collect::<Vec<_>>());
assert!(p.objects.iter().all(|o| !matches!(o, InlineObject::Field { instr, .. } if instr.starts_with("HYPERLINK"))));
let pageref = format!(r#"PAGEREF {anchor} \h"#);
assert!(p.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, result, .. } if *instr == pageref && result == page)));
}
assert_eq!(d.field_ranges(StoryRef::Body).len(), 1);
assert_eq!(d.body[2].as_para().unwrap().plain_text(), "After.");
}
}
/// A `HYPERLINK` field's target, whatever the field's spelling: a web address with a bookmark,
/// quoted words with spaces, a tooltip or target frame before it, and a simple field. One with
/// no target stays a field.
#[test]
fn hyperlink_field_targets_follow_the_field_code() {
let simple = |code: &str| format!(r#"<w:p><w:fldSimple w:instr="{code}"><w:r><w:t>Go</w:t></w:r></w:fldSimple></w:p>"#);
let complex = |code: &str| format!(r#"<w:p>{}<w:r><w:t>Go</w:t></w:r>{END}</w:p>"#, start(code));
for (para, link) in [
(complex(r#"HYPERLINK "https://example.org/a" \l "sec""#), Some("https://example.org/a#sec")),
(complex(r#"hyperlink \o "A tip" \t "_blank" "https://example.org/""#), Some("https://example.org/")),
(complex(r#"HYPERLINK "C:\\Docs\\My file.docx""#), Some(r"C:\Docs\My file.docx")),
(simple(r#"HYPERLINK \l &quot;Sec&quot; \m"#), Some("#Sec")),
(simple("HYPERLINK https://example.org/b"), Some("https://example.org/b")),
(complex("HYPERLINK"), None),
(complex(r#"HYPERLINKS "https://example.org/""#), None),
] {
let doc = wordcraft_docx::read(&package(&para)).unwrap();
let p = doc.body[0].as_para().unwrap();
assert_eq!(p.plain_text(), "Go");
match link {
Some(l) => assert_eq!(linked_chars(p), "Go".chars().map(|c| (c, Some(l.to_string()))).collect::<Vec<_>>(), "{para}"),
None => assert!(matches!(p.objects.as_slice(), [InlineObject::Field { result, .. }] if result == "Go"), "{para}"),
}
}
}
+32 -6
View File
@@ -183,8 +183,8 @@ fn a_pict_of_a_rect_a_text_box_and_a_group_all_come_through() {
assert_eq!(kinds(&ps[1].objects), [ShapeKind::TextBox]); assert_eq!(kinds(&ps[1].objects), [ShapeKind::TextBox]);
let InlineObject::Group { children, .. } = &ps[2].objects[0] else { panic!("{:?}", ps[2].objects) }; let InlineObject::Group { children, .. } = &ps[2].objects[0] else { panic!("{:?}", ps[2].objects) };
assert_eq!(kinds(&children.iter().map(|c| c.obj.clone()).collect::<Vec<_>>()), [ShapeKind::Rectangle], "the green rectangle is read"); assert_eq!(kinds(&children.iter().map(|c| c.obj.clone()).collect::<Vec<_>>()), [ShapeKind::Rectangle], "the green rectangle is read");
let InlineObject::Shape { fill, .. } = children[0].obj else { panic!() }; let InlineObject::Shape { fill, .. } = children[0].obj.clone() else { panic!() };
assert_eq!(fill, Some(wordcraft_doc::Rgb(0x00, 0xff, 0x00)), "with its colour"); assert_eq!(fill, Some(wordcraft_doc::fill::Fill::solid(wordcraft_doc::Rgb(0x00, 0xff, 0x00))), "with its colour");
// And it is still there after a save, which is what the round trip lost. // And it is still there after a save, which is what the round trip lost.
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(paras(&back).iter().map(|p| p.objects.len()).collect::<Vec<_>>(), [1, 1, 1]); assert_eq!(paras(&back).iter().map(|p| p.objects.len()).collect::<Vec<_>>(), [1, 1, 1]);
@@ -255,9 +255,9 @@ fn a_group_member_finds_its_shapetype_beside_the_group() {
))); )));
let InlineObject::Group { children, .. } = &objects(&d)[0] else { panic!("{:?}", objects(&d)) }; let InlineObject::Group { children, .. } = &objects(&d)[0] else { panic!("{:?}", objects(&d)) };
assert_eq!(children.len(), 1, "the member is read"); assert_eq!(children.len(), 1, "the member is read");
let InlineObject::Shape { kind, fill, .. } = children[0].obj else { panic!("{:?}", children[0].obj) }; let InlineObject::Shape { kind, fill, .. } = children[0].obj.clone() else { panic!("{:?}", children[0].obj) };
assert_eq!(kind, ShapeKind::Diamond, "the preset comes from the shapetype's o:spt"); assert_eq!(kind, ShapeKind::Diamond, "the preset comes from the shapetype's o:spt");
assert_eq!(fill, Some(wordcraft_doc::Rgb(0x00, 0x00, 0xff)), "and the fill from its fillcolor"); assert_eq!(fill, Some(wordcraft_doc::fill::Fill::solid(wordcraft_doc::Rgb(0x00, 0x00, 0xff))), "and the fill from its fillcolor");
} }
#[test] #[test]
@@ -267,6 +267,32 @@ fn a_top_level_shape_still_finds_its_shapetype() {
r##"<v:shapetype id="_x0000_t4" o:spt="4" fillcolor="#00ff00"/>"##, r##"<v:shapetype id="_x0000_t4" o:spt="4" fillcolor="#00ff00"/>"##,
r##"<v:shape style="width:40pt;height:40pt" type="#_x0000_t4"/>"##, r##"<v:shape style="width:40pt;height:40pt" type="#_x0000_t4"/>"##,
))); )));
let InlineObject::Shape { kind, fill, .. } = objects(&d)[0] else { panic!("{:?}", objects(&d)) }; let InlineObject::Shape { kind, fill, .. } = objects(&d)[0].clone() else { panic!("{:?}", objects(&d)) };
assert_eq!((kind, fill), (ShapeKind::Diamond, Some(wordcraft_doc::Rgb(0x00, 0xff, 0x00)))); assert_eq!((kind, fill), (ShapeKind::Diamond, Some(wordcraft_doc::fill::Fill::solid(wordcraft_doc::Rgb(0x00, 0xff, 0x00)))));
}
#[test]
fn a_preset_takes_its_gradient_from_the_shapetype() {
// #1500: the shape states nothing but its shapetype; the shapetype's `v:fill` (a gradient to
// `color2`, its opacity in 65536ths) is the shape's fill, and it survives saving.
use std::sync::Arc;
use wordcraft_doc::fill::{Fill, Gradient, GradientFlip, GradientKind};
use wordcraft_doc::wordart::GradStop;
let d = read_body(&pict(concat!(
r##"<v:shapetype id="_x0000_t4" o:spt="4" fillcolor="#ff0000"><v:fill type="gradient" color2="#0000ff" opacity="32768f"/></v:shapetype>"##,
r##"<v:shape style="width:40pt;height:40pt" type="#_x0000_t4"/>"##,
)));
let stop = |pos: f32, color, transparency: f32| GradStop { pos, color, transparency };
let want = Fill::Gradient(Arc::new(Gradient {
stops: vec![stop(0.0, wordcraft_doc::Rgb(0xff, 0, 0), 50.0), stop(100.0, wordcraft_doc::Rgb(0, 0, 0xff), 0.0)],
kind: GradientKind::Linear { angle: 90.0, scaled: false },
tile: None,
rot_with_shape: true,
flip: GradientFlip::None,
}));
let saved = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
for doc in [&d, &saved] {
let InlineObject::Shape { kind, fill, .. } = objects(doc)[0].clone() else { panic!("{:?}", objects(doc)) };
assert_eq!((kind, fill), (ShapeKind::Diamond, Some(want.clone())));
}
} }
+3 -2
View File
@@ -23,13 +23,14 @@ serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
log = { workspace = true } log = { workspace = true }
chrono = { workspace = true }
regex = { workspace = true } regex = { workspace = true }
regex-automata = { workspace = true } regex-automata = { workspace = true }
image = { workspace = true } image = { workspace = true }
zeroize = { workspace = true } zeroize = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
# The browser clock for `now_iso` (`SystemTime::now()` panics on wasm32). # The browser clock for `now_iso` (`SystemTime::now()` panics on wasm32-unknown-unknown).
js-sys = "0.3" js-sys = "0.3"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+6
View File
@@ -30,6 +30,7 @@ File|Backstage|Version History|file.versions
File|Backstage|Recover Unsaved|file.recover File|Backstage|Recover Unsaved|file.recover
File|Backstage|New from Template|file.newFromTemplate File|Backstage|New from Template|file.newFromTemplate
File|Backstage|Save as Template|file.saveTemplate File|Backstage|Save as Template|file.saveTemplate
File|Backstage|Personal Templates|file.listTemplates
Quick Access|Toolbar|Undo|edit.undo Quick Access|Toolbar|Undo|edit.undo
Quick Access|Toolbar|Undo List|edit.undoTo Quick Access|Toolbar|Undo List|edit.undoTo
Quick Access|Toolbar|Redo|edit.redo Quick Access|Toolbar|Redo|edit.redo
@@ -56,6 +57,7 @@ Home|Font|Bold|format.bold
Home|Font|Italic|format.italic Home|Font|Italic|format.italic
Home|Font|Underline|format.underline Home|Font|Underline|format.underline
Home|Font|Double Underline|format.doubleUnderline Home|Font|Double Underline|format.doubleUnderline
Home|Font|Underline Color|format.underlineColor
Home|Font|Strikethrough|format.strikethrough Home|Font|Strikethrough|format.strikethrough
Home|Font|Subscript|format.subscript Home|Font|Subscript|format.subscript
Home|Font|Superscript|format.superscript Home|Font|Superscript|format.superscript
@@ -455,6 +457,8 @@ Chart Design|Chart Layouts|Chart Title|chart.title
Chart Design|Chart Layouts|Legend|chart.legend Chart Design|Chart Layouts|Legend|chart.legend
Chart Design|Chart Layouts|Data Labels|chart.dataLabels Chart Design|Chart Layouts|Data Labels|chart.dataLabels
Shape Format|Shape Styles|Shape Fill|shape.fill Shape Format|Shape Styles|Shape Fill|shape.fill
Shape Format|Shape Styles|Shape Fill Gradient|shape.fill
Shape Format|Shape Styles|Format Shape Pane|view.formatShapePane
Shape Format|Shape Styles|Shape Outline|shape.outline Shape Format|Shape Styles|Shape Outline|shape.outline
Shape Format|Shape Styles|Shape Effects|shape.effects Shape Format|Shape Styles|Shape Effects|shape.effects
Shape Format|WordArt Styles|WordArt Quick Styles|wordArt.style Shape Format|WordArt Styles|WordArt Quick Styles|wordArt.style
@@ -474,6 +478,7 @@ Editing|Keyboard|New Paragraph|text.newParagraph
Editing|Keyboard|Line Break|text.lineBreak Editing|Keyboard|Line Break|text.lineBreak
Editing|Keyboard|Column Break|text.columnBreak Editing|Keyboard|Column Break|text.columnBreak
Editing|Keyboard|Tab|text.tab Editing|Keyboard|Tab|text.tab
Editing|Keyboard|Tab Character|text.insertTab
Editing|Keyboard|Backspace|text.backspace Editing|Keyboard|Backspace|text.backspace
Editing|Keyboard|Delete|text.delete Editing|Keyboard|Delete|text.delete
Editing|Keyboard|Delete Previous Word|text.deleteWordBack Editing|Keyboard|Delete Previous Word|text.deleteWordBack
@@ -515,6 +520,7 @@ Tools|Customize|Customize Ribbon|tools.customizeRibbon
Tools|Customize|Customize Keyboard|tools.customizeKeyboard Tools|Customize|Customize Keyboard|tools.customizeKeyboard
Tools|Protection|Encrypt with Password|file.encrypt Tools|Protection|Encrypt with Password|file.encrypt
Tools|Templates|Templates and Add-ins|tools.templates Tools|Templates|Templates and Add-ins|tools.templates
Tools|Views|Full Screen View|view.fullScreenView
"; ";
/// Catalog entries: (tab, group, feature, id). /// Catalog entries: (tab, group, feature, id).
+7 -3
View File
@@ -58,7 +58,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
let w = p::f32(v, "width").unwrap_or(432.0).clamp(MIN_SIDE, MAX_SIDE); let w = p::f32(v, "width").unwrap_or(432.0).clamp(MIN_SIDE, MAX_SIDE);
let h = p::f32(v, "height").unwrap_or(216.0).clamp(MIN_SIDE, MAX_SIDE); let h = p::f32(v, "height").unwrap_or(216.0).clamp(MIN_SIDE, MAX_SIDE);
let style = CanvasStyle { let style = CanvasStyle {
fill: p::str(v, "fill").and_then(wordcraft_doc::props::Rgb::parse), fill: p::str(v, "fill").and_then(wordcraft_doc::props::Rgb::parse).map(wordcraft_doc::fill::Fill::solid),
stroke: p::str(v, "outline").and_then(wordcraft_doc::props::Rgb::parse), stroke: p::str(v, "outline").and_then(wordcraft_doc::props::Rgb::parse),
stroke_width: 0.75, stroke_width: 0.75,
}; };
@@ -275,10 +275,14 @@ mod tests {
// Fill and outline colour the canvas. // Fill and outline colour the canvas.
s.run("shape.fill", &json!({"color": "FFF2CC"})).unwrap(); s.run("shape.fill", &json!({"color": "FFF2CC"})).unwrap();
s.run("shape.outline", &json!({"color": "1F4E79", "width": 1.5})).unwrap(); s.run("shape.outline", &json!({"color": "1F4E79", "width": 1.5})).unwrap();
let style = *canvas(&s).canvas_style().unwrap(); let style = canvas(&s).canvas_style().unwrap().clone();
assert_eq!( assert_eq!(
style, style,
CanvasStyle { fill: wordcraft_doc::props::Rgb::parse("FFF2CC"), stroke: wordcraft_doc::props::Rgb::parse("1F4E79"), stroke_width: 1.5 } CanvasStyle {
fill: wordcraft_doc::props::Rgb::parse("FFF2CC").map(wordcraft_doc::fill::Fill::solid),
stroke: wordcraft_doc::props::Rgb::parse("1F4E79"),
stroke_width: 1.5
}
); );
// Members move, resize and go within the canvas. // Members move, resize and go within the canvas.
s.run("canvas.member", &json!({"index": 1, "x": 10, "y": 20, "width": 30})).unwrap(); s.run("canvas.member", &json!({"index": 1, "x": 10, "y": 20, "width": 30})).unwrap();
+27 -2
View File
@@ -378,8 +378,33 @@ fn select_sentence(s: &mut Session, _: &Value) -> CmdResult {
select_unit(s, Unit::Sentence) select_unit(s, Unit::Sentence)
} }
fn select_paragraph(s: &mut Session, _: &Value) -> CmdResult { /// A triple-click (`cell: true`) on the last paragraph of a table cell selects the cell (Word
select_unit(s, Unit::Paragraph) /// takes its end mark too, which shades it), so its borders then move on their own
/// (`table.resizeDrag`). Without `cell` (the ribbon's Select Paragraph) only the paragraph is selected.
fn select_paragraph(s: &mut Session, v: &Value) -> CmdResult {
let r = select_unit(s, Unit::Paragraph);
if v.get("cell").and_then(Value::as_bool) != Some(true) {
return r;
}
let (a, b) = (s.sel.anchor.clone(), s.sel.focus.clone());
let n = a.path.0.len();
if a.story == b.story
&& a.path.0.len() == b.path.0.len()
&& let Some(tp) = a.path.0.get(..n.saturating_sub(3))
&& !tp.is_empty()
&& a.path.0.get(..n.saturating_sub(1)) == b.path.0.get(..n.saturating_sub(1))
&& let (Some(&row), Some(&cell)) = (a.path.0.get(n.saturating_sub(3)), a.path.0.get(n.saturating_sub(2)))
{
let tp = wordcraft_doc::Path(tp.to_vec());
let (row, cell) = (row as usize, cell as usize);
let last = s.doc.table(a.story, &tp).and_then(|t| t.rows.get(row)?.cells.get(cell)).map(|c| c.blocks.len().saturating_sub(1));
let only = last.is_some_and(|l| a.path.0.last().is_some_and(|&i| i as usize == l));
let grid = s.doc.table(a.story, &tp).map(|t| super::table::grid_cols(t, row, cell));
if let (true, Some(cols)) = (only, grid) {
return super::table::select_range(s, a.story, &tp, (row, row), cols);
}
}
r
} }
/// A click in the selection bar (#856): the whole line at `pos` (default: the caret's), its /// A click in the selection bar (#856): the whole line at `pos` (default: the caret's), its
+345 -17
View File
@@ -8,6 +8,7 @@ use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop}; use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, Source, StoryRef}; use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, Source, StoryRef};
use super::references::FieldCode;
use super::{delete_selection, sel_result}; use super::{delete_selection, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
@@ -312,12 +313,9 @@ pub fn specs() -> Vec<CommandSpec> {
sel_result(s) sel_result(s)
}) })
.params(r#"{"title"?: string}"#), .params(r#"{"title"?: string}"#),
CommandSpec::new("references.tableOfFigures", "Insert Table of Figures", "References › Captions", |s, v| { CommandSpec::new("references.tableOfFigures", "Insert Table of Figures", "References › Captions", table_of_figures).params(
let label = p::str(v, "label").unwrap_or("Figure").to_string(); r#"{"label"?: "Figure|Table|Equation" or a caption label of your own, "includeLabel"?: bool, "pageNumbers"?: bool, "rightAlign"?: bool, "tabLeader"?: "dot|hyphen|underscore|none"}"#,
generated_list(s, &format!("TOC \\c \"{label}\""), "")?; ),
sel_result(s)
})
.params(r#"{"label"?: "Figure|Table|Equation"}"#),
CommandSpec::new("references.updateFigures", "Update Table", "References › Captions", |s, _| { CommandSpec::new("references.updateFigures", "Update Table", "References › Captions", |s, _| {
update_generated(s)?; update_generated(s)?;
sel_result(s) sel_result(s)
@@ -771,9 +769,43 @@ fn refresh_citations(s: &mut Session) -> Result<(), CmdError> {
Ok(()) Ok(())
} }
/// The TOC field for `references.tableOfFigures` (ECMA-376 §17.16.5.68): `\c "<label>"` lists the
/// captions that label's SEQ field numbers, `\a "<label>"` lists them without their label and
/// number; `\n` leaves page numbers out and `\p " "` puts them right after the text.
pub fn figures_instr(v: &Value) -> String {
let label: String = p::str(v, "label").unwrap_or("Figure").chars().filter(|c| !matches!(c, '"' | '\\') && !c.is_control()).take(40).collect();
let ident = seq_ident(&label);
let ident = if ident.is_empty() { "Figure".to_string() } else { ident };
let on = |k: &str| p::bool(v, k).unwrap_or(true);
let mut s = format!("TOC \\{} \"{ident}\"", if on("includeLabel") { 'c' } else { 'a' });
if !on("pageNumbers") {
s.push_str(" \\n");
} else if !on("rightAlign") {
s.push_str(" \\p \" \"");
}
s
}
/// The SEQ identifier of a caption label: Word joins a multi-word label with underscores.
fn seq_ident(label: &str) -> String {
label.split_whitespace().collect::<Vec<_>>().join("_")
}
fn table_of_figures(s: &mut Session, v: &Value) -> CmdResult {
generated_list_with(s, &figures_instr(v), "", p::str(v, "tabLeader").map(super::references::leader))?;
sel_result(s)
}
/// Generated lists (BIBLIOGRAPHY, INDEX, table of figures, TOA): a heading paragraph holding /// Generated lists (BIBLIOGRAPHY, INDEX, table of figures, TOA): a heading paragraph holding
/// the field, then entry paragraphs marked with the "GeneratedEntry" style family. /// the field, then entry paragraphs marked with the "GeneratedEntry" style family.
pub(crate) fn generated_list(s: &mut Session, instr: &str, title: &str) -> Result<(), CmdError> { pub(crate) fn generated_list(s: &mut Session, instr: &str, title: &str) -> Result<(), CmdError> {
generated_list_with(s, instr, title, None)
}
/// [`generated_list`] whose entries take `leader`. The leader lives in the entries' tab stops (as
/// in a table of contents) and an update keeps the first entry's, so a placeholder entry carries
/// it through the rebuild, however the lists above it change.
fn generated_list_with(s: &mut Session, instr: &str, title: &str, leader: Option<TabLeader>) -> Result<(), CmdError> {
let at = delete_selection(s)?; let at = delete_selection(s)?;
if at.story != StoryRef::Body || at.path.depth() > 0 { if at.story != StoryRef::Body || at.path.depth() > 0 {
return Err(CmdError::Failed("lists go in the main text".into())); return Err(CmdError::Failed("lists go in the main text".into()));
@@ -787,6 +819,11 @@ pub(crate) fn generated_list(s: &mut Session, instr: &str, title: &str) -> Resul
)?; )?;
let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) { at.path.last() } else { s.doc.split_paragraph(&at)?.path.last() }; let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) { at.path.last() } else { s.doc.split_paragraph(&at)?.path.last() };
s.doc.insert_block(StoryRef::Body, &Path::top(i), Block::Para(head))?; s.doc.insert_block(StoryRef::Body, &Path::top(i), Block::Para(head))?;
if let Some(leader) = leader {
let mut seed = Paragraph::new().styled(ENTRY_STYLE);
seed.props.tabs = Some(vec![TabStop { pos: 0.0, align: TabAlign::Right, leader }]);
s.doc.insert_block(StoryRef::Body, &Path::top(i.saturating_add(1)), Block::Para(seed))?;
}
update_generated(s) update_generated(s)
} }
@@ -794,12 +831,83 @@ fn list_kind(p: &Paragraph) -> Option<String> {
p.objects.iter().find_map(|o| match o { p.objects.iter().find_map(|o| match o {
InlineObject::Field { instr, .. } => { InlineObject::Field { instr, .. } => {
let t = instr.trim_start(); let t = instr.trim_start();
(t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || t.starts_with("TOC \\c")).then(|| t.to_string()) (t.starts_with("BIBLIOGRAPHY") || t.starts_with("INDEX") || t.starts_with("TOA") || figures_label(t).is_some()).then(|| t.to_string())
} }
_ => None, _ => None,
}) })
} }
/// The caption label (SEQ identifier) a table-of-figures field lists, `\c` or `\a`.
fn figures_label(instr: &str) -> Option<String> {
let f = FieldCode::parse(instr);
if !f.is_table_of_figures() {
return None;
}
Some(f.arg('c').or_else(|| f.arg('a')).map_or_else(|| "Figure".to_string(), seq_ident))
}
/// Whether `p` is a caption numbered by SEQ identifier `ident` (any case, as Word matches it). A
/// caption typed by hand, without a SEQ field, goes by its label.
fn captioned_as(p: &Paragraph, ident: &str) -> bool {
if p.props.style.as_deref() != Some("Caption") {
return false;
}
let seq = p.objects.iter().find_map(|o| match o {
InlineObject::Field { instr, .. } => Some(FieldCode::parse(instr)).filter(|f| f.name == "SEQ"),
_ => None,
});
match seq {
Some(f) => f.args.first().is_some_and(|id| id.eq_ignore_ascii_case(ident)),
None => caption_label(p, &p.plain_text()).is_some_and(|l| seq_ident(&l).eq_ignore_ascii_case(ident)),
}
}
/// A caption's text without its label and number (`\a`): what follows its SEQ field, or for a
/// caption typed by hand what follows the label and the number, less the separator.
fn caption_body(p: &Paragraph, label: &str) -> String {
let seq_end = p.object_offsets().into_iter().find_map(|o| match p.object_at(o) {
Some(InlineObject::Field { instr, .. }) if FieldCode::parse(instr).name == "SEQ" => Some(o + wordcraft_doc::para::OBJ.len_utf8()),
_ => None,
});
let rest = match seq_end {
Some(end) => {
let mut k = 0;
let mut rest = String::new();
for (at, c) in p.text.char_indices() {
if c == wordcraft_doc::para::OBJ {
if at >= end
&& let Some(o) = p.objects.get(k)
{
rest.push_str(o.plain_text());
}
k += 1;
} else if at >= end {
rest.push(c);
}
}
rest
}
None => {
let text = p.plain_text();
// The label in any case, as captions are matched (ASCII case keeps the byte length).
let start = text.trim_start();
let after = start
.get(..label.len())
.filter(|head| head.eq_ignore_ascii_case(label))
.and_then(|_| start.get(label.len()..))
.unwrap_or(&text)
.trim_start();
// The number: the word after the label, when it starts with a digit.
match after.find(char::is_whitespace) {
Some(n) if after.starts_with(|c: char| c.is_ascii_digit()) => after[n..].to_string(),
_ if after.starts_with(|c: char| c.is_ascii_digit()) => String::new(),
_ => after.to_string(),
}
}
};
rest.trim_start_matches(|c: char| c.is_whitespace() || matches!(c, ':' | '.' | '-' | '\u{2013}' | '\u{2014}')).trim_end().to_string()
}
const ENTRY_STYLE: &str = "ListEntry"; const ENTRY_STYLE: &str = "ListEntry";
/// Rebuild every generated list. /// Rebuild every generated list.
@@ -817,7 +925,38 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
i += 1; i += 1;
continue; continue;
} }
// Remove old entries. // A table of figures from Word can hold the list it saved as the field's result, in the
// field's own paragraph: the rebuilt entries replace it, keeping its leader.
let mut saved_leader = None;
if figures_label(&kind).is_some()
&& let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(i))
{
let leader = p.props.tabs.as_ref().and_then(|t| t.last()).map(|t| t.leader);
let mut cleared = false;
for o in &mut p.objects {
if let InlineObject::Field { instr, result, .. } = o
&& !result.is_empty()
&& figures_label(instr).is_some()
{
result.clear();
cleared = true;
}
}
if cleared {
saved_leader = leader;
p.touch();
}
}
// Remove old entries, keeping their tab leader.
let leader = s
.doc
.body
.get(i + 1)
.and_then(|b| b.as_para())
.filter(|p| p.props.style.as_deref() == Some(ENTRY_STYLE))
.and_then(|p| p.props.tabs.as_ref()?.last().map(|t| t.leader))
.or(saved_leader)
.unwrap_or(TabLeader::Dot);
while s.doc.body.get(i + 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some(ENTRY_STYLE)) { while s.doc.body.get(i + 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some(ENTRY_STYLE)) {
s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?; s.doc.remove_block(StoryRef::Body, &Path::top(i + 1))?;
} }
@@ -830,7 +969,7 @@ pub fn update_generated(s: &mut Session) -> Result<(), CmdError> {
para.props.indent_first = Some(-36.0); para.props.indent_first = Some(-36.0);
} }
if text.contains('\t') { if text.contains('\t') {
para.props.tabs = Some(vec![TabStop { pos: width - 0.5, align: TabAlign::Right, leader: TabLeader::Dot }]); para.props.tabs = Some(vec![TabStop { pos: width - 0.5, align: TabAlign::Right, leader }]);
} }
s.doc.insert_block(StoryRef::Body, &Path::top(i + 1 + k), Block::Para(para))?; s.doc.insert_block(StoryRef::Body, &Path::top(i + 1 + k), Block::Para(para))?;
} }
@@ -874,12 +1013,14 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
} }
// Collect marks with positions. // Collect marks with positions.
let mut marks: Vec<(String, Pos)> = Vec::new(); let mut marks: Vec<(String, Pos)> = Vec::new();
let caption_label = kind.split('"').nth(1).unwrap_or("Figure").to_string(); let figures = figures_label(kind);
let bare = FieldCode::parse(kind).has('a');
for path in s.doc.para_paths(StoryRef::Body) { for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
if kind.starts_with("TOC \\c") { if let Some(ident) = &figures {
if p.props.style.as_deref() == Some("Caption") && p.plain_text().starts_with(&caption_label) { if captioned_as(p, ident) {
marks.push((p.plain_text(), Pos { story: StoryRef::Body, path: path.clone(), off: 0 })); let text = if bare { caption_body(p, &ident.replace('_', " ")) } else { p.plain_text() };
marks.push((text, Pos { story: StoryRef::Body, path: path.clone(), off: 0 }));
} }
continue; continue;
} }
@@ -900,8 +1041,8 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
let mut v = Vec::new(); let mut v = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) { for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
if kind.starts_with("TOC \\c") { if let Some(ident) = &figures {
if p.props.style.as_deref() == Some("Caption") && p.plain_text().starts_with(&caption_label) { if captioned_as(p, ident) {
v.push(Pos { story: StoryRef::Body, path: path.clone(), off: 0 }); v.push(Pos { story: StoryRef::Body, path: path.clone(), off: 0 });
} }
continue; continue;
@@ -923,8 +1064,11 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
x.1 = pg; x.1 = pg;
} }
} }
if kind.starts_with("TOC \\c") { if figures.is_some() {
return with_pages.into_iter().map(|(t, p)| (format!("{t}\t{p}"), false)).collect(); // `\n` without page numbers; `\p` the separator before them (a tab by default).
let f = FieldCode::parse(kind);
let sep = f.arg('p').unwrap_or("\t");
return with_pages.into_iter().map(|(t, p)| (if f.has('n') { t } else { format!("{t}{sep}{p}") }, false)).collect();
} }
// Index / TOA: group by entry, sort, list pages. // Index / TOA: group by entry, sort, list pages.
let lang = super::default_lang(s); let lang = super::default_lang(s);
@@ -1297,6 +1441,190 @@ mod tests {
assert!(s.doc.plain_text(StoryRef::Body).ends_with("See Photo Plate I"), "{}", s.doc.plain_text(StoryRef::Body)); assert!(s.doc.plain_text(StoryRef::Body).ends_with("See Photo Plate I"), "{}", s.doc.plain_text(StoryRef::Body));
} }
/// The entries of every generated list, in order, with their last tab stop's leader.
fn list_entries(s: &Session) -> Vec<(String, Option<TabLeader>)> {
s.doc
.body
.iter()
.filter_map(|b| b.as_para())
.filter(|p| p.props.style.as_deref() == Some(ENTRY_STYLE))
.map(|p| (p.plain_text(), p.props.tabs.as_ref().and_then(|t| t.last()).map(|t| t.leader)))
.collect()
}
/// #3013: a table of figures for any caption label, so a document can have a list of figures
/// and a list of tables; each keeps its own label and leader when updated.
#[test]
fn table_of_figures_for_each_caption_label() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Kiln\nPress\nRock\nTablet\n"));
for (text, label) in [("Kiln", "Figure"), ("Press", "Table"), ("Rock", "Photo Plate"), ("Tablet", "Figure")] {
s.run("select.text", &json!({"text": text})).unwrap();
s.run("references.caption", &json!({"label": label, "text": text})).unwrap();
}
s.run("caret.docEnd", &json!({})).unwrap();
s.run("references.tableOfFigures", &json!({"label": "Table", "tabLeader": "hyphen"})).unwrap();
assert_eq!(list_entries(&s), [("Table 1: Press\t1".to_string(), Some(TabLeader::Hyphen))]);
s.run("caret.docEnd", &json!({})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("references.tableOfFigures", &json!({"label": "Photo Plate"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("references.tableOfFigures", &json!({})).unwrap();
let instrs: Vec<String> = fields(&s).into_iter().map(|(i, _)| i).filter(|i| i.starts_with("TOC")).collect();
assert_eq!(instrs, [r#"TOC \c "Table""#, r#"TOC \c "Photo_Plate""#, r#"TOC \c "Figure""#]);
let want = [
("Table 1: Press\t1", TabLeader::Hyphen),
("Photo Plate 1: Rock\t1", TabLeader::Dot),
("Figure 1: Kiln\t1", TabLeader::Dot),
("Figure 2: Tablet\t1", TabLeader::Dot),
];
let want: Vec<(String, Option<TabLeader>)> = want.iter().map(|(t, l)| (t.to_string(), Some(*l))).collect();
assert_eq!(list_entries(&s), want);
// Update Table rebuilds each list with its own label and keeps the leader.
s.run("references.updateFigures", &json!({})).unwrap();
assert_eq!(list_entries(&s), want);
// A table of figures is not the table of contents.
assert!(s.run("references.updateToc", &json!({})).is_ok());
assert_eq!(list_entries(&s), want);
}
/// #3013: Insert Table of Figures' options as TOC switches: `\a` without the label and number,
/// `\n` without page numbers, `\p " "` with the page right after the text.
#[test]
fn table_of_figures_options() {
assert_eq!(figures_instr(&json!({})), r#"TOC \c "Figure""#);
assert_eq!(figures_instr(&json!({"label": "Table", "includeLabel": false})), r#"TOC \a "Table""#);
assert_eq!(figures_instr(&json!({"pageNumbers": false})), r#"TOC \c "Figure" \n"#);
assert_eq!(figures_instr(&json!({"rightAlign": false})), r#"TOC \c "Figure" \p " ""#);
assert_eq!(figures_instr(&json!({"label": "\"\\ "})), r#"TOC \c "Figure""#, "an empty label is Figure");
for (params, entry) in [
(json!({"includeLabel": false}), "The kiln\t1"),
(json!({"pageNumbers": false}), "Figure 1: The kiln"),
(json!({"rightAlign": false, "includeLabel": false}), "The kiln 1"),
] {
let mut s = Session::new(wordcraft_doc::Document::from_text("Kiln\n"));
s.run("select.text", &json!({"text": "Kiln"})).unwrap();
s.run("references.caption", &json!({"label": "Figure", "text": "The kiln"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("references.tableOfFigures", &params).unwrap();
let entries = list_entries(&s);
assert_eq!(entries.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(), [entry], "{params}");
assert_eq!(entries.first().and_then(|(_, l)| *l).is_some(), entry.contains('\t'), "a tab stop only for a tab");
}
}
/// #3013: a table of figures from Word (`TOC \h \z \c "Figure"`, switches in any order) and
/// hand-typed captions are listed (the label in any case); a caption whose label only starts
/// with another's is not.
#[test]
fn table_of_figures_from_word_and_typed_captions() {
let mut d = wordcraft_doc::Document::from_text("Figure 1: Typed\nFigures 2: Not a figure\nFigure 3 \u{2014} Dashed\nfigure 4: Lower\nList");
for path in d.para_paths(StoryRef::Body).into_iter().take(4) {
d.para_mut(StoryRef::Body, &path).unwrap().props.style = Some("Caption".into());
}
let mut s = Session::new(d);
s.run("caret.docEnd", &json!({})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
let at = s.sel.focus.clone();
let field = InlineObject::Field {
instr: r#"TOC \h \z \a "Figure""#.into(),
result: String::new(),
locked: false,
dirty: false,
code: false,
form: String::new(),
};
s.doc.insert_object(&at, field, &CharProps::default()).unwrap();
s.run("references.updateFigures", &json!({})).unwrap();
let entries: Vec<String> = list_entries(&s).into_iter().map(|(t, _)| t).collect();
assert_eq!(entries, ["Typed\t1", "Dashed\t1", "Lower\t1"]);
}
/// #3013 review: the leader asked for lands on the new list even when a list above it is out
/// of date and gains entries (moving the new one down) while every list is rebuilt.
#[test]
fn table_of_figures_leader_survives_a_stale_list_above() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Kiln\nPress\nRock\n"));
for (text, label) in [("Kiln", "Figure"), ("Press", "Table")] {
s.run("select.text", &json!({"text": text})).unwrap();
s.run("references.caption", &json!({"label": label, "text": text})).unwrap();
}
s.run("caret.docEnd", &json!({})).unwrap();
s.run("references.tableOfFigures", &json!({})).unwrap();
s.run("select.text", &json!({"text": "Rock"})).unwrap();
s.run("references.caption", &json!({"label": "Figure", "text": "Rock"})).unwrap();
assert_eq!(list_entries(&s).len(), 1, "the list of figures is out of date");
s.run("caret.docEnd", &json!({})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("references.tableOfFigures", &json!({"label": "Table", "tabLeader": "hyphen"})).unwrap();
let want = [("Figure 1: Kiln\t1", TabLeader::Dot), ("Figure 2: Rock\t1", TabLeader::Dot), ("Table 1: Press\t1", TabLeader::Hyphen)];
let want: Vec<(String, Option<TabLeader>)> = want.iter().map(|(t, l)| (t.to_string(), Some(*l))).collect();
assert_eq!(list_entries(&s), want);
}
/// #3013 review: a table of figures from Word whose saved list sits in the field's result (one
/// paragraph) is replaced by the rebuilt entries, not listed twice, and keeps its leader.
#[test]
fn table_of_figures_from_word_replaces_its_saved_list() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Kiln\nList"));
s.run("select.text", &json!({"text": "Kiln"})).unwrap();
s.run("references.caption", &json!({"label": "Figure", "text": "Kiln"})).unwrap();
let last = s.doc.body.len() - 1;
let p = s.doc.para_mut(StoryRef::Body, &Path::top(last)).unwrap();
p.props.style = Some("TableofFigures".into());
p.props.tabs = Some(vec![TabStop { pos: 400.0, align: TabAlign::Right, leader: TabLeader::Hyphen }]);
let end = p.len();
p.delete(0, end).unwrap();
let field = InlineObject::Field {
instr: r#"TOC \h \z \c "Figure""#.into(),
result: "Figure 1: Kiln\t1".into(),
locked: false,
dirty: false,
code: false,
form: String::new(),
};
p.insert_object(0, field, &CharProps::default()).unwrap();
for _ in 0..2 {
s.run("references.updateFigures", &json!({})).unwrap();
let text = s.doc.plain_text(StoryRef::Body);
assert_eq!(text.matches("Figure 1: Kiln\t1").count(), 1, "{text:?}");
assert_eq!(list_entries(&s), [("Figure 1: Kiln\t1".to_string(), Some(TabLeader::Hyphen))]);
}
}
/// #3013 review: `\c` picks captions by their SEQ identifier, as Word does, not by the text
/// before the number (`STYLEREF`, " Specimen ", `SEQ Figure` is a figure).
#[test]
fn table_of_figures_goes_by_the_seq_identifier() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Rock\n"));
let field = |instr: &str| InlineObject::Field {
instr: instr.into(),
result: String::new(),
locked: false,
dirty: false,
code: false,
form: String::new(),
};
let plain = CharProps::default();
let mut imported = Paragraph::new().styled("Caption");
imported.insert_object(0, field("STYLEREF 1 \\s"), &plain).unwrap();
imported.insert_text(imported.len(), " Specimen ", &plain).unwrap();
imported.insert_object(imported.len(), field("SEQ Figure \\* ARABIC"), &plain).unwrap();
imported.insert_text(imported.len(), ": A rock", &plain).unwrap();
s.doc.insert_block(StoryRef::Body, &Path::top(1), Block::Para(imported)).unwrap();
for (label, listed) in [("Figure", true), ("Specimen", false)] {
let mut s = Session::new(s.doc.clone());
s.run("caret.docEnd", &json!({})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("references.tableOfFigures", &json!({"label": label, "includeLabel": false})).unwrap();
let entries: Vec<String> = list_entries(&s).into_iter().map(|(t, _)| t).collect();
assert_eq!(entries.len(), usize::from(listed), "{label}: {entries:?}");
if listed {
assert_eq!(entries.first().map(|t| t.split('\t').next()), Some(Some("A rock")));
}
}
}
/// #2940: a caption may start with its chapter number (`STYLEREF`) and carry its label text /// #2940: a caption may start with its chapter number (`STYLEREF`) and carry its label text
/// between that and the `SEQ` number; the text is the label, not the `SEQ` identifier. /// between that and the `SEQ` number; the text is the label, not the `SEQ` identifier.
#[test] #[test]
+7
View File
@@ -166,6 +166,13 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
{ {
return r; return r;
} }
// Delete (and Backspace that doesn't delete them) empties whole cells to one paragraph each,
// as in Word (#2802).
if matches!(id, "text.delete" | "text.backspace" | "text.deleteWordBack" | "text.deleteWordForward")
&& let Some(r) = super::table::clear_cells(s)
{
return r;
}
let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) }; let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) };
let cells = s.cell_range().is_some(); let cells = s.cell_range().is_some();
match id { match id {
+38 -12
View File
@@ -123,23 +123,33 @@ pub fn parse_date(t: &str) -> Option<(i64, u32, u32)> {
} }
pub fn days_in_month(y: i64, m: u32) -> u32 { pub fn days_in_month(y: i64, m: u32) -> u32 {
match m { // The length of a month is the day before the first of the next month. Month lengths repeat
2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, // every 400 years, so the year within its Gregorian cycle (`rem_euclid(400)`) is enough and
2 => 28, // always fits chrono, for any `y`. An invalid month keeps the old 31-day default.
4 | 6 | 9 | 11 => 30, let y = y.rem_euclid(400);
let first = |y: i64, m: u32| i32::try_from(y).ok().and_then(|y| chrono::NaiveDate::from_ymd_opt(y, m, 1));
match (first(y, m), if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }) {
(Some(a), Some(b)) => u32::try_from((b - a).num_days()).unwrap_or(31),
_ => 31, _ => 31,
} }
} }
/// Days since 1970-01-01 for a date (inverse of [`super::civil_from_days`]). /// Days since 1970-01-01 for a date (inverse of [`super::civil_from_days`]), exact for any year.
///
/// The Gregorian calendar repeats every 400 years (146_097 days): chrono converts the year
/// within its cycle and whole cycles are added back as days (an i128 so the extremes can't
/// overflow). 1970-01-01 is `num_days_from_ce` 719_163; the result matches the proleptic
/// Gregorian count the old hand-rolled code gave.
pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { pub fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
let y = if m <= 2 { y - 1 } else { y }; let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400));
let era = if y >= 0 { y } else { y - 399 } / 400; i32::try_from(year)
let yoe = y - era * 400; .ok()
let mp = i64::from((m + 9) % 12); .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d))
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; .and_then(|date| {
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; let days = i128::from(chrono::Datelike::num_days_from_ce(&date)) - 719_163 + cycles * 146_097;
era * 146_097 + doe - 719_468 i64::try_from(days).ok()
})
.unwrap_or(0)
} }
/// The built-in name of the placeholder style. Its id is localized: German Word writes /// The built-in name of the placeholder style. Its id is localized: German Word writes
@@ -750,6 +760,22 @@ mod tests {
assert_eq!(days_from_civil(1970, 1, 1), 0); assert_eq!(days_from_civil(1970, 1, 1), 0);
assert_eq!(super::super::civil_from_days(days_from_civil(2000, 3, 1)), (2000, 3, 1)); assert_eq!(super::super::civil_from_days(days_from_civil(2000, 3, 1)), (2000, 3, 1));
} }
#[test]
fn days_from_civil_and_days_in_month_exact_beyond_chronos_range() {
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the result; values from the
// pre-chrono formula, both directions, both sides of the epoch.
for (y, m, d, days) in
[(262_144i64, 1u32, 1u32, 95_026_602i64), (273_022, 10, 18, 99_000_000), (-262_145, 3, 1, -96_465_964), (-271_821, 4, 21, -99_999_999)]
{
assert_eq!(days_from_civil(y, m, d), days, "{y}-{m}-{d}");
assert_eq!(super::super::civil_from_days(days), (y, m, d), "{days}");
}
assert_eq!(days_in_month(272_952, 2), 29, "272952 is a leap year");
assert_eq!(days_in_month(-269_153, 2), 28);
// The extremes must not overflow or panic (i128 intermediates / `rem_euclid`).
let _ = (days_from_civil(i64::MAX, 12, 31), days_from_civil(i64::MIN, 1, 1), days_in_month(i64::MAX, 2), days_in_month(i64::MIN, 2));
}
} }
#[cfg(test)] #[cfg(test)]
+11
View File
@@ -670,7 +670,17 @@ fn step(s: &mut Session, dir: i64) -> CmdResult {
Ok(json!({"index": idx, "count": n})) Ok(json!({"index": idx, "count": n}))
} }
/// Replace needs something to look for: the `text` given, or the last search's (Find then Replace
/// with just a `with`). Neither is a call that would silently replace nothing (a misspelt `text`).
fn need_query(s: &Session, v: &Value) -> Result<(), CmdError> {
if p::str(v, "text").is_none() && s.find.query.is_empty() {
return Err(CmdError::Params("`text` (string) is required".into()));
}
Ok(())
}
fn replace(s: &mut Session, v: &Value) -> CmdResult { fn replace(s: &mut Session, v: &Value) -> CmdResult {
need_query(s, v)?;
read_opts(s, v); read_opts(s, v);
let story = s.sel.focus.story; let story = s.sel.focus.story;
let results = search_full(s, story, true)?; let results = search_full(s, story, true)?;
@@ -686,6 +696,7 @@ fn replace(s: &mut Session, v: &Value) -> CmdResult {
/// Every match replaced, last first so earlier positions stay valid (one undo step). A `\r` in /// Every match replaced, last first so earlier positions stay valid (one undo step). A `\r` in
/// the replacement (`^p`) splits the paragraph. /// the replacement (`^p`) splits the paragraph.
fn replace_all(s: &mut Session, v: &Value) -> CmdResult { fn replace_all(s: &mut Session, v: &Value) -> CmdResult {
need_query(s, v)?;
read_opts(s, v); read_opts(s, v);
let story = s.sel.focus.story; let story = s.sel.focus.story;
let results = search_full(s, story, true)?; let results = search_full(s, story, true)?;
+49 -1
View File
@@ -193,6 +193,7 @@ fn shown_text(s: &mut Session, f: &FieldAt) -> String {
.map(|p| l.pages.iter().filter(|pg| pg.section == p.section).count() as u32) .map(|p| l.pages.iter().filter(|pg| pg.section == p.section).count() as u32)
.unwrap_or_else(|| u32::try_from(l.pages.len()).unwrap_or(u32::MAX)); .unwrap_or_else(|| u32::try_from(l.pages.len()).unwrap_or(u32::MAX));
let page_format = page.and_then(|p| s.doc.sections().get(p.section).map(|(_, sec)| sec.page_num_format)).unwrap_or_default(); let page_format = page.and_then(|p| s.doc.sections().get(p.section).map(|(_, sec)| sec.page_num_format)).unwrap_or_default();
let (fn_name, fn_path) = s.resolved_filenames();
let ctx = wordcraft_layout::fields::FieldCtx { let ctx = wordcraft_layout::fields::FieldCtx {
page: page.map(|p| p.number).unwrap_or(1), page: page.map(|p| p.number).unwrap_or(1),
page_format, page_format,
@@ -201,7 +202,8 @@ fn shown_text(s: &mut Session, f: &FieldAt) -> String {
section, section,
title: s.doc.core.title.as_str().into(), title: s.doc.core.title.as_str().into(),
author: s.doc.core.creator.as_str().into(), author: s.doc.core.creator.as_str().into(),
filename: s.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_default().into(), filename: fn_name.into(),
filepath: fn_path.into(),
..Default::default() ..Default::default()
}; };
wordcraft_layout::fields::field_text(&instr, &result, &ctx).0 wordcraft_layout::fields::field_text(&instr, &result, &ctx).0
@@ -283,6 +285,52 @@ mod tests {
s s
} }
/// #1798: Update Field evaluates `DOCPROPERTY` against custom and built-in document
/// properties (with a `\* MERGEFORMAT` switch), instead of leaving the cached result.
#[test]
fn update_fields_evaluates_docproperty() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("file.properties", &json!({"custom": {"ProjectName": "Atlas"}})).unwrap();
s.run("insert.field", &json!({"instr": "DOCPROPERTY \"ProjectName\" \\* MERGEFORMAT", "result": "stale"})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
s.doc.core.title = "Report".into();
s.run("insert.field", &json!({"instr": "DOCPROPERTY Title", "result": ""})).unwrap();
s.run("references.updateFields", &json!({})).unwrap();
let results: Vec<String> = s
.doc
.body
.first()
.and_then(|b| b.as_para())
.map(|p| {
p.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.clone()),
_ => None,
})
.collect()
})
.unwrap_or_default();
assert_eq!(results, ["Atlas", "Report"]);
}
/// `DOCPROPERTY LastSavedBy`, Word's name for the last author, reads the last-modified-by
/// property.
#[test]
fn update_fields_evaluates_docproperty_last_saved_by() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.doc.core.last_modified_by = "Ada".into();
s.run("insert.field", &json!({"instr": "DOCPROPERTY LastSavedBy", "result": "stale"})).unwrap();
s.run("references.updateFields", &json!({})).unwrap();
let result = s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| {
p.objects.iter().find_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.clone()),
_ => None,
})
});
assert_eq!(result.as_deref(), Some("Ada"));
}
fn page_text(s: &mut Session) -> String { fn page_text(s: &mut Session) -> String {
let l = s.layout(); let l = s.layout();
let mut out = String::new(); let mut out = String::new();
+30 -6
View File
@@ -45,8 +45,16 @@ fn rename(s: &mut Session, v: &Value) -> CmdResult {
if let Some(path) = &s.path { if let Some(path) = &s.path {
let mut destination = path.with_file_name(name); let mut destination = path.with_file_name(name);
// The field edits the stem; retain the original format, including macro/template files. // The field edits the stem; retain the original format, including macro/template files.
// A name that already ends in that extension is taken as the whole file name, since a
// script or an agent passes one ("renamed.docx" became "renamed.docx.docx", #2983). The
// extension alone (".docx") is a stem, not a file name, so it still gets one.
if let Some(ext) = path.extension() { if let Some(ext) = path.extension() {
destination.set_file_name(format!("{name}.{}", ext.to_string_lossy())); let ext = ext.to_string_lossy();
// Split at the name's own last dot rather than counting bytes back from its end: a
// name whose characters are more than one byte each ("résumé.docm") would be cut
// inside one of them, and slicing there panics (#3128 review).
let given = name.rsplit_once('.').is_some_and(|(stem, suffix)| !stem.is_empty() && suffix.eq_ignore_ascii_case(ext.as_ref()));
destination.set_file_name(if given { name.to_string() } else { format!("{name}.{ext}") });
} }
if destination != *path { if destination != *path {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@@ -54,16 +62,18 @@ fn rename(s: &mut Session, v: &Value) -> CmdResult {
crate::io::rename_file(path, &destination).map_err(CmdError::Failed)?; crate::io::rename_file(path, &destination).map_err(CmdError::Failed)?;
} }
s.path = Some(destination); s.path = Some(destination);
s.relayout();
} }
} else { } else {
s.file_name = Some(name.to_string()); s.file_name = Some(name.to_string());
s.relayout();
} }
Ok(json!({"name": name, "path": s.path})) Ok(json!({"name": name, "path": s.path}))
} }
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
CommandSpec::new("file.rename", "Rename Document", "File › Info", rename).params(r#"{"name": string (document name without its extension)}"#).pure(), CommandSpec::new("file.rename", "Rename Document", "File › Info", rename).params(r#"{"name": string (document name; its extension is kept, so the name may be given with or without it)}"#).pure(),
CommandSpec::new("file.new", "New", "File", new).key("Mod+N").params(r#"{"template"?: "blank|sample|letter|resume|report"}"#).pure(), CommandSpec::new("file.new", "New", "File", new).key("Mod+N").params(r#"{"template"?: "blank|sample|letter|resume|report"}"#).pure(),
CommandSpec::new("file.open", "Open", "File", open) CommandSpec::new("file.open", "Open", "File", open)
.key("Mod+O / Mod+F12 / Mod+Alt+F2") .key("Mod+O / Mod+F12 / Mod+Alt+F2")
@@ -182,6 +192,7 @@ fn privacy(s: &Session) -> Value {
json!({"removePersonalInfo": s.doc.settings.remove_personal_info, "removeDateTime": s.doc.settings.remove_date_time}) json!({"removePersonalInfo": s.doc.settings.remove_personal_info, "removeDateTime": s.doc.settings.remove_date_time})
} }
/// Create a blank document in the saved proofing language, or use the requested template.
fn new(s: &mut Session, v: &Value) -> CmdResult { fn new(s: &mut Session, v: &Value) -> CmdResult {
let doc = match p::str(v, "template").unwrap_or("blank") { let doc = match p::str(v, "template").unwrap_or("blank") {
"sample" => crate::sample::sample_document(), "sample" => crate::sample::sample_document(),
@@ -199,12 +210,13 @@ fn new(s: &mut Session, v: &Value) -> CmdResult {
/// language (the templates' text is English). /// language (the templates' text is English).
fn normal_document(s: &Session) -> Document { fn normal_document(s: &Session) -> Document {
let mut d = s.blank_document(); let mut d = s.blank_document();
if let Some(lang) = &s.editing_lang { if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref()) {
d.styles.default_chr.lang = Some(lang.clone()); d.styles.default_chr.lang = Some(lang.clone());
} }
d d
} }
/// Open a document, using the saved proofing default only for plain text and Markdown.
fn open(s: &mut Session, v: &Value) -> CmdResult { fn open(s: &mut Session, v: &Value) -> CmdResult {
let Some(path) = p::str(v, "path") else { let Some(path) = p::str(v, "path") else {
s.ui_requests.push(json!({"open": "openFile"})); s.ui_requests.push(json!({"open": "openFile"}));
@@ -227,7 +239,7 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
}; };
// Plain text and Markdown carry no language: they are in the editing language. // Plain text and Markdown carry no language: they are in the editing language.
let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); let ext = std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
if let Some(lang) = &s.editing_lang if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown") && matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
{ {
doc.styles.default_chr.lang = Some(lang.clone()); doc.styles.default_chr.lang = Some(lang.clone());
@@ -297,15 +309,27 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
return Err(CmdError::Params(format!("`format` is \"{FORMAT_WORD_2003}\" or \"{FORMAT_FLAT_OPC}\", and only for an .xml file"))); return Err(CmdError::Params(format!("`format` is \"{FORMAT_WORD_2003}\" or \"{FORMAT_FLAT_OPC}\", and only for an .xml file")));
} }
} }
// Undone below if the write fails: a failed save (no permission, disk full, a bad path)
// must not leave the document's own save stamps — zoom, modified/created, author, revision —
// changed, or a later save would persist them for a write that never actually happened.
let before_stamp =
(s.doc.settings.zoom, s.doc.core.last_modified_by.clone(), s.doc.core.created.clone(), s.doc.core.modified.clone(), s.doc.core.revision);
s.stamp_save(); s.stamp_save();
let password = s.password.as_ref().map(crate::io::Password::as_str); let password = s.password.as_ref().map(crate::io::Password::as_str);
// A header/footer just made and left empty isn't saved (#960). // A header/footer just made and left empty isn't saved (#960).
let copy = super::insert::saved_copy(s); let copy = super::insert::saved_copy(s);
crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts).map_err(CmdError::Failed)?; if let Err(e) = crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts) {
(s.doc.settings.zoom, s.doc.core.last_modified_by, s.doc.core.created, s.doc.core.modified, s.doc.core.revision) = before_stamp;
return Err(CmdError::Failed(e));
}
let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy()); let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy());
if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) { // A save in a format that doesn't keep everything (plain text, Markdown, a page image) is a
// copy, except over the document's own file: a note.txt opened and saved is saved (#2376).
let own_file = s.path.as_deref() == Some(path.as_path());
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) {
s.path = Some(path.clone()); s.path = Some(path.clone());
s.dirty = false; s.dirty = false;
s.relayout();
} }
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted})) Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted}))
} }
+49
View File
@@ -106,6 +106,16 @@ fn code_char(n: u32) -> Option<char> {
}) })
} }
/// A Windows-1252 code as its Unicode scalar: 128–159 hold typographic characters there (151 is
/// an em dash); the five undefined slots and every other code stay as they are.
fn ansi(n: u32) -> u32 {
const HIGH: [u32; 32] = [
0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018,
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178,
];
n.checked_sub(128).and_then(|k| HIGH.get(k as usize)).copied().unwrap_or(n)
}
/// Read a numeric code after `^` (`13`, `0151`, `u8212`) starting at `chars[i]`; returns the /// Read a numeric code after `^` (`13`, `0151`, `u8212`) starting at `chars[i]`; returns the
/// character and how many characters it took. /// character and how many characters it took.
fn numeric_code(chars: &[char], i: usize) -> Option<(char, usize)> { fn numeric_code(chars: &[char], i: usize) -> Option<(char, usize)> {
@@ -115,6 +125,8 @@ fn numeric_code(chars: &[char], i: usize) -> Option<(char, usize)> {
return None; return None;
} }
let n: u32 = digits.parse().ok()?; let n: u32 = digits.parse().ok()?;
// `^0nnn` is a Windows-1252 (ANSI) code; `^nnn` and `^unnnn` are Unicode.
let n = if start == i && digits.starts_with('0') { ansi(n) } else { n };
Some((code_char(n)?, start - i + digits.chars().count())) Some((code_char(n)?, start - i + digits.chars().count()))
} }
@@ -415,3 +427,40 @@ pub fn expand(pieces: &[Piece], found: &str, group: &dyn Fn(usize) -> Option<Str
} }
out out
} }
#[cfg(test)]
mod tests {
use super::*;
fn finds(query: &str, hay: &str) -> bool {
let c = compile(query, &Opts::default()).unwrap();
regex::Regex::new(&c.pattern).unwrap().is_match(hay)
}
fn replaced(with: &str) -> String {
expand(&parse_replace(with, &Opts::default()), "", &|_| None, "")
}
#[test]
fn ansi_codes_128_to_159_are_windows_1252() {
for (code, ch) in [("^0151", '\u{2014}'), ("^0150", '\u{2013}'), ("^0128", '\u{20AC}'), ("^0145", '\u{2018}')] {
let hay = format!("a{ch}b");
assert!(finds(code, &hay), "{code} should find {ch:?}");
assert!(!finds(code, code), "{code} should not match its literal text");
let w = Opts { wildcards: true, ..Opts::default() };
let c = compile(code, &w).unwrap();
assert!(regex::Regex::new(&c.pattern).unwrap().is_match(&hay), "wildcard {code} should find {ch:?}");
}
assert_eq!(replaced("x^0151y"), "x\u{2014}y");
assert_eq!(replaced("^0128"), "\u{20AC}");
// Unchanged: other codes and the undefined Windows-1252 slots.
assert!(finds("^13", "a\rb"));
assert!(finds("^0065", "A"));
for undefined in ["^0129", "^0141", "^0143", "^0144", "^0157"] {
assert_eq!(replaced(undefined), undefined);
}
// Without the leading zero the code is not read as Windows-1252.
assert!(!finds("^151", "\u{2014}"));
assert!(!finds("^150", "\u{2013}"));
}
}
+32 -1
View File
@@ -12,6 +12,7 @@ use std::sync::Arc;
use serde::Serialize; use serde::Serialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use wordcraft_doc::props::VMerge;
use wordcraft_doc::props::{CellProps, CharProps, ParaProps, PropChange, RowProps, TableProps}; use wordcraft_doc::props::{CellProps, CharProps, ParaProps, PropChange, RowProps, TableProps};
use wordcraft_doc::section::SectionProps; use wordcraft_doc::section::SectionProps;
use wordcraft_doc::table::{Cell, Row, Table}; use wordcraft_doc::table::{Cell, Row, Table};
@@ -146,15 +147,30 @@ impl Recorder<'_> {
(Block::Para(pb), Block::Para(pa)) if pb.text == pa.text => { (Block::Para(pb), Block::Para(pa)) if pb.text == pa.text => {
if let Block::Para(pa) = Arc::make_mut(a) { if let Block::Para(pa) = Arc::make_mut(a) {
self.para(pb, pa); self.para(pb, pa);
// The section's properties on the paragraph (`w:sectPrChange`).
let (Some(b), Some(x)) = (pb.section.as_deref(), pa.section.as_deref_mut()) else { continue };
if let Some(w) = self.change(b, x, strip_section, |s| &s.fmt_change) {
x.fmt_change = w;
}
} }
} }
(Block::Table(tb), Block::Table(ta)) if tb.rows.len() == ta.rows.len() => { (Block::Table(tb), Block::Table(ta)) if tb.rows.len() == ta.rows.len() && tb.grid == ta.grid => {
let Block::Table(ta) = Arc::make_mut(a) else { continue }; let Block::Table(ta) = Arc::make_mut(a) else { continue };
if let Some(w) = self.change(&tb.props, &ta.props, strip_table, |t| &t.fmt_change) {
ta.props.fmt_change = w;
}
for (rb, ra) in tb.rows.iter().zip(ta.rows.iter_mut()) { for (rb, ra) in tb.rows.iter().zip(ta.rows.iter_mut()) {
if let Some(w) = self.change(&rb.props, &ra.props, strip_row, |r| &r.fmt_change) {
ra.props.fmt_change = w;
}
if rb.cells.len() != ra.cells.len() { if rb.cells.len() != ra.cells.len() {
continue; continue;
} }
for (cb, ca) in rb.cells.iter().zip(ra.cells.iter_mut()) { for (cb, ca) in rb.cells.iter().zip(ra.cells.iter_mut()) {
// Merges are structure, not formatting: span and vertical merge stay out.
if let Some(w) = self.change(&cb.props, &ca.props, strip_cell, |c| &c.fmt_change) {
ca.props.fmt_change = w;
}
self.blocks(&cb.blocks, &mut ca.blocks, depth + 1); self.blocks(&cb.blocks, &mut ca.blocks, depth + 1);
} }
} }
@@ -165,6 +181,21 @@ impl Recorder<'_> {
} }
} }
/// The formatting-comparison strips: what isn't formatting in these properties. A cell's merge
/// is structure, a section's headers and footers are content.
fn strip_table(t: &TableProps) -> TableProps {
TableProps { fmt_change: None, ..t.clone() }
}
fn strip_row(r: &RowProps) -> RowProps {
RowProps { fmt_change: None, ..r.clone() }
}
fn strip_cell(c: &CellProps) -> CellProps {
CellProps { fmt_change: None, span: 0, vmerge: VMerge::None, ..c.clone() }
}
fn strip_section(s: &SectionProps) -> SectionProps {
SectionProps { fmt_change: None, headers: Default::default(), footers: Default::default(), ..s.clone() }
}
/// Keep the formatting changes the command `id` made to `s.doc` (compared with `before`) as /// Keep the formatting changes the command `id` made to `s.doc` (compared with `before`) as
/// tracked formatting changes, when Track Changes and Track Formatting are on. /// tracked formatting changes, when Track Changes and Track Formatting are on.
pub(crate) fn record(s: &mut Session, id: &str, before: &Document) { pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
+25 -1
View File
@@ -102,6 +102,8 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("format.growFont1", "Grow Font 1 Point", "Home › Font", |s, _| nudge_size(s, 1.0)).key("Mod+]"), CommandSpec::new("format.growFont1", "Grow Font 1 Point", "Home › Font", |s, _| nudge_size(s, 1.0)).key("Mod+]"),
CommandSpec::new("format.shrinkFont1", "Shrink Font 1 Point", "Home › Font", |s, _| nudge_size(s, -1.0)).key("Mod+["), CommandSpec::new("format.shrinkFont1", "Shrink Font 1 Point", "Home › Font", |s, _| nudge_size(s, -1.0)).key("Mod+["),
CommandSpec::new("format.color", "Font Color", "Home › Font", color).params(r#"{"color": "RRGGBB" | "auto"}"#), CommandSpec::new("format.color", "Font Color", "Home › Font", color).params(r#"{"color": "RRGGBB" | "auto"}"#),
CommandSpec::new("format.underlineColor", "Underline Color", "Home › Font › Underline", underline_color)
.params(r#"{"color": "RRGGBB" | "auto"} (the underline's own colour; "auto" follows the text colour)"#),
CommandSpec::new("format.highlight", "Text Highlight Color", "Home › Font", highlight) CommandSpec::new("format.highlight", "Text Highlight Color", "Home › Font", highlight)
.params( .params(
r#"{"color": "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"}"#, r#"{"color": "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"}"#,
@@ -366,6 +368,17 @@ fn color(s: &mut Session, v: &Value) -> CmdResult {
apply(s, &|x| x.color = Some(tc)) apply(s, &|x| x.color = Some(tc))
} }
/// `format.underlineColor`: the underline's own colour (`w:u w:color`), or `auto` to drop it so the
/// underline follows the text colour again. The underline style is left as it is.
fn underline_color(s: &mut Session, v: &Value) -> CmdResult {
let c = p::req_str(v, "color")?;
let rgb = match TextColor::parse(c).ok_or_else(|| CmdError::Params("color must be RRGGBB or auto".into()))? {
TextColor::Auto => None,
TextColor::Rgb(rgb) => Some(rgb),
};
apply(s, &|x| x.underline_color = rgb)
}
fn highlight(s: &mut Session, v: &Value) -> CmdResult { fn highlight(s: &mut Session, v: &Value) -> CmdResult {
let c = p::str(v, "color").unwrap_or("yellow"); let c = p::str(v, "color").unwrap_or("yellow");
let h = Highlight::ALL let h = Highlight::ALL
@@ -603,7 +616,10 @@ fn convert_chars(src: &str, mode: &str, para_start: bool) -> Vec<String> {
} else { } else {
lower(i, c) lower(i, c)
}; };
if matches!(c, '.' | '!' | '?') { let is_decimal = c == '.'
&& i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(|p| p.is_ascii_digit())
&& chars.get(i + 1).is_some_and(|n| n.is_ascii_digit());
if matches!(c, '!' | '?') || (c == '.' && !is_decimal) {
up = true; up = true;
} }
n n
@@ -677,6 +693,7 @@ pub fn state(s: &Session) -> Value {
"bold": all.iter().all(|r| r.bold), "bold": all.iter().all(|r| r.bold),
"italic": all.iter().all(|r| r.italic), "italic": all.iter().all(|r| r.italic),
"underline": all.iter().all(|r| r.underline != Underline::None), "underline": all.iter().all(|r| r.underline != Underline::None),
"underlineColor": same(&|r| json!(r.underline_color.map_or_else(|| "auto".to_string(), |c| c.hex()))),
"strike": all.iter().all(|r| r.strike), "strike": all.iter().all(|r| r.strike),
"border": all.iter().all(|r| r.border.is_some()), "border": all.iter().all(|r| r.border.is_some()),
"subscript": all.iter().all(|r| r.vert_align == VertAlign::Subscript), "subscript": all.iter().all(|r| r.vert_align == VertAlign::Subscript),
@@ -710,6 +727,13 @@ mod tests {
assert_eq!(convert("hello world", "title", false), "Hello World"); assert_eq!(convert("hello world", "title", false), "Hello World");
assert_eq!(convert("ΣΟΦΟΣ ΟΔΟΣ", "lower", true), "σοφος οδος"); assert_eq!(convert("ΣΟΦΟΣ ΟΔΟΣ", "lower", true), "σοφος οδος");
} }
#[test]
fn sentence_case_ignores_decimal_points() {
// #3485: A period between two digits is a decimal point, not a sentence boundary.
assert_eq!(convert("one 3.14 apples and two 2.5 pears.", "sentence", true), "One 3.14 apples and two 2.5 pears.");
assert_eq!(convert("pi is 3.14. next sentence.", "sentence", true), "Pi is 3.14. Next sentence.");
}
} }
/// What Enclose Characters works on: the selected text (one full-width or up to two half-width /// What Enclose Characters works on: the selected text (one full-width or up to two half-width
+98 -12
View File
@@ -65,11 +65,13 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("insert.createFooter", "Footer", "Insert › Header & Footer", |s, v| create_hf(s, v, false)) CommandSpec::new("insert.createFooter", "Footer", "Insert › Header & Footer", |s, v| create_hf(s, v, false))
.params(r#"{"page"?: number (0-based; default: the caret's section)} — the footer that page shows, created if it has none, with the caret in it"#), .params(r#"{"page"?: number (0-based; default: the caret's section)} — the footer that page shows, created if it has none, with the caret in it"#),
CommandSpec::new("insert.closeHeader", "Close Header and Footer", "Header & Footer", |s, _| { CommandSpec::new("insert.closeHeader", "Close Header and Footer", "Header & Footer", |s, _| {
// Back where the caret was in the body, as Word does, not on page 1.
let back = s.hf_return.take().filter(|_| s.in_header_footer());
if !new_empty_headers(s).is_empty() { if !new_empty_headers(s).is_empty() {
// Its own undo step; a protected document keeps it, and the caret still leaves. // Its own undo step; a protected document keeps it, and the caret still leaves.
let _ = s.run("insert.removeEmptyHeaders", &json!({})); let _ = s.run("insert.removeEmptyHeaders", &json!({}));
} }
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body)); s.sel = back.unwrap_or_else(|| Selection::caret(s.doc.start_of(StoryRef::Body)));
sel_result(s) sel_result(s)
}) })
.pure(), .pure(),
@@ -340,8 +342,19 @@ fn shape(s: &mut Session, v: &Value) -> CmdResult {
Float { wrap: Wrap::Inline, ..Default::default() } Float { wrap: Wrap::Inline, ..Default::default() }
}; };
let stroke = p::str(v, "stroke").and_then(Rgb::parse).or(Some(Rgb(0x0E, 0x40, 0x5A))); let stroke = p::str(v, "stroke").and_then(Rgb::parse).or(Some(Rgb(0x0E, 0x40, 0x5A)));
let obj = let obj = InlineObject::Shape {
InlineObject::Shape { kind, w, h, fill, stroke, stroke_width: 1.0, float, story: None, freeform: None, effects: Default::default(), extra }; kind,
w,
h,
fill: fill.map(wordcraft_doc::fill::Fill::solid),
stroke,
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
extra,
};
// A selected Drawing Canvas takes it in (and stays selected for the next one). // A selected Drawing Canvas takes it in (and stays selected for the next one).
if super::canvas::add_member(s, obj.clone())?.is_some() { if super::canvas::add_member(s, obj.clone())?.is_some() {
return sel_result(s); return sel_result(s);
@@ -365,7 +378,7 @@ fn text_box(s: &mut Session, v: &Value) -> CmdResult {
kind: ShapeKind::TextBox, kind: ShapeKind::TextBox,
w, w,
h, h,
fill: Some(Rgb::WHITE), fill: Some(wordcraft_doc::fill::Fill::solid(Rgb::WHITE)),
stroke: Some(Rgb::BLACK), stroke: Some(Rgb::BLACK),
stroke_width: 0.75, stroke_width: 0.75,
float: Float::default(), float: Float::default(),
@@ -456,9 +469,11 @@ fn link(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s) sel_result(s)
} }
/// Whether `name` can name a bookmark: no spaces, at most 40 characters, not starting with a digit. /// Whether `name` can name a bookmark: starts with a letter or underscore, contains only alphanumeric characters
/// or underscores, and is at most 40 characters (#3491). Word allows leading underscores for hidden bookmarks (_Toc, _Ref).
pub fn bookmark_name_ok(name: &str) -> bool { pub fn bookmark_name_ok(name: &str) -> bool {
!name.is_empty() && !name.contains(' ') && name.len() <= 40 && !name.chars().next().is_some_and(|c| c.is_ascii_digit()) let mut chars = name.chars();
chars.next().is_some_and(|c| c.is_alphabetic() || c == '_') && chars.all(|c| c.is_alphanumeric() || c == '_') && name.chars().count() <= 40
} }
/// Where bookmark `name` is: see [`bookmark_span`]. `None` when there is no such bookmark. /// Where bookmark `name` is: see [`bookmark_span`]. `None` when there is no such bookmark.
@@ -842,18 +857,19 @@ pub fn format_date_parts(fmt: &str, y: i64, m: usize, d: u32, (hh, mm, ss): (u32
n += 1; n += 1;
} }
match (c, n) { match (c, n) {
('y', 4..) => out.push_str(&format!("{y:04}")), ('y' | 'Y', 4..) => out.push_str(&format!("{y:04}")),
('y', _) => out.push_str(&format!("{:02}", y % 100)), ('y' | 'Y', _) => out.push_str(&format!("{:02}", y % 100)),
('M', 4..) => out.push_str(month), ('M', 4..) => out.push_str(month),
('M', 3) => out.push_str(month.get(..3).unwrap_or(month)), ('M', 3) => out.push_str(month.get(..3).unwrap_or(month)),
('M', 2) => out.push_str(&format!("{m:02}")), ('M', 2) => out.push_str(&format!("{m:02}")),
('M', 1) => out.push_str(&m.to_string()), ('M', 1) => out.push_str(&m.to_string()),
('d', 4..) => out.push_str(day), ('d' | 'D', 4..) => out.push_str(day),
('d', 3) => out.push_str(day.get(..3).unwrap_or(day)), ('d' | 'D', 3) => out.push_str(day.get(..3).unwrap_or(day)),
('d', 2) => out.push_str(&format!("{d:02}")), ('d' | 'D', 2) => out.push_str(&format!("{d:02}")),
('d', 1) => out.push_str(&d.to_string()), ('d' | 'D', 1) => out.push_str(&d.to_string()),
('H', 2..) => out.push_str(&format!("{hh:02}")), ('H', 2..) => out.push_str(&format!("{hh:02}")),
('H', _) => out.push_str(&hh.to_string()), ('H', _) => out.push_str(&hh.to_string()),
('h', 2..) => out.push_str(&format!("{:02}", if hh.is_multiple_of(12) { 12 } else { hh % 12 })),
('h', _) => out.push_str(&(if hh.is_multiple_of(12) { 12 } else { hh % 12 }).to_string()), ('h', _) => out.push_str(&(if hh.is_multiple_of(12) { 12 } else { hh % 12 }).to_string()),
('m', 2..) => out.push_str(&format!("{mm:02}")), ('m', 2..) => out.push_str(&format!("{mm:02}")),
('m', _) => out.push_str(&mm.to_string()), ('m', _) => out.push_str(&mm.to_string()),
@@ -967,6 +983,28 @@ fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
mod tests { mod tests {
use super::*; use super::*;
/// Closing the header or footer (Esc, the ribbon) puts the caret back where it was in the
/// body, not on page 1 (#3331).
#[test]
fn close_header_returns_to_the_body_selection() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "One"})).unwrap();
s.run("insert.pageBreak", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "Two words here"})).unwrap();
s.run("select.text", &json!({"text": "words"})).unwrap();
let before = s.sel.clone();
// A header written to stays.
s.run("insert.editHeader", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "Head"})).unwrap();
s.run("insert.closeHeader", &json!({})).unwrap();
assert_eq!(s.sel, before);
// An empty one made by a double-click on page 2 is taken away again.
s.run("insert.editFooter", &json!({"page": 1})).unwrap();
s.run("insert.closeHeader", &json!({})).unwrap();
assert_eq!(s.sel, before);
assert_eq!(s.doc.last_section.footers, Default::default());
}
/// #960: editing the header of a page that has none creates the one that page shows (first /// #960: editing the header of a page that has none creates the one that page shows (first
/// page, even page or default); typing goes into it, and closing an unwritten one takes it /// page, even page or default); typing goes into it, and closing an unwritten one takes it
/// away again so the saved file stays as it was. /// away again so the saved file stays as it was.
@@ -1173,6 +1211,47 @@ mod tests {
assert_eq!(crate::cmd::references::bookmark_text(&s.doc, "Spot").as_deref(), Some("delta")); assert_eq!(crate::cmd::references::bookmark_text(&s.doc, "Spot").as_deref(), Some("delta"));
} }
/// #3491: Bookmark names are validated by Unicode character count and character classes.
#[test]
fn bookmark_name_validation_rules() {
// Valid names:
assert!(bookmark_name_ok("Mark1"));
assert!(bookmark_name_ok("A"));
assert!(bookmark_name_ok("Alpha_Beta_123"));
// Valid leading underscore (Word hidden bookmarks, e.g. _Toc, _Ref, _Hidden):
assert!(bookmark_name_ok("_Hidden"));
assert!(bookmark_name_ok("_foo"));
// Valid 27-character Unicode Cyrillic name (51 UTF-8 bytes):
assert!(bookmark_name_ok("быстрый_доступ_к_документам"));
// Valid 40-character name:
let exactly_40 = "a".repeat(40);
assert!(bookmark_name_ok(&exactly_40));
let exactly_40_unicode = "я".repeat(40);
assert!(bookmark_name_ok(&exactly_40_unicode));
// Invalid: empty
assert!(!bookmark_name_ok(""));
// Invalid: leading digit
assert!(!bookmark_name_ok("1mark"));
// Invalid: symbols / punctuation
assert!(!bookmark_name_ok("a&b"));
assert!(!bookmark_name_ok("a!b"));
assert!(!bookmark_name_ok("a-b"));
assert!(!bookmark_name_ok("a.b"));
assert!(!bookmark_name_ok("foo bar"));
// Invalid: more than 40 characters
assert!(!bookmark_name_ok(&"a".repeat(41)));
assert!(!bookmark_name_ok(&"я".repeat(41)));
// Verification through `insert.bookmark` command
let mut s = Session::new(wordcraft_doc::Document::from_text("Test content"));
assert!(s.run("insert.bookmark", &json!({"name": "быстрый_доступ_к_документам"})).is_ok());
assert!(s.run("insert.bookmark", &json!({"name": "_Hidden"})).is_ok());
assert!(s.run("insert.bookmark", &json!({"name": "1mark"})).is_err());
assert!(s.run("insert.bookmark", &json!({"name": "a&b"})).is_err());
assert!(s.run("insert.bookmark", &json!({"name": "a!b"})).is_err());
}
/// Insert › Symbol with a font: only the symbol takes it, the caret's font carries on after. /// Insert › Symbol with a font: only the symbol takes it, the caret's font carries on after.
#[test] #[test]
fn a_mixed_symbol_string_keeps_symbols_and_text_apart() { fn a_mixed_symbol_string_keeps_symbols_and_text_apart() {
@@ -1210,6 +1289,13 @@ mod tests {
#[test] #[test]
fn date_pictures() { fn date_pictures() {
// Word accepts either case for the day and year instructions.
assert_eq!(format_date_parts("MM/DD/YYYY", 2026, 10, 21, (17, 27, 3), 0), "10/21/2026");
assert_eq!(format_date_parts("D MMMM YYYY", 2026, 10, 21, (17, 27, 3), 0), "21 October 2026");
// Both 12-hour instructions pad a single-digit hour; only the 12-hour one wraps at noon.
assert_eq!(format_date_parts("hh:mm", 2026, 10, 21, (17, 27, 3), 0), "05:27");
assert_eq!(format_date_parts("h:mm", 2026, 10, 21, (17, 27, 3), 0), "5:27");
assert_eq!(format_date_parts("HH:mm", 2026, 10, 21, (5, 7, 3), 0), "05:07");
let d = format_date("yyyy-MM-dd"); let d = format_date("yyyy-MM-dd");
assert_eq!(d.len(), 10); assert_eq!(d.len(), 10);
assert!(format_date("MMMM d, yyyy").contains(", ")); assert!(format_date("MMMM d, yyyy").contains(", "));
+57 -16
View File
@@ -35,6 +35,7 @@ pub mod phonetic;
pub mod protect; pub mod protect;
pub mod references; pub mod references;
pub mod review; pub mod review;
pub mod shape_fill;
pub mod smart_art; pub mod smart_art;
pub mod speech; pub mod speech;
pub mod table; pub mod table;
@@ -46,6 +47,7 @@ pub mod table_style;
pub mod templates; pub mod templates;
pub mod text; pub mod text;
pub mod tools; pub mod tools;
pub mod user_templates;
pub mod view; pub mod view;
pub mod wordart; pub mod wordart;
@@ -89,6 +91,7 @@ pub fn registry() -> Registry {
v.extend(citations::specs()); v.extend(citations::specs());
v.extend(index::specs()); v.extend(index::specs());
v.extend(objects::specs()); v.extend(objects::specs());
v.extend(shape_fill::specs());
v.extend(smart_art::specs()); v.extend(smart_art::specs());
v.extend(chart::specs()); v.extend(chart::specs());
v.extend(draw::specs()); v.extend(draw::specs());
@@ -97,6 +100,7 @@ pub fn registry() -> Registry {
v.extend(connectors::specs()); v.extend(connectors::specs());
v.extend(tools::specs()); v.extend(tools::specs());
v.extend(templates::specs()); v.extend(templates::specs());
v.extend(user_templates::specs());
v.extend(protect::specs()); v.extend(protect::specs());
v.extend(speech::specs()); v.extend(speech::specs());
v.extend(controls::specs()); v.extend(controls::specs());
@@ -420,19 +424,27 @@ fn split_para_as(s: &mut Session, at: &Pos, enter: bool) -> Result<Pos, CmdError
/// ISO-8601 timestamp (UTC, seconds) for `secs` since the Unix epoch. /// ISO-8601 timestamp (UTC, seconds) for `secs` since the Unix epoch.
pub fn iso_from_unix_secs(secs: u64) -> String { pub fn iso_from_unix_secs(secs: u64) -> String {
let (y, m, d) = civil_from_days((secs / 86_400) as i64); use chrono::Datelike;
let t = secs % 86_400; let t = secs % 86_400;
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", t / 3600, t / 60 % 60, t % 60) let hms = format!("{:02}:{:02}:{:02}", t / 3600, t / 60 % 60, t % 60);
// Within chrono's range, read the date from it; past it, keep the proleptic Gregorian answer
// `civil_from_days` gives rather than falling back to the epoch.
let (y, m, d) = match i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)) {
Some(dt) => (i64::from(dt.year()), dt.month(), dt.day()),
None => civil_from_days((secs / 86_400) as i64),
};
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{m:02}-{d:02}T{hms}Z")
} }
/// Seconds since the Unix epoch (UTC): the system clock, or the browser's on the web. /// Seconds since the Unix epoch (UTC): the system clock, or the browser's on the web.
pub fn now_unix() -> u64 { pub fn now_unix() -> u64 {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
{ {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
} }
// `SystemTime::now()` panics on wasm32-unknown-unknown, so ask the browser's clock. // `SystemTime::now()` panics on wasm32-unknown-unknown, so ask the browser's clock.
#[cfg(target_arch = "wasm32")] #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
{ {
let ms = js_sys::Date::now(); let ms = js_sys::Date::now();
// Clamp a NaN or pre-epoch clock to 0 rather than fail. // Clamp a NaN or pre-epoch clock to 0 rather than fail.
@@ -445,18 +457,25 @@ pub fn now_iso() -> String {
iso_from_unix_secs(now_unix()) iso_from_unix_secs(now_unix())
} }
/// Days since 1970-01-01 → (year, month, day) (Howard Hinnant's algorithm). /// Days since 1970-01-01 → (year, month, day).
///
/// `NaiveDate` only covers about ±262143 years, but this is reached by the system clock (and by
/// [`iso_from_unix_secs`] for a `u64` seconds count past it), so it must stay exact for every
/// `z`. The Gregorian calendar repeats every 400 years (146_097 days): chrono converts the day
/// within its cycle and whole cycles go back into the year (an i128 so i64::MIN/MAX can't
/// overflow). 1970-01-01 is `num_days_from_ce` 719_163, and 0..146_097 days from CE day 0 always
/// fit chrono. The result matches the proleptic Gregorian count the old hand-rolled code gave.
pub fn civil_from_days(z: i64) -> (i64, u32, u32) { pub fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468; use chrono::Datelike;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097; let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
let doe = z - era * 146_097; match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; Some(d) => {
let y = yoe + era * 400; let y = i128::from(d.year()) + i128::from(cycles) * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
let mp = (5 * doy + 2) / 153; }
let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // Unreachable: `rest + 719_163` is 719_163..865_259, always a valid CE day.
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; None => (1970, 1, 1),
(if m <= 2 { y + 1 } else { y }, m, d) }
} }
/// Story given in params (`"story": "body"` / `{"part": 3}`), else the caret's. /// Story given in params (`"story": "body"` / `{"part": 3}`), else the caret's.
@@ -484,7 +503,7 @@ pub fn default_lang(s: &Session) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod iso_tests { mod iso_tests {
use super::iso_from_unix_secs; use super::{civil_from_days, iso_from_unix_secs};
#[test] #[test]
fn formats_epoch_and_known_timestamps() { fn formats_epoch_and_known_timestamps() {
@@ -498,4 +517,26 @@ mod iso_tests {
assert_eq!(iso_from_unix_secs(951_782_400), "2000-02-29T00:00:00Z"); assert_eq!(iso_from_unix_secs(951_782_400), "2000-02-29T00:00:00Z");
assert_eq!(iso_from_unix_secs(1_735_689_599), "2024-12-31T23:59:59Z"); assert_eq!(iso_from_unix_secs(1_735_689_599), "2024-12-31T23:59:59Z");
} }
#[test]
fn formats_dates_outside_chronos_range() {
// Year 10000 is still inside chrono's range: a plain zero-padded year, no leading `+`.
assert_eq!(iso_from_unix_secs(253_402_300_800), "10000-01-01T00:00:00Z");
// Past chrono's range, keep the proleptic Gregorian date instead of the Unix epoch.
assert_eq!(iso_from_unix_secs(10_000_000_000_000), "318857-05-20T17:46:40Z");
}
#[test]
fn civil_from_days_is_exact_beyond_chronos_range() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(20_734), (2026, 10, 8));
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the year; values from the
// pre-chrono formula, both directions, both sides of the epoch.
assert_eq!(civil_from_days(95_026_602), (262_144, 1, 1));
assert_eq!(civil_from_days(99_000_000), (273_022, 10, 18));
assert_eq!(civil_from_days(-96_465_964), (-262_145, 3, 1));
assert_eq!(civil_from_days(-99_000_000), (-269_083, 3, 17));
// The extremes must not overflow or panic (i128 intermediates).
let _ = (civil_from_days(i64::MAX), civil_from_days(i64::MIN));
}
} }
+239 -45
View File
@@ -73,7 +73,7 @@ fn has_group(s: &Session) -> Option<&'static str> {
} }
} }
/// Shape Fill and Shape Outline: a shape, or a Drawing Canvas's background and border. /// Shape Fill and Shape Outline: a shape, or a Drawing Canvas's background and border.
fn has_shape_or_canvas(s: &Session) -> Option<&'static str> { pub(crate) fn has_shape_or_canvas(s: &Session) -> Option<&'static str> {
match selected(s) { match selected(s) {
Some((_, o)) if o.is_canvas() => None, Some((_, o)) if o.is_canvas() => None,
_ => has_shape(s), _ => has_shape(s),
@@ -98,7 +98,7 @@ pub fn specs() -> Vec<CommandSpec> {
.when(has_sizable), .when(has_sizable),
CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", crop) CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", crop)
.params( .params(
r#"{"left"?, "top"?, "right"?, "bottom"? (fractions of the source 0–0.45; a side left out is uncropped), "width"?: pt, "height"?: pt (the new display size, in the same undo step; a floating picture also moves by what a left/top crop trims, so the visible part stays put)}"#, r#"{"left"?, "top"?, "right"?, "bottom"? (fractions of the source 0–0.95; the two sides of an axis keep at least 5% between them; a side left out is uncropped), "width"?: pt, "height"?: pt (the new display size, in the same undo step; a floating picture also moves by what a left/top crop trims, so the visible part stays put)}"#,
) )
.when(has_picture), .when(has_picture),
// Kept for scripts written before every object had alt text: `object.altText` on a picture. // Kept for scripts written before every object had alt text: `object.altText` on a picture.
@@ -324,10 +324,10 @@ pub fn specs() -> Vec<CommandSpec> {
.when(has_movable), .when(has_movable),
CommandSpec::new("arrange.selectionPane", "Selection Pane", "Layout › Arrange", objects_list).pure(), CommandSpec::new("arrange.selectionPane", "Selection Pane", "Layout › Arrange", objects_list).pure(),
CommandSpec::new("arrange.bounds", "Move or Resize", "Layout › Arrange", bounds) CommandSpec::new("arrange.bounds", "Move or Resize", "Layout › Arrange", bounds)
.params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object floats it)"#) .params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n, "to"?: Pos} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object by x/y floats it. to: an In Line with Text object moves to that text position and stays inline, as a drag does)"#)
.when(has_movable), .when(has_movable),
CommandSpec::new("arrange.copyTo", "Copy to", "Layout › Arrange", copy_to) CommandSpec::new("arrange.copyTo", "Copy to", "Layout › Arrange", copy_to)
.params(r#"{"x": pt, "y": pt, "page"?: n} (Ctrl+drag: a copy of the selected object lands with its top-left there and is selected; the original and the clipboard stay as they were)"#) .params(r#"{"x": pt, "y": pt, "page"?: n} | {"to": Pos} (Ctrl+drag: a copy of the selected object lands with its top-left there, or an In Line with Text one at the text position `to`, and is selected; the original and the clipboard stay as they were)"#)
.when(has_movable), .when(has_movable),
CommandSpec::new("arrange.nudge", "Nudge", "Layout › Arrange", |s, v| { CommandSpec::new("arrange.nudge", "Nudge", "Layout › Arrange", |s, v| {
let d = |k| p::f32(v, k).unwrap_or(0.0).clamp(-MAX_OFFSET, MAX_OFFSET); let d = |k| p::f32(v, k).unwrap_or(0.0).clamp(-MAX_OFFSET, MAX_OFFSET);
@@ -357,24 +357,14 @@ pub fn specs() -> Vec<CommandSpec> {
}) })
.params(r#"{"index"?: n}"#) .params(r#"{"index"?: n}"#)
.pure(), .pure(),
CommandSpec::new("shape.fill", "Shape Fill", "Shape Format › Shape Styles", |s, v| {
let c = p::str(v, "color").and_then(Rgb::parse);
with_obj(s, |o| {
if let InlineObject::Shape { fill, .. } = o {
*fill = c;
} else if let Some(style) = super::canvas::style_mut(o) {
style.fill = c;
}
})
})
.params(r#"{"color": "RRGGBB" | null} (a selected drawing canvas: its background)"#)
.when(has_shape_or_canvas),
CommandSpec::new("shape.outline", "Shape Outline", "Shape Format › Shape Styles", |s, v| { CommandSpec::new("shape.outline", "Shape Outline", "Shape Format › Shape Styles", |s, v| {
let c = p::str(v, "color").and_then(Rgb::parse); let c = p::str(v, "color").and_then(Rgb::parse);
let w = p::f32(v, "width").map(|w| w.clamp(0.0, 72.0)); let w = p::f32(v, "width").map(|w| w.clamp(0.0, 72.0));
with_obj(s, |o| { with_obj(s, |o| {
if let InlineObject::Shape { stroke, stroke_width, .. } = o { if let InlineObject::Shape { stroke, stroke_width, extra, .. } = o {
*stroke = c; *stroke = c;
// A colour chosen here replaces an outline gradient from a file.
extra.outline_fill = None;
if let Some(w) = w { if let Some(w) = w {
*stroke_width = w; *stroke_width = w;
} }
@@ -678,6 +668,10 @@ fn bounds(s: &mut Session, v: &Value) -> CmdResult {
(None, None) => None, (None, None) => None,
_ => return Err(CmdError::Params("`x` and `y` go together".into())), _ => return Err(CmdError::Params("`x` and `y` go together".into())),
}; };
let in_text = text_target(v, &obj)?;
if in_text.is_some() && to.is_some() {
return Err(CmdError::Params("`to` or `x`/`y`, not both".into()));
}
let (w0, h0) = obj_size(&obj); let (w0, h0) = obj_size(&obj);
let min = min_size(&obj); let min = min_size(&obj);
let w = p::f32(v, "width").unwrap_or(w0).clamp(min, MAX_OFFSET); let w = p::f32(v, "width").unwrap_or(w0).clamp(min, MAX_OFFSET);
@@ -685,24 +679,92 @@ fn bounds(s: &mut Session, v: &Value) -> CmdResult {
if (w, h) != (w0, h0) { if (w, h) != (w0, h0) {
with_obj(s, |o| o.set_size(w, h))?; with_obj(s, |o| o.set_size(w, h))?;
} }
let pos = match to { let pos = match (to, in_text) {
Some((x, y)) => move_object(s, pos, p::u64(v, "page"), x, y, (w, h))?, (Some((x, y)), _) => move_object(s, pos, p::u64(v, "page"), x, y, (w, h))?,
None => pos, (None, Some(at)) => move_in_text(s, pos, &at)?,
(None, None) => pos,
}; };
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() } }; select_object(s, &pos);
let o = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned(); let o = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned();
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null), "pos": super::pos_json(&pos)})) Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null), "pos": super::pos_json(&pos)}))
} }
/// Select the object whose character is at `pos`.
fn select_object(s: &mut Session, pos: &Pos) {
s.sel = Selection { anchor: pos.clone(), focus: Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() } };
}
/// The text position in `v`'s `to`, for an In Line with Text `obj` (a floating one is placed by
/// `x`/`y`); None without one.
fn text_target(v: &Value, obj: &InlineObject) -> Result<Option<Pos>, CmdError> {
let Some(to) = v.get("to").filter(|t| !t.is_null()) else { return Ok(None) };
if obj.is_floating() {
return Err(CmdError::Params("`to` places an In Line with Text object; move a floating one by `x` and `y`".into()));
}
super::parse_pos(to).map(Some).ok_or_else(|| CmdError::Params("bad `to`".into()))
}
/// The text position `to` an In Line with Text object at `pos` can land at: in the object's own
/// story (a table cell there too), not inside a field's code or such (it's clamped to a
/// character).
fn landing(s: &Session, pos: &Pos, to: &Pos) -> Result<Pos, CmdError> {
if pos.path.depth() > 0 || !movable_story(s, pos.story) {
return Err(CmdError::Disabled("only objects in the body text, headers and footers can be moved".into()));
}
if to.story != pos.story || s.doc.para_at(to).is_none() {
return Err(CmdError::Params("`to` must be a position in the object's own text".into()));
}
Ok(s.doc.clamp(to))
}
/// Move the In Line with Text object at `pos` to the text position `to`, where it stays In Line
/// with Text, keeping its character formatting: Word's drag of an inline picture, which lands
/// where its dashed insertion cursor was (#2233, #2234). Dropped on itself, nothing changes.
/// Returns the object's position afterwards.
fn move_in_text(s: &mut Session, pos: Pos, to: &Pos) -> Result<Pos, CmdError> {
let to = landing(s, &pos, to)?;
let len = wordcraft_doc::para::OBJ.len_utf8();
let same_para = to.path == pos.path;
if same_para && (to.off == pos.off || to.off == pos.off + len) {
return Ok(pos);
}
let para = s.doc.para_at(&pos).ok_or_else(|| CmdError::Failed("object vanished".into()))?;
let obj = para.object_at(pos.off).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
let props = para.props_of_char(pos.off).clone();
s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?;
let mut at = to;
if same_para && at.off > pos.off {
at.off = at.off.saturating_sub(len);
}
let at = s.doc.clamp(&at);
s.doc.insert_object(&at, obj, &props)?;
Ok(at)
}
/// Ctrl+drag: copy the selected object (a text box gets its own story) and move the copy to /// Ctrl+drag: copy the selected object (a text box gets its own story) and move the copy to
/// (x, y), leaving the original in place and the clipboard alone. The copy is selected. /// (x, y), or for an In Line with Text object put it at the text position `to`, leaving the
/// original in place and the clipboard alone. The copy is selected.
fn copy_to(s: &mut Session, v: &Value) -> CmdResult { fn copy_to(s: &mut Session, v: &Value) -> CmdResult {
let (pos, _) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?; let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
let len = wordcraft_doc::para::OBJ.len_utf8();
let end = Pos { off: pos.off + len, ..pos.clone() };
if let Some(to) = text_target(v, &obj)? {
if p::f32(v, "x").is_some() || p::f32(v, "y").is_some() {
return Err(CmdError::Params("`to` or `x`/`y`, not both".into()));
}
let at = landing(s, &pos, &to)?;
let frag = s.doc.copy_range(&pos, &end);
s.sel = Selection::caret(at.clone());
super::paste::insert(s, frag)?;
// The copy starts where it was pasted.
let copy = s.doc.clamp(&at);
select_object(s, &copy);
let o = s.doc.para_at(&copy).and_then(|p| p.object_at(copy.off)).cloned();
return Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null), "pos": super::pos_json(&copy)}));
}
if p::f32(v, "x").is_none() || p::f32(v, "y").is_none() { if p::f32(v, "x").is_none() || p::f32(v, "y").is_none() {
return Err(CmdError::Params("`x` and `y` are required".into())); return Err(CmdError::Params("`x` and `y` are required".into()));
} }
let len = wordcraft_doc::para::OBJ.len_utf8();
let end = Pos { off: pos.off + len, ..pos.clone() };
let frag = s.doc.copy_range(&pos, &end); let frag = s.doc.copy_range(&pos, &end);
s.sel = Selection::caret(end.clone()); s.sel = Selection::caret(end.clone());
super::paste::insert(s, frag)?; super::paste::insert(s, frag)?;
@@ -775,27 +837,27 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
s.doc.insert_object(&at, obj, &wordcraft_doc::CharProps::default())?; s.doc.insert_object(&at, obj, &wordcraft_doc::CharProps::default())?;
at at
}; };
let (h_rel, v_rel) = if on_page { (Anchor::Page, Anchor::Page) } else { (Anchor::Column, Anchor::Paragraph) }; // First on the page where it was dropped, so the text wraps around it as it will in the end.
edit_float(s, &at, |f| { edit_float(s, &at, |f| {
if f.wrap == Wrap::Inline { if f.wrap == Wrap::Inline {
f.wrap = Wrap::Square; f.wrap = Wrap::Square;
} }
f.h_rel = h_rel; f.h_rel = Anchor::Page;
f.v_rel = v_rel; f.v_rel = Anchor::Page;
f.h_align = None; f.h_align = None;
f.v_align = None; f.v_align = None;
f.x = 0.0; f.x = x.clamp(-MAX_OFFSET, MAX_OFFSET);
f.y = 0.0; f.y = y.clamp(-MAX_OFFSET, MAX_OFFSET);
})?; })?;
// Offsets from where the anchor puts it (its paragraph may have moved with the edit). if on_page {
let origin = if on_page { return Ok(at);
wordcraft_geom::Point::new(0.0, 0.0) }
} else { // Then offsets from its paragraph, as laid out with the object there.
s.touch(); // lay out the edit so far s.touch();
let layout = s.layout(); let origin = s.layout().object(&at, page).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?.origin;
layout.object(&at, page).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?.origin
};
edit_float(s, &at, |f| { edit_float(s, &at, |f| {
f.h_rel = Anchor::Column;
f.v_rel = Anchor::Paragraph;
f.x = (x - origin.x).clamp(-MAX_OFFSET, MAX_OFFSET); f.x = (x - origin.x).clamp(-MAX_OFFSET, MAX_OFFSET);
f.y = (y - origin.y).clamp(-MAX_OFFSET, MAX_OFFSET); f.y = (y - origin.y).clamp(-MAX_OFFSET, MAX_OFFSET);
})?; })?;
@@ -945,7 +1007,7 @@ fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdResult {
} }
/// Change the object at `pos`; returns it afterwards. /// Change the object at `pos`; returns it afterwards.
fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result<InlineObject, CmdError> { pub(crate) fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result<InlineObject, CmdError> {
let para = s.doc.para_mut(pos.story, &pos.path)?; let para = s.doc.para_mut(pos.story, &pos.path)?;
let o = para.object_at_mut(pos.off).ok_or_else(|| CmdError::Failed("object vanished".into()))?; let o = para.object_at_mut(pos.off).ok_or_else(|| CmdError::Failed("object vanished".into()))?;
f(o); f(o);
@@ -980,13 +1042,17 @@ fn with_float(s: &mut Session, f: impl Fn(&mut Float)) -> CmdResult {
/// trims (or restores) — for a flipped picture that's the source's right/bottom — so what stays /// trims (or restores) — for a flipped picture that's the source's right/bottom — so what stays
/// visible keeps its place on the page: a crop handle drop is one command. /// visible keeps its place on the page: a crop handle drop is one command.
fn crop(s: &mut Session, v: &Value) -> CmdResult { fn crop(s: &mut Session, v: &Value) -> CmdResult {
let c = [p::f32(v, "left"), p::f32(v, "top"), p::f32(v, "right"), p::f32(v, "bottom")].map(|x| x.unwrap_or(0.0).clamp(0.0, 0.45)); let mut c = [p::f32(v, "left"), p::f32(v, "top"), p::f32(v, "right"), p::f32(v, "bottom")].map(|x| x.unwrap_or(0.0).clamp(0.0, 0.95));
// The two sides of an axis keep at least 5% of the source between them, as the renderer
// draws it: the second side stops where the first one already left less than that.
c[2] = c[2].min(0.95 - c[0]);
c[3] = c[3].min(0.95 - c[1]);
let size = |k: &str| p::f32(v, k).map(|x| x.clamp(1.0, 4000.0)); let size = |k: &str| p::f32(v, k).map(|x| x.clamp(1.0, 4000.0));
let (nw, nh) = (size("width"), size("height")); let (nw, nh) = (size("width"), size("height"));
with_obj(s, |o| { with_obj(s, |o| {
if let InlineObject::Image { crop, w, h, float, .. } = o { if let InlineObject::Image { crop, w, h, float, .. } = o {
// The source's full size at the new display size (the visible part is 1-lo-hi of it). // The source's full size at the new display size (the visible part is 1-lo-hi of it).
let full = |len: f32, lo: f32, hi: f32| len / (1.0 - lo - hi).max(0.1); let full = |len: f32, lo: f32, hi: f32| len / (1.0 - lo - hi).max(0.05);
// Flipped, the source's right (bottom) edge is the one on the page's left (top). // Flipped, the source's right (bottom) edge is the one on the page's left (top).
let (left, top) = (if float.flip_h { 2 } else { 0 }, if float.flip_v { 3 } else { 1 }); let (left, top) = (if float.flip_h { 2 } else { 0 }, if float.flip_v { 3 } else { 1 });
if let Some(nw) = nw { if let Some(nw) = nw {
@@ -1035,7 +1101,7 @@ pub fn original_size(s: &Session, media: &str) -> Option<OriginalSize> {
pub fn cropped_original_size(s: &Session, o: &InlineObject) -> Option<(f32, f32)> { pub fn cropped_original_size(s: &Session, o: &InlineObject) -> Option<(f32, f32)> {
let InlineObject::Image { media, crop, .. } = o else { return None }; let InlineObject::Image { media, crop, .. } = o else { return None };
let orig = original_size(s, media)?; let orig = original_size(s, media)?;
let part = |lo: f32, hi: f32| (1.0 - lo - hi).max(0.1); let part = |lo: f32, hi: f32| (1.0 - lo - hi).max(0.05);
Some((orig.w * part(crop[0], crop[2]), orig.h * part(crop[1], crop[3]))) Some((orig.w * part(crop[0], crop[2]), orig.h * part(crop[1], crop[3])))
} }
@@ -1611,7 +1677,7 @@ fn shown_members(group: &InlineObject, [x, y, w, h]: [f32; 4]) -> Vec<([f32; 4],
.collect() .collect()
} }
fn all_objects(s: &Session) -> Vec<(Pos, InlineObject)> { pub(crate) fn all_objects(s: &Session) -> Vec<(Pos, InlineObject)> {
let mut v = Vec::new(); let mut v = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) { for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
@@ -1724,6 +1790,103 @@ mod tests {
assert_eq!(count(&s), 1); assert_eq!(count(&s), 1);
} }
/// The body's top-level paragraphs, an object's character shown as ▣.
fn body_text(s: &Session) -> String {
(0..2)
.filter_map(|i| s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(i)))
.map(|p| p.text.replace(wordcraft_doc::para::OBJ, "▣"))
.collect::<Vec<_>>()
.join("\n")
}
/// Two paragraphs, the second starting with an inline picture (bold, so its formatting can be
/// followed), selected.
fn inline_picture_session() -> Session {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "First paragraph."})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("format.bold", &json!({})).unwrap();
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data, "width": 100})).unwrap();
s.run("format.bold", &json!({})).unwrap();
s.run("text.insert", &json!({"text": " second paragraph."})).unwrap();
let pic = Pos::body(1, 0);
s.run("select.range", &json!({"anchor": pic, "focus": Pos { off: pic.off + wordcraft_doc::para::OBJ.len_utf8(), ..pic.clone() }})).unwrap();
assert!(selected(&s).is_some_and(|(_, o)| !o.is_floating()));
s
}
/// #2234: dragging an In Line with Text picture into the text (`to`) moves it there and it stays
/// inline, with its formatting, selected, in one undo step; it never floats.
#[test]
fn an_inline_picture_dragged_into_the_text_stays_inline() {
let mut s = inline_picture_session();
let text = body_text;
assert_eq!(text(&s), "First paragraph.\n▣ second paragraph.");
// Into the first paragraph, after "First".
let r = s.run("arrange.bounds", &json!({"to": Pos::body(0, 5)})).unwrap();
assert_eq!(text(&s), "First▣ paragraph.\n second paragraph.");
assert_eq!(r["object"]["float"]["wrap"], "inline", "{r}");
let (pos, obj) = selected(&s).expect("still selected");
assert_eq!(pos, Pos::body(0, 5));
assert!(!obj.is_floating());
assert!(s.doc.para_at(&pos).unwrap().props_of_char(pos.off).bold == Some(true), "keeps its run's formatting");
// Later in its own paragraph (after "paragraph", counted with it still there): the offset
// accounts for its own character leaving.
s.run("arrange.bounds", &json!({"to": Pos::body(0, 18)})).unwrap();
assert_eq!(text(&s), "First paragraph▣.\n second paragraph.");
// Each move is one undo step.
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s), "First▣ paragraph.\n second paragraph.");
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s), "First paragraph.\n▣ second paragraph.");
// Dropped on itself (either side): nothing changes.
let here = selected(&s).unwrap().0;
s.run("arrange.bounds", &json!({"to": here})).unwrap();
s.run("arrange.bounds", &json!({"to": Pos { off: here.off + wordcraft_doc::para::OBJ.len_utf8(), ..here.clone() }})).unwrap();
assert_eq!(text(&s), "First paragraph.\n▣ second paragraph.");
assert_eq!(selected(&s).unwrap().0, here);
}
/// `to` is only for In Line with Text objects, in their own text; bad targets change nothing.
#[test]
fn moving_into_the_text_refuses_bad_targets() {
let mut s = inline_picture_session();
let before = body_text(&s);
for bad in [
json!({"to": "junk"}),
json!({"to": {"story": "body", "path": [9], "off": 0}}),
json!({"to": Pos::body(0, 0), "x": 10, "y": 10}),
json!({"to": {"story": {"part": 99}, "path": [0], "off": 0}}),
] {
assert!(s.run("arrange.bounds", &bad).is_err(), "{bad}");
assert!(s.run("arrange.copyTo", &bad).is_err(), "{bad}");
}
// A huge offset is clamped to the paragraph's end.
s.run("arrange.bounds", &json!({"to": {"story": "body", "path": [0], "off": 999_999}})).unwrap();
assert_eq!(body_text(&s), "First paragraph.▣\n second paragraph.");
assert_ne!(body_text(&s), before);
// A floating object is moved by x/y, not into the text.
s.run("arrange.bounds", &json!({"page": 0, "x": 200, "y": 300})).unwrap();
assert!(s.run("arrange.bounds", &json!({"to": Pos::body(1, 0)})).is_err());
}
/// Ctrl+drag of an inline picture into the text: a copy lands there, inline and selected; the
/// original stays.
#[test]
fn copying_an_inline_picture_into_the_text() {
let mut s = inline_picture_session();
s.clipboard_text = "keep".into();
s.run("arrange.copyTo", &json!({"to": Pos::body(0, 5)})).unwrap();
assert_eq!(body_text(&s), "First▣ paragraph.\n▣ second paragraph.");
let (pos, obj) = selected(&s).expect("the copy is selected");
assert_eq!(pos, Pos::body(0, 5));
assert!(!obj.is_floating());
assert_eq!(s.clipboard_text, "keep");
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(body_text(&s), "First paragraph.\n▣ second paragraph.");
}
#[test] #[test]
fn shapes_can_be_clicked_resized_and_dragged() { fn shapes_can_be_clicked_resized_and_dragged() {
for kind in ["rectangle", "ellipse", "star", "arrow"] { for kind in ["rectangle", "ellipse", "star", "arrow"] {
@@ -2273,14 +2436,45 @@ mod tests {
assert_eq!(image(&s), (150.0, 90.0, [0.25, 0.0, 0.0, 0.1])); assert_eq!(image(&s), (150.0, 90.0, [0.25, 0.0, 0.0, 0.1]));
s.run("edit.undo", &json!({})).unwrap(); s.run("edit.undo", &json!({})).unwrap();
assert_eq!(image(&s), (200.0, 100.0, [0.0; 4])); assert_eq!(image(&s), (200.0, 100.0, [0.0; 4]));
// Clamps: each side at most 0.45; hostile sizes are ignored or clamped, never applied raw. // Clamps: each side at most 0.95; hostile sizes are ignored or clamped, never applied raw.
s.run("picture.crop", &json!({"left": 9.0, "right": -3.0, "width": "x", "height": -5.0})).unwrap(); s.run("picture.crop", &json!({"left": 9.0, "right": -3.0, "width": "x", "height": -5.0})).unwrap();
let (w, h, crop) = image(&s); let (w, h, crop) = image(&s);
assert_eq!(crop, [0.45, 0.0, 0.0, 0.0]); assert_eq!(crop, [0.95, 0.0, 0.0, 0.0]);
assert_eq!(w, 200.0); assert_eq!(w, 200.0);
assert!(h >= 1.0, "{h}"); assert!(h >= 1.0, "{h}");
} }
/// #3135: a picture can be cropped to any part of itself, not just its middle. A side goes to
/// 0.95 (5% stays, as the renderer draws), and the two sides of an axis keep their 5%.
#[test]
fn a_picture_can_be_cropped_to_any_part_of_itself() {
let mut s = Session::new(wordcraft_doc::Document::new());
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data})).unwrap();
s.run("picture.size", &json!({"width": 200.0, "height": 100.0, "lockAspect": false})).unwrap();
let crop = |s: &Session| match selected(s) {
Some((_, InlineObject::Image { crop, .. })) => crop,
other => panic!("expected image, got {other:?}"),
};
let left = |s: &Session| {
let (_, o) = selected(s).unwrap();
super::cropped_original_size(s, &o).unwrap().0
};
// 90% off the left keeps the rightmost tenth: the side is not held at 45%.
s.run("picture.crop", &json!({"left": 0.9})).unwrap();
assert_eq!(crop(&s), [0.9, 0.0, 0.0, 0.0]);
assert!((left(&s) - 1.5).abs() < 1e-3, "a tenth of the 15 pt original: {}", left(&s));
// With the left at 0.9 the right can take only 5% more, and what the crop leaves of the
// original follows the crop down to that 5%.
s.run("picture.crop", &json!({"left": 0.9, "right": 0.9})).unwrap();
let c = crop(&s);
assert!((c[0] - 0.9).abs() < 1e-6 && (c[2] - 0.05).abs() < 1e-5 && c[1] == 0.0 && c[3] == 0.0, "{c:?}");
assert!((left(&s) - 0.75).abs() < 1e-3, "5% of the 15 pt original: {}", left(&s));
// A single side past the cap stops at 0.95.
s.run("picture.crop", &json!({"right": 4.0})).unwrap();
assert_eq!(crop(&s), [0.0, 0.0, 0.95, 0.0]);
}
/// Cropping a floating picture's left or top with its new size keeps the visible part where /// Cropping a floating picture's left or top with its new size keeps the visible part where
/// it is on the page: the frame moves by the trimmed amount, in the same undo step. /// it is on the page: the frame moves by the trimmed amount, in the same undo step.
#[test] #[test]
+25 -8
View File
@@ -11,7 +11,7 @@ pub const PANES: [&str; 8] = ["general", "display", "proofing", "save", "languag
/// Longest initials kept, in characters. /// Longest initials kept, in characters.
pub const MAX_INITIALS_CHARS: usize = 9; pub const MAX_INITIALS_CHARS: usize = 9;
/// Longest dictionaries folder path taken, in characters. /// Longest folder path (dictionaries, user templates) taken, in characters.
const MAX_FOLDER_CHARS: usize = 4096; const MAX_FOLDER_CHARS: usize = 4096;
/// Longest word the custom dictionary takes, in characters. /// Longest word the custom dictionary takes, in characters.
pub const MAX_WORD_CHARS: usize = 64; pub const MAX_WORD_CHARS: usize = 64;
@@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
CommandSpec::new("file.options", "Options", "File", options) CommandSpec::new("file.options", "Options", "File", options)
.params( .params(
r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#, r#"{"pane"?: "general|display|proofing|save|language|accessibility|advanced|agents", "author"?: string, "initials"?: string, "units"?: "inches|centimeters|millimeters|points|picas|auto" (auto: the region's), "region"?: string (a locale tag like pl-PL, saved: the paper File › New starts on, the default units and the decimal separator; auto: the system's), "typingReplacesSelection"?: bool, "insertKeyOvertype"?: bool, "overtype"?: bool, "marks"?: {"tabs"?: bool, "spaces"?: bool, "paragraphs"?: bool, "hidden"?: bool}, "checkSpelling"?: bool, "markGrammar"?: bool, "ignoreUppercase"?: bool, "ignoreNumbers"?: bool, "ignoreInternet"?: bool, "dictionariesFolder"?: string (a folder of Hunspell .aff/.dic files searched first, saved; auto: the system locations only), "userTemplatesFolder"?: string (the folder File › New lists personal templates from, saved; auto: the app's own Templates folder; not a network folder), "addWords"?: [string], "removeWords"?: [string]} — with any setting it changes them and opens nothing; without, it opens File › Options (at `pane`). Returns the settings."#,
) )
.pure(), .pure(),
CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| { CommandSpec::new("text.overtype", "Overtype", "File › Options › Advanced", |s, v| {
@@ -117,9 +117,15 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
}; };
let folder = match v.get("dictionariesFolder") { let folder = match v.get("dictionariesFolder") {
None | Some(Value::Null) => None, None | Some(Value::Null) => None,
Some(Value::String(f)) => Some(dictionaries_folder(f)?), Some(Value::String(f)) => Some(folder_setting("dictionariesFolder", f, false)?),
Some(_) => return Err(CmdError::Params("`dictionariesFolder` must be a folder path, or auto".into())), Some(_) => return Err(CmdError::Params("`dictionariesFolder` must be a folder path, or auto".into())),
}; };
let templates_folder = match v.get("userTemplatesFolder") {
None | Some(Value::Null) => None,
// Never a folder on another machine (#2852): listing it would make Windows authenticate there.
Some(Value::String(f)) => Some(folder_setting("userTemplatesFolder", f, true)?),
Some(_) => return Err(CmdError::Params("`userTemplatesFolder` must be a folder path, or auto".into())),
};
let mut flag_values = Vec::new(); let mut flag_values = Vec::new();
for (k, f) in flags() { for (k, f) in flags() {
if let Some(b) = bool_param(v, k)? { if let Some(b) = bool_param(v, k)? {
@@ -147,6 +153,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
|| units.is_some() || units.is_some()
|| region.is_some() || region.is_some()
|| folder.is_some() || folder.is_some()
|| templates_folder.is_some()
|| !flag_values.is_empty() || !flag_values.is_empty()
|| !mark_values.is_empty() || !mark_values.is_empty()
|| check_spelling.is_some() || check_spelling.is_some()
@@ -168,6 +175,9 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
use_dictionaries_folder(f.as_deref()); use_dictionaries_folder(f.as_deref());
s.prefs.dictionaries_folder = f; s.prefs.dictionaries_folder = f;
} }
if let Some(f) = templates_folder {
s.prefs.user_templates_folder = f;
}
for (f, b) in flag_values { for (f, b) in flag_values {
*f(&mut s.prefs) = b; *f(&mut s.prefs) = b;
} }
@@ -176,6 +186,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
} }
if let Some(b) = check_spelling { if let Some(b) = check_spelling {
s.view.proofing = b; s.view.proofing = b;
s.prefs.check_spelling = b;
} }
for w in &add { for w in &add {
wordcraft_proof::add_word(w); wordcraft_proof::add_word(w);
@@ -221,6 +232,7 @@ pub fn current(s: &Session) -> Value {
"ignoreInternet": pr.ignore_internet, "ignoreInternet": pr.ignore_internet,
"dictionary": wordcraft_proof::user_dictionary(), "dictionary": wordcraft_proof::user_dictionary(),
"dictionariesFolder": pr.dictionaries_folder, "dictionariesFolder": pr.dictionaries_folder,
"userTemplatesFolder": pr.user_templates_folder,
"dictionaries": wordcraft_proof::installed_dictionaries() "dictionaries": wordcraft_proof::installed_dictionaries()
.into_iter() .into_iter()
.map(|(lang, dic)| json!({"language": lang.code(), "file": dic.map(|d| d.to_string_lossy().into_owned())})) .map(|(lang, dic)| json!({"language": lang.code(), "file": dic.map(|d| d.to_string_lossy().into_owned())}))
@@ -228,23 +240,28 @@ pub fn current(s: &Session) -> Value {
}) })
} }
/// The `dictionariesFolder` setting: `auto` (or nothing) forgets it; otherwise an existing /// A folder setting (`dictionariesFolder`, `userTemplatesFolder`): `auto` (or nothing) forgets
/// folder, given as an absolute path or one starting with `~/`. /// it; otherwise an existing folder, given as an absolute path or one starting with `~/`. With
fn dictionaries_folder(f: &str) -> Result<Option<String>, CmdError> { /// `local_only`, a network folder is refused before anything looks at it (#2852): even checking
/// that it exists would make Windows authenticate to that host.
fn folder_setting(key: &str, f: &str, local_only: bool) -> Result<Option<String>, CmdError> {
let f = f.trim(); let f = f.trim();
if f.is_empty() || f.eq_ignore_ascii_case("auto") { if f.is_empty() || f.eq_ignore_ascii_case("auto") {
return Ok(None); return Ok(None);
} }
if f.chars().count() > MAX_FOLDER_CHARS || f.chars().any(char::is_control) { if f.chars().count() > MAX_FOLDER_CHARS || f.chars().any(char::is_control) {
return Err(CmdError::Params("`dictionariesFolder` isn't a folder path".into())); return Err(CmdError::Params(format!("`{key}` isn't a folder path")));
} }
let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(std::path::PathBuf::from); let home = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(std::path::PathBuf::from);
let path = match (f.strip_prefix("~/"), home) { let path = match (f.strip_prefix("~/"), home) {
(Some(rest), Some(h)) => h.join(rest), (Some(rest), Some(h)) => h.join(rest),
_ => std::path::PathBuf::from(f), _ => std::path::PathBuf::from(f),
}; };
if local_only && (super::templates::is_network_path(f) || super::templates::is_network_path(&path.to_string_lossy())) {
return Err(CmdError::Params(format!("`{key}` can't be a network folder")));
}
if !path.is_absolute() { if !path.is_absolute() {
return Err(CmdError::Params(format!("`dictionariesFolder`: {f:?} isn't a full path"))); return Err(CmdError::Params(format!("`{key}`: {f:?} isn't a full path")));
} }
if !path.is_dir() { if !path.is_dir() {
return Err(CmdError::Failed(format!("no folder at {}", path.display()))); return Err(CmdError::Failed(format!("no folder at {}", path.display())));
+33 -4
View File
@@ -72,15 +72,21 @@ pub fn specs() -> Vec<CommandSpec> {
}), }),
CommandSpec::new("layout.pageNumberFormat", "Format Page Numbers", "Insert › Header & Footer › Page Number", |s, v| { CommandSpec::new("layout.pageNumberFormat", "Format Page Numbers", "Insert › Header & Footer › Page Number", |s, v| {
let fmt = p::str(v, "format").map(wordcraft_doc::section::NumFormat::from_ooxml); let fmt = p::str(v, "format").map(wordcraft_doc::section::NumFormat::from_ooxml);
let start = p::u64(v, "start").map(|x| x.min(100_000) as u32);
with_sect(s, |x| { with_sect(s, |x| {
if let Some(f) = fmt { if let Some(f) = fmt {
x.page_num_format = f; x.page_num_format = f;
} }
x.page_num_start = start; // Left out entirely: the numbering keeps its start. Given as null: the pages
// number on from the section before (the dialog's "Continue from previous
// section"). A number: numbering starts there.
if v.get("start").is_some() {
x.page_num_start = p::u64(v, "start").map(|n| n.min(100_000) as u32);
}
}) })
}) })
.params(r#"{"format"?: "decimal|lowerRoman|upperRoman|lowerLetter|upperLetter", "start"?: n}"#), .params(
r#"{"format"?: "decimal|lowerRoman|upperRoman|lowerLetter|upperLetter", "start"?: n | null} ("start" left out: the numbering keeps its start; null: the pages number on from the section before; a number: numbering starts there)"#,
),
CommandSpec::new("layout.documentGrid", "Document Grid", "Layout › Page Setup › Document Grid", document_grid).params( CommandSpec::new("layout.documentGrid", "Document Grid", "Layout › Page Setup › Document Grid", document_grid).params(
r#"{"type"?: "none|lines|linesAndChars|snapToChars", "linesPerPage"?: n (sets the line pitch from the text height), "charsPerLine"?: n (sets the character pitch from the text width), "linePitch"?: pt, "charSpace"?: n (4096ths of a point added to the default font size: the character pitch)} — left out stays as it is; returns the grid with its lines per page and characters per line"#, r#"{"type"?: "none|lines|linesAndChars|snapToChars", "linesPerPage"?: n (sets the line pitch from the text height), "charsPerLine"?: n (sets the character pitch from the text width), "linePitch"?: pt, "charSpace"?: n (4096ths of a point added to the default font size: the character pitch)} — left out stays as it is; returns the grid with its lines per page and characters per line"#,
), ),
@@ -234,7 +240,7 @@ fn section_of_part(s: &Session, id: u32) -> usize {
0 0
} }
fn section_index_of_focus(s: &Session) -> usize { pub fn section_index_of_focus(s: &Session) -> usize {
match s.sel.focus.story { match s.sel.focus.story {
wordcraft_doc::StoryRef::Body => s.doc.section_index_of(s.sel.focus.path.0.first().copied().unwrap_or(0) as usize), wordcraft_doc::StoryRef::Body => s.doc.section_index_of(s.sel.focus.path.0.first().copied().unwrap_or(0) as usize),
wordcraft_doc::StoryRef::Part(id) => section_of_part(s, id), wordcraft_doc::StoryRef::Part(id) => section_of_part(s, id),
@@ -1171,4 +1177,27 @@ mod tests {
assert_eq!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).matches('\u{ad}').count(), found); assert_eq!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).matches('\u{ad}').count(), found);
assert!(s.run("layout.manualHyphenation", &json!({"from": {"block": 999, "off": 0}})).unwrap()["done"] == true); assert!(s.run("layout.manualHyphenation", &json!({"from": {"block": 999, "off": 0}})).unwrap()["done"] == true);
} }
/// `layout.pageNumberFormat` sets the section's number format and start; a call without
/// `start` keeps it (the ribbon's old plain call cleared it), and a null `start` numbers on
/// from the section before — the Format Page Numbers dialog's "Continue from previous
/// section" (#1113).
#[test]
fn page_number_format_keeps_or_clears_the_start() {
use wordcraft_doc::section::NumFormat;
let mut s = doc();
s.run("layout.pageNumberFormat", &json!({"format": "upperRoman", "start": 3})).unwrap();
let x = sect(&s);
assert_eq!((x.page_num_format, x.page_num_start), (NumFormat::UpperRoman, Some(3)));
s.run("layout.pageNumberFormat", &json!({"format": "lowerLetter"})).unwrap();
let x = sect(&s);
assert_eq!((x.page_num_format, x.page_num_start), (NumFormat::LowerLetter, Some(3)), "a format change keeps the start");
s.run("layout.pageNumberFormat", &json!({"start": null})).unwrap();
let x = sect(&s);
assert_eq!((x.page_num_format, x.page_num_start), (NumFormat::LowerLetter, None), "continue numbers on from the section before");
// The result carries the section back (scripts read it instead of the dialog).
let back = s.run("layout.pageNumberFormat", &json!({"format": "decimal", "start": 1})).unwrap();
assert_eq!(back["pageNumFormat"], "decimal");
assert_eq!(back["pageNumStart"], 1);
}
} }
+10 -6
View File
@@ -510,17 +510,20 @@ fn apply_style(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s) sel_result(s)
} }
/// Toggle lists using effective paragraph numbering; an explicit off request removes numbering.
fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult { fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
let kind = p::str(v, "kind").and_then(ListKind::parse).unwrap_or(default); let kind = p::str(v, "kind").and_then(ListKind::parse).unwrap_or(default);
let off = p::bool(v, "off").unwrap_or(false); let off = p::bool(v, "off").unwrap_or(false);
let a = s.sel.ordered().0; let a = s.sel.ordered().0;
// Toggle: if every selected paragraph already has this kind of list, remove it. // Toggle: if every selected paragraph already has this kind of list, remove it.
let current: Vec<Option<NumRef>> = let current: Vec<Option<NumRef>> = super::selected_paths(s)
super::selected_paths(s).iter().map(|p| s.doc.para(a.story, p).and_then(|x| x.props.numbering).filter(|n| n.num != 0)).collect(); .iter()
.map(|p| s.doc.para(a.story, p).and_then(|x| s.doc.styles.resolve_para(&x.props).numbering).filter(|n| n.num != 0))
.collect();
let same_kind = |n: &NumRef| { let same_kind = |n: &NumRef| {
s.doc.numbering.level(n.num, 0).is_some_and(|l| { s.doc.numbering.level(n.num, n.level).is_some_and(|l| {
let want = wordcraft_doc::numbering::levels_for(kind); let want = wordcraft_doc::numbering::levels_for(kind);
want.first().is_some_and(|w| { want.get(usize::from(n.level)).is_some_and(|w| {
(w.format == wordcraft_doc::section::NumFormat::Bullet) == (l.format == wordcraft_doc::section::NumFormat::Bullet) (w.format == wordcraft_doc::section::NumFormat::Bullet) == (l.format == wordcraft_doc::section::NumFormat::Bullet)
&& (p::str(v, "kind").is_none() || w.text == l.text) && (p::str(v, "kind").is_none() || w.text == l.text)
}) })
@@ -528,13 +531,14 @@ fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
}; };
let remove = off || (!current.is_empty() && current.iter().all(|n| n.as_ref().is_some_and(same_kind))); let remove = off || (!current.is_empty() && current.iter().all(|n| n.as_ref().is_some_and(same_kind)));
if remove { if remove {
let keep_list_style = s.doc.styles.resolve_para(&ParaProps { style: Some("ListParagraph".into()), ..Default::default() }).numbering.is_some();
return fmt(s, &|p| { return fmt(s, &|p| {
p.numbering = Some(NumRef { num: 0, level: 0 }); p.numbering = Some(NumRef { num: 0, level: 0 });
p.indent_left = None; p.indent_left = None;
p.indent_first = None; p.indent_first = None;
p.indent_left_chars = None; p.indent_left_chars = None;
p.indent_first_chars = None; p.indent_first_chars = None;
if p.style.as_deref() == Some("ListParagraph") { if p.style.as_deref() == Some("ListParagraph") && !keep_list_style {
p.style = None; p.style = None;
} }
}); });
@@ -544,7 +548,7 @@ fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult {
.doc .doc
.prev_para(a.story, &a.path) .prev_para(a.story, &a.path)
.and_then(|q| s.doc.para(a.story, &q).and_then(|x| x.props.numbering)) .and_then(|q| s.doc.para(a.story, &q).and_then(|x| x.props.numbering))
.filter(|n| n.num != 0 && same_kind(n)); .filter(|n| n.num != 0 && same_kind(&NumRef { level: 0, ..*n }));
let num = match prev_num { let num = match prev_num {
Some(n) => n.num, Some(n) => n.num,
None => match kind { None => match kind {
+40 -13
View File
@@ -207,6 +207,23 @@ fn always_allowed(id: &str) -> bool {
id == "review.restrict" || id.starts_with("file.") || id == "edit.undo" || id == "edit.redo" id == "review.restrict" || id.starts_with("file.") || id == "edit.undo" || id == "edit.redo"
} }
/// Why protection refuses command `id` whatever its params and the selection, so the ribbon greys
/// it out ([`Session::why_disabled`]); cheap enough to ask for every button on every frame.
/// Observed in Word for Mac: with Filling in forms enforced, the Developer tab's control buttons
/// and Properties are greyed out and a field's shortcut menu offers no Remove Content Control.
/// The form's fields stay as they are; only their content is filled in.
pub(crate) fn greyed_out(s: &Session, id: &str) -> Option<&'static str> {
if s.doc.settings.protection.as_deref() != Some("forms") {
return None;
}
match id {
"developer.removeControl" => Some("stop protection before removing a content control"),
"developer.properties" => Some("stop protection before changing a content control's properties"),
_ if CONTROL_INSERTS.contains(&id) => Some("stop protection before inserting a content control"),
_ => None,
}
}
/// What a protected document lets command `id` do before it runs: `Err` refuses it; `Ok(true)` /// What a protected document lets command `id` do before it runs: `Err` refuses it; `Ok(true)`
/// lets it run as long as it only changes the exceptions (checked afterwards with /// lets it run as long as it only changes the exceptions (checked afterwards with
/// [`outside_unchanged`]). /// [`outside_unchanged`]).
@@ -223,19 +240,8 @@ pub(crate) fn check_before(s: &Session, id: &str, params: &Value) -> Result<bool
if id == "developer.properties" && (params.get("lockEdit").is_some() || params.get("lockDelete").is_some()) { if id == "developer.properties" && (params.get("lockEdit").is_some() || params.get("lockDelete").is_some()) {
return Err(CmdError::Disabled(format!("{id}: stop protection before changing a content control's locks"))); return Err(CmdError::Disabled(format!("{id}: stop protection before changing a content control's locks")));
} }
// Observed in Word for Mac: with Filling in forms enforced, the Developer tab's control if let Some(why) = greyed_out(s, id) {
// buttons and Properties are greyed out and a field's shortcut menu offers no Remove Content return Err(CmdError::Disabled(format!("{id}: {why}")));
// Control. The form's fields stay as they are; only their content is filled in.
if mode == "forms" {
if id == "developer.removeControl" {
return Err(CmdError::Disabled(format!("{id}: stop protection before removing a content control")));
}
if id == "developer.properties" {
return Err(CmdError::Disabled(format!("{id}: stop protection before changing a content control's properties")));
}
if CONTROL_INSERTS.contains(&id) {
return Err(CmdError::Disabled(format!("{id}: stop protection before inserting a content control")));
}
} }
if s.doc.settings.protect_formatting { if s.doc.settings.protect_formatting {
check_formatting(s, id, params)?; check_formatting(s, id, params)?;
@@ -766,6 +772,27 @@ mod tests {
assert!(s.doc.control_ranges(StoryRef::Body).is_empty()); assert!(s.doc.control_ranges(StoryRef::Body).is_empty());
} }
/// The ribbon greys out what forms mode refuses outright (as Word for Mac does), and only that:
/// typing into the field and the rest of the ribbon stay live.
#[test]
fn forms_mode_greys_out_the_control_commands() {
let mut s = Session::new(Document::from_text("Name: Fill name."));
select(&mut s, "Fill name.");
s.run("developer.richText", &json!({"tag": "name"})).unwrap();
let greyed = ["developer.removeControl", "developer.properties"].into_iter().chain(CONTROL_INSERTS);
assert!(greyed.clone().all(|id| s.why_disabled(id).is_none()), "unprotected");
s.run("review.restrict", &json!({"mode": "forms"})).unwrap();
for id in greyed {
let why = s.why_disabled(id).unwrap_or_default();
assert!(why.contains("stop protection"), "{id}: {why:?}");
}
for id in ["text.insert", "format.bold", "review.restrict", "view.zoom"] {
assert_eq!(s.why_disabled(id), None, "{id}");
}
s.run("review.restrict", &json!({"stop": true})).unwrap();
assert_eq!(s.why_disabled("developer.removeControl"), None);
}
#[test] #[test]
fn forms_mode_without_fillable_controls_stays_locked() { fn forms_mode_without_fillable_controls_stays_locked() {
let mut s = Session::new(Document::from_text("No controls here.")); let mut s = Session::new(Document::from_text("No controls here."));
+96 -4
View File
@@ -149,6 +149,11 @@ impl FieldCode {
self.switches.iter().any(|(k, _)| *k == c) self.switches.iter().any(|(k, _)| *k == c)
} }
/// A table of figures (`TOC \c` or `TOC \a`, ECMA-376 §17.16.5.68), not a table of contents.
pub fn is_table_of_figures(&self) -> bool {
self.name == "TOC" && (self.has('c') || self.has('a'))
}
/// The argument of switch `c`, if the switch is there with one. /// The argument of switch `c`, if the switch is there with one.
pub fn arg(&self, c: char) -> Option<&str> { pub fn arg(&self, c: char) -> Option<&str> {
self.switches.iter().find(|(k, _)| *k == c).and_then(|(_, a)| a.as_deref()) self.switches.iter().find(|(k, _)| *k == c).and_then(|(_, a)| a.as_deref())
@@ -360,7 +365,7 @@ fn convertible_toc_range(s: &Session, r: &wordcraft_doc::FieldRange) -> bool {
let field = FieldCode::parse(&r.instr); let field = FieldCode::parse(&r.instr);
toc_range(s).is_none() toc_range(s).is_none()
&& field.name == "TOC" && field.name == "TOC"
&& !field.has('c') && !field.is_table_of_figures()
&& r.depth == 0 && r.depth == 0
&& !r.locked && !r.locked
&& r.start.path.depth() == 0 && r.start.path.depth() == 0
@@ -425,7 +430,7 @@ fn toc_field(p: &Paragraph) -> Option<String> {
p.objects.iter().find_map(|o| match o { p.objects.iter().find_map(|o| match o {
InlineObject::Field { instr, .. } => { InlineObject::Field { instr, .. } => {
let f = FieldCode::parse(instr); let f = FieldCode::parse(instr);
(f.name == "TOC" && !f.has('c')).then(|| instr.clone()) (f.name == "TOC" && !f.is_table_of_figures()).then(|| instr.clone())
} }
_ => None, _ => None,
}) })
@@ -436,7 +441,7 @@ fn cached_toc_tabs(s: &Session) -> Vec<(String, Vec<TabStop>)> {
let span = toc_range(s).map(|(start, end)| (start.saturating_add(1), end)).or_else(|| { let span = toc_range(s).map(|(start, end)| (start.saturating_add(1), end)).or_else(|| {
s.doc.field_ranges(StoryRef::Body).into_iter().find_map(|r| { s.doc.field_ranges(StoryRef::Body).into_iter().find_map(|r| {
let f = FieldCode::parse(&r.instr); let f = FieldCode::parse(&r.instr);
(r.depth == 0 && !r.locked && r.start.path.depth() == 0 && r.end.path.depth() == 0 && f.name == "TOC" && !f.has('c')) (r.depth == 0 && !r.locked && r.start.path.depth() == 0 && r.end.path.depth() == 0 && f.name == "TOC" && !f.is_table_of_figures())
.then_some((r.start.path.last(), r.end.path.last())) .then_some((r.start.path.last(), r.end.path.last()))
}) })
}); });
@@ -560,7 +565,7 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>, tabs: &[(String, V
for o in &mut p.objects { for o in &mut p.objects {
if let InlineObject::Field { instr, result, .. } = o { if let InlineObject::Field { instr, result, .. } = o {
let f = FieldCode::parse(instr); let f = FieldCode::parse(instr);
if f.name == "TOC" && !f.has('c') { if f.name == "TOC" && !f.is_table_of_figures() {
result.clear(); result.clear();
} }
} }
@@ -1013,6 +1018,19 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
"TITLE" => updates.push((k, s.doc.core.title.clone())), "TITLE" => updates.push((k, s.doc.core.title.clone())),
"AUTHOR" => updates.push((k, s.doc.core.creator.clone())), "AUTHOR" => updates.push((k, s.doc.core.creator.clone())),
"NUMWORDS" => updates.push((k, s.doc.word_count().to_string())), "NUMWORDS" => updates.push((k, s.doc.word_count().to_string())),
// DOCPROPERTY <name>: a custom property first, then a built-in one (#1798).
"DOCPROPERTY" => {
if let Some(name) = doc_property_arg(instr)
&& let Some(v) = doc_property(&s.doc, &name)
{
updates.push((k, v));
}
}
"FILENAME" => {
let (fn_name, fn_path) = s.resolved_filenames();
let text = if has_switch(instr, 'p') { fn_path } else { fn_name };
updates.push((k, text));
}
_ => {} _ => {}
} }
} }
@@ -1074,6 +1092,38 @@ pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
} }
} }
/// The property name of a `DOCPROPERTY` field: the word (or quoted text) after the field name,
/// before any switch (`DOCPROPERTY "Project Name" \* MERGEFORMAT`).
fn doc_property_arg(instr: &str) -> Option<String> {
let rest = instr.trim().split_once(char::is_whitespace)?.1.trim_start();
let name = match rest.strip_prefix('"') {
Some(q) => q.split('"').next().unwrap_or(""),
None => rest.split([' ', '\\']).next().unwrap_or(""),
};
(!name.is_empty()).then(|| name.to_string())
}
/// The value of a document property under the name a `DOCPROPERTY` field uses: a custom property
/// when one has this name, else the built-in property (English names, case-insensitively, as
/// Word's field accepts). `None` for a name this application doesn't know.
fn doc_property(doc: &wordcraft_doc::Document, name: &str) -> Option<String> {
if let Some(v) = doc.custom_prop(name) {
return Some(v.to_string());
}
let c = &doc.core;
Some(match name.trim().to_ascii_lowercase().as_str() {
"title" => c.title.clone(),
"subject" => c.subject.clone(),
"author" | "creator" => c.creator.clone(),
"keywords" => c.keywords.clone(),
"comments" | "description" => c.description.clone(),
"category" => c.category.clone(),
"lastmodifiedby" | "last modified by" | "lastsavedby" | "last saved by" => c.last_modified_by.clone(),
"revisionnumber" | "revision number" | "revision" => c.revision.to_string(),
_ => return None,
})
}
/// A field's `\@` date picture: the quoted text after it (switches may follow), or one word. /// A field's `\@` date picture: the quoted text after it (switches may follow), or one word.
pub fn date_picture(instr: &str) -> Option<String> { pub fn date_picture(instr: &str) -> Option<String> {
let rest = instr.split_once("\\@")?.1.trim_start(); let rest = instr.split_once("\\@")?.1.trim_start();
@@ -1325,6 +1375,48 @@ mod tests {
out out
} }
#[test]
fn update_fields_filename() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.path = Some(std::path::PathBuf::from("/docs/my_thesis.docx"));
s.run("insert.field", &serde_json::json!({"instr": "FILENAME"})).unwrap();
let fields = story_fields(&s, StoryRef::Body);
assert_eq!(fields[0], ("FILENAME".into(), "my_thesis.docx".into()));
s.run("insert.field", &serde_json::json!({"instr": r"FILENAME \p"})).unwrap();
let fields = story_fields(&s, StoryRef::Body);
assert_eq!(fields[1], (r"FILENAME \p".into(), "/docs/my_thesis.docx".into()));
}
#[test]
fn update_fields_filename_titled_unsaved() {
let mut doc = wordcraft_doc::Document::new();
doc.core.title = "Important Document".into();
let mut s = Session::new(doc);
s.run("insert.field", &serde_json::json!({"instr": "FILENAME"})).unwrap();
let fields = story_fields(&s, StoryRef::Body);
assert_eq!(fields[0], ("FILENAME".into(), "Document1".into()));
let l = s.layout();
let text: String = l.pages[0]
.items
.iter()
.filter_map(|it| {
if let wordcraft_layout::Placed::Lines { para, .. } = it {
Some(para.shown.iter().map(|x| x.1.clone()).collect::<String>())
} else {
None
}
})
.collect();
assert!(text.contains("Document1"), "live layout renders Document1: {text}");
assert!(!text.contains("Important Document"), "live layout must not render title: {text}");
s.run("references.updateFields", &serde_json::json!({})).unwrap();
let fields_after = story_fields(&s, StoryRef::Body);
assert_eq!(fields_after[0], ("FILENAME".into(), "Document1".into()));
}
/// #1706 (Word check): Update Field with the caret in the body leaves a header's fields alone. /// #1706 (Word check): Update Field with the caret in the body leaves a header's fields alone.
#[test] #[test]
fn update_fields_in_the_body_leaves_headers_alone() { fn update_fields_in_the_body_leaves_headers_alone() {
+271 -13
View File
@@ -12,17 +12,21 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
CommandSpec::new("review.newComment", "New Comment", "Review › Comments", new_comment).key(super::mac_or("Mod+Alt+A / Mod+Alt+M", "Mod+Alt+M")).params(r#"{"text": string}"#), CommandSpec::new("review.newComment", "New Comment", "Review › Comments", new_comment).key(super::mac_or("Mod+Alt+A / Mod+Alt+M", "Mod+Alt+M")).params(r#"{"text": string}"#),
CommandSpec::new("review.reply", "Reply", "Review › Comments", reply).params(r#"{"id": n, "text": string}"#).when(comments_change), CommandSpec::new("review.reply", "Reply", "Review › Comments", reply)
CommandSpec::new("review.editComment", "Edit Comment", "Review › Comments", edit_comment) .params(r#"{"id": non-negative JSON integer, "text": string}"#)
.params(r#"{"id": n, "text": string}"#)
.when(comments_change), .when(comments_change),
CommandSpec::new("review.deleteComment", "Delete", "Review › Comments", delete_comment).params(r#"{"id"?: n, "all"?: bool}"#), CommandSpec::new("review.editComment", "Edit Comment", "Review › Comments", edit_comment)
.params(r#"{"id": non-negative JSON integer, "text": string}"#)
.when(comments_change),
CommandSpec::new("review.deleteComment", "Delete", "Review › Comments", delete_comment).params(r#"{"id"?: non-negative JSON integer (omit to use the caret), "all"?: bool}"#),
CommandSpec::new("review.resolveComment", "Resolve", "Review › Comments", |s, v| { CommandSpec::new("review.resolveComment", "Resolve", "Review › Comments", |s, v| {
validate_comment_id(v)?;
let id = p::u64(v, "id").map(|x| x as u32).or_else(|| comment_at_caret(s)).ok_or_else(|| CmdError::Params("`id` required".into()))?; let id = p::u64(v, "id").map(|x| x as u32).or_else(|| comment_at_caret(s)).ok_or_else(|| CmdError::Params("`id` required".into()))?;
let c = s.doc.comments.get_mut(&id).ok_or_else(|| CmdError::Params("no such comment".into()))?; let c = s.doc.comments.get_mut(&id).ok_or_else(|| CmdError::Params("no such comment".into()))?;
c.resolved = p::bool(v, "value").unwrap_or(!c.resolved); c.resolved = p::bool(v, "value").unwrap_or(!c.resolved);
sel_result(s) sel_result(s)
}), })
.params(r#"{"id"?: non-negative JSON integer (omit to use the caret), "value"?: bool (omit to toggle)}"#),
CommandSpec::new("review.nextComment", "Next", "Review › Comments", |s, _| nav_comment(s, 1)).pure(), CommandSpec::new("review.nextComment", "Next", "Review › Comments", |s, _| nav_comment(s, 1)).pure(),
CommandSpec::new("review.previousComment", "Previous", "Review › Comments", |s, _| nav_comment(s, -1)).pure(), CommandSpec::new("review.previousComment", "Previous", "Review › Comments", |s, _| nav_comment(s, -1)).pure(),
CommandSpec::new("review.comments", "Show Comments", "Review › Comments", list_comments).pure(), CommandSpec::new("review.comments", "Show Comments", "Review › Comments", list_comments).pure(),
@@ -110,6 +114,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"text": string}"#), .params(r#"{"text": string}"#),
CommandSpec::new("review.proofing", "Check Spelling as You Type", "File › Options › Proofing", |s, v| { CommandSpec::new("review.proofing", "Check Spelling as You Type", "File › Options › Proofing", |s, v| {
s.view.proofing = p::bool(v, "value").unwrap_or(!s.view.proofing); s.view.proofing = p::bool(v, "value").unwrap_or(!s.view.proofing);
s.prefs.check_spelling = s.view.proofing;
Ok(json!({"value": s.view.proofing})) Ok(json!({"value": s.view.proofing}))
}) })
.pure(), .pure(),
@@ -146,7 +151,17 @@ fn new_comment(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"id": id, "story": part})) Ok(json!({"id": id, "story": part}))
} }
/// An omitted ID permits caret targeting; an explicit malformed value must not.
/// Unlike the general numeric parameter helper, comment IDs never coerce floats.
fn validate_comment_id(v: &Value) -> Result<(), CmdError> {
if v.get("id").is_some_and(|id| id.as_u64().is_none_or(|n| u32::try_from(n).is_err())) {
return Err(CmdError::Params("`id` must be a non-negative 32-bit JSON integer".into()));
}
Ok(())
}
fn reply(s: &mut Session, v: &Value) -> CmdResult { fn reply(s: &mut Session, v: &Value) -> CmdResult {
validate_comment_id(v)?;
let parent = p::u64(v, "id").map(|x| x as u32).ok_or_else(|| CmdError::Params("`id` required".into()))?; let parent = p::u64(v, "id").map(|x| x as u32).ok_or_else(|| CmdError::Params("`id` required".into()))?;
if !s.doc.comments.contains_key(&parent) { if !s.doc.comments.contains_key(&parent) {
return Err(CmdError::Params("no such comment".into())); return Err(CmdError::Params("no such comment".into()));
@@ -179,6 +194,7 @@ fn comments_change(s: &Session) -> Option<&'static str> {
/// Replace a comment's (or reply's) text; each line becomes a paragraph. /// Replace a comment's (or reply's) text; each line becomes a paragraph.
fn edit_comment(s: &mut Session, v: &Value) -> CmdResult { fn edit_comment(s: &mut Session, v: &Value) -> CmdResult {
validate_comment_id(v)?;
let id = p::u64(v, "id").and_then(|x| u32::try_from(x).ok()).ok_or_else(|| CmdError::Params("`id` required".into()))?; let id = p::u64(v, "id").and_then(|x| u32::try_from(x).ok()).ok_or_else(|| CmdError::Params("`id` required".into()))?;
let text = p::req_str(v, "text")?; let text = p::req_str(v, "text")?;
let part = s.doc.comments.get(&id).map(|c| c.part).ok_or_else(|| CmdError::Params("no such comment".into()))?; let part = s.doc.comments.get(&id).map(|c| c.part).ok_or_else(|| CmdError::Params("no such comment".into()))?;
@@ -269,6 +285,7 @@ fn thread_of(s: &Session, id: u32) -> Vec<u32> {
} }
fn delete_comment(s: &mut Session, v: &Value) -> CmdResult { fn delete_comment(s: &mut Session, v: &Value) -> CmdResult {
validate_comment_id(v)?;
let ids: Vec<u32> = if p::bool(v, "all").unwrap_or(false) { let ids: Vec<u32> = if p::bool(v, "all").unwrap_or(false) {
s.doc.comments.keys().copied().collect() s.doc.comments.keys().copied().collect()
} else { } else {
@@ -285,14 +302,22 @@ fn delete_comment(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"deleted": ids})) Ok(json!({"deleted": ids}))
} }
/// Comments in document order with their anchors. /// Comments in reading order with their anchors: every story the reader sees, in
/// `Document::shown_stories` order (#2289). A comment anchored only in a story nothing shows
/// (a removed header) has no anchor.
pub fn comment_list(s: &Session) -> Vec<(u32, Option<Pos>)> { pub fn comment_list(s: &Session) -> Vec<(u32, Option<Pos>)> {
let mut found: Vec<(u32, Option<Pos>)> = Vec::new(); let mut found: Vec<(u32, Option<Pos>)> = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) { // The balloons ask every frame; most documents have no comments.
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; if s.doc.comments.is_empty() {
for o in p.object_offsets() { return found;
if let Some(InlineObject::CommentStart { id }) = p.object_at(o) { }
found.push((*id, Some(Pos { story: StoryRef::Body, path: path.clone(), off: o }))); for story in s.doc.shown_stories() {
for path in s.doc.para_paths(story) {
let Some(p) = s.doc.para(story, &path) else { continue };
for o in p.object_offsets() {
if let Some(InlineObject::CommentStart { id }) = p.object_at(o) {
found.push((*id, Some(Pos { story, path: path.clone(), off: o })));
}
} }
} }
} }
@@ -322,8 +347,16 @@ fn list_comments(s: &mut Session, _: &Value) -> CmdResult {
fn nav_comment(s: &mut Session, dir: i32) -> CmdResult { fn nav_comment(s: &mut Session, dir: i32) -> CmdResult {
let list: Vec<Pos> = comment_list(s).into_iter().filter_map(|(_, p)| p).collect(); let list: Vec<Pos> = comment_list(s).into_iter().filter_map(|(_, p)| p).collect();
let caret = s.sel.focus.clone(); // Reading order: the story's place in `shown_stories`, then the place in the story. Part ids
let t = if dir > 0 { list.iter().find(|p| **p > caret).or(list.first()) } else { list.iter().rev().find(|p| **p < caret).or(list.last()) }; // follow creation, not reading, so `Pos` order won't do across stories.
let rank: BTreeMap<StoryRef, usize> = s.doc.shown_stories().into_iter().enumerate().map(|(i, st)| (st, i)).collect();
let key = |p: &Pos| rank.get(&p.story).map(|i| (*i, p.path.clone(), p.off));
let caret = key(&s.sel.focus);
let t = if dir > 0 {
list.iter().find(|p| caret.is_some() && key(p) > caret).or(list.first())
} else {
list.iter().rev().find(|p| caret.is_some() && key(p) < caret).or(list.last())
};
if let Some(p) = t { if let Some(p) = t {
s.sel = Selection::caret(p.clone()); s.sel = Selection::caret(p.clone());
} }
@@ -1747,6 +1780,231 @@ mod tests {
assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed());
} }
/// A comment on the whole of the story the caret is in; its id and that story.
fn comment_here(s: &mut Session, text: &str) -> (u64, wordcraft_doc::StoryRef) {
let story = s.sel.focus.story;
s.run("select.all", &json!({})).unwrap();
(s.run("review.newComment", &json!({"text": text})).unwrap()["id"].as_u64().unwrap(), story)
}
/// Comments in a header, a footer, a text box and a footnote, made in that order, then one
/// on a word of the body. Returns each comment's id and story in reading order, which isn't
/// the order they were made: the body, the text box and footnote it shows, then the header
/// and the footer.
fn comments_in_every_story(s: &mut Session) -> Vec<(u64, wordcraft_doc::StoryRef)> {
s.view.proofing = false;
s.run("document.setText", &json!({"text": "Body text with a comment on it."})).unwrap();
s.run("insert.header", &json!({"text": "header"})).unwrap();
let header = comment_here(s, "header");
s.run("insert.closeHeader", &json!({})).unwrap();
s.run("insert.footer", &json!({"text": "footer"})).unwrap();
let footer = comment_here(s, "footer");
s.run("insert.closeHeader", &json!({})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.textBox", &json!({"text": "boxed"})).unwrap();
let text_box = comment_here(s, "text box");
s.run("insert.closeHeader", &json!({})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("references.footnote", &json!({"text": "noted"})).unwrap();
let note = comment_here(s, "footnote");
s.run("insert.closeHeader", &json!({})).unwrap();
s.run("select.text", &json!({"text": "comment"})).unwrap();
let body = (s.run("review.newComment", &json!({"text": "body"})).unwrap()["id"].as_u64().unwrap(), wordcraft_doc::StoryRef::Body);
s.run("caret.docStart", &json!({})).unwrap();
vec![body, text_box, note, header, footer]
}
/// `review.comments` as (id, the anchor's story).
fn listed(s: &mut Session) -> Vec<(u64, Option<wordcraft_doc::StoryRef>)> {
let list = s.run("review.comments", &json!({})).unwrap();
list.as_array().unwrap().iter().map(|c| (c["id"].as_u64().unwrap(), serde_json::from_value(c["anchor"]["story"].clone()).ok())).collect()
}
/// The stories Next Comment visits, `n` presses from the caret.
fn next_stories(s: &mut Session, n: usize) -> Vec<wordcraft_doc::StoryRef> {
(0..n)
.map(|_| {
s.run("review.nextComment", &json!({})).unwrap();
s.sel.focus.story
})
.collect()
}
/// #2289: comments in headers, footers, text boxes and footnotes are listed with their
/// anchors, in reading order rather than the order their stories were made.
#[test]
fn comments_outside_the_body_are_listed_with_their_anchors() {
let mut s = Session::new(Document::new());
let want: Vec<_> = comments_in_every_story(&mut s).into_iter().map(|(id, st)| (id, Some(st))).collect();
assert_eq!(listed(&mut s), want);
}
/// #2289: Next and Previous Comment go through every story's comments in reading order and
/// wrap around.
#[test]
fn next_and_previous_comment_reach_comments_outside_the_body() {
let mut s = Session::new(Document::new());
let want: Vec<_> = comments_in_every_story(&mut s).into_iter().map(|(_, st)| st).collect();
let mut wrapped = want.clone();
wrapped.push(want[0]);
assert_eq!(next_stories(&mut s, want.len() + 1), wrapped, "Next visits each comment, then wraps to the first");
let back: Vec<_> = (0..want.len())
.map(|_| {
s.run("review.previousComment", &json!({})).unwrap();
s.sel.focus.story
})
.collect();
let mut rev = want.clone();
rev.reverse();
assert_eq!(back, rev, "Previous from the first comment wraps to the last");
}
/// #2289: a header comes before the footer whichever was made first.
#[test]
fn next_comment_reaches_the_header_before_the_footer() {
let mut s = Session::new(Document::new());
s.view.proofing = false;
s.run("document.setText", &json!({"text": "Body"})).unwrap();
s.run("insert.footer", &json!({"text": "footer"})).unwrap();
let footer = comment_here(&mut s, "footer").1;
s.run("insert.closeHeader", &json!({})).unwrap();
s.run("insert.header", &json!({"text": "header"})).unwrap();
let header = comment_here(&mut s, "header").1;
s.run("insert.closeHeader", &json!({})).unwrap();
assert_eq!(next_stories(&mut s, 2), vec![header, footer]);
}
/// #2289: a comment left in a story nothing shows any more (a removed header, a deleted
/// footnote's text) has no anchor, and Next never takes the caret into that hidden story.
#[test]
fn comments_in_hidden_stories_are_not_navigated_to() {
let mut s = Session::new(Document::new());
let all = comments_in_every_story(&mut s);
let (note, header) = (all[2], all[3]);
s.run("insert.removeHeader", &json!({})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("text.backspace", &json!({})).unwrap();
assert!(!s.doc.shown_stories().contains(&note.1), "the footnote's reference is gone");
let list = listed(&mut s);
for hidden in [note, header] {
assert!(list.contains(&(hidden.0, None)), "comment {} sits in a hidden story: {list:?}", hidden.0);
}
let shown = [all[0].1, all[1].1, all[4].1];
for st in next_stories(&mut s, all.len()) {
assert!(shown.contains(&st), "Next went to hidden {st:?}");
}
}
/// A body comment, then, with `option` on, a comment in the header of page `page`; `option`
/// is turned off again. Returns the header comment's id and story.
fn comment_in_header_version(s: &mut Session, option: &str, page: usize) -> (u64, wordcraft_doc::StoryRef) {
s.view.proofing = false;
s.run("document.setText", &json!({"text": "Body text with a comment on it."})).unwrap();
s.run("select.text", &json!({"text": "comment"})).unwrap();
s.run("review.newComment", &json!({"text": "body"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.pageBreak", &json!({})).unwrap();
s.run(option, &json!({"value": true})).unwrap();
s.run("insert.editHeader", &json!({"page": page})).unwrap();
s.run("text.insert", &json!({"text": "header"})).unwrap();
let header = comment_here(s, "header");
s.run("insert.closeHeader", &json!({})).unwrap();
s.run(option, &json!({"value": false})).unwrap();
s.run("caret.docStart", &json!({})).unwrap();
header
}
/// A first-page or even-page header is hidden while its option is off: its comments have no
/// anchor and Next skips them.
#[test]
fn comments_in_header_versions_whose_option_is_off_are_not_navigated_to() {
for (option, page, slot) in [("layout.differentFirstPage", 0, "first"), ("layout.differentOddEven", 1, "even")] {
let mut s = Session::new(Document::new());
let (id, story) = comment_in_header_version(&mut s, option, page);
let headers = s.doc.sections().first().map(|(_, sec)| sec.headers).unwrap();
let want = if slot == "first" { headers.first } else { headers.even };
assert_eq!(want.map(wordcraft_doc::StoryRef::Part), Some(story), "{option}: the comment is in the {slot} header");
assert!(listed(&mut s).contains(&(id, None)), "{option} off: no anchor");
for st in next_stories(&mut s, 3) {
assert_eq!(st, wordcraft_doc::StoryRef::Body, "{option} off: Next went to the hidden {slot} header");
}
s.run(option, &json!({"value": true})).unwrap();
assert!(listed(&mut s).contains(&(id, Some(story))), "{option} on: anchored again");
}
}
/// With Different First Page on, the first page's header comes before the later pages'
/// header, in the list and for Next.
#[test]
fn first_page_header_comes_before_the_default_header() {
let mut s = Session::new(Document::new());
s.view.proofing = false;
s.run("document.setText", &json!({"text": "Page one"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.pageBreak", &json!({})).unwrap();
s.run("layout.differentFirstPage", &json!({"value": true})).unwrap();
let mut header = |page: usize| {
s.run("insert.editHeader", &json!({"page": page})).unwrap();
s.run("text.insert", &json!({"text": "header"})).unwrap();
let c = comment_here(&mut s, "header");
s.run("insert.closeHeader", &json!({})).unwrap();
c
};
let (first, default) = (header(0), header(1));
assert_ne!(first.1, default.1);
s.run("caret.docStart", &json!({})).unwrap();
assert_eq!(listed(&mut s), vec![(first.0, Some(first.1)), (default.0, Some(default.1))]);
assert_eq!(next_stories(&mut s, 2), vec![first.1, default.1]);
}
/// A linked text box shows the first box's text: comments in its own text have no anchor and
/// Next skips them.
#[test]
fn comments_in_a_linked_text_box_own_text_are_not_navigated_to() {
let mut s = Session::new(Document::new());
s.view.proofing = false;
s.run("document.setText", &json!({"text": "Body text with a comment on it."})).unwrap();
s.run("select.text", &json!({"text": "comment"})).unwrap();
s.run("review.newComment", &json!({"text": "body"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.textBox", &json!({"text": "first box"})).unwrap();
let first = s.sel.focus.story;
s.run("insert.closeHeader", &json!({})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
// Only a blank box can be linked to.
s.run("insert.textBox", &json!({"text": " "})).unwrap();
let (id, follower) = comment_here(&mut s, "follower");
s.run("insert.closeHeader", &json!({})).unwrap();
s.run("caret.set", &json!({"pos": {"story": first, "path": [0], "off": 0}})).unwrap();
s.run("shape.link", &json!({"target": 1})).unwrap();
assert_eq!(s.doc.text_box_chains().first().and_then(|c| c.get(1)).copied().map(wordcraft_doc::StoryRef::Part), Some(follower));
s.run("caret.docStart", &json!({})).unwrap();
assert!(listed(&mut s).contains(&(id, None)), "linked: no anchor");
for st in next_stories(&mut s, 3) {
assert_ne!(st, follower, "Next went into the linked box's own text");
}
s.run("caret.set", &json!({"pos": {"story": first, "path": [0], "off": 0}})).unwrap();
s.run("shape.breakLink", &json!({})).unwrap();
assert!(listed(&mut s).contains(&(id, Some(follower))), "unlinked: anchored again");
}
/// Text inside a comment isn't an anchor: a comment marker in a comment's own story is not
/// listed as that comment's place in the document.
#[test]
fn comment_list_skips_comment_text() {
let mut s = Session::new(Document::new());
s.view.proofing = false;
s.run("document.setText", &json!({"text": "hello"})).unwrap();
s.run("select.all", &json!({})).unwrap();
let id = s.run("review.newComment", &json!({"text": "note"})).unwrap()["id"].as_u64().unwrap() as u32;
let part = s.doc.comments.get(&id).unwrap().part;
let at = wordcraft_doc::Pos { story: wordcraft_doc::StoryRef::Part(part), path: wordcraft_doc::Path::top(0), off: 0 };
s.doc.insert_object(&at, wordcraft_doc::InlineObject::CommentStart { id }, &Default::default()).unwrap();
let list = super::comment_list(&s);
assert_eq!(list.iter().filter(|(x, _)| *x == id).count(), 1);
assert_eq!(list.first().and_then(|(_, p)| p.as_ref()).map(|p| p.story), Some(wordcraft_doc::StoryRef::Body));
}
#[test] #[test]
fn rtf_tracked_changes_accept_and_reject_after_opening_and_round_trip() { fn rtf_tracked_changes_accept_and_reject_after_opening_and_round_trip() {
// Gap 19: an RTF file's tracked insertions and deletions open as tracked changes, also // Gap 19: an RTF file's tracked insertions and deletions open as tracked changes, also
+447
View File
@@ -0,0 +1,447 @@
//! Shape Format › Shape Fill (#1500): a solid colour with its transparency, or a gradient, for
//! every selected shape, text box and drawing canvas.
use serde_json::Value;
use std::sync::Arc;
use wordcraft_doc::Pos;
use wordcraft_doc::fill::{Fill, Gradient, GradientFlip, GradientKind, PathShape};
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::Rgb;
use wordcraft_doc::wordart::GradStop;
use super::objects::{edit_obj, picked_objects, selected_all};
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// Most shapes one command fills (a selection over a whole document).
const MAX_FILL_SHAPES: usize = 10_000;
/// Most stops read from a command (the fill keeps at most `MAX_STOPS` after sanitizing).
const MAX_PARAM_STOPS: usize = 64;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("shape.fill", "Shape Fill", "Shape Format › Shape Styles", shape_fill)
.params(
r#"{"color": "RRGGBB" | null, "transparency"?: %} | {"gradient": {"stops": [{"pos": %, "color": "RRGGBB", "transparency"?: %}], "type"?: "linear|radial|rectangular|path", "angle"?: deg, "focus"?: [l, t, r, b], "tile"?: [l, t, r, b], "scaled"?: bool, "rotateWithShape"?: bool}} (tile: percent insets of the gradient's area; scaled: a linear angle scaled with the shape; a colour alone keeps the shape's transparency; angle: linear only, 0 = left to right, 90 = top to bottom; focus: percent insets of where a radial, rectangular or path gradient starts; applies to every selected shape, text box and canvas background)"#,
)
.when(super::objects::has_shape_or_canvas),
]
}
/// What a `shape.fill` command sets.
enum Change {
None,
/// A colour, with its transparency or (`None`) keeping each shape's.
Solid(Rgb, Option<f32>),
Fill(Fill),
}
fn shape_fill(s: &mut Session, v: &Value) -> CmdResult {
let change = parse(v)?;
let targets = targets(s);
if targets.is_empty() {
return Err(CmdError::Disabled("no shape selected".into()));
}
let mut last = Value::Null;
for pos in &targets {
let o = edit_obj(s, pos, |o| {
let slot = match o {
InlineObject::Shape { fill, .. } => fill,
o => match super::canvas::style_mut(o) {
Some(style) => &mut style.fill,
None => return,
},
};
*slot = match &change {
Change::None => None,
Change::Fill(f) => Some(f.clone()),
Change::Solid(color, t) => {
let kept = match slot {
Some(Fill::Solid { transparency, .. }) => *transparency,
_ => 0.0,
};
Fill::Solid { color: *color, transparency: t.unwrap_or(kept) }.sanitized()
}
};
})?;
last = serde_json::to_value(o).unwrap_or(Value::Null);
}
Ok(serde_json::json!({"object": last, "count": targets.len()}))
}
/// The shapes and canvases in the selection and those added to it (Shift+click).
fn targets(s: &Session) -> Vec<Pos> {
let fillable = |o: &InlineObject| matches!(o, InlineObject::Shape { .. }) || o.is_canvas();
let mut out: Vec<Pos> = selected_all(s, MAX_FILL_SHAPES).into_iter().filter(|(_, o)| fillable(o)).map(|(pos, _)| pos).collect();
for pos in picked_objects(s) {
if !out.contains(&pos) && s.doc.para_at(&pos).and_then(|q| q.object_at(pos.off)).is_some_and(fillable) {
out.push(pos);
}
}
out
}
fn color(v: &Value) -> Result<Rgb, CmdError> {
p::str(v, "color").and_then(Rgb::parse).ok_or_else(|| CmdError::Params("`color`: \"RRGGBB\"".into()))
}
fn parse(v: &Value) -> Result<Change, CmdError> {
let Some(g) = v.get("gradient") else {
return match v.get("color") {
None | Some(Value::Null) => Ok(Change::None),
Some(_) => Ok(Change::Solid(color(v)?, p::f32(v, "transparency"))),
};
};
let bad = |m: &str| CmdError::Params(format!("`gradient`: {m}"));
if !g.is_object() {
return Err(bad("an object"));
}
let stops = g
.get("stops")
.and_then(Value::as_array)
.ok_or_else(|| bad("`stops` is a list"))?
.iter()
.take(MAX_PARAM_STOPS)
.map(|st| Ok(GradStop { pos: p::f32(st, "pos").unwrap_or(0.0), color: color(st)?, transparency: p::f32(st, "transparency").unwrap_or(0.0) }))
.collect::<Result<Vec<_>, CmdError>>()?;
if stops.len() < 2 {
return Err(bad("two or more stops"));
}
let rect = |key: &str, default: f32| -> Option<[f32; 4]> {
let a = g.get(key)?.as_array()?;
let mut r = [default; 4];
for (slot, x) in r.iter_mut().zip(a) {
*slot = x.as_f64().map_or(default, |x| x as f32);
}
Some(r)
};
let path = |shape| GradientKind::Path { shape, focus: rect("focus", 50.0).unwrap_or([50.0; 4]) };
let kind = match p::str(g, "type").unwrap_or("linear") {
"linear" => {
GradientKind::Linear { angle: p::f32(g, "angle").unwrap_or(90.0), scaled: g.get("scaled").and_then(Value::as_bool).unwrap_or(false) }
}
"radial" => path(PathShape::Circle),
"rectangular" => path(PathShape::Rect),
"path" => path(PathShape::Shape),
t => return Err(bad(&format!("unknown type `{t}`"))),
};
let rot_with_shape = g.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true);
let fill = Fill::Gradient(Arc::new(Gradient { stops, kind, tile: rect("tile", 0.0), rot_with_shape, flip: GradientFlip::None }));
fill.sanitized().map(Change::Fill).ok_or_else(|| bad("no stops"))
}
/// The `shape.fill` params that set `fill` (`None`: no fill).
pub fn params(fill: Option<&Fill>) -> Value {
use serde_json::json;
match fill {
None => json!({"color": null}),
Some(Fill::Solid { color, transparency }) => json!({"color": color.hex(), "transparency": transparency}),
Some(Fill::Gradient(g)) => {
let stops: Vec<Value> = g.stops.iter().map(|s| json!({"pos": s.pos, "color": s.color.hex(), "transparency": s.transparency})).collect();
let mut out = json!({"stops": stops, "rotateWithShape": g.rot_with_shape});
if let Some(t) = g.tile {
out["tile"] = json!(t);
}
match g.kind {
GradientKind::Linear { angle, scaled } => {
out["type"] = json!("linear");
out["angle"] = json!(angle);
out["scaled"] = json!(scaled);
}
GradientKind::Path { shape, focus } => {
out["type"] = json!(match shape {
PathShape::Circle => "radial",
PathShape::Rect => "rectangular",
PathShape::Shape => "path",
});
out["focus"] = json!(focus);
}
}
json!({"gradient": out})
}
}
}
/// The directions Format Shape offers for a linear gradient, as angles (0 = left to right, 90 =
/// top to bottom).
pub const LINEAR_DIRECTIONS: [f32; 8] = [45.0, 90.0, 135.0, 0.0, 180.0, 315.0, 270.0, 225.0];
/// The directions Format Shape offers for radial and rectangular gradients, as where they start
/// (percent insets, left, top, right, bottom): each corner, then the centre.
pub const PATH_DIRECTIONS: [[f32; 4]; 5] =
[[100.0, 100.0, 0.0, 0.0], [0.0, 100.0, 100.0, 0.0], [50.0, 50.0, 50.0, 50.0], [100.0, 0.0, 0.0, 100.0], [0.0, 0.0, 100.0, 100.0]];
fn grad(stops: &[(f32, Rgb)], kind: GradientKind) -> Fill {
let stops = stops.iter().map(|&(pos, color)| GradStop { pos, color, transparency: 0.0 }).collect();
Fill::Gradient(Arc::new(Gradient { stops, kind, tile: None, rot_with_shape: true, flip: GradientFlip::None }))
}
/// The gradient Format Shape's Gradient fill starts from, from the theme's first accent
/// whatever the shape's colour (as Word 365 does): four stops, 0% at 95% lighter, 74% and 83%
/// at 55% lighter, 100% at 70% lighter, top to bottom, scaled with the shape.
pub fn default_gradient(accent1: Rgb) -> Fill {
let lighter = |off: f64| {
let c = accent1.modified("lumMod", 1.0 - off);
c.modified("lumOff", off)
};
let stops = [(0.0, 0.95), (74.0, 0.55), (83.0, 0.55), (100.0, 0.7)]
.into_iter()
.map(|(pos, off)| GradStop { pos, color: lighter(off), transparency: 0.0 })
.collect();
let g = Gradient { stops, kind: GradientKind::Linear { angle: 90.0, scaled: true }, tile: None, rot_with_shape: true, flip: GradientFlip::None };
Fill::Gradient(Arc::new(g))
}
/// Where each corner tile's gradient starts (percent insets) and the tile rectangle Word gives
/// it: from the bottom-right, bottom-left, top-right and top-left corners. Observed for the
/// bottom-right corner (`fillToRect l=t=100%`, `tileRect r=b=-100%`); the others mirror it.
const CORNERS: [([f32; 4], [f32; 4]); 4] = [
([100.0, 100.0, 0.0, 0.0], [0.0, 0.0, -100.0, -100.0]),
([0.0, 100.0, 100.0, 0.0], [-100.0, 0.0, 0.0, -100.0]),
([100.0, 0.0, 0.0, 100.0], [0.0, -100.0, -100.0, 0.0]),
([0.0, 0.0, 100.0, 100.0], [-100.0, -100.0, 0.0, 0.0]),
];
/// The tile rectangle Word gives a radial or rectangular gradient starting at `focus`: a corner's,
/// or none from the centre.
pub fn corner_tile(focus: [f32; 4]) -> Option<[f32; 4]> {
CORNERS.iter().find(|(f, _)| *f == focus).map(|(_, t)| *t)
}
/// Shape Fill › Gradient's Light and Dark Variations of `c`, each with its name, in Word's
/// order (a grid five wide): three stops at 0, 50 and 100%, light ones `c` tinted to 66, 44.5
/// and 23.5% with saturation ×1.6, dark ones shaded to 30, 67.5 and 100% with saturation
/// ×1.15 (as Word writes them); linear ones scaled with the shape, the rest circular.
pub fn variations(c: Rgb) -> [Vec<(Fill, String)>; 2] {
let tiles = |m: &str, steps: [f64; 3], sat: f64| -> Vec<(Fill, String)> {
let stop = |i: usize| {
let color = c.modified(m, steps[i]).modified("satMod", sat);
GradStop { pos: i as f32 * 50.0, color, transparency: 0.0 }
};
let fill = |kind, tile| {
let g = Gradient { stops: (0..3).map(stop).collect(), kind, tile, rot_with_shape: true, flip: GradientFlip::None };
Fill::Gradient(Arc::new(g))
};
let lin = |angle: f32| (fill(GradientKind::Linear { angle, scaled: true }, None), linear_direction_name(angle));
let corner = |i: usize| {
let (focus, tile) = CORNERS[i];
(fill(GradientKind::Path { shape: PathShape::Circle, focus }, Some(tile)), path_direction_name(focus))
};
let centre = (fill(GradientKind::Path { shape: PathShape::Circle, focus: [50.0; 4] }, None), path_direction_name([50.0; 4]));
vec![
lin(45.0),
lin(90.0),
lin(135.0),
corner(0),
corner(1),
lin(0.0),
centre,
lin(180.0),
corner(2),
corner(3),
lin(315.0),
lin(270.0),
lin(225.0),
]
};
[tiles("tint", [0.66, 0.445, 0.235], 1.6), tiles("shade", [0.3, 0.675, 1.0], 1.15)]
}
/// The 30 preset gradients from the theme's six accents: per accent, soft, lit from the top,
/// deep, lit from the bottom and centre glow. Our own design (the repo's clean-room rule: no
/// copying Word's presets), on the same accents-by-styles grid as Word's.
pub fn presets(theme: &[Rgb]) -> Vec<Fill> {
let accent = |i: usize| theme.get(4 + i).copied().unwrap_or(Rgb(68, 114, 196));
let down = GradientKind::Linear { angle: 90.0, scaled: false };
let mut out = Vec::with_capacity(30);
for style in 0..5 {
for i in 0..6 {
let c = accent(i);
out.push(match style {
0 => grad(&[(0.0, c.modified("tint", 0.2)), (100.0, c.modified("tint", 0.6))], down),
1 => grad(&[(0.0, c.modified("tint", 0.4)), (50.0, c), (100.0, c.modified("shade", 0.75))], down),
2 => grad(&[(0.0, c), (100.0, c.modified("shade", 0.5))], down),
3 => grad(&[(0.0, c.modified("shade", 0.75)), (50.0, c), (100.0, c.modified("tint", 0.4))], down),
_ => grad(
&[(0.0, c.modified("tint", 0.4)), (100.0, c.modified("shade", 0.75))],
GradientKind::Path { shape: PathShape::Circle, focus: [50.0; 4] },
),
});
}
}
out
}
/// The preset styles' names, in [`presets`]'s order (each for accents 1 to 6).
pub const PRESET_STYLES: [&str; 5] = ["Soft", "Lit from Top", "Deep", "Lit from Bottom", "Center Glow"];
/// The name of the linear direction at `angle`.
pub fn linear_direction_name(angle: f32) -> String {
match angle.round() as i32 {
0 => "Linear Right",
45 => "Linear Diagonal - Top Left to Bottom Right",
90 => "Linear Down",
135 => "Linear Diagonal - Top Right to Bottom Left",
180 => "Linear Left",
225 => "Linear Diagonal - Bottom Right to Top Left",
270 => "Linear Up",
315 => "Linear Diagonal - Bottom Left to Top Right",
_ => "Linear",
}
.into()
}
/// The name of the radial or rectangular direction starting at `focus`.
pub fn path_direction_name(focus: [f32; 4]) -> String {
match focus.map(|v| v.round() as i32) {
[0, 0, 100, 100] => "From Top Left Corner",
[100, 0, 0, 100] => "From Top Right Corner",
[0, 100, 100, 0] => "From Bottom Left Corner",
[100, 100, 0, 0] => "From Bottom Right Corner",
_ => "From Center",
}
.into()
}
/// Stop editing for the Format Shape pane's gradient stops bar. Stops stay in position order; a
/// gradient keeps 2 to `MAX_STOPS` of them.
pub mod stops {
use wordcraft_doc::props::Rgb;
use wordcraft_doc::wordart::{GradStop, MAX_STOPS};
/// The gradient's colour and transparency at `pos` (percent).
pub fn at(stops: &[GradStop], pos: f32) -> (Rgb, f32) {
let after = stops.iter().position(|s| s.pos >= pos);
let (a, b) = match after {
Some(0) | None => {
let s = if after.is_none() { stops.last() } else { stops.first() };
return s.map_or((Rgb::BLACK, 0.0), |s| (s.color, s.transparency));
}
Some(i) => (stops[i - 1], stops[i]),
};
let t = if b.pos > a.pos { (pos - a.pos) / (b.pos - a.pos) } else { 0.0 };
let mix = |x: u8, y: u8| (f32::from(x) + (f32::from(y) - f32::from(x)) * t).round().clamp(0.0, 255.0) as u8;
(Rgb(mix(a.color.0, b.color.0), mix(a.color.1, b.color.1), mix(a.color.2, b.color.2)), a.transparency + (b.transparency - a.transparency) * t)
}
/// Adds a stop at `pos` in the gradient's colour there; its index, or `None` when full.
pub fn add(stops: &mut Vec<GradStop>, pos: f32) -> Option<usize> {
if stops.len() >= MAX_STOPS {
return None;
}
let pos = if pos.is_finite() { pos.clamp(0.0, 100.0) } else { 50.0 };
let (color, transparency) = at(stops, pos);
let i = stops.iter().position(|s| s.pos > pos).unwrap_or(stops.len());
stops.insert(i, GradStop { pos, color, transparency });
Some(i)
}
/// Removes stop `i` unless only two are left; whether it did.
pub fn remove(stops: &mut Vec<GradStop>, i: usize) -> bool {
if stops.len() <= 2 || i >= stops.len() {
return false;
}
stops.remove(i);
true
}
/// Moves stop `i` to `pos`, keeping the stops in order; its new index.
pub fn move_to(stops: &mut Vec<GradStop>, i: usize, pos: f32) -> usize {
if i >= stops.len() || !pos.is_finite() {
return i;
}
let mut s = stops.remove(i);
s.pos = pos.clamp(0.0, 100.0);
let j = stops.iter().position(|o| o.pos > s.pos).unwrap_or(stops.len());
stops.insert(j, s);
j
}
}
#[cfg(test)]
mod tests {
use super::{Change, params, parse, stops};
use std::sync::Arc;
use wordcraft_doc::fill::{Fill, Gradient, GradientFlip, GradientKind, PathShape};
/// The variations Word writes for accent 1 (`tint`/`shade` with `satMod`, observed in Word 365).
#[test]
fn variations_follow_words() {
use super::variations;
let c = Rgb(0x15, 0x60, 0x82);
let [light, dark] = variations(c);
assert_eq!((light.len(), dark.len()), (13, 13));
assert_eq!(light[1].1, "Linear Down");
assert_eq!(light[6].1, "From Center");
let Fill::Gradient(g) = &light[0].0 else { panic!() };
assert_eq!(g.kind, GradientKind::Linear { angle: 45.0, scaled: true });
assert_eq!(g.stops.iter().map(|s| s.pos).collect::<Vec<_>>(), [0.0, 50.0, 100.0]);
assert!(g.stops[0].color.0 > g.stops[2].color.0 || g.stops[0].color.2 < g.stops[2].color.2, "lighter towards the end: {:?}", g.stops);
let Fill::Gradient(g) = &light[3].0 else { panic!() };
assert_eq!(
(g.kind, g.tile),
(GradientKind::Path { shape: PathShape::Circle, focus: [100.0, 100.0, 0.0, 0.0] }, Some([0.0, 0.0, -100.0, -100.0]))
);
let Fill::Gradient(g) = super::default_gradient(c) else { panic!() };
assert_eq!(g.stops.iter().map(|s| s.pos).collect::<Vec<_>>(), [0.0, 74.0, 83.0, 100.0], "Word's starting gradient");
assert_eq!(g.stops[1].color, g.stops[2].color);
assert!(g.stops.iter().all(|s| s.color.0 > c.0 && s.color.1 > c.1), "lighter than the accent");
let Fill::Gradient(g) = &dark[1].0 else { panic!() };
assert_eq!(g.stops[2].color, c.modified("satMod", 1.15), "the last dark stop is the colour itself");
}
#[test]
fn params_set_the_fill_they_were_made_from() {
let g = |kind| {
Fill::Gradient(Arc::new(Gradient {
stops: vec![st(0.0, 10, 0.0), st(40.0, 90, 25.0), st(100.0, 250, 100.0)],
kind,
tile: None,
rot_with_shape: false,
flip: GradientFlip::None,
}))
};
let fills = [
Fill::Solid { color: Rgb(1, 2, 3), transparency: 35.0 },
g(GradientKind::Linear { angle: 135.0, scaled: false }),
g(GradientKind::Linear { angle: 45.0, scaled: true }),
g(GradientKind::Path { shape: PathShape::Circle, focus: [100.0, 0.0, 0.0, 100.0] }),
g(GradientKind::Path { shape: PathShape::Rect, focus: [50.0; 4] }),
g(GradientKind::Path { shape: PathShape::Shape, focus: [50.0; 4] }),
];
let tiled = super::variations(Rgb(9, 9, 9))[0][3].0.clone();
for f in fills.into_iter().chain([tiled]) {
let v = params(Some(&f));
let back = match parse(&v) {
Ok(Change::Fill(f)) => f,
Ok(Change::Solid(c, t)) => Fill::Solid { color: c, transparency: t.unwrap_or(0.0) },
_ => panic!("{v}"),
};
assert_eq!(back, f, "{v}");
}
assert!(matches!(parse(&params(None)), Ok(Change::None)));
}
use wordcraft_doc::props::Rgb;
use wordcraft_doc::wordart::{GradStop, MAX_STOPS};
fn st(pos: f32, c: u8, transparency: f32) -> GradStop {
GradStop { pos, color: Rgb(c, c, c), transparency }
}
#[test]
fn stops_are_added_in_the_gradients_colour_moved_in_order_and_kept_two_to_ten() {
let mut g = vec![st(0.0, 0, 0.0), st(100.0, 200, 50.0)];
assert_eq!(stops::add(&mut g, 25.0), Some(1));
assert_eq!((g[1].color, g[1].transparency), (Rgb(50, 50, 50), 12.5));
assert_eq!(stops::add(&mut g, f32::NAN), Some(2), "a junk position goes in the middle");
assert_eq!(stops::move_to(&mut g, 1, 300.0), 3, "past the end: last");
assert_eq!(g.iter().map(|s| s.pos).collect::<Vec<_>>(), [0.0, 50.0, 100.0, 100.0]);
assert_eq!(stops::move_to(&mut g, 3, -1.0), 1, "after the stop already at 0%");
assert!(stops::remove(&mut g, 0) && stops::remove(&mut g, 0));
assert!(!stops::remove(&mut g, 0), "two stops stay");
assert!(!stops::remove(&mut vec![st(0.0, 0, 0.0); 3], 7));
while stops::add(&mut g, 10.0).is_some() {}
assert_eq!(g.len(), MAX_STOPS);
assert_eq!(stops::at(&g, 200.0).0, g[g.len() - 1].color);
assert_eq!(stops::at(&[], 5.0), (Rgb::BLACK, 0.0));
}
}
+51
View File
@@ -615,6 +615,57 @@ pub(crate) fn backspace_cells(s: &mut Session) -> Option<CmdResult> {
Some(r) Some(r)
} }
/// Delete with whole cells selected (#2802): each cell is emptied to one empty paragraph, as in
/// Word, nested tables included. That paragraph keeps the formatting of the cell's last one (its
/// mark is the end-of-cell mark). `None` without whole cells, and with Track Changes on (the text
/// is marked deleted as for any selection).
pub(crate) fn clear_cells(s: &mut Session) -> Option<CmdResult> {
if s.doc.settings.track_changes {
return None;
}
let cr = s.cell_range()?.clone();
let t = s.doc.table(cr.story, &cr.table)?;
let prefixes: Vec<Vec<u32>> = cr.cells(t).into_iter().map(|(r, c)| cell_prefix(&cr.table, r, c)).collect();
let spans = text_spans(s, cr.story, &prefixes);
Some(clear_spans(s, cr.table.0.len().saturating_add(2), &spans))
}
/// Empty each span (one cell's text, from its first paragraph to its last; the cell's path is the
/// first `depth` steps of its paths) to one paragraph with the last paragraph's formatting, bottom
/// up so earlier spans stay valid; the caret goes to the start of the first cell.
fn clear_spans(s: &mut Session, depth: usize, spans: &[(Pos, Pos)]) -> CmdResult {
// A cell's first block (its first paragraph may have been in a nested table that goes).
let first_block = |p: &Pos| {
let mut v = p.path.0.get(..depth).unwrap_or_default().to_vec();
v.push(0);
Pos { story: p.story, path: Path(v), off: 0 }
};
for (a, b) in spans {
super::controls::check_delete(s, a, b)?;
}
for (a, b) in spans.iter().rev() {
let Some(last) = s.doc.para(b.story, &b.path).map(|p| (p.props.clone(), p.mark.clone())) else { continue };
s.doc.delete_range(a, b)?;
// Now the cell's only block, unless it holds a table nested deeper than the paragraph
// walk (`delete_range` leaves those).
let first = first_block(a);
if s.doc.container(first.story, &first.path).is_some_and(|bl| bl.len() == 1)
&& let Ok(p) = s.doc.para_mut(first.story, &first.path)
{
(p.props, p.mark) = last;
p.touch();
}
}
let at = spans.first().map(|(a, _)| s.doc.clamp(&first_block(a))).unwrap_or_else(|| s.sel.focus.clone());
s.sel = Selection::caret(at);
s.column = None;
s.column_mode = false;
s.goal_x = None;
s.pending = None;
super::controls::restore_placeholder(s)?;
sel_result(s)
}
fn del_table(s: &mut Session, _: &Value) -> CmdResult { fn del_table(s: &mut Session, _: &Value) -> CmdResult {
let (tp, _, _) = cell(s)?; let (tp, _, _) = cell(s)?;
let story = s.sel.focus.story; let story = s.sel.focus.story;
+28 -2
View File
@@ -724,13 +724,21 @@ fn call(name: &str, args: &[Expr], here: &Here) -> Result<f64, CmdError> {
} }
"ABS" => Ok(one(name, args, here)?.abs()), "ABS" => Ok(one(name, args, here)?.abs()),
"INT" => Ok(one(name, args, here)?.floor()), "INT" => Ok(one(name, args, here)?.floor()),
"SIGN" => Ok(one(name, args, here)?.signum()), "SIGN" => {
let x = one(name, args, here)?;
Ok(if x == 0.0 { 0.0 } else { x.signum() })
}
"ROUND" => match args { "ROUND" => match args {
[a, b] => { [a, b] => {
let x = scalar(eval(a, here)?)?; let x = scalar(eval(a, here)?)?;
let digits = scalar(eval(b, here)?)?.clamp(-15.0, 15.0).trunc(); let digits = scalar(eval(b, here)?)?.clamp(-15.0, 15.0).trunc();
let scale = 10f64.powi(digits as i32); let scale = 10f64.powi(digits as i32);
finite((x * scale).round() / scale) let scaled = x * scale;
if !scaled.is_finite() {
// Too large to have a fractional part at this precision.
return finite(x);
}
finite(scaled.round() / scale)
} }
_ => Err(CmdError::Failed("ROUND takes two arguments".into())), _ => Err(CmdError::Failed("ROUND takes two arguments".into())),
}, },
@@ -769,6 +777,16 @@ mod tests {
evaluate(t, r, c, f) evaluate(t, r, c, f)
} }
#[test]
fn sign_of_zero_is_zero() {
let t = table(&[&["0", "5", "-5"]]);
assert_eq!(ev(&t, 0, 0, "=SIGN(0)"), Ok(0.0));
assert_eq!(ev(&t, 0, 0, "=SIGN(-0)"), Ok(0.0));
assert_eq!(ev(&t, 0, 1, "=SIGN(A1)"), Ok(0.0), "a cell holding 0");
assert_eq!(ev(&t, 0, 0, "=SIGN(5)"), Ok(1.0));
assert_eq!(ev(&t, 0, 0, "=SIGN(-5)"), Ok(-1.0));
}
#[test] #[test]
fn cell_references_count_from_one_and_from_a() { fn cell_references_count_from_one_and_from_a() {
assert_eq!(cell_ref("A1"), Some((0, 0))); assert_eq!(cell_ref("A1"), Some((0, 0)));
@@ -807,6 +825,14 @@ mod tests {
assert!(err.contains("MEDIAN"), "{err}"); assert!(err.contains("MEDIAN"), "{err}");
} }
#[test]
fn round_large_values_return_input() {
let t = table(&[&["1", "2"], &["3", "4"], &["", ""]]);
let big = format!("1{}", "0".repeat(300));
assert_eq!(ev(&t, 2, 0, &format!("=ROUND({big},15)")), Ok(1e300));
assert_eq!(ev(&t, 2, 0, &format!("=ROUND({big},2)")), Ok(1e300));
}
#[test] #[test]
fn ranges_and_references_and_operators() { fn ranges_and_references_and_operators() {
let t = table(&[&["1", "2"], &["3", "4"], &["", ""]]); let t = table(&[&["1", "2"], &["3", "4"], &["", ""]]);
+57 -2
View File
@@ -199,6 +199,30 @@ pub fn handle_table(l: &DocLayout, doc: &Document, story: StoryRef, page: usize,
.max_by_key(|g| g.table.0.len()) .max_by_key(|g| g.table.0.len())
} }
/// The move and resize handles of table `table` of `story` on this page, as Word shows them
/// while the caret is in the table: the move handle where it starts, the resize handle where it
/// ends.
pub fn caret_handles(l: &DocLayout, doc: &Document, story: StoryRef, table: &Path, page: usize) -> (Option<Rect>, Option<Rect>) {
let rows = doc.table(story, table).map_or(0, |t| t.rows.len());
let has_row = |p: usize, r: usize| {
l.pages.get(p).is_some_and(|pg| {
pg.items.iter().any(|it| matches!(it, Placed::Cell { table: t, story: st, row, .. } if *st == story && t == table && *row == r))
})
};
// A repeated header row, or a row running on to the next page, is drawn on more than one
// page: the handles go where the table starts and where it ends (its pages are consecutive).
let first = has_row(page, 0) && !page.checked_sub(1).is_some_and(|p| has_row(p, 0));
let last = rows.checked_sub(1).is_some_and(|r| has_row(page, r) && !page.checked_add(1).is_some_and(|p| has_row(p, r)));
if !first && !last {
return (None, None);
}
// The table's geometry on this page that the pointer handles are drawn and grabbed from.
let Some(g) = table_resize::tables_on(l, doc, page).into_iter().find(|g| g.story == story && g.table == *table) else {
return (None, None);
};
(first.then(|| handle(&g)), last.then(|| g.handle()))
}
/// The insert button a target is, if it's one. /// The insert button a target is, if it's one.
pub fn insert_mark(t: &Target) -> Option<InsertMark> { pub fn insert_mark(t: &Target) -> Option<InsertMark> {
let g = &t.geom; let g = &t.geom;
@@ -374,7 +398,7 @@ fn select_at(s: &mut Session, v: &Value) -> CmdResult {
/// before it) or, at the table's right edge, a row's last cell (after it). (row, cell, after) /// before it) or, at the table's right edge, a row's last cell (after it). (row, cell, after)
fn beside_line(t: &Table, line: usize) -> Option<(usize, usize, bool)> { fn beside_line(t: &Table, line: usize) -> Option<(usize, usize, bool)> {
t.rows.iter().enumerate().find_map(|(r, row)| { t.rows.iter().enumerate().find_map(|(r, row)| {
let mut g = 0usize; let mut g = row.first_grid();
for (c, cell) in row.cells.iter().enumerate() { for (c, cell) in row.cells.iter().enumerate() {
if g == line { if g == line {
return Some((r, c, false)); return Some((r, c, false));
@@ -425,7 +449,7 @@ fn insert_at(s: &mut Session, v: &Value) -> CmdResult {
} }
/// Where table `table` of `story` is first drawn: its page and top-left corner. /// Where table `table` of `story` is first drawn: its page and top-left corner.
fn corner(l: &DocLayout, story: StoryRef, table: &Path) -> Option<(usize, f32, f32)> { pub fn corner(l: &DocLayout, story: StoryRef, table: &Path) -> Option<(usize, f32, f32)> {
l.pages.iter().enumerate().find_map(|(pi, pg)| { l.pages.iter().enumerate().find_map(|(pi, pg)| {
let cells = pg.items.iter().filter_map(|it| match it { let cells = pg.items.iter().filter_map(|it| match it {
Placed::Cell { rect, table: t, story: st, .. } if *st == story && t == table => Some(*rect), Placed::Cell { rect, table: t, story: st, .. } if *st == story && t == table => Some(*rect),
@@ -1027,4 +1051,35 @@ mod tests {
let moved = cell_rect(&mut s, 0, 0); let moved = cell_rect(&mut s, 0, 0);
assert!(moved.x >= -0.5 && moved.right() <= 612.5 && moved.y >= -0.5, "{moved:?}"); assert!(moved.x >= -0.5 && moved.right() <= 612.5 && moved.y >= -0.5, "{moved:?}");
} }
/// #2038: a table dragged to the right floats and the empty paragraph after it moves up beside
/// it. As in Word, a click anywhere below the table then goes to that paragraph, not to a cell of the last row; a click in a cell still lands there.
#[test]
fn a_click_below_a_floating_table_goes_to_the_text_not_its_cells() {
let mut s = table_session(3, 3);
let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]);
let (hx, hy) = (h.x + h.w / 2.0, h.y + h.h / 2.0);
s.run("table.moveDrag", &json!({"page": 0, "x": hx, "y": hy, "toX": hx + 60.0, "toY": hy + 10.0})).unwrap();
assert!(first_table(&s).props.float.is_some());
let after = Path(vec![table_path(&s).0[0] + 1]);
assert!(s.doc.block(StoryRef::Body, &after).is_some_and(|b| b.as_table().is_none()), "a paragraph follows the table");
let (first, last) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 2, 2));
let (mid, bottom, margin) = ((first.x + last.right()) / 2.0, last.bottom(), s.layout().pages[0].body.x + 5.0);
let at = |s: &mut Session, x: f32, y: f32| {
s.run("caret.set", &json!({"page": 0, "x": x, "y": y})).unwrap();
s.sel.focus.path.clone()
};
for (name, x, y) in [
("just below the middle", mid, bottom + 12.0),
("far below the middle", mid, bottom + 200.0),
("below, at the margin", margin, bottom + 12.0),
] {
assert_eq!(at(&mut s, x, y), after, "a click {name}");
}
let b2 = cell_rect(&mut s, 1, 1);
assert_eq!(at(&mut s, b2.x + b2.w / 2.0, mid_y(b2)), cell_pos(&s, 1, 1).path, "in B2");
assert_eq!(at(&mut s, last.x + last.w / 2.0, last.bottom() - 1.0), cell_pos(&s, 2, 2).path, "low in C3");
s.run("caret.docEnd", &json!({})).unwrap();
assert_eq!(s.sel.focus.path, after, "Ctrl+End");
}
} }
+126
View File
@@ -1016,4 +1016,130 @@ mod tests {
assert_eq!(first_table(&s).grid.len(), 3, "no new grid line"); assert_eq!(first_table(&s).grid.len(), 3, "no new grid line");
assert!((cell_rect(&mut s, 0, 2).x - (c.x + 20.0)).abs() < 1.0, "the first row follows"); assert!((cell_rect(&mut s, 0, 2).x - (c.x + 20.0)).abs() < 1.0, "the first row follows");
} }
/// Selects the cell at (row, cell) one of the ways Word does: Select Cell, the left-edge
/// arrow, or a triple-click on its text.
fn select_one_cell(s: &mut Session, how: &str, r: usize, c: usize) {
let b = cell_rect(s, r, c);
let y = b.y + b.h / 2.0;
match how {
"arrow" => {
s.run("table.selectAt", &json!({"page": 0, "x": b.x + 4.5, "y": y})).unwrap();
}
"triple" => {
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap();
s.run("select.paragraph", &json!({"cell": true})).unwrap();
}
_ => {
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap();
s.run("table.selectCell", &json!({})).unwrap();
}
}
}
/// Each way of selecting one cell makes its right and its left border move in its row
/// only; the neighbour on that side gives up or gains the room and the row keeps its width.
#[test]
fn a_single_selected_cells_borders_move_only_in_its_row() {
for how in ["cell", "arrow", "triple"] {
for left in [false, true] {
let mut s = table_session(3, 3);
let (a, b, c) = (cell_rect(&mut s, 1, 0), cell_rect(&mut s, 1, 1), cell_rect(&mut s, 1, 2));
let y = b.y + b.h / 2.0;
select_one_cell(&mut s, how, 1, 1);
let start = first_table(&s);
let x = if left { b.x } else { b.right() };
s.run("table.resizeDrag", &json!({"page": 0, "x": x, "y": y, "toX": x + 30.0, "toY": y})).unwrap();
let (a1, b1, c1) = (cell_rect(&mut s, 1, 0), cell_rect(&mut s, 1, 1), cell_rect(&mut s, 1, 2));
if left {
assert!((b1.w - (b.w - 30.0)).abs() < 1.0 && (a1.w - (a.w + 30.0)).abs() < 1.0, "{how} left: {a1:?} {b1:?}");
assert!((c1.w - c.w).abs() < 0.5, "{how} left");
} else {
assert!((b1.w - (b.w + 30.0)).abs() < 1.0 && (c1.w - (c.w - 30.0)).abs() < 1.0, "{how} right: {b1:?} {c1:?}");
assert!((a1.w - a.w).abs() < 0.5, "{how} right");
}
assert!((c1.right() - c.right()).abs() < 0.5, "{how}: the row keeps its width");
for r in [0, 2] {
assert!(
(cell_rect(&mut s, r, 1).x - b.x).abs() < 0.5 && (cell_rect(&mut s, r, 1).right() - b.right()).abs() < 0.5,
"{how}: row {r} unchanged"
);
}
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(first_table(&s), start, "{how}: one undo step");
}
}
}
/// A caret, a part of a cell's text, or another cell's border with a cell selected: the
/// whole column moves.
#[test]
fn without_that_cell_selected_the_whole_column_moves() {
let mut s = table_session(3, 3);
let b = cell_rect(&mut s, 1, 1);
let y = b.y + b.h / 2.0;
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap();
s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap();
assert!((cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0, "caret only");
s.run("edit.undo", &json!({})).unwrap();
select_one_cell(&mut s, "arrow", 2, 2);
s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap();
assert!((cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0, "another cell selected");
s.run("edit.undo", &json!({})).unwrap();
// All of a cell's text, picked as a range (no cell mark): not a cell selection.
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": y})).unwrap();
s.run("text.insert", &json!({"text": "Wide"})).unwrap();
s.run("select.text", &json!({"text": "Wide"})).unwrap();
let (anchor, focus) = (serde_json::to_value(&s.sel.anchor).unwrap(), serde_json::to_value(&s.sel.focus).unwrap());
s.run("select.range", &json!({"anchor": anchor, "focus": focus})).unwrap();
assert!(s.cell_range().is_none());
let b = cell_rect(&mut s, 1, 1);
s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap();
assert!((cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0, "all of a cell's text selected");
}
/// The jagged row a single cell's drag leaves is saved to .docx and read back the same.
#[test]
fn a_jagged_row_survives_a_docx_round_trip() {
let mut s = table_session(3, 3);
let b = cell_rect(&mut s, 1, 1);
let y = b.y + b.h / 2.0;
select_one_cell(&mut s, "arrow", 1, 1);
s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap();
let t = first_table(&s);
let bytes = crate::io::save_bytes("jagged.docx", &s.doc).unwrap();
let doc = crate::io::open_bytes("jagged.docx", &bytes).unwrap();
let back = doc.body.iter().find_map(|b| b.as_table()).cloned().unwrap();
let spans = |t: &Table| t.rows.iter().map(|r| r.cells.iter().map(Cell::span).collect::<Vec<_>>()).collect::<Vec<_>>();
assert_eq!(spans(&back), spans(&t));
let mut s2 = Session::new(doc);
for c in 0..spans(&t)[1].len() {
assert!((cell_rect(&mut s2, 1, c).w - cell_rect(&mut s, 1, c).w).abs() < 1.0, "cell {c}");
}
}
/// In a cell of two paragraphs, a triple-click on the last one selects the cell (its border
/// then moves in its row only); on the first one it selects text, and the whole column moves.
#[test]
fn triple_click_selects_a_cell_only_on_its_last_paragraph() {
for last in [true, false] {
let mut s = table_session(3, 3);
let b = cell_rect(&mut s, 1, 1);
s.run("caret.set", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": b.y + b.h / 2.0})).unwrap();
s.run("text.insert", &json!({"text": "One"})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("text.insert", &json!({"text": "Two"})).unwrap();
s.run("select.text", &json!({"text": if last { "Two" } else { "One" }})).unwrap();
s.run("select.paragraph", &json!({})).unwrap();
assert!(s.cell_range().is_none(), "the ribbon's Select Paragraph never selects the cell");
s.run("select.text", &json!({"text": if last { "Two" } else { "One" }})).unwrap();
s.run("select.paragraph", &json!({"cell": true})).unwrap();
assert_eq!(s.cell_range().is_some(), last);
let b = cell_rect(&mut s, 1, 1);
let y = b.y + b.h / 2.0;
s.run("table.resizeDrag", &json!({"page": 0, "x": b.right(), "y": y, "toX": b.right() + 30.0, "toY": y})).unwrap();
let moved = (cell_rect(&mut s, 0, 1).w - (b.w + 30.0)).abs() < 1.0;
assert_eq!(moved, !last, "last paragraph {last}: the column moves only without a cell selection");
}
}
} }
+8
View File
@@ -97,6 +97,14 @@ fn build_new(s: &Session, v: &Value) -> Result<Style, CmdError> {
}; };
let id = s.doc.styles.new_id(&name); let id = s.doc.styles.new_id(&name);
let mut st = Style { id, name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() }; let mut st = Style { id, name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() };
// Bands of one row and one column unless the style it's based on sets them: Word, like us,
// reads a style with no band size as having no bands (#3415).
let base = st.based_on.as_deref().and_then(|b| s.doc.styles.table_style(b)).map(|t| t.parts).unwrap_or_default();
if base.band_size.is_none() || base.col_band_size.is_none() {
let parts = st.table.get_or_insert_with(TableStyleParts::default);
parts.band_size = base.band_size.is_none().then_some(1);
parts.col_band_size = base.col_band_size.is_none().then_some(1);
}
edit(&mut st, v)?; edit(&mut st, v)?;
Ok(st) Ok(st)
} }
+1 -1
View File
@@ -240,7 +240,7 @@ pub fn local_path(target: &str, base: Option<&Path>) -> Option<PathBuf> {
Some(p) Some(p)
} }
fn is_network_path(p: &str) -> bool { pub(crate) fn is_network_path(p: &str) -> bool {
let b = p.as_bytes(); let b = p.as_bytes();
matches!((b.first(), b.get(1)), (Some(b'\\' | b'/'), Some(b'\\' | b'/'))) matches!((b.first(), b.get(1)), (Some(b'\\' | b'/'), Some(b'\\' | b'/')))
} }
+13 -5
View File
@@ -17,6 +17,9 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("text.columnBreak", "Column Break", "Layout › Breaks", |s, _| ins_char(s, COLUMN_BREAK)).key("Mod+Shift+Enter"), CommandSpec::new("text.columnBreak", "Column Break", "Layout › Breaks", |s, _| ins_char(s, COLUMN_BREAK)).key("Mod+Shift+Enter"),
CommandSpec::new("text.tab", "Tab", "Editing", tab).key("Tab"), CommandSpec::new("text.tab", "Tab", "Editing", tab).key("Tab"),
CommandSpec::new("text.backTab", "Shift+Tab", "Editing", back_tab).key("Shift+Tab"), CommandSpec::new("text.backTab", "Shift+Tab", "Editing", back_tab).key("Shift+Tab"),
// A tab character even in a table cell, where Tab moves to the next cell (#3137); ⌥Tab on macOS.
CommandSpec::new("text.insertTab", "Tab Character", "Editing", |s, _| ins_char(s, '\t'))
.key(if cfg!(target_os = "macos") { "Alt+Tab" } else { "Mod+Tab" }),
CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"), CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"),
CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"), CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"),
CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"), CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"),
@@ -133,14 +136,20 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s) sel_result(s)
} }
/// "* " / "- " / "> " → bullets, "1. " / "a) " → numbering, typed at the start of a paragraph. /// "* " / "- " / "> " → bullets, "1. " / "a) " → numbering, typed at the start of a paragraph
/// (Word triggers on the paragraph's first characters, whatever text already follows the caret,
/// not only when the trigger is the paragraph's entire content — #3352).
fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> { fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
let f = s.sel.focus.clone(); let f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return Ok(false) }; let Some(para) = s.doc.para_at(&f) else { return Ok(false) };
if para.props.numbering.is_some_and(|n| n.num != 0) || f.off != para.len() { // A style-linked list (a heading-linked Multilevel List preset, #1793) numbers the paragraph
// without `props.numbering` itself being set: resolve it, so autoformat doesn't paper over an
// already-numbered heading with a fresh bullet.
if s.doc.styles.resolve_para(&para.props).numbering.is_some_and(|n| n.num != 0) {
return Ok(false); return Ok(false);
} }
let kind = match para.text.as_str() { let Some(prefix) = para.text.get(..f.off) else { return Ok(false) };
let kind = match prefix {
"* " => ListKind::Bullet, "* " => ListKind::Bullet,
"- " => ListKind::BulletChar('-'), "- " => ListKind::BulletChar('-'),
// Arrow bullets (Word draws these from a symbol font; we use the Unicode arrows). // Arrow bullets (Word draws these from a symbol font; we use the Unicode arrows).
@@ -168,8 +177,7 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
None => s.doc.numbering.try_add_list(kind)?, None => s.doc.numbering.try_add_list(kind)?,
}; };
let para = s.doc.para_mut(f.story, &f.path)?; let para = s.doc.para_mut(f.story, &f.path)?;
let len = para.len(); para.delete(0, f.off)?;
para.delete(0, len)?;
para.props.numbering = Some(NumRef { num, level: 0 }); para.props.numbering = Some(NumRef { num, level: 0 });
// A heading stays a heading (Word: "1. " in a Heading 1 gives a numbered Heading 1). // A heading stays a heading (Word: "1. " in a Heading 1 gives a numbered Heading 1).
if para.props.style.as_deref().is_none_or(|st| st == "Normal") { if para.props.style.as_deref().is_none_or(|st| st == "Normal") {
+27 -14
View File
@@ -489,17 +489,20 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(), .pure(),
CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", |s, v| { CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", |s, v| {
let path = p::req_str(v, "path")?; let path = p::req_str(v, "path")?;
let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?; let mut doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
// As in Word, the new document is attached to the template it came from (#2852).
doc.settings.attached_template = Some(path.to_string());
s.set_document(doc); s.set_document(doc);
s.path = None; s.path = None;
s.dirty = true; s.dirty = true;
sel_result(s) sel_result(s)
}) })
.params(r#"{"path": string}"#) .params(r#"{"path": string} → a new, untitled document from the template, attached to it"#)
.pure(), .pure(),
CommandSpec::new("file.saveTemplate", "Save as Template", "File › Save As", |s, v| { CommandSpec::new("file.saveTemplate", "Save as Template", "File › Save As", |s, v| {
let path = p::req_str(v, "path")?; let path = p::req_str(v, "path")?;
let path = if path.ends_with(".dotx") { path.to_string() } else { format!("{path}.dotx") }; // A .dotm keeps its name (#2852); anything else becomes a .dotx.
let path = if super::user_templates::is_template_name(path) { path.to_string() } else { format!("{path}.dotx") };
crate::io::save_path(std::path::Path::new(&path), &s.doc).map_err(CmdError::Failed)?; crate::io::save_path(std::path::Path::new(&path), &s.doc).map_err(CmdError::Failed)?;
Ok(json!({"path": path})) Ok(json!({"path": path}))
}) })
@@ -629,14 +632,19 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"header"?: pt, "footer"?: pt}"#), .params(r#"{"header"?: pt, "footer"?: pt}"#),
CommandSpec::new("table.cellMargins", "Cell Margins", "Table Layout › Alignment", |s, v| { CommandSpec::new("table.cellMargins", "Cell Margins", "Table Layout › Alignment", |s, v| {
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return Err(CmdError::Disabled("not in a table".into())) }; let Some((tp, _, _)) = s.sel.focus.path.cell() else { return Err(CmdError::Disabled("not in a table".into())) };
let story = s.sel.focus.story;
let current = {
let table = s.doc.table(story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?;
let style = table.props.style.as_deref().and_then(|id| s.doc.styles.table_style(id));
table.props.cell_margins.or(style.and_then(|st| st.parts.cell_margins)).unwrap_or([0.0, 5.4, 0.0, 5.4])
};
let m = [ let m = [
p::f32(v, "top").unwrap_or(0.0), p::f32(v, "top").unwrap_or(current[0]),
p::f32(v, "left").unwrap_or(5.4), p::f32(v, "left").unwrap_or(current[1]),
p::f32(v, "bottom").unwrap_or(0.0), p::f32(v, "bottom").unwrap_or(current[2]),
p::f32(v, "right").unwrap_or(5.4), p::f32(v, "right").unwrap_or(current[3]),
] ]
.map(|x| x.clamp(0.0, 144.0)); .map(|x| x.clamp(0.0, 144.0));
let story = s.sel.focus.story;
s.doc.table_mut(story, &tp)?.props.cell_margins = Some(m); s.doc.table_mut(story, &tp)?.props.cell_margins = Some(m);
for path in s.doc.para_paths(story).into_iter().filter(|p| p.0.starts_with(&tp.0) && p.0.len() > tp.0.len()) { for path in s.doc.para_paths(story).into_iter().filter(|p| p.0.starts_with(&tp.0) && p.0.len() > tp.0.len()) {
s.doc.para_mut(story, &path)?.touch(); s.doc.para_mut(story, &path)?.touch();
@@ -699,7 +707,7 @@ pub fn specs() -> Vec<CommandSpec> {
}) })
.params(r#"{"format": "decimal|upperRoman|lowerLetter…", "text"?: "%1.", "start"?: n}"#), .params(r#"{"format": "decimal|upperRoman|lowerLetter…", "text"?: "%1.", "start"?: n}"#),
CommandSpec::new("review.language", "Language", "Review › Language", language).params( CommandSpec::new("review.language", "Language", "Review › Language", language).params(
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default language)}"#, r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents)}"#,
), ),
CommandSpec::new("review.showMarkup", "Show Markup", "Review › Tracking", |s, v| { CommandSpec::new("review.showMarkup", "Show Markup", "Review › Tracking", |s, v| {
s.view.show_markup = p::bool(v, "value").unwrap_or(!s.view.show_markup); s.view.show_markup = p::bool(v, "value").unwrap_or(!s.view.show_markup);
@@ -752,7 +760,8 @@ pub fn specs() -> Vec<CommandSpec> {
} }
/// Set Proofing Language: the selection's language and "do not check spelling or grammar", /// Set Proofing Language: the selection's language and "do not check spelling or grammar",
/// or (`default`) the document's default language; `detect` is the per-user preference. /// or (`default`) the document's default and the saved language for future blank documents;
/// `detect` is the per-user detection preference.
/// Nothing at all sets US English, as before. /// Nothing at all sets US English, as before.
fn language(s: &mut Session, v: &Value) -> CmdResult { fn language(s: &mut Session, v: &Value) -> CmdResult {
let lang = match p::str(v, "lang") { let lang = match p::str(v, "lang") {
@@ -784,14 +793,18 @@ fn language(s: &mut Session, v: &Value) -> CmdResult {
if lang.is_none() && no_proof.is_none() { if lang.is_none() && no_proof.is_none() {
return Ok(json!({"detect": s.prefs.detect_language})); return Ok(json!({"detect": s.prefs.detect_language}));
} }
super::format::apply(s, &|c| { let result = super::format::apply(s, &|c| {
if let Some(l) = &lang { if let Some(l) = &lang {
c.lang = Some(l.clone()); c.lang = Some(l.clone());
} }
if let Some(np) = no_proof { if let Some(np) = no_proof {
c.no_proof = Some(np); c.no_proof = Some(np);
} }
}) })?;
if p::bool(v, "default") == Some(true) {
s.prefs.default_language = lang;
}
Ok(result)
} }
fn macros(s: &mut Session, v: &Value) -> CmdResult { fn macros(s: &mut Session, v: &Value) -> CmdResult {
@@ -892,8 +905,8 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
word = format!("{}{fixed}{}", word.get(..at).unwrap_or(""), word.get(at + letters.len()..).unwrap_or("")); word = format!("{}{fixed}{}", word.get(..at).unwrap_or(""), word.get(at + letters.len()..).unwrap_or(""));
} }
// A whole-word replacement, else "wait..." → "wait…". Proofing decides what is English the // A whole-word replacement, else "wait..." → "wait…". Proofing decides what is English the
// same way (#835). // same way (#835); every English (en-GB, en-CA…) counts (#1281).
let english = s.doc.para_at(&f).is_none_or(|_| wordcraft_proof::ProofLang::from_tag(lang.as_deref()) == wordcraft_proof::ProofLang::En); let english = s.doc.para_at(&f).is_none_or(|_| wordcraft_proof::ProofLang::from_tag(lang.as_deref()).is_english());
let found = if ac.replace_text { ac.replacement_in(&word, english) } else { None }; let found = if ac.replace_text { ac.replacement_in(&word, english) } else { None };
let (from, rep) = match found { let (from, rep) = match found {
Some(r) => (start, Some(r)), Some(r) => (start, Some(r)),
+393
View File
@@ -0,0 +1,393 @@
//! Personal templates (#2852): the `.dotx`/`.dotm` files in the user templates folder, which
//! File › New lists next to the built-in templates.
//!
//! The folder is the user's own (File › Options › Advanced › File locations, else the app's
//! `Templates` folder, which the front end passes as `dir`). As in Word, the templates at its top
//! level are listed, and each folder directly inside it is a category of the templates it holds;
//! deeper folders are not looked into. A template is named by its file name, as in Word's
//! gallery; its title (the core properties' `dc:title`), when it has one, comes along for search
//! (a template saved from a document often keeps that document's title, so it can't be the name).
//!
//! Everything read here comes from the disk, so it is bounded: the number of entries looked at,
//! of categories and of templates listed, the size of a template whose title is read and of the
//! part it is read from. A file that can't be read is listed by its name or skipped, never an
//! error. A network folder (`\\host\share`) is refused, as attached templates are
//! ([`super::templates`]), and the automation read root applies to the folder and each file.
use serde_json::{Value, json};
use crate::{CmdError, CmdResult, CommandSpec, Session};
/// Most templates one listing returns.
pub const MAX_TEMPLATES: usize = 500;
/// Most category folders looked into.
pub const MAX_CATEGORIES: usize = 64;
/// Most directory entries looked at in one folder.
const MAX_ENTRIES: usize = 10_000;
/// Longest folder path taken, in bytes.
const MAX_DIR: usize = 32 * 1024;
/// Largest template whose title is read (bigger ones are listed by file name).
#[cfg(not(target_arch = "wasm32"))]
const MAX_TITLE_FILE: u64 = 64 << 20;
/// Most bytes of the core properties part read for the title.
#[cfg(not(target_arch = "wasm32"))]
const MAX_CORE_XML: u64 = 64 << 10;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("file.listTemplates", "Personal Templates", "File › New", list)
.params(r#"{"dir"?: string (the user templates folder; default: the one saved in File › Options)} → {"dir", "templates": [{"label" (the file name without its extension), "title": string|null (dc:title), "path", "category" ("" at the top level, else the subfolder's name), "modified": ISO-8601|null}] (.dotx and .dotm, at most 500, by category then label), "truncated": bool}; a network folder is refused"#)
.pure(),
]
}
/// One template in the folder.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PersonalTemplate {
/// Its file name without the extension.
pub label: String,
/// The title in its core properties, if any.
pub title: Option<String>,
pub path: String,
/// The subfolder it is in; empty at the top level.
pub category: String,
/// When the file last changed (ISO-8601, UTC), if the system says.
pub modified: Option<String>,
}
/// What [`list_templates`] found.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Listing {
pub templates: Vec<PersonalTemplate>,
/// Some were left out (a cap was reached).
pub truncated: bool,
}
fn list(s: &mut Session, v: &Value) -> CmdResult {
let dir = match v.get("dir") {
None | Some(Value::Null) => {
s.prefs.user_templates_folder.clone().ok_or_else(|| CmdError::Params("give `dir`: no user templates folder is saved".into()))?
}
Some(Value::String(d)) => d.clone(),
Some(_) => return Err(CmdError::Params("`dir` must be a folder path".into())),
};
let found = list_templates(&dir)?;
Ok(json!({
"dir": dir,
"templates": found.templates.iter().map(|t| json!({"label": t.label, "title": t.title, "path": t.path, "category": t.category, "modified": t.modified})).collect::<Vec<_>>(),
"truncated": found.truncated,
}))
}
/// Check that `dir` may be listed: a full local path, not too long, not on another machine.
pub fn check_dir(dir: &str) -> Result<(), CmdError> {
let d = dir.trim();
if d.is_empty() {
return Err(CmdError::Params("`dir` is empty".into()));
}
if d.len() > MAX_DIR || d.chars().any(char::is_control) {
return Err(CmdError::Params("`dir` isn't a folder path".into()));
}
// A folder on another machine: listing it would make Windows authenticate to that host.
if super::templates::is_network_path(d) {
return Err(CmdError::Params("templates aren't listed from a network folder".into()));
}
if !std::path::Path::new(d).is_absolute() {
return Err(CmdError::Params(format!("`dir`: {d:?} isn't a full path")));
}
Ok(())
}
/// The personal templates in `dir` (see the module docs).
#[cfg(not(target_arch = "wasm32"))]
pub fn list_templates(dir: &str) -> Result<Listing, CmdError> {
use std::path::Path;
check_dir(dir)?;
let dir = dir.trim();
crate::file_access::check_read(dir).map_err(CmdError::Failed)?;
let root = Path::new(dir);
if !root.is_dir() {
return Err(CmdError::Failed(format!("no folder at {dir}")));
}
let mut out = Listing::default();
let (files, folders, cut) = entries(root);
out.truncated |= cut;
let mut found: Vec<(String, crate::io::DirEntry)> = files.into_iter().map(|f| (String::new(), f)).collect();
let mut folders = folders;
folders.sort();
if folders.len() > MAX_CATEGORIES {
folders.truncate(MAX_CATEGORIES);
out.truncated = true;
}
for folder in folders {
let Some(category) = folder.file_name().map(|n| n.to_string_lossy().into_owned()) else { continue };
if crate::file_access::check_read(&folder.to_string_lossy()).is_err() {
continue;
}
let (files, _, cut) = entries(&folder);
out.truncated |= cut;
found.extend(files.into_iter().map(|f| (category.clone(), f)));
}
// The cap applies to what is listed; titles are read only for those.
found.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()).then_with(|| a.1.path.cmp(&b.1.path)));
if found.len() > MAX_TEMPLATES {
found.truncate(MAX_TEMPLATES);
out.truncated = true;
}
for (category, e) in found {
let title = title_of(&e.path);
let modified = e.modified.map(super::iso_from_unix_secs);
out.templates.push(PersonalTemplate { label: label_of(&e.name), title, path: e.path.to_string_lossy().into_owned(), category, modified });
}
// Two templates of one category that would show the same name (`Memo.dotx`, `Memo.dotm`)
// show their file names instead, so they can be told apart.
let key = |t: &PersonalTemplate| (t.category.to_lowercase(), t.label.to_lowercase());
let mut seen = std::collections::HashMap::<(String, String), usize>::new();
for t in &out.templates {
*seen.entry(key(t)).or_default() += 1;
}
for t in &mut out.templates {
if seen.get(&key(t)).is_some_and(|n| *n > 1)
&& let Some(name) = std::path::Path::new(&t.path).file_name()
{
t.label = name.to_string_lossy().into_owned();
}
}
// The top level first, then each category; by label within them.
out.templates.sort_by_cached_key(|t| (!t.category.is_empty(), t.category.to_lowercase(), t.label.to_lowercase(), t.path.clone()));
Ok(out)
}
#[cfg(target_arch = "wasm32")]
pub fn list_templates(dir: &str) -> Result<Listing, CmdError> {
check_dir(dir)?;
Err(CmdError::Failed("templates folders can't be listed on the web".into()))
}
/// The template files and the folders directly in `dir` (hidden ones left out), and whether the
/// entry cap cut the listing short. An unreadable folder has none.
#[cfg(not(target_arch = "wasm32"))]
fn entries(dir: &std::path::Path) -> (Vec<crate::io::DirEntry>, Vec<std::path::PathBuf>, bool) {
let Ok((list, cut)) = crate::io::list_dir(dir, MAX_ENTRIES) else { return (Vec::new(), Vec::new(), false) };
let (mut files, mut folders) = (Vec::new(), Vec::new());
for e in list {
if e.name.starts_with('.') || e.name.starts_with("~$") {
continue;
}
if e.dir {
folders.push(e.path);
} else if is_template_name(&e.name) {
files.push(e);
}
}
(files, folders, cut)
}
/// The name a template shows: its file name without the template extension, every one of them
/// (`Contract.dotm.dotx`, as older Save as Template made, is `Contract`); the whole name if
/// nothing would be left.
fn label_of(name: &str) -> String {
let mut label = name;
while is_template_name(label) {
let Some(stem) = label.get(..label.len().saturating_sub(5)) else { break };
label = stem;
}
let label = label.trim_end();
if label.is_empty() { name.to_string() } else { label.to_string() }
}
/// A Word template by its name: `.dotx` or `.dotm`, in any case.
pub fn is_template_name(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
(lower.ends_with(".dotx") || lower.ends_with(".dotm")) && lower.len() > 5
}
/// The template's title from its core properties, read from the package's directory and at most
/// [`MAX_CORE_XML`] bytes of the part; `None` for anything else.
#[cfg(not(target_arch = "wasm32"))]
fn title_of(path: &std::path::Path) -> Option<String> {
let title = crate::io::package_title(path, MAX_TITLE_FILE, MAX_CORE_XML)?;
// One line of a sensible length for a gallery caption.
let title: String = title.chars().map(|c| if c.is_control() { ' ' } else { c }).take(200).collect();
let title = title.trim();
(!title.is_empty()).then(|| title.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn scratch(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("wc-personal-templates-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// A real template written by the engine, with `title` as its core title (empty: none).
fn template(path: &std::path::Path, text: &str, title: &str) {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("document.setText", &json!({"text": text})).unwrap();
s.doc.core.title = title.into();
crate::io::save_path(path, &s.doc).unwrap();
}
fn labels(r: &Value) -> Vec<(String, String)> {
r["templates"]
.as_array()
.unwrap()
.iter()
.map(|t| (t["category"].as_str().unwrap().to_string(), t["label"].as_str().unwrap().to_string()))
.collect()
}
#[test]
fn lists_dotx_and_dotm_with_subfolders_as_categories() {
let d = scratch("list");
template(&d.join("Weekly report.dotx"), "Report body", "");
template(&d.join("letterhead.dotx"), "Letter body", "Company letterhead");
template(&d.join("Macros.dotm"), "Macro body", "");
template(&d.join("Not a template.docx"), "Doc body", "");
std::fs::write(d.join("notes.txt"), "x").unwrap();
std::fs::write(d.join("Broken.dotx"), b"this is no zip").unwrap();
std::fs::write(d.join(".hidden.dotx"), b"x").unwrap();
std::fs::create_dir_all(d.join("Legal/Deeper")).unwrap();
template(&d.join("Legal/NDA.DOTX"), "NDA body", "");
template(&d.join("Legal/Deeper/Too deep.dotx"), "x", "");
std::fs::create_dir_all(d.join("Empty")).unwrap();
let mut s = Session::new(wordcraft_doc::Document::new());
let r = s.run("file.listTemplates", &json!({"dir": d.to_string_lossy()})).unwrap();
assert_eq!(
labels(&r),
[
("".into(), "Broken".into()),
("".into(), "letterhead".into()),
("".into(), "Macros".into()),
("".into(), "Weekly report".into()),
("Legal".into(), "NDA".into()),
],
"{r}"
);
assert_eq!(r["truncated"], json!(false));
let first = &r["templates"][1];
assert_eq!(first["title"], json!("Company letterhead"));
assert_eq!(r["templates"][0]["title"], json!(null), "a broken file has no title");
assert!(first["path"].as_str().unwrap().ends_with("letterhead.dotx"));
assert!(first["modified"].as_str().is_some_and(|m| m.ends_with('Z')));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn refuses_network_relative_and_missing_folders() {
let mut s = Session::new(wordcraft_doc::Document::new());
for dir in [r"\\host\share\Templates", "//host/share/Templates", "relative/Templates", "", " "] {
assert!(matches!(s.run("file.listTemplates", &json!({"dir": dir})), Err(CmdError::Params(_))), "{dir:?}");
}
let missing = std::env::temp_dir().join(format!("wc-no-such-templates-{}", std::process::id()));
assert!(s.run("file.listTemplates", &json!({"dir": missing.to_string_lossy()})).is_err());
// Without `dir` the saved folder is used; none saved is an error.
assert!(matches!(s.run("file.listTemplates", &json!({})), Err(CmdError::Params(_))));
for junk in [json!(null), json!({"dir": 5}), json!({"dir": []}), json!({"dir": "x".repeat(40_000)}), json!({"dir": "/a\u{0}b"})] {
let _ = s.run("file.listTemplates", &junk);
}
}
#[test]
fn the_saved_folder_is_listed_without_dir() {
let d = scratch("saved");
template(&d.join("Saved.dotx"), "x", "");
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("file.options", &json!({"userTemplatesFolder": d.to_string_lossy()})).unwrap();
let r = s.run("file.listTemplates", &json!({})).unwrap();
assert_eq!(labels(&r), [("".to_string(), "Saved".to_string())]);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_listing_is_capped() {
let d = scratch("cap");
for i in 0..MAX_TEMPLATES + 5 {
std::fs::write(d.join(format!("t{i:04}.dotx")), b"").unwrap();
}
let found = list_templates(&d.to_string_lossy()).unwrap();
assert_eq!(found.templates.len(), MAX_TEMPLATES);
assert!(found.truncated);
assert_eq!(found.templates.first().map(|t| t.label.as_str()), Some("t0000"));
let _ = std::fs::remove_dir_all(&d);
}
/// A document made from a personal template is untitled, unsaved and attached to the
/// template, as in Word.
#[test]
fn new_from_template_is_untitled_and_attached() {
let d = scratch("new");
let path = d.join("Memo.dotx");
template(&path, "Memo text", "");
let mut s = Session::new(wordcraft_doc::Document::new());
s.path = Some(d.join("Earlier.docx"));
s.run("file.newFromTemplate", &json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(s.path, None);
assert!(s.dirty);
assert_eq!(s.doc.settings.attached_template.as_deref(), Some(path.to_string_lossy().as_ref()));
assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Memo text"));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_templates_folder_option_is_saved_and_checked() {
let d = scratch("option");
let mut s = Session::new(wordcraft_doc::Document::new());
for bad in [json!(r"\\host\share"), json!("//host/share"), json!("relative/x"), json!(7)] {
assert!(s.run("file.options", &json!({"userTemplatesFolder": bad})).is_err(), "{bad}");
}
assert!(s.run("file.options", &json!({"userTemplatesFolder": d.join("missing").to_string_lossy()})).is_err());
assert_eq!(s.prefs.user_templates_folder, None, "a refused folder changes nothing");
let r = s.run("file.options", &json!({"userTemplatesFolder": d.to_string_lossy()})).unwrap();
assert_eq!(r["userTemplatesFolder"], json!(d.to_string_lossy()));
assert_eq!(serde_json::to_value(&s.prefs).unwrap()["userTemplatesFolder"], json!(d.to_string_lossy()));
s.run("file.options", &json!({"userTemplatesFolder": "auto"})).unwrap();
assert_eq!(s.prefs.user_templates_folder, None);
let _ = std::fs::remove_dir_all(&d);
}
/// .dotx and .dotm templates show their names the same way, without the extension; two that
/// would look alike in one category show their file names.
#[test]
fn labels_drop_the_extension_alike_and_tell_twins_apart() {
let d = scratch("labels");
template(&d.join("Service contract.dotm"), "a", "");
template(&d.join("Old contract.dotm.dotx"), "b", "");
template(&d.join("Memo.dotx"), "c", "");
template(&d.join("Memo.DOTM"), "d", "");
std::fs::create_dir_all(d.join("Other")).unwrap();
template(&d.join("Other/Memo.dotx"), "e", "");
let found = list_templates(&d.to_string_lossy()).unwrap();
let got: Vec<(&str, &str)> = found.templates.iter().map(|t| (t.category.as_str(), t.label.as_str())).collect();
assert_eq!(got, [("", "Memo.DOTM"), ("", "Memo.dotx"), ("", "Old contract"), ("", "Service contract"), ("Other", "Memo")]);
assert_eq!(label_of(".dotx.dotm"), ".dotx", "never empty");
assert_eq!(label_of(" .dotx"), " .dotx", "nothing left: the whole name");
assert_eq!(label_of("é.dotx"), "é");
let _ = std::fs::remove_dir_all(&d);
}
/// Save as Template keeps a .dotm (any case) and makes anything else a .dotx.
#[test]
fn save_template_keeps_a_template_extension() {
let d = scratch("save");
let mut s = Session::new(wordcraft_doc::Document::new());
for (asked, saved) in [("A.dotm", "A.dotm"), ("B.DOTX", "B.DOTX"), ("C", "C.dotx"), ("D.docx", "D.docx.dotx")] {
let r = s.run("file.saveTemplate", &json!({"path": d.join(asked).to_string_lossy()})).unwrap();
assert_eq!(r["path"], json!(d.join(saved).to_string_lossy()));
assert!(d.join(saved).is_file(), "{saved}");
}
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn template_names() {
assert!(is_template_name("a.dotx") && is_template_name("B.DOTM"));
assert!(!is_template_name(".dotx") && !is_template_name("a.docx") && !is_template_name("a.dot"));
}
}
+21 -1
View File
@@ -41,7 +41,22 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space)) CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
.params(r#"{"value"?: bool}"#) .params(r#"{"value"?: bool}"#)
.pure(), .pure(),
CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| toggle(s, v, |x| &mut x.focus_mode)).pure(), // Focus and Full Screen View (#1678) fill the screen; turning one on turns the other off.
// Full Screen View has no key of its own: Customize Keyboard binds one.
CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| {
let r = toggle(s, v, |x| &mut x.focus_mode);
s.view.full_screen_view &= !s.view.focus_mode;
r
})
.params(r#"{"value"?: bool}"#)
.pure(),
CommandSpec::new("view.fullScreenView", "Full Screen View", "View › Immersive", |s, v| {
let r = toggle(s, v, |x| &mut x.full_screen_view);
s.view.focus_mode &= !s.view.full_screen_view;
r
})
.params(r#"{"value"?: bool}"#)
.pure(),
CommandSpec::new("view.ruler", "Ruler", "View › Show", |s, v| toggle(s, v, |x| &mut x.ruler)).pure(), CommandSpec::new("view.ruler", "Ruler", "View › Show", |s, v| toggle(s, v, |x| &mut x.ruler)).pure(),
// The drawing grid over the page (screen only; never printed or exported). // The drawing grid over the page (screen only; never printed or exported).
CommandSpec::new("view.gridlines", "Gridlines", "View › Show", |s, v| toggle(s, v, |x| &mut x.gridlines)) CommandSpec::new("view.gridlines", "Gridlines", "View › Show", |s, v| toggle(s, v, |x| &mut x.gridlines))
@@ -100,6 +115,11 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("view.altTextPane", "Alt Text Pane", "Review › Accessibility", |s, v| toggle(s, v, |x| &mut x.alt_text_pane)) CommandSpec::new("view.altTextPane", "Alt Text Pane", "Review › Accessibility", |s, v| toggle(s, v, |x| &mut x.alt_text_pane))
.params(r#"{"value"?: bool}"#) .params(r#"{"value"?: bool}"#)
.pure(), .pure(),
CommandSpec::new("view.formatShapePane", "Format Shape Pane", "Shape Format › Shape Styles", |s, v| {
toggle(s, v, |x| &mut x.format_shape_pane)
})
.params(r#"{"value"?: bool}"#)
.pure(),
CommandSpec::new("view.newWindow", "New Window", "View › Window", |s, _| { CommandSpec::new("view.newWindow", "New Window", "View › Window", |s, _| {
s.ui_requests.push(json!({"open": "newWindow"})); s.ui_requests.push(json!({"open": "newWindow"}));
sel_result(s) sel_result(s)
+87 -8
View File
@@ -398,12 +398,7 @@ pub fn open_path_with(path: &std::path::Path, password: Option<&str>) -> Result<
/// [`open_path_with`] in `region` (see [`open_bytes_in`]). /// [`open_path_with`] in `region` (see [`open_bytes_in`]).
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> { pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wordcraft_geom::Region) -> Result<(Document, bool), String> {
crate::file_access::check_read(&path.to_string_lossy())?; let bytes = read_document(path)?;
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
if meta.len() > 2 << 30 {
return Err(format!("{}: file is larger than 2 GB", path.display()));
}
let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
let name = path.to_string_lossy(); let name = path.to_string_lossy();
if matches!(ext_of(&name).as_str(), "html" | "htm" | "xhtml") { if matches!(ext_of(&name).as_str(), "html" | "htm" | "xhtml") {
// Pictures referenced by a relative path load from beside the HTML file. // Pictures referenced by a relative path load from beside the HTML file.
@@ -417,6 +412,32 @@ pub fn open_path_in(path: &std::path::Path, password: Option<&str>, region: &wor
open_bytes_in(&name, &bytes, password, region) open_bytes_in(&name, &bytes, password, region)
} }
/// Largest document file opened by path.
#[cfg(not(target_arch = "wasm32"))]
const MAX_DOCUMENT: u64 = 2 << 30;
/// The bytes of the document at `path`, read as `file.open` reads a path: inside the automation
/// read root, and only a regular file of at most 2 GB. A named pipe would block the open and a
/// device such as `/dev/zero` would never end ([`read_regular_file`]). Public for the app, which
/// opens a file whose name isn't valid Unicode through `file.open`'s `data` (#1440).
#[cfg(not(target_arch = "wasm32"))]
pub fn read_document(path: &std::path::Path) -> Result<Vec<u8>, String> {
crate::file_access::check_read(&path.to_string_lossy())?;
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
if !meta.is_file() {
return Err(format!("{}: not a file", path.display()));
}
if meta.len() > MAX_DOCUMENT {
return Err(format!("{}: file is larger than 2 GB", path.display()));
}
// Checked again on the opened file, which may not be the one just looked at.
match read_regular_file(path, MAX_DOCUMENT) {
Some(bytes) if bytes.len() as u64 <= MAX_DOCUMENT => Ok(bytes),
Some(_) => Err(format!("{}: file is larger than 2 GB", path.display())),
None => Err(format!("{}: could not be read as a file", path.display())),
}
}
/// The pictures an HTML file names by a path relative to its folder. /// The pictures an HTML file names by a path relative to its folder.
/// ///
/// Only regular files inside the folder (after resolving `..` and symlinks) are read. The folder is /// Only regular files inside the folder (after resolving `..` and symlinks) are read. The folder is
@@ -521,12 +542,17 @@ fn read_regular_file(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
Some(out) Some(out)
} }
/// Largest file [`read_file`] reads whole — the same 2 GB ceiling [`open_path_with`] gives a
/// document.
pub const MAX_READ_FILE: u64 = 2 << 30;
/// The bytes of the file at `path`, for commands that take a path (pictures, mail-merge data). /// The bytes of the file at `path`, for commands that take a path (pictures, mail-merge data).
/// Every such read goes through here so the automation read root applies ([`crate::file_access`]). /// Every such read goes through here so the automation read root applies ([`crate::file_access`]).
/// Only a regular file is read, and at most [`MAX_READ_FILE`] bytes of it: a named pipe blocks
/// when opened and a device like `/dev/zero` never reaches end of file.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub fn read_file(path: &str) -> Result<Vec<u8>, String> { pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
crate::file_access::check_read(path)?; read_file_capped(path, MAX_READ_FILE, "file")
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
} }
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
@@ -561,6 +587,59 @@ pub fn read_file_head(path: &str, max: u64) -> Result<Vec<u8>, String> {
Ok(head) Ok(head)
} }
/// An entry of [`list_dir`]: a file or folder (links followed).
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone, Debug)]
pub struct DirEntry {
pub name: String,
pub path: std::path::PathBuf,
pub dir: bool,
/// The file's size in bytes (0 for a folder).
pub len: u64,
/// When it last changed, in seconds since the Unix epoch, if the system says.
pub modified: Option<u64>,
}
/// The files and folders in the folder at `path`, inside the automation read root (entries
/// leading outside it, and ones that can't be read, are left out); at most `max` entries are
/// looked at, and `true` says the listing was cut short there.
#[cfg(not(target_arch = "wasm32"))]
pub fn list_dir(path: &std::path::Path, max: usize) -> Result<(Vec<DirEntry>, bool), String> {
crate::file_access::check_read(&path.to_string_lossy())?;
let read = std::fs::read_dir(path).map_err(|e| format!("{}: {e}", path.display()))?;
let mut out = Vec::new();
for (seen, entry) in read.enumerate() {
if seen >= max {
return Ok((out, true));
}
let Ok(entry) = entry else { continue };
let path = entry.path();
if crate::file_access::check_read(&path.to_string_lossy()).is_err() {
continue;
}
let Ok(meta) = std::fs::metadata(&path) else { continue };
if !meta.is_dir() && !meta.is_file() {
continue;
}
let modified = meta.modified().ok().and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs());
let name = entry.file_name().to_string_lossy().into_owned();
out.push(DirEntry { name, path, dir: meta.is_dir(), len: if meta.is_file() { meta.len() } else { 0 }, modified });
}
Ok((out, false))
}
/// The title in the core properties of the Word package at `path` (a file of at most `max_file`
/// bytes; at most `max_part` bytes of the part are read), inside the automation read root.
#[cfg(not(target_arch = "wasm32"))]
pub fn package_title(path: &std::path::Path, max_file: u64, max_part: u64) -> Option<String> {
crate::file_access::check_read(&path.to_string_lossy()).ok()?;
let file = std::fs::File::open(path).ok()?;
if file.metadata().ok()?.len() > max_file {
return None;
}
wordcraft_docx::package_title(std::io::BufReader::new(file), max_part)
}
/// Rename without replacing another file, respecting both automation access roots. /// Rename without replacing another file, respecting both automation access roots.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub fn rename_file(source: &std::path::Path, destination: &std::path::Path) -> Result<(), String> { pub fn rename_file(source: &std::path::Path, destination: &std::path::Path) -> Result<(), String> {
+66 -1
View File
@@ -24,7 +24,7 @@ pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
let written = replace_with(path, private, |file| { let written = replace_with(path, private, |file| {
file.write_all(bytes)?; file.write_all(bytes)?;
if let Some(permissions) = permissions { if let Some(permissions) = permissions {
file.set_permissions(permissions)?; keep_permissions(file.set_permissions(permissions))?;
} }
// Synchronize the prepared file before replacing the destination. Directory durability // Synchronize the prepared file before replacing the destination. Directory durability
// and network-filesystem/power-loss guarantees are outside this operation's contract. // and network-filesystem/power-loss guarantees are outside this operation's contract.
@@ -33,6 +33,34 @@ pub(super) fn write(path: &Path, bytes: &[u8]) -> io::Result<()> {
written.map_err(explain) written.map_err(explain)
} }
/// A folder that keeps no Unix modes refuses to set one with "Operation not supported": a NAS
/// share mounted by GVFS (`smb://` in the Linux file manager) refuses every mode change (#3003).
/// There is no mode to keep there, so the save goes on; any other failure still stops it.
fn keep_permissions(set: io::Result<()>) -> io::Result<()> {
match set {
Err(e) if not_supported(&e) => Ok(()),
set => set,
}
}
/// ENOTSUP and EOPNOTSUPP, which std doesn't count as `Unsupported` (only ENOSYS), plus that kind.
fn not_supported(e: &io::Error) -> bool {
const CODES: &[i32] = if cfg!(any(target_os = "linux", target_os = "android")) {
&[95]
} else if cfg!(any(target_os = "macos", target_os = "ios")) {
&[45, 102]
} else if cfg!(target_os = "openbsd") {
&[45, 91]
} else if cfg!(target_os = "netbsd") {
&[45, 86]
} else if cfg!(any(target_os = "freebsd", target_os = "dragonfly")) {
&[45]
} else {
&[]
};
e.kind() == io::ErrorKind::Unsupported || e.raw_os_error().is_some_and(|code| CODES.contains(&code))
}
fn replace_with(path: &Path, private: bool, prepare: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> { fn replace_with(path: &Path, private: bool, prepare: impl FnOnce(&mut File) -> io::Result<()>) -> io::Result<()> {
let mut temp = match Temporary::create(path, private) { let mut temp = match Temporary::create(path, private) {
Ok(temp) => temp, Ok(temp) => temp,
@@ -245,6 +273,43 @@ mod tests {
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 2); assert_eq!(fs::read_dir(&d.0).unwrap().count(), 2);
} }
/// #3003: a folder that refuses mode changes (a GVFS-mounted NAS share) still takes the
/// save; a refused mode change for any other reason still stops it and keeps the original.
#[test]
fn a_folder_without_unix_modes_still_takes_the_save() {
let d = Scratch::new();
let path = d.target();
fs::write(&path, b"Original bytes.").unwrap();
replace_with(&path, true, |file| {
file.write_all(b"Saved on the share.")?;
keep_permissions(Err(io::Error::from(io::ErrorKind::Unsupported)))?;
file.sync_all()
})
.unwrap();
assert_eq!(fs::read(&path).unwrap(), b"Saved on the share.");
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1);
let err = replace_with(&path, true, |file| {
file.write_all(b"Refused.")?;
keep_permissions(Err(io::Error::from(io::ErrorKind::PermissionDenied)))
})
.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
assert_eq!(fs::read(&path).unwrap(), b"Saved on the share.");
assert_eq!(fs::read_dir(&d.0).unwrap().count(), 1);
}
/// GVFS reports a refused mode change as ENOTSUP, which std leaves uncategorized; other OS
/// errors still stop the save.
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn enotsup_from_the_os_is_an_unsupported_mode_change() {
let enotsup = if cfg!(target_os = "linux") { 95 } else { 45 };
assert!(keep_permissions(Err(io::Error::from_raw_os_error(enotsup))).is_ok());
let eacces = 13;
assert!(keep_permissions(Err(io::Error::from_raw_os_error(eacces))).is_err());
}
/// Makes `dir` read-only while `f` runs. `None` when running as root, where the folder isn't /// Makes `dir` read-only while `f` runs. `None` when running as root, where the folder isn't
/// read-only to us. /// read-only to us.
#[cfg(unix)] #[cfg(unix)]
+15
View File
@@ -88,6 +88,21 @@ impl Session {
} }
None None
} }
/// Waits for recovery to finish anything already under way, discarding this session's own
/// copy first if it no longer needs one (the document was saved, say). Desktop hosts call
/// this right before the process exits, the same as they wait out pending print jobs:
/// otherwise a save just before close can race the process exiting before a now-redundant
/// recovery copy actually gets removed, so the next launch wrongly offers to recover a
/// document that was in fact saved cleanly.
pub fn finish_recovery(&mut self) {
#[cfg(not(target_arch = "wasm32"))]
if let Some(mut r) = self.recovery.take() {
if let Err(e) = r.finish(self) {
log::warn!("recovery: {e}");
}
self.recovery = Some(r);
}
}
/// Only an explicit protect/save action waits for the worker and removes plaintext copies. /// Only an explicit protect/save action waits for the worker and removes plaintext copies.
/// A failure prevents applying the password; typing never waits. /// A failure prevents applying the password; typing never waits.
pub(crate) fn clear_recovery_before_password(&mut self) -> Result<(), CmdError> { pub(crate) fn clear_recovery_before_password(&mut self) -> Result<(), CmdError> {
+188 -16
View File
@@ -25,6 +25,29 @@ pub(crate) struct Snapshot {
generation: u64, generation: u64,
pub document: Document, pub document: Document,
} }
/// A failed snapshot write. `permanent` failures come from the snapshot itself (its size, the
/// folder or disk budget, a value the format cannot keep): writing the same document again would
/// fail the same way, so it is not retried until the document changes (#2625).
#[derive(Debug)]
struct SaveError {
message: String,
permanent: bool,
}
impl SaveError {
fn permanent(message: impl Into<String>) -> Self {
Self { message: message.into(), permanent: true }
}
}
impl From<String> for SaveError {
fn from(message: String) -> Self {
Self { message, permanent: false }
}
}
impl From<&str> for SaveError {
fn from(message: &str) -> Self {
message.to_owned().into()
}
}
struct Entry { struct Entry {
info: RecoveryInfo, info: RecoveryInfo,
path: PathBuf, path: PathBuf,
@@ -222,7 +245,7 @@ impl Store {
} }
Ok(used) Ok(used)
} }
fn save(&mut self, s: Snapshot) -> Result<(), String> { fn save(&mut self, s: Snapshot) -> Result<(), SaveError> {
let id = s.info.id.clone(); let id = s.info.id.clone();
let result = self.write_snapshot(s); let result = self.write_snapshot(s);
// Include failures before temp-file creation (quota checks, locking, etc.). // Include failures before temp-file creation (quota checks, locking, etc.).
@@ -235,11 +258,11 @@ impl Store {
} }
result result
} }
fn write_snapshot(&mut self, mut s: Snapshot) -> Result<(), String> { fn write_snapshot(&mut self, mut s: Snapshot) -> Result<(), SaveError> {
let dir = self.root.join(&s.info.id); let dir = self.root.join(&s.info.id);
if !dir.exists() { if !dir.exists() {
if self.directories()?.len() >= MAX_RECORDS { if self.directories()?.len() >= MAX_RECORDS {
return Err("recovery folder is full; discard an older copy".into()); return Err(SaveError::permanent("recovery folder is full; discard an older copy"));
} }
private_dir(&dir).map_err(|e| e.to_string())?; private_dir(&dir).map_err(|e| e.to_string())?;
} }
@@ -253,16 +276,20 @@ impl Store {
let limit = MAX_BYTES.min(DISK_BUDGET.saturating_sub(self.disk_used()?)); let limit = MAX_BYTES.min(DISK_BUDGET.saturating_sub(self.disk_used()?));
let tmp = dir.join(format!("pending-{}.tmp", new_id()?)); let tmp = dir.join(format!("pending-{}.tmp", new_id()?));
let final_path = dir.join(format!("snapshot-{:020}.json", s.generation)); let final_path = dir.join(format!("snapshot-{:020}.json", s.generation));
let result = (|| { let result = (|| -> Result<(), SaveError> {
let file = private_file(&tmp, true).map_err(|e| e.to_string())?; let file = private_file(&tmp, true).map_err(|e| e.to_string())?;
let mut writer = Limited { inner: BufWriter::new(file), remaining: limit }; let mut writer = Limited { inner: BufWriter::new(file), remaining: limit, exceeded: false };
serde_json::to_writer(&mut writer, &s).map_err(|e| e.to_string())?; let written = serde_json::to_writer(&mut writer, &s).map_err(|e| e.to_string()).and_then(|()| writer.flush().map_err(|e| e.to_string()));
writer.flush().map_err(|e| e.to_string())?; if let Err(e) = written {
return Err(SaveError { message: e, permanent: writer.exceeded });
}
writer.inner.get_ref().sync_all().map_err(|e| e.to_string())?; writer.inner.get_ref().sync_all().map_err(|e| e.to_string())?;
drop(writer); drop(writer);
let check = read_snapshot(&tmp)?; // The copy was written and synchronized; one that does not decode back to the same
// document fails the same way every time.
let check = read_snapshot(&tmp).map_err(SaveError::permanent)?;
if check.document != s.document { if check.document != s.document {
return Err("recovery format did not preserve the full document".into()); return Err(SaveError::permanent("recovery format did not preserve the full document"));
} }
// A new generation is never written over the last valid file. // A new generation is never written over the last valid file.
fs::rename(&tmp, &final_path).map_err(|e| e.to_string())?; fs::rename(&tmp, &final_path).map_err(|e| e.to_string())?;
@@ -329,10 +356,12 @@ impl Store {
struct Limited<W> { struct Limited<W> {
inner: W, inner: W,
remaining: u64, remaining: u64,
exceeded: bool,
} }
impl<W: Write> Write for Limited<W> { impl<W: Write> Write for Limited<W> {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> { fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
if bytes.len() as u64 > self.remaining { if bytes.len() as u64 > self.remaining {
self.exceeded = true;
return Err(io::Error::other("recovery snapshot or folder size limit exceeded")); return Err(io::Error::other("recovery snapshot or folder size limit exceeded"));
} }
let n = self.inner.write(bytes)?; let n = self.inner.write(bytes)?;
@@ -357,6 +386,8 @@ enum Outcome {
struct Event { struct Event {
documents: Vec<RecoveryInfo>, documents: Vec<RecoveryInfo>,
outcome: Result<Outcome, String>, outcome: Result<Outcome, String>,
/// The failure would repeat for the same document; see [`SaveError`].
permanent: bool,
} }
pub(crate) struct Recovery { pub(crate) struct Recovery {
tx: mpsc::SyncSender<Job>, tx: mpsc::SyncSender<Job>,
@@ -381,24 +412,28 @@ impl Recovery {
let mut store = match Store::new(root) { let mut store = match Store::new(root) {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
let _ = events.send(Event { documents: Vec::new(), outcome: Err(e) }); let _ = events.send(Event { documents: Vec::new(), outcome: Err(e), permanent: false });
wake(); wake();
return; return;
} }
}; };
let initial = store.scan().map(|_| Outcome::Done); let initial = store.scan().map(|_| Outcome::Done);
if events.send(Event { documents: store.inventory(), outcome: initial }).is_err() { if events.send(Event { documents: store.inventory(), outcome: initial, permanent: false }).is_err() {
return; return;
} }
wake(); wake();
while let Ok(job) = jobs.recv() { while let Ok(job) = jobs.recv() {
let mut permanent = false;
let result = match job { let result = match job {
Job::Save(s) => store.save(*s).map(|_| Outcome::Done), Job::Save(s) => store.save(*s).map(|_| Outcome::Done).map_err(|e| {
permanent = e.permanent;
e.message
}),
Job::Scan => store.scan().map(|_| Outcome::Done), Job::Scan => store.scan().map(|_| Outcome::Done),
Job::Load(id, expected) => store.load(&id).map(|s| Outcome::Loaded(Box::new(s), expected)), Job::Load(id, expected) => store.load(&id).map(|s| Outcome::Loaded(Box::new(s), expected)),
Job::Discard(id) => store.discard(&id).map(|_| Outcome::Discarded(id)), Job::Discard(id) => store.discard(&id).map(|_| Outcome::Discarded(id)),
}; };
if events.send(Event { documents: store.inventory(), outcome: result }).is_err() { if events.send(Event { documents: store.inventory(), outcome: result, permanent }).is_err() {
break; break;
} }
wake(); wake();
@@ -437,6 +472,7 @@ impl Recovery {
self.busy = false; self.busy = false;
self.ready = true; self.ready = true;
self.documents = e.documents; self.documents = e.documents;
let event_permanent = e.permanent;
match e.outcome { match e.outcome {
Ok(Outcome::Done) => None, Ok(Outcome::Done) => None,
Ok(Outcome::Discarded(id)) => { Ok(Outcome::Discarded(id)) => {
@@ -448,7 +484,11 @@ impl Recovery {
Ok(Outcome::Loaded(s, expected)) => Some((*s, expected)), Ok(Outcome::Loaded(s, expected)) => Some((*s, expected)),
Err(e) => { Err(e) => {
self.error = Some(e); self.error = Some(e);
self.revision = None; // A transient failure (a lock, an I/O error) is retried at the next
// interval; one the snapshot itself causes waits for the next edit.
if !event_permanent {
self.revision = None;
}
self.due = Instant::now() + INTERVAL; self.due = Instant::now() + INTERVAL;
None None
} }
@@ -549,6 +589,51 @@ impl Recovery {
self.revision = None; self.revision = None;
Ok(()) Ok(())
} }
/// Waits out any job already in flight (an autosave queued just before shutdown, say), then,
/// if the document turns out to need no recovery copy kept (not dirty, or password-protected
/// — [`tick`](Self::tick)'s own condition), discards it and waits for that too. Desktop hosts
/// call this before the process exits, the same as the print spooler's own pending-job wait
/// (`finish_pending_jobs`): otherwise a save that lands right before close can race the
/// process exiting before the now-redundant recovery copy is actually removed, so the *next*
/// launch wrongly offers to recover a document that was in fact saved cleanly.
pub fn finish(&mut self, s: &Session) -> Result<(), String> {
let mut error = None;
if self.busy {
let e = self.rx.recv().map_err(|e| e.to_string())?;
self.documents = e.documents;
self.busy = false;
self.ready = true;
// An in-flight job failing (e.g. a stray autosave) doesn't mean the document itself
// wasn't saved cleanly by an explicit Save — still try the discard below, so one
// doesn't keep the other from doing its job; log it once there's nothing further to do.
if let Err(e) = e.outcome {
error = Some(e);
}
}
// Same guard `tick` uses: a document switch with no regular tick in between to notice and
// reset `self.id` must not let the *new* document's clean state discard the *previous*
// document's still-live recovery copy under a stale identity.
if self.document == Some(s.document_id())
&& let Some(id) = self.id.clone()
&& (!s.dirty || s.password.is_some())
{
self.queue(Job::Discard(id))?;
let e = self.rx.recv().map_err(|e| e.to_string())?;
self.busy = false;
self.documents = e.documents;
match e.outcome {
Ok(_) => {
self.id = None;
self.revision = None;
}
Err(e) => error = Some(e),
}
}
match error {
Some(e) => Err(e),
None => Ok(()),
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -628,6 +713,49 @@ mod tests {
settle(s); settle(s);
} }
/// #3140: a save that lands right before the app closes can race the process exiting before
/// the now-redundant recovery copy is actually removed, so the next launch wrongly offers to
/// recover a document that was in fact saved cleanly. `finish_recovery` (desktop hosts call it
/// on exit, the same as they wait for pending print jobs) must discard it synchronously
/// instead, with no regular tick in between — simulating closing right after Save, not after
/// however long it'd take the next normal autosave tick to notice and clean up.
#[test]
fn finish_recovery_discards_its_own_copy_for_a_document_saved_right_before_exit() {
let profile = Temp::new();
let mut s = session(&profile.0);
s.run("document.setText", &json!({"text": "About to be saved"})).unwrap();
save_now(&mut s);
assert_eq!(s.recovery_documents().len(), 1, "a recovery copy exists for the dirty document");
// A real Save does this, then the app can close on the very next frame, with no regular
// recovery_tick() in between to notice `!s.dirty` and queue the discard itself.
s.dirty = false;
assert_eq!(
s.recovery_documents().len(),
1,
"confirms the race: setting dirty=false alone (what Save does) doesn't discard anything by itself"
);
s.finish_recovery();
assert!(s.recovery_documents().is_empty(), "finish_recovery should have discarded the now-redundant copy before returning");
}
/// `tick` resets `self.id` the moment the session's document no longer matches what it last
/// saw (so it never discards under a stale identity); `finish_recovery` must guard the same
/// way. Switching documents and exiting immediately, with no regular tick in between to reset
/// that identity, must not let the *new* (clean, blank) document's state discard the
/// *previous* document's still-live recovery copy.
#[test]
fn finish_recovery_does_not_discard_a_previous_document_s_copy_after_switching() {
let profile = Temp::new();
let mut s = session(&profile.0);
s.run("document.setText", &json!({"text": "The document the user is still working on"})).unwrap();
save_now(&mut s);
assert_eq!(s.recovery_documents().len(), 1, "a recovery copy exists for the dirty document");
s.set_document(Document::new()); // e.g. File > New, with no tick() in between
assert!(!s.dirty, "a freshly switched-to document starts clean");
s.finish_recovery();
assert_eq!(s.recovery_documents().len(), 1, "switching to a new, unrelated document must not discard the previous one's recovery copy");
}
#[test] #[test]
fn recovery_commands_only_address_known_profile_records() { fn recovery_commands_only_address_known_profile_records() {
use crate::file_access::{AutomationRoots, confine}; use crate::file_access::{AutomationRoots, confine};
@@ -690,9 +818,10 @@ mod tests {
#[test] #[test]
fn size_limit_and_lossy_values_fail_without_committing() { fn size_limit_and_lossy_values_fail_without_committing() {
let mut out = Limited { inner: Vec::new(), remaining: 3 }; let mut out = Limited { inner: Vec::new(), remaining: 3, exceeded: false };
assert!(out.write_all(b"four").is_err()); assert!(out.write_all(b"four").is_err());
assert!(out.inner.is_empty()); assert!(out.inner.is_empty());
assert!(out.exceeded);
let t = Temp::new(); let t = Temp::new();
let id = new_id().unwrap(); let id = new_id().unwrap();
let mut store = Store::new(t.0.clone()).unwrap(); let mut store = Store::new(t.0.clone()).unwrap();
@@ -701,11 +830,54 @@ mod tests {
let bytes = fs::read(&first).unwrap(); let bytes = fs::read(&first).unwrap();
let mut invalid = snapshot(&id, "invalid"); let mut invalid = snapshot(&id, "invalid");
invalid.document.last_section.page_w = f32::NAN; invalid.document.last_section.page_w = f32::NAN;
assert!(store.save(invalid).is_err()); assert!(store.save(invalid).is_err_and(|e| e.permanent));
assert_eq!(fs::read(first).unwrap(), bytes); assert_eq!(fs::read(first).unwrap(), bytes);
assert_eq!(Store::files(&t.0.join(id)).unwrap().len(), 1); assert_eq!(Store::files(&t.0.join(id)).unwrap().len(), 1);
} }
/// Copies are written with picture bytes in base64 (#586 follow-up); those written before kept
/// them as arrays of numbers and are still listed and restored, bytes intact.
#[test]
fn copies_with_byte_arrays_written_before_still_restore() {
let t = Temp::new();
let id = new_id().unwrap();
let mut s = snapshot(&id, "Synthetic draft with a picture");
s.generation = 1;
s.document.media.insert("media/image1.png".into(), Arc::new(vec![0, 1, 127, 128, 254, 255]));
let mut value = serde_json::to_value(&s).unwrap();
assert_eq!(value["document"]["media"]["media/image1.png"], "AAF/gP7/");
value["document"]["media"]["media/image1.png"] = json!([0, 1, 127, 128, 254, 255]);
private_dir(&t.0.join(&id)).unwrap();
fs::write(t.0.join(&id).join("snapshot-00000000000000000001.json"), serde_json::to_vec(&value).unwrap()).unwrap();
let mut store = Store::new(t.0.clone()).unwrap();
store.scan().unwrap();
assert_eq!(store.inventory().len(), 1);
assert_eq!(store.load(&id).unwrap().document, s.document);
}
#[test]
fn snapshot_that_cannot_be_copied_is_not_retried_until_the_document_changes() {
// #2625: a failure caused by the snapshot itself (here a value the format cannot keep;
// in the field, the 256 MiB limit) used to be rewritten every interval while dirty.
let t = Temp::new();
let mut s = session(&t.0);
s.run("document.setText", &json!({"text": "Synthetic draft"})).unwrap();
s.doc.last_section.page_w = f32::NAN;
save_now(&mut s);
assert!(s.recovery_error().is_some());
assert!(s.recovery_wait().is_none());
s.recovery.as_mut().unwrap().due = Instant::now();
s.recovery_tick();
assert!(!s.recovery_busy(), "the unchanged document was snapshotted again");
assert!(s.recovery_error().is_some());
// An edit tries again.
s.doc.last_section.page_w = 612.0;
s.run("text.insert", &json!({"text": "!"})).unwrap();
save_now(&mut s);
assert!(s.recovery_error().is_none());
assert_eq!(s.recovery_documents().len(), 1);
}
#[test] #[test]
fn failed_pruning_cannot_push_a_record_beyond_the_discovery_limit() { fn failed_pruning_cannot_push_a_record_beyond_the_discovery_limit() {
let t = Temp::new(); let t = Temp::new();

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