Compare commits

..
Author SHA1 Message Date
FredApps 4b0d399599 Merge pull request #3195 from vangel76/zoom-stretch
Pointer zoom stretches page rasters until it stops
2026-11-08 18:14:29 +01:00
FredApps fda8758ec9 Merge upstream text hinting and window scrolling 2026-11-08 17:59:05 +01:00
FredApps c5fe6c6816 Merge pull request #3197 from vangel76/ui-text-hinting
Dark-theme interface text on Linux no longer looks semi-bold
2026-11-08 17:55:10 +01:00
FredApps 438f82977a Merge pull request #2723 from vangel76/feat/recent-remove-2716
Remove documents from the recent list, one by one or all at once
2026-11-08 17:53:13 +01:00
vangel76andClaude Opus 5.5 72a7afd9b5 Merge upstream/main into PR #3195
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:37:24 +01:00
FredApps 36ed94a09e Merge upstream control delivery changes 2026-11-08 17:35:46 +01:00
FredApps 0655e651fb Merge upstream control delivery changes 2026-11-08 17:35:06 +01:00
FredApps 0e3f4dd315 Merge pull request #2596 from vangel76/control-mcp-delivery-1326-1331
Control/MCP: no replay of mutations with unknown outcome; queued requests honour timeouts (#1326, #1331)
2026-11-08 17:32:33 +01:00
FredApps e01a88b0be Fix paper color tests for hinted page keys 2026-11-08 17:29:14 +01:00
FredApps ae257ba784 Merge upstream font tests and window scrolling 2026-11-08 17:26:55 +01:00
FredApps 0495fbb789 Merge pull request #2783 from vangel76/test/fixed-layout-fonts-2729
#2729: Layout tests use pinned bundled fonts
2026-11-08 17:24:43 +01:00
vangel76andClaude Opus 5.5 e7e9bc2759 Merge upstream/main into PR #3195
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:24:03 +01:00
FredApps be75ee90e3 Merge upstream main and reconcile control documentation 2026-11-08 17:22:59 +01:00
FredApps 480511eaa5 Merge upstream main into control delivery changes 2026-11-08 17:15:38 +01:00
FredApps aa262cf7d8 Merge pull request #2595 from vangel76/mcp-insert-table-2242
MCP: insert_table tool for filled tables (#2242)
2026-11-08 17:11:08 +01:00
FredApps 2d97c8443e Merge pull request #1056 from vangel76/feat/1053-rtf-notes-comments
RTF: footnotes, endnotes and comments are read and written (#1053)
2026-11-08 16:38:59 +01:00
FredApps d5d2b22dac Merge pull request #331 from vangel76/feat/322-view-windows
View › Window: Switch Windows, Arrange All, View Side by Side and Synchronous Scrolling
2026-11-08 16:37:54 +01:00
FredApps 2794ae44e9 Merge pull request #5115 from vangel76/feat/paper-colors-3398
Add persistent screen-only paper color presets (#3398)
2026-11-08 16:36:10 +01:00
vangel76andClaude Opus 5.5 5125de6f1f Merge upstream/main into PR #1056
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:18:07 +01:00
vangel76andClaude Opus 5.5 59030b1bb5 Merge upstream/main into PR #331
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:18:07 +01:00
vangel76andClaude Opus 5.5 4050f1bf8b Merge upstream/main into PR #5115
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:18:07 +01:00
02e74a5d9c Add Delete Previous Sentence with Ctrl+Shift+Backspace (#5094)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-08 16:13:33 +01:00
FredApps d5a1c0e181 Merge pull request #5116 from vangel76/feat/paragraph-tools-5043
Home › Paragraph Tools: nine paragraph conversion and cleanup commands (#5043)
2026-11-08 16:12:57 +01:00
Valentin KleinandClaude Opus 5.5 69d2a2e131 docs/gaps: gap 16 RTL in HTML/ODT/RTF landed (#2696)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 15:57:36 +01:00
Dylan M. TaylorandMartin Ball 661a790540 Render: skip drawing paths far past every canvas before flattening (#5453)
* fix(render): skip drawing paths far past every canvas before flattening

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

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 08:15:55 +01:00
Dylan M. TaylorandMartin Ball 04217419b6 DOCX save: collect an embedded object's relationship ids in a set (#5414)
* fix(docx): collect an embedded object's relationship ids in a set on save

* style: rustfmt

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 07:59:33 +01:00
Dylan M. TaylorandMartin Ball 4afff61f10 fix(geom): measure PNGs from their chunk headers, not the decoder's header read (#5459)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 07:45:02 +01:00
Dylan M. TaylorandMartin Ball e76293a564 fix(doc): refuse export links holding characters the writers drop (#5426)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 07:30:15 +01:00
Dylan M. TaylorandMartin Ball e0d8312fbe fix(formats): count spreadsheet cell characters as they are added (#5421)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 07:17:12 +01:00
Dylan M. TaylorandMartin Ball 523e319424 fix(formats): parse at most MAX_COLS cells of a LaTeX tabular row (#5415)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 07:01:59 +01:00
David PrietoandMartin Ball 735c2a5382 layout: keep post-break anchors with text across a wrapped row (#5937)
* layout: keep wrapped-row anchors after page breaks

* test: pin wrapped picture drops, history and DOCX

* docs: record wrapped-row anchor coverage

* docs: log wrapped-row picture anchoring fix

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 06:59:44 +01:00
Dylan M. TaylorandMartin Ball 7592d1440e fix(docx): refuse to carry parts named like WordCraft's bookkeeping (#5411)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 06:48:33 +01:00
batistalucasvandMartin Ball fde39ab061 Desktop: constrain newly opened window dimensions to available screen width (#5871) (#5876)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 06:44:01 +01:00
d34e341d75 Charts: an automatic series colour follows the series' c:idx (#5963) (#5975)
* Charts: an automatic series colour follows the series' c:idx (#5963)

A series without its own colour took the accent for its place in the
file. Word picks it by the series' c:idx (c:order doesn't matter), so a
chart whose series aren't numbered 0, 1, 2... in file order, such as a
lone series with idx 2 after a deletion, got different colours.

c:idx is read strictly as the unsignedInt the schema says; a missing or
invalid value falls back to the place in the file.

* Charts: the renumbered test helper fails if ser's layout changes (#5963)

---------

Co-authored-by: kiwi4boy <kiwi4boy@gmail.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 06:41:38 +01:00
535b675e8a DOCX: paragraph indents and spacing beyond 16 bits wrap as in Word (#5655)
## What changed

Word stores paragraph indents (`w:ind` left/start, right/end, hanging, firstLine) in 16 signed bits and paragraph
spacing (`w:spacing` before/after) in 16 unsigned bits. A file that writes a larger plain integer is read by Word
modulo 2^16: `w:left="66256"` is a 720-twip (0.5") indent in Word. We read the full value, so such a paragraph was
indented by about 46 inches and its text was pushed off the page.

- `crates/docx/src/units.rs`: new `tw_i16` / `tw_u16` readers. A plain integer keeps its low 16 bits (two's
  complement for indents, so 66256 -> 720 and 65535 -> -1); values with a unit or a fraction read as before.
- `crates/docx/src/read/props.rs` and `read/styles.rs` use them for `w:ind` and `w:spacing` before/after, in direct
  and style formatting.
- In-range values read exactly as before.

## Testing

- `crates/docx/tests/it/twips_wrap.rs`: synthetic documents with out-of-range indents and spacing in paragraph and
  style properties check the wrapped values; in-range values and values with units are unchanged.
- Unit tests for the two readers; `cargo xtask ci` passes.

Found by a real-file corpus comparison against Word's own PDFs: a file whose indents exceed 16 bits shows its text on
the page in Word and off the page here.

Co-authored-by: tpawley2001 <268951182+tpawley2001@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 06:39:22 +01:00
Dylan M. TaylorandMartin Ball c8618d3e05 fix(docx): look relationships up by id through an index (#5410)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 06:34:27 +01:00
a891d861b7 Layout: contextual spacing drops the space after a cell's last Normal paragraph (#5450)
Word treats the end-of-cell mark as a paragraph in the default (Normal)
style, so a cell's last paragraph with contextual spacing in that style
(or in an undefined style, formatted as Normal) loses its space after, as
beside any same-style neighbour; one in another style keeps it.
`layout_box` kept the space after of every last cell paragraph except
automatic spacing, so the row grew by it and the text below the table sat
that much lower (docxide-pdf cell-end-text-context, cell-end-empty-context:
8 pt).

A middle or bottom cell of a row split across pages lines up with the
other cells' kept text, which `align_in_part` counted with its full space
after; it now stops at the cell's content end, so a dropped space after
(contextual or automatic) no longer pushes the aligned cell down.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 06:27:07 +01:00
Kevin JosephandMartin Ball 85434d2c20 layout: line numbers take the "Line Number" style, not a fixed 10 pt grey (#5788)
* layout: line numbers take the "Line Number" style, not a fixed 10 pt grey

Line numbers were laid out in 10 pt grey in the default font, whatever
the document said. Word formats them with the "Line Number" character
style over the document's defaults: in a manuscript whose defaults are
Aptos 12 pt, Word's PDF shows them in Aptos 12 pt black (measured in
Word for Mac), WordCraft's in 10 pt grey.

Lay the number out with the document's "Line Number" style when it has
one, else its plain defaults, so size, font and colour follow the
document.

Test: line numbers take the document's default size and automatic
colour, and a "Line Number" style's size and colour when there is one.

* docs: progress log and parity entries for line-number-style

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 06:26:44 +01:00
vangel76andClaude Opus 5.5 833c0154aa Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 06:18:12 +01:00
vangel76andClaude Opus 5.5 0a3fb5828e Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 06:18:12 +01:00
Kevin JosephandMartin Ball 7634553900 Layout: superscript and subscript text at 0.65 of its size rounded to the half point (#5802)
Word for Mac sets raised and lowered text at 0.65 of the run's size
rounded to the half point (measured from digit advances in its PDF
export at base sizes 8 to 36 pt: 11 pt -> 7, 12 -> 8, 24 -> 15.5).
WordCraft used 0.65 exactly, so lines with superscripts were up to
0.5 pt wider and some broke a word early.

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 06:02:01 +01:00
cd4f755dcf DOCX: nested block-level content controls stay block-level when a bookmark follows them, so Word opens the file (#5811)
## What changed

The DOCX writer's content-control planner (`crates/docx/src/write/sdt.rs`) decides whether a control that
covers a whole paragraph is written block-level (`w:sdt` around `w:p`) or run-level (inside the paragraph).
It only treated the control as covering the whole paragraph when nothing but control-end markers followed
its end. Bookmark, comment and permission range markers after the end now count as "nothing" too, so the
control stays block-level and the markers stay at the end of its paragraph.

## Why

Word writes a body-level `_GoBack` bookmark (`<w:bookmarkStart w:name="_GoBack"/><w:bookmarkEnd/>` directly in
`w:body`) in nearly every file it saves. When the last body-level content control was followed by it, the
reader put the bookmark at the end of the control's paragraph, and the writer then turned the control (and
any controls nested in it) into run-level `w:sdt`s inside that paragraph. For a repeating section
(`w15:repeatingSection` / `w15:repeatingSectionItem`), which must wrap whole paragraphs or rows, Microsoft
365 Word then refuses to open the saved file ("the file appears to be corrupted"). Plain nested block
controls changed what they cover the same way.

Found by the real-file corpus: Word reported our saved copy of a form with a repeating section as corrupted.

## How it was tested

- New tests in `crates/docx/tests/it/nested_block_controls.rs` (synthetic packages): a repeating section around
  one paragraph, plain nested block controls, nested block controls in a table cell, a run-level control that
  must stay in its paragraph, and repeating sections / block controls followed by a body-level bookmark (with
  and without data bindings) all round-trip with the controls at the same level and the text kept.
- `cargo xtask ci` passes.

Co-authored-by: tpawley2001 <268951182+tpawley2001@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:59:51 +01:00
batistalucasv c4d5f70c1d DOCX: resolve LibreOffice font fallback lists to first installed family (#5843) (#5851)
* #5843: resolve LibreOffice semicolon-separated font fallback lists to first installed family

* #5843: address review comments on font fallback list resolution

* #5843: verify fallback list text on cache hit and accept Times New Roman in tests

* #5843: bound the fallback lists read from a DOCX and reuse their classification

A document could name thousands of fallback families in `w:ascii`/`w:hAnsi`.
Nothing bounded that value, and the name-based rules (roman classification,
Thai family, named substitutes) then scanned every candidate for each shaped
piece, so opening the document took seconds.

`w:ascii` and `w:hAnsi` now keep the leading families within MAX_FONT_NAME
bytes, cut at a `;` so no name is split, as `w:cs`/`w:eastAsia` already bound
their names. The family a real list would pick is kept: a LibreOffice list
names a few families, well under the bound.

The Thai-family and named-substitute answers are worked out once per list and
kept in the fallback-list cache, keyed by the list text, so shaping no longer
rescans candidates for every piece. Resolution still reads every candidate.

A 500-paragraph document whose font names 20,000 candidates laid out in 10.8 s;
bounded on read it takes 17 ms.

Tests:
- a hostile fallback list keeps its leading families, cut on a `;`
- a real LibreOffice list is read whole
- a later Thai or named fallback still classifies, and lists are cached
2026-11-08 05:55:24 +01:00
Helen Kwok de4ca29309 wordcraft-web: window.wordcraft, the page command API from craft-web-api (#5860)
The web shell queues the page's calls as control requests (the channel the desktop control server uses) and answers them on the next frame. The API is installed in every mode; opening, saving, the file name and the unsaved flag stay with the host messages.
2026-11-08 05:53:01 +01:00
neilcawse b8a7e2acdb docx: normalize TOCHeading gallery content controls with trailing FieldEnd paragraph (#5952)
* docx: normalize TOCHeading gallery content controls with trailing FieldEnd paragraph

* test(docx): assert TOC entries survive round-trip in section-break TOC control test
2026-11-08 05:50:41 +01:00
Jhong-Ken ChenandMartin Ball f0fb31c0c9 XML: normalise line ends in text and CDATA (XML 1.0 §2.11) (#5897) (#5898)
Text nodes were decoded without line-end normalisation, so a file
saved with Windows line ends kept the CR of each CR LF: a line break
inside w:t read as two spaces. Text and CDATA now go through
quick-xml's xml_content, which reads CR LF and a lone CR as one line
feed; a CR written as &#13; stays a CR. Attribute values were already
normalised.

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 05:48:20 +01:00
David PrietoandMartin Ball 4cd6ffa564 doc: preserve following formatting when deleting an empty paragraph (#5929)
* doc: preserve following formatting when deleting an empty paragraph (Leaf #152)

* doc: regress empty paragraph merge formatting (Leaf #152)

* engine: cover empty heading deletion, history and DOCX (Leaf #152)

* typing: pin empty heading deletion semantics (Leaf #152)

* docs: record empty paragraph formatting coverage (Leaf #152)

* docs: record the empty paragraph deletion fix (Leaf #152)

* test: retain structured controls across an empty paragraph join

* docs: explain empty paragraph engine regressions

* docs: explain the empty heading keyboard regression

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 05:48:03 +01:00
Senura DissanayakeandMartin Ball 52934ef110 docbin: brace and bracket shapes are unfilled unless the file fills them (#5943) (#5945)
A .doc bracket or brace AutoShape (MSOSPT 85-88, 185, 186) is drawn as
its box, and with no fFilled property it got the default white fill, an
opaque block over the text behind it. Word's shapetypes for these shapes
are unfilled by default (VML filled="f"), so they now get a fill only
when fFilled is set.

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 05:45:35 +01:00
3291be0fbf Layout: an empty paragraph after a leading floating table takes no space before (#5440)
Word drops the space before of an empty paragraph that follows a document's
leading floating table: its PDFs of docxide-pdf's leading-empty-before-float-0
and -12 put the text after the empty anchor (beside the table) at the same
place for 0 pt and 12 pt before.

Flow::gap_before (crates/layout/src/flow.rs) kept it: a floating table
takes no room in the flow and clears `pb.prev`, so the paragraph after it
was laid out like a document's first paragraph, with its full space before.
It now drops it for body block 1 when block 0 was placed floating on this
page (the page's float_tables), the page is fresh and the paragraph holds
nothing but markers (bookmarks, comment and permission ranges). It
doesn't check that the paragraph sits beside the table, so below a
full-width leading float an empty paragraph loses its space before too;
that case is untested. Later pages and columns, and paragraphs with text,
keep their space before.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 05:45:17 +01:00
Kevin JosephandMartin Ball 5dfb3acf15 picture.change: keep the width and take the new picture's aspect ratio (#5761)
* picture.change: keep the width and take the new picture's aspect ratio

Change Picture swapped only the media key, so a replacement of another
shape was stretched into the old frame (a revised figure came out ~2%
taller in a real manuscript). Keep the display width, set the height
from the new picture's pixel size, and drop the old crop, which
described the old picture. Regression test included.

* docs: progress log and parity entries for picture-change-aspect

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 05:40:51 +01:00
4259dd1b2e Layout: before Word 2013 layout a floating table moved below another keeps its own left edge (#5442)
A floating table with tblOverlap=never that is moved below another floating
table steps in to its distance from the text's left edge only in Word 2013
layout (compatibility mode 15). clear_of_float_tables read the mode from the
legacy shift, the first cell's left margin before mode 15, being zero, so a
mode 12 table whose first cell has no left margin was taken for Word 2013
layout and moved 9 pt right (docxide displaced-anchor-*-compat12-overlap0-*:
Word 72 pt, WordCraft 81 pt). Pass the mode itself.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 05:31:43 +01:00
vangel76andClaude Opus 5.5 e344bb4243 Merge upstream/main into PR #3197
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:19:14 +01:00
vangel76andClaude Opus 5.5 4a507de3d6 Merge upstream/main into PR #3195
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:19:14 +01:00
vangel76andClaude Opus 5.5 d07f50dd8b Merge upstream/main into PR #5116
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:19:14 +01:00
vangel76andClaude Opus 5.5 87e08051bf Merge upstream/main into PR #2596
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 05:19:14 +01:00
5c0a62a736 Layout: no line break between a full stop and an opening parenthesis (#5437)
UAX #14 allows a break between a full stop (IS) and an opening
parenthesis (OP), so `break_positions` (crates/layout/src/para/paragraph.rs)
let "29.05.2023г.(оформление ЗУ)" and "2023.(Example detail)" wrap after
the full stop. Word keeps such a token whole and wraps it at the space
before, as the docxide-pdf dot-open-token* Word references show. The
break between the two is now dropped (before the kinsoku pass, so
break-anywhere wrapping still applies).

Test: layout::tests::a_full_stop_before_a_parenthesis_is_no_break.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 05:18:09 +01:00
855a0fe9bd Layout: a nested table's Can't Split row moves on with its outer row (#5456)
A row is split across pages from its flattened items, so a row of a nested
table marked w:cantSplit was cut between its lines like any other text in
the outer cell. Word leaves such a row whole: the outer row moves to the
next page whole when that row comes first in it (docxide-pdf
nested-first-row-prefix300/330-cant1), and otherwise splits above it
(inferred).

table_flow now moves the cut up to just below the top of the highest nested
Can't Split row it runs through (nested_keep_cut): the outer row's bottom
band below that top, so the text and nested rows ending there stay on the
page. A row runs through the cut when it ends below the room left for text,
as in the split itself; one walk over the rows from the lowest top up moves
the cut until none does. A vertically merged cell doesn't stretch its row's
band, and a cell aligned to the middle or bottom is measured from its top, as
the split runs it. This applies to the first part of a row and to the kept-chain and
header-row checks. On a fresh page, where moving on gains nothing, a nested
row taller than the room still splits at the room rather than running into
the bottom margin.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 05:03:54 +01:00
Ashok Ramandashokram-si 5f1db14954 Proofing: Check spelling as you type is one setting for every open document (#5740) (#5766)
* Proofing: Check spelling as you type is one setting for every open document (#5740)

Each open document's view kept its own copy of the setting. Turned off with one document active, it stayed on in the others, and the settings saved for the next run took the value of whichever document was active then, so it came back on. A document made active now takes the saved preference, as Read Mode's page colour already does.

* Contributors: credit ashokram-si as Ashok Ram

* Proofing: every view made active follows the app-wide settings, a window's view put back too (#5740)

put_view restored a window's view with the Check spelling as you type value it had when it was taken, after switch_to had applied the shared preference, so the settings saved for the next run could take the old value again. One ViewState::follow_prefs now applies the app-wide settings a view keeps a copy of (Read Mode's page colour and Check spelling as you type) wherever a view is made active: switch_to, a new document's view, set_document and put_view.

* test(ui): Check spelling off in a second window stays off in every window (#5740)

The whole sequence through the app: View › New Window, spelling turned off in the second window, the first window's own view put back (put_view), a new document's window, back to the second window, then the saved settings read by a new app. Fails if put_view doesn't follow the setting.

---------

Co-authored-by: ashokram-si <347905169+ashokram-si@users.noreply.github.com>
2026-11-08 05:01:43 +01:00
Bardan GhimireandBardan b016ecc26c fix: reject non-regular files when reading template titles (#5904)
Co-authored-by: Bardan <bardan.ghimire.10@gmail.com>
2026-11-08 04:59:16 +01:00
batistalucasv 625f598b64 Fix #5750: style-set gallery follows RTL interface direction (#5857) 2026-11-08 04:56:55 +01:00
Bardan GhimireandBardan fa791d29b2 test(mcp): gate the names helper to unix, where its only test runs (#5879)
Co-authored-by: Bardan <bardan.ghimire.10@gmail.com>
2026-11-08 04:56:38 +01:00
Senura Dissanayake 796b39dde6 docbin: open Word 97-2003 files in compatibility mode 11 (#5905)
Word 97-2003 documents were laid out with the model default of mode 15. Word opens them in mode 11, which changes justification and line breaking.

Test: a_doc_opens_in_word_97_2003_compatibility_mode
2026-11-08 04:54:06 +01:00
33fa77a892 Track Changes: resolving away an empty paragraph with a section break keeps the next paragraph's properties (#5667) (#5734)
Rejecting an inserted paragraph (or accepting a deleted one) whose mark carries a section break
left it empty; remove_empty_para refused it for the break, so the next paragraph was joined into
it and took its properties (style, numbering, indents). The empty paragraph now goes whole and
its section joins the following one first, as deleting the break does (remove_section_break).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 04:51:36 +01:00
efd476cbc7 #5197: the ruler shows the spaces between text columns (#5261)
In a section of several text columns the horizontal ruler was one white
strip from margin to margin. The space between each two columns is now
shaded like the margins, so the ruler shows where each column ends and
the next starts.

The edges of a space can be dragged, through layout.columns, one undo
step per drag, to change the spacing. Equal columns stay equal, so every
space changes with it, as in Word for Mac. Between unequal columns only
the column at that edge and the space change. As in Word for Mac, the
middle of a space has no grip of its own. Columns stay at least a
quarter inch wide and spaces 0-300 pt, as the Columns dialog allows,
snapped to 1/16 inch. Right-to-left sections are read from the right
edge.

Outside the main text, outside Print Layout and in a table the ruler is
unchanged. A selection across sections shows no spaces, since a drag
would set the columns of each section it touches; nor does a page the
caret isn't on. Columns from a file that the command wouldn't take back
(over 12, a column under a quarter inch, a space over 300 pt, wider than
the text) are shaded but not dragged. A tab stop's marker keeps its own
drag where it sits on an edge. The indent markers still follow the page
rather than the caret's column; that is #2196 (PR #2215).

Fixes #5197.

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

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 04:51:18 +01:00
d3fcf10bcc Layout: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288) (#5347)
* Layout: a picture in a paragraph whose hidden mark closes a section keeps its room (#5288)

A paragraph with a hidden mark and no plain text was a quiet mark: laid out
with no room, its drawing kept. An in-line picture adds no plain text, so a
hidden-mark paragraph holding only a picture qualified, and the text after it
(the next continuous section in the report) was drawn over the picture. Such
a paragraph, or one holding a note reference mark, now keeps its line; one
with only floating objects stays quiet.

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

* Layout: a legacy check box in a hidden closing mark keeps its room too (#5288)

A FORMCHECKBOX field has an empty result, so a hidden-mark paragraph holding
only a check box still counted as quiet and the next continuous section was
drawn over the box. Fields now count as visible content when the paragraph
layout shows them as something other than their result: their code, a legacy
check box, or a drop-down's entry (the same test as the layout's `field`).

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

---------

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 04:48:48 +01:00
b9936609dc Output counts locked SEQ fields, and not tracked-deleted ones, as Word does (#5594)
* Count locked SEQ fields and not tracked-deleted ones in output

Exporting, Word counts a locked SEQ, its \r too, though the field keeps its result, and a SEQ in a tracked deletion keeps its result and doesn't count, with markup shown or hidden (measured in Word for Windows). Output counted it the other way round: a locked SEQ was skipped and a deleted one counted.

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

* Docs: locked and tracked-deleted SEQ fields in layout parity

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

* count_seq walks the deleted ranges with one cursor

The runs come in order and the objects by offset, so a forward cursor
replaces the per-field scan of every deleted range.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 04:38:32 +01:00
7152143e45 Layout: a line can break after a hyphen before a slash, like Word (#3408) (#5348)
Word wraps "Dataprotection-/Secrecyviolation" as "Dataprotection-" /
"/Secrecyviolation". UAX #14 (LB13) forbids a break before the slash, so
the compound had no break opportunity: lines moved it whole and auto-fit
table columns widened to fit it, taking width from their neighbours.
Add the break after U+002D when a slash and a letter follow, next to the
existing break before a digit (#2712); Word keeps "fitness-/1589" whole,
so a slash before a digit gains no break. Always sort the added breaks.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 04:36:10 +01:00
c40719f431 Layout: a turned inline picture with no recorded room for the turn keeps its line to its frame, as in Word (#5448)
A file that turns an inline picture but writes no wp:effectExtent (or a zero
one), as other tools do, makes Word keep the picture's line to its frame and
let the turn overhang it, cut at the line's top and bottom: the frame as it
is in Word 2007 layout (compatibilityMode < 15), with width and height
exchanged from 45 to 135 and 225 to 315 degrees in Word 2013+ layout.
WordCraft always reserved the exact rotated bounds (Float::spin_pad), which
is what Word's own files encode through their effect extents, so the text
after such a picture sat up to 34 pt too low (docxide-pdf
standalone-picture-compat12-* / compat15-angle45/135).

The docx reader now marks such objects (Float::bare_turn, cleared when the
object is turned again); the inline room comes from Float::line_pad, which
display uses too to place the frame in it, and the line clips the picture's
overhang. The writer keeps writing no turn overhang for them, so a save
round-trips. Word-saved pictures are unchanged.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:34:00 +01:00
Harrison Booth 6d0bd73127 Preserve paragraph text direction in DOCX (#5392)
* WC6-07: preserve paragraph text direction

* Resolve gaps summary merge

* WC6-07: retain paragraph direction variants

* WC6-07: preserve strict paragraph direction

* Update lockfile for DOCX test dependency

* Close paragraph direction implementation

* Format paragraph direction imports
2026-11-08 04:31:23 +01:00
Dylan M. TaylorandMartin Ball cd81d5da49 XLSX import: index worksheet relationships by id (#5424)
* fix(formats): index XLSX worksheet relationships by id

* style: rustfmt

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 04:28:57 +01:00
b7c2fe8dff DOCX: a VML text box keeps its text direction (layout-flow) (#5193) (#5346)
Compatibility-mode documents store text boxes as VML only. The reader
ignored v:textbox's text direction, so a header text box turned 270
degrees was drawn level. It now reads it as Word for Windows does:
mso-layout-flow-alt:bottom-to-top is vert270 whatever layout-flow says;
otherwise layout-flow:vertical or vertical-ideographic is vert; anything
else stays horizontal.

Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 04:28:39 +01:00
Marc 586f85df96 fix(ui): the File page scrolls across its full width, with the bar at the window edge (#5282) (#5320)
* fix(ui): the File page scrolls across its full width, with the bar at the right edge (#5282)

Turn off horizontal auto_shrink on the File page ScrollArea so the scroll area expands across the full CentralPanel width. This positions the vertical scrollbar at the right edge of the window and allows mouse wheel scrolling over the entire panel width.

* fix(ui): the File page's scroll area reaches the window edge (#5282)

The side padding moves from the panel into the pages, so the scrollbar sits at
the edge and the wheel also works in the rightmost 40 points.

* test(ui): assert the File page's scrollbar sits at the window edge (#5282)
2026-11-08 04:26:26 +01:00
vangel76andClaude Opus 5.5 6367400215 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:21:51 +01:00
vangel76andClaude Opus 5.5 9655b2da50 Merge upstream/main into test/fixed-layout-fonts-2729
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:21:38 +01:00
vangel76andClaude Opus 5.5 66e169c343 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:21:35 +01:00
vangel76andClaude Opus 5.5 1c6c7c732e Merge upstream/main into feat/paper-colors-3398
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 04:20:39 +01:00
Dylan M. TaylorandMartin Ball bd8bc96cb5 Layout: look at the text after a decimal tab once, not once per cluster (#5343)
* Layout: look at the text after a decimal tab once, not once per cluster

Text after a decimal tab is shifted after each of its clusters, and each
shift walked the text again from the tab to find its first '.'. A long run
without a '.' (soft hyphens take no room, so it stays on one line) made a
single paragraph's line breaking quadratic. The scan now carries on from the
cluster it reached.

* address review: layout progress doc for decimal-tab scan

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 04:15:42 +01:00
Fin WarmanandMartin Ball 7830333c34 Layout: a page break before applies at a continuous section's top mid-page (#5287)
* Layout: a page break before applies at a continuous section's top mid-page

A continuous section moves the column top to the current line, so at_top() was true mid-page and the paragraph's page break before was skipped. Check fresh() instead, as Word does.

* 1485: record continuous-section page-break parity

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 04:15:24 +01:00
Dylan M. TaylorandMartin Ball e349439bb6 fix(pdf): leave the author out when personal information is removed (#5452)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 04:13:03 +01:00
ReesandMartin Ball 4c8c1f1268 Find: match smart single quotes from a keyboard apostrophe (#5388) (#5401)
* M2.1: find smart-quoted words with a keyboard apostrophe (#5388)

* M2.1: align Find parity status and regression docs (#5388)

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 04:10:50 +01:00
caeb87c754 Shapes in Strict Open XML documents open (#3548) (#5697)
A Strict document opened with its text and no shapes at all. Strict puts
shapes, groups and canvases (wsp, wgp, wpc) and their parts in its one
WordprocessingDrawing namespace, which the reader maps to wp:, while the
drawing reader looks for Word 2010's wps:, wpg: and wpc: names. Word also
writes a Strict text box's text in wne:txbxContent.

The XML reader now names those elements as Transitional documents do,
from the element and its parent (xml::element_prefix), and reads a text
box's content from wne:txbxContent and from the schema's own
txbxContent. Saving writes them as Transitional.


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

Co-authored-by: Filip Strajnar <filip.strajnar@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 04:08:36 +01:00
0f5a701171 #5469: Size keeps the side it isn't asked to change (#5474)
* #5469: Size keeps the side it isn't asked to change

Typing only the width of a straight line (height 0) gave it a 1 pt
height: picture.size fitted both sides into 1-4000 pt, also the one the
call never set. A side the call leaves as it was now keeps its size,
a line's 0 and a side past 4000 pt from a file too.

Sides set in proportion (the original size, or a scale or one side
typed with the proportions locked while both sides have a length) still
fit the limits together, even when one of them comes out as it was;
any other side the call sets fits on its own.

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

* #5469: the table test checks exact sizes

Where both sides follow each other the test only checked that they
stay within 1-4000 pt and keep their proportions. The rule gives one
size there (the one factor nearest 1 that fits both), so each case now
checks that size, worked out by hand.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:57:17 +01:00
Valentin KleinandClaude Sonnet 5.5 677e610886 Tests: the file name field updates on Save As and Rename (#3796) (#5308)
Pin that FILENAME and FILENAME \p in a header show the new name and path after Save As, that a Word-style FILENAME \* MERGEFORMAT field drops its cached name, and that Rename is picked up by Update Field. No production code changes.

Refs #3796

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 03:55:02 +01:00
d2a2709e14 Pictures: a linked picture shows its link on hover and follows it on Ctrl+click; DOCX keeps the link on the drawing (#5267) (#5295)
* #5267: a linked picture shows and follows its link; DOCX keeps it on the drawing

A link put on a picture from its context menu was stored, but nothing showed
it: hovering the picture gave no tip, and Ctrl+click picked the picture for a
copy instead of following the link. Now the hover tip and Ctrl/Cmd+click work
on a linked picture, shape or text box, in line or floating, as on linked text.

DOCX: Word keeps a picture's hyperlink on the drawing (wp:docPr/a:hlinkClick,
ECMA-376 Part 1 20.4.2.5). It is now read (a w:hyperlink around the run wins)
and written beside the w:hyperlink, sharing one relationship. A link to a place
in the document stays the w:hyperlink w:anchor only.

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

* #5267: ink and Office 2016 charts take their drawing's link; Ctrl+click over text on a Behind Text picture stays a text click

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

* #5267: rustfmt

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

* #5267: a chart or ink takes the link of the choice it was read from; Ctrl/⌘+click in the docs

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

* #5267: the decoy choice in the chart-link fixture needs a namespace no reader knows

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

* #5267: inside a linked text box, hover and Ctrl/⌘+click take the box's link

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

* #5267: the picture-link test sets the image's fx after merging main

Main gave InlineObject::Image picture effects (fx); the round-trip test this PR adds built
an image without them and no longer compiled.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:50:38 +01:00
050b6b0da3 Arabic letters join across a change of formatting (#4093) (#5691)
* Arabic letters join across a change of formatting (#4093)

Each run of a paragraph was shaped on its own, so a letter at a run's edge
took the form of a word's start or end. A coloured letter stood apart from
its word, and a word edited with Track Changes on read with a space in it
(ال ذكاء).

Runs are now shaped with the text shown on either side of them, through the
shaper's pre- and post-context (fonts::ShapeContext). Runs left out of the
layout (hidden text, deletions in a final view) are skipped, so letters join
what is on the page. Only paragraphs with a joining script do the work.

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

* #4093 review: no letter joins a drop cap formatted on its own

A drop cap letter in a run of its own (coloured, say) was still passed to
the next run as the text before it, so that run's first letter joined the
drop cap, which is drawn apart from the word. The shown text now has a floor
past the drop cap, and no run takes context from before it. The drop cap
test also covers the letter in a run of its own.

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

---------

Co-authored-by: Filip Strajnar <filip.strajnar@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:44:01 +01:00
Dylan M. TaylorandMartin Ball 6b163b33dc xtask: no symlink walks, exclusive test dir; Paste Special picture files must be regular files (#5471)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:39:35 +01:00
ReesandMartin Ball 4ba444a342 Table Sort: validate explicit columns (#5531) (#5533)
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:39:17 +01:00
4821a13ee0 Font menus: entries are drawn from a few shared textures, so Windows no longer shows them on black, white or red strips (#5105) (#5111)
* #5105: font menu entries are drawn from a few shared textures

Each font menu entry was a texture of its own, created and freed as the list
was browsed: on Windows (DirectX 12) the entries showed other textures' memory,
black strips in light mode, white or red ones in dark mode, as the Equation
tab's pictures did before #1002. They now share an atlas written in place, the
one the equation pictures use (MathAtlas, now PreviewAtlas).

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

* #5105: font menu entries are drawn again in every window once fonts change

The font atlas remembers the font database generation its entries were drawn
with and starts over when it changes, so another window's entries and a removed
font's entry (Recently Used Fonts) don't keep an old face.

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

* #5105: the atlas test doesn't compare with the shared font generation

Review: tests running alongside add and remove fonts, so the database's
generation may change between the draw and the assertion.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:36:54 +01:00
vangel76andClaude Opus 5.5 dba2b3b5e9 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:26:05 +01:00
vangel76andClaude Opus 5.5 beab4e8e9c Merge upstream/main into zoom-stretch
Upstream's new layout tests start with crate::test_fonts(), like the others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:25:18 +01:00
852e67d587 A template opened from the file manager starts a new document (#3433) (#5698)
A Word template (.dotx, .dotm, .dot) the platform hands the app, from a
double-click in the file manager, the command line or the Finder, opened
as itself: the window called it saved, AutoSave was on, and Ctrl+S wrote
over the template. Word's default verb for a template makes a new,
untitled document based on it.

WordApp::open_from_platform sends a Word template to file.newFromTemplate
and opens anything else as File > Open does; main.rs (also for a name that
isn't valid Unicode) and the Apple Events handler use it.
file.newFromTemplate reads the template as file.open reads a file
(password, data, an empty file) and, with replaceBlank, takes an untouched
blank document's place, so a launch shows one window. The Password box
retries the command that asked for it, so a protected template still
starts a new document. File > Open and drops still open a template itself.


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

Co-authored-by: Filip Strajnar <filip.strajnar@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:25:17 +01:00
9b6e9564a2 Lists: a numFmt none level shows its literal text, so RESPONSE: labels the paragraph (#4985) (#5055)
* #4985: a numFmt none list level shows its literal text

Word labels a paragraph whose level has numFmt none and lvlText RESPONSE:
with RESPONSE:. Counters::next_label returned no label for any none level;
now only a none level of placeholders shows nothing. Its own %n stays
empty, and it still doesn't count. Numbering::shows_label agrees.

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

* #4985: rustfmt

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

* #4985: a "none" list level counts, and only its own number is empty

Taken from MeHail-02's numbering-literal-none branch (46405e66), checked in Word
for Windows: a none level is an ordinary level whose own number reads as
nothing. It counts and restarts the levels below it, stays empty in legal
numbering, and an empty label still tabs the text to the indent unless it
already ends there (the Chapter 1 list's Heading 2 stays at the margin).

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

* #4985: STYLEREF in full context keeps a none-level heading's parent

Review: a none level now has a label, so its headings reach the style index,
which leaves the none level out of the levels it keeps; \w and \r took the last
kept level for the heading's own and dropped the parent. Levels now carry their
index, and the levels above are picked by it.

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

* #4985: STYLEREF number switches on a none-level heading

Review: in full context an empty label of its own added a trailing period (3.),
and the suppress switch gave a none level's label (Note), which holds no number,
back whole. An empty own part is left out, and a none level's label with no
number is suppressed to nothing.

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

* #4985: STYLEREF \s and \t on none-level labels

Review: \s took a none level's label (Note) for a chapter number; the heading's
count stands in, as for an unnumbered heading. \t matched any ancestor's number
inside a word (the a of Chapter); only the numbers the label's template shows
count now.

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

* #4985: STYLEREF takes a label's numbers by their spans

Review: \s took the last word of a label (Response of "3 Response") and fell
back on the heading count for a none level showing its parent's number (%1);
\t kept text attached to a number (Section3). A label's numbers are now the
spans of the values its template shows, with their delimiters (a letter number
only standing apart from other letters); \s takes the last of them.

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

* #4985: tests edit the numbering through Arc::make_mut after merging main

Main shares the numbering between undo steps (#2694), so `Document::numbering` is an
`Arc<Numbering>`; the tests this PR adds wrote to it directly and no longer built.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: MeHail <mihailsmetanin15@gmail.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:20:49 +01:00
vangel76andClaude Opus 5.5 c9090ba95a Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:17:59 +01:00
vangel76andClaude Opus 5.5 c606533853 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:17:59 +01:00
eb100edfd6 Linux: ignore an AppImage environment inherited from another program (#5653) (#5690)
* Linux: ignore an AppImage environment inherited from another program (#5653)

An AppImage app that opens a document through xdg-open passes its APPIMAGE,
APPDIR, ARGV0 and OWD on to the WordCraft it starts. The self-update trusts
APPIMAGE, so an installed WordCraft replaced the other app's file with its own
AppImage.

The app now starts over without those variables, first thing in main, unless
its executable is under APPDIR. The update then goes through the package or
plain file this copy really is, and a real AppImage keeps its variables.

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

* #5653 review: turn the self-update off when starting over fails

If the process can't start again without another program's AppImage
variables, it still carries APPIMAGE, and an accepted update would replace
that program's file. The app now turns its self-update off for that
process: no update is offered, and Help > Check for Updates says why. The
app still opens, so the document the user asked for isn't lost.

Also documents the test helpers.

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

---------

Co-authored-by: Filip Strajnar <filip.strajnar@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:09:52 +01:00
c38dc17ba9 Search: the title bar's box takes the typing in place, its results dropping down under it, as in Word (#5398) (#5475)
* #5398: the title bar's Search box takes the typing in place, its results dropping down under it

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

* #5398: Search in Focus opens the dialog, and Search whose box is gone doesn't keep Escape

Review: Focus hides the title bar's Search box, so Alt+Q on the frame Focus comes on opened
Search in a box that wasn't drawn, and that hidden Search kept Escape from leaving Focus.

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

* #5398: Search takes the keys from its first frame; Enter on a result isn't also the new dialog's OK

Review fixes:
- The Search box is focused before it reads the frame's input, so keys typed on the frame
  right after Alt+Q go into it (they were lost). Alt+Q's own text is still swallowed: Alt is
  read from the shortcut's key event too.
- Enter that runs a result is used up, so a dialog the result opens (Word Count) doesn't take
  the same Enter as its OK and close at once.
- The Focus test opens Search with the command Alt+Q runs, so it also runs on macOS, where
  Option+Q types a character (the test failed on the macOS runner).

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

* #5398: Tab moves from the Search box to its results, and Enter runs the focused one

Review: the box took the focus back every frame, so Tab couldn't reach a result, and Enter
always ran the first hit. The box now takes the focus back unless it is on one of its results
(read from last frame's rects, so the page taking it is still undone), and runs the first hit on
Enter only while it has the focus; a focused result takes Enter or Space itself. Either way the
Enter that ran a result is used up, so the dialog it opens doesn't take it as its OK.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:07:24 +01:00
5997aef2f9 ODT: a file's font declarations are kept (gap 19) (#5720)
* ODT gap 19: a file's font declarations are kept

WordCraft declared each font itself, guessing its kind, in place of the file's
declaration. A reader without the font picks its substitute by that, so on a
computer without it a document came back in LibreOffice from a save with no
edits reflowed, 70 pages instead of 83. A font the file declares stays declared
as the file does.

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

* ODT: font declarations in single quotes and their name spaces are kept (gap 19)

Single-quoted names weren't recognised, so the declaration wasn't replaced and
could be added twice. A prefix only the declarations' container declared was left
unbound in the saved part. Found by CodeRabbit on this PR.

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

* ODT: a font name spelled another way in XML is the same declaration (gap 19)

Declarations were matched on their raw text, so O'Brien and O&#39;Brien counted as two fonts and one was declared twice. They are now compared as they read.

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

* ODT: a face named other than its family gives that family the file's declaration (gap 19)

A face named F1 of the family Mystery: text written from the model names Mystery, and WordCraft's declaration of it kept a guess at the font's kind. It now says what the file's says. Faces are matched in one walk, and namespace prefixes are tracked in a set, as a hostile file with many on its top-level elements could stall import.

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

* ODT: font faces are found by parsing and renamed at their own attribute (gap 19)

The walk matched text, so a kept script reading like a declaration could be rewritten. It now takes the declarations the parser finds. A rename uses the position of style:name itself, not a search that another attribute with that in its name could mislead.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:05:10 +01:00
Zinadin Zidan 73356ed82a Styles gallery: reads from the right in a right-to-left interface (#5566) (#5708)
In the mirrored Arabic interface the Home tab's Styles gallery still laid its
tiles out left to right with the arrows on the right, so Normal sat at the
far end. Tiles now start at the right edge and the up/down/more arrows sit at
the left; left-to-right is unchanged. The "more" button gets the Styles Pane
label it opens, so screen readers can name it.
2026-11-08 03:02:42 +01:00
6d3b51fab1 Decode HTML character references written without ';' (#4820)
Found while testing crates/formats/src/html/entity.rs:250-251 (unescape)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:00:23 +01:00
TheRenegadeDevandClaude Opus 5.5 750e26657e Navigation pane: test that the tabs fit the narrowest pane in every language (#4482) (#4883)
* #4482: test that every Navigation tab fits the narrowest pane in every language

The new kittest draws the Navigation pane body at the 200 pt minimum width with the
longest translation of each tab from craft-i18n (Überschriften, Страницы, Eredmények)
as a fixture language, so it runs in CI without the catalogs. With the catalogs built
in, it also checks every interface language. Each tab must lie inside the 178 pt body.

Main already wraps the tabs onto a second row, so the test passes there. It fails on
a single-row tab strip that doesn't wrap, such as the one in #4480.

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

* #4482: draw the tab test in the app's style; catch a stale fixture

The test now applies the app theme with the fonts. With the catalogs built in, it
also fails when any language's tab label is wider than the fixture's, so a
craft-i18n bump that brings a longer label asks for the fixture to be updated.

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

* #4482: the tab test fails when a tab elides its label to fit

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

* #4482: the tab test puts English back even when an assertion fails

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

* #4482: the stale-fixture failure says which label to put in the fixture

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 03:00:05 +01:00
f7e060ac3f Escape a heading's trailing '#' on Markdown export (#4808)
Found while testing crates/formats/src/markdown/export.rs:377 (write_blocks, Kind::Heading arm)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 02:57:32 +01:00
b61aee61ba Styles pane: the scroll bar no longer covers the a / ¶ markers (#4753) (#4882)
* #4753: Styles pane scroll bar keeps clear of the a / ¶ markers

The Styles pane list used egui's default floating scroll bar, which is drawn
over the right edge of the rows; on hover it widens to 10 pt with a translucent
track and covers the right-aligned "a" and "¶" style-type markers. The list now
uses a solid scroll bar, which takes a column of its own beside the rows (as in
the TOC options dialog and vectorcraft#2232's menus). Its handle keeps the text
colour: the solid preset fills it with the button colour, which is white on the
pane's white track and would leave the handle invisible.

The regression test draws the pane with the app theme, hovers the bar and
checks that no scroll bar rectangle meets a marker and that the handle stands
out from the track.

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

* #4753: Styles pane scroll bar test hovers the bar and checks dark too

The test now takes the hover point from the pane's own right edge instead of
the harness size (kittest insets the UI, so the old point sat just outside the
bar), asserts the pointer is on the bar, and runs in the dark appearance as
well as the light one.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 02:55:02 +01:00
bc0f691c13 Layout: a picture lowered past its height lowers its line as far, as in Word (#5796)
* Layout: a picture lowered past its height lowers its line as far, as in Word

A picture lowered further than its own height had its lowering cut at that
height. Word keeps its top on the baseline but lowers the line as far as the
position says: a 6 pt picture lowered 30 pt makes a 30 pt line. Exact spacing
keeps the line's baseline. Closes the gap noted with #3984: all 156 measured
lines now match Word.

* Lowered pictures: an exact-spacing test; gaps and progress log

An exact-spacing line keeps its baseline when a picture is lowered past its
height (the test fails without the cap). Gap 4 and the progress log note the
change.

---------

Co-authored-by: Daniele Mori <Dinasty@hotmail.it>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 02:54:47 +01:00
cac121fd89 Shading on text selected inside a paragraph shades the text, so No Color takes off an orange shading (#4685) (#4702)
* #4685: Shading on text selected inside a paragraph shades the text

Home › Paragraph › Shading always set the paragraph's shading. Word shades the selected
text when the selection is inside one paragraph (w:shd in the run properties), and the
paragraph for a caret or a whole paragraph. So an orange text shading from a .docx (which
Text Highlight Color › No Color leaves, in Word too) couldn't be taken off from the ribbon.

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

* #4685: review — a paragraph's whole text is text unless it's the last; a selected object isn't text

A selection with a paragraph's mark ends at the next paragraph's start, so 0..len of a
paragraph that isn't the last of its story, cell or box is text only and No Color clears its
shading. A selected text box or picture (its object character) shades the paragraph as before,
instead of every line of the box.

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

* #4685: review — a selected field is text; Apply to: Paragraph shades the paragraph

Only a selected picture, shape or text box (`object_selection`) keeps Shading on the
paragraph; a field's object character is text, so No Color clears its shading. The Borders
and Shading dialog's Apply to: Paragraph passes `paragraph: true`, so it shades the
paragraphs even for text selected inside one.

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

* #4685: ui-parity wording (CodeRabbit): text shading applies to part of one paragraph

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

* #4685: ui-parity wording (CodeRabbit): removes text shading imported from a file

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

* UI parity: status line after merging main

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

* #4685: a test of Shading in a cell's last paragraph

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 02:52:26 +01:00
MexandMartin Ball ea8c45a4ca CLI: preserve non-Unicode input paths after options (#5611)
* M14: keep native CLI input names after options

* docs(M14): clarify native CLI path option exceptions

Qualify the roadmap summary and history: text option values need Unicode, while run --file and run --script retain native paths. Addresses review4485129728. Documentation only; no CLI behavior changes.

* docs(M14): qualify Unicode values in format parity notes

Exclude the existing run --file and run --script native path operands from the text option Unicode requirement. Addresses review4485129728; documentation only.

* docs(M14): qualify Unicode values in CLI gap notes

Document the existing native path exceptions for run --file and run --script. Completes the three-file wording correction requested in review4485129728, without changing readiness estimates or CLI behavior.

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 02:52:08 +01:00
vangel76andClaude Opus 5.5 4774749a05 Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 02:27:44 +01:00
vangel76andClaude Opus 5.5 20a10a5f33 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 02:26:30 +01:00
MexandMartin Ball e53cae3482 RTF: honor Unicode fallback counts above eight (#5607)
* RTF: honor the declared Unicode fallback count

* RTF: record declared Unicode fallback counts

* M12: count unknown RTF symbols in Unicode fallback

Address the unrecognized-control-symbol review finding in parse.rs. Each symbol consumes one Unicode fallback unit; outside fallback it remains ignored. Four additional actual-import cases cover uc0, uc1, uc2 and uc18. The uc2 regression fails before the fix (≤ rest versus ≤b rest). All 664 formats tests, scoped rustfmt and strict all-target formats Clippy pass in the exact-source isolated closure. Binary fallback remains separate in #4806.

* M12: count structural RTF words in Unicode fallback

Treat par, pard, cell and row like other control words while consuming declared Unicode fallback. Add actual import cases for those fallback units and a uc0 paragraph control. Scoped rustfmt passes; full validation is pending hosted CI.

* M12: describe RTF fallback units precisely

Address the review wording finding: the Unicode fallback count measures units, including control words and escaped symbols, rather than fixed-length bytes. Preserve every roadmap history and progress record.

* M12: count all RTF fallback control symbols

Consume non-hex control symbols before dispatch while Unicode fallback remains, covering escaped LF/CR and star without applying their side effects. Keep valid hex escapes on their four-byte path, and include revision-author text in the existing control-word guard. Add six actual body-import cases and two actual revision-author cases, including uc0 controls. Both regressions fail before the patch in the exact current closure; all 665 formats tests, scoped rustfmt and strict all-target formats Clippy pass afterward. Binary and invalid-hex handling remain separate. Full hosted gate will be checked on the draft PR.

* M12: preserve the roadmap final newline

Restore the trailing LF omitted by copying the Raw view. The roadmap now differs only by the requested fallback-unit wording; production code and tests are unchanged.

* M12: skip RTF info effects during Unicode fallback

Move the existing info_depth assignment after the existing fallback-control-word guard. A skipped info word inside a Unicode title fallback must not overwrite metadata depth and drop the following author. Preserve the binary and escaped-hex paths, other destination handling and uc0 effects. The supplied metadata import regresses before the move; all 667 formats tests, scoped rustfmt and strict all-target formats Clippy pass afterward. Regression coverage follows in this draft PR.

* M12: cover RTF fallback metadata depth

Add four actual RTF import cases asserting title, creator and body text: skipped info controls at uc9 and uc1 retain the following author; uc0 title and a real info group after body Unicode still work. The supplied uc9 case fails before the parser reorder with an empty creator instead of Alice. All 667 formats tests, scoped rustfmt and strict all-target formats Clippy pass in the exact c153 source closure plus this two-file follow-up. Full hosted CI will be checked on the draft PR.

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 02:10:58 +01:00
Harrison BoothandMartin Ball 7855a2d814 fix(fields): quoted-argument backslash escapes; body PAGE in a cardinal-text section (#4579)
* fix(fields): quoted-argument backslash escapes and body PAGE in a cardinal-text section

A backslash inside a quoted field argument makes the next character literal, so TITLE "x \* Upper" shows x * Upper as Word does. A body PAGE field now uses the section's page number format (cardinal text shows words), as headers and footers already did.

* Fields: PAGE in a body table follows the section number format; parity docs

* fmt

* docs: clarify quoted field escape rule

* Fix page fields in fitted text boxes

* Move fitted cache test after layout helpers

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 02:08:54 +01:00
Zinadin ZidanandMartin Ball 871401509c Scrolling: the document's scroll bars show without the pointer on them (#5746) (#5828)
The document used egui's floating scroll bars, invisible until the pointer
reached the right edge. The canvas now draws solid bars in the 14 px strip
it already left for them, with a grey thumb that stands out from its track
in light and dark themes and darkens under the pointer. Read mode still has
no bars.

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 02:07:56 +01:00
Bra1nZandBra1nZ 4ce92e8afd backstage: the community link honors the community-button setting (#5909)
File › Options' "Show the community button in the title bar" hid only the
title-bar pill; the backstage menu still showed "Discord community" at
its bottom — one setting, half honored. The menu now draws the link only
when the same setting is on, next to About.

Co-authored-by: Bra1nZ <bra1nz@astraoffice.com>
2026-11-08 01:56:04 +01:00
Aritz VelascoandClaude Opus 5.5 746577406c Links: Ctrl+K on a selection shows the changed Text to display (#5497)
* Ctrl+K on a selection shows the changed Text to display

With text selected, the Link dialog's Text to display was ignored:
insert.link only linked the selection. A changed text now retypes the
selection (keeping its formatting, tracked with Track Changes on) and
links it. The same text, text with a line break, or a selection over
several paragraphs or holding a picture or field keeps its text.

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

* Links: keep selections a changed Text to display can't replace

Review of the Ctrl+K retype: a selection holding a manual line break,
page or column break, or sitting in a field's result is only linked, so
neither both lines nor the field's result are lost. `select.text` clears
the caret formatting as `select.word` does, so a second retyped link
keeps its selection's bold instead of the first link's default props.

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

* Selections: select.all, select.range and select.word drop pending formatting

A link leaves plain formatting pending for what is typed after it. These
commands kept it, so retyping the next selection took that instead of the
selection's own formatting, as select.text and select.word's caret moves
already avoid.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:51:45 +01:00
Valentin KleinandClaude Sonnet 5.5 0131d5c3aa .doc import: resolve character formatting per run, not per character (#5478) (#5495)
* docbin: a field's cached result keeps its formatting per run, not per character (#5477)

The result was a Vec<(char, CharProps)>: 365 bytes per character, 1.44 GB
at peak for a 4 MB file with one field. It is now a String plus a run
list that gains an entry only when the formatting changes (25 MB peak
for the same file). Output is unchanged.

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

* docbin: show the objects when the field-result test fails

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

* docbin: resolve character formatting per run, not per character (#5478)

The text loop looked up the piece four or five times and the CHPX once
(a backward scan over up to 101 FKP entries) for every character, and
cloned the memoised 368-byte CharProps each time. Now the walk keeps the
piece span and the CHPX run of the previous character and reuses them
while the next character is inside, and hands the memoised properties
to the paragraph builder by reference: they are cloned when a run
starts and not compared while the resolved formatting is unchanged.
Output is identical, also on corrupt pages (a run's FC range is
bounded by the smallest later start).

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

* docbin: tests for run ranges across bins, empty pieces and a whole read

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 01:51:17 +01:00
Mex fa59ed0e8a fix(ui): keep Page Setup fields bounded after long paste (#5696)
* M14: keep Page Setup margin and grid editors within their width (#5650)

Use the pinned egui DragValue clipping option for four margins and both grid counts. Canonical actual WordApp regression and 12 Page Setup controls pass; strict all-target UI Clippy and scoped rustfmt pass.

* M14: keep Page Setup paper-size editors within their width (#5650)

Apply the same bounded text editing to paper width and height while preserving their existing limits, units and speed. Actual Page Setup regression and existing controls pass.

* M14: regress Page Setup paste, blur and reopen widths (#5650)

Exercise all eight numeric fields through the actual WordApp harness. Baseline Bottom paste reproduced width growth from 354 to 2973 pt; the fixed canonical 12-test Page Setup suite passes. Private rendering hooks are excluded.

* M14: record bounded Page Setup number editors (#5650)

Add the focused UI fix to the progress and revision logs and status line. Readiness estimates remain unchanged.

* M14: note the focused Page Setup paste fix in gaps (#5650)

Record bounded numeric editing in the gap note, status and revision history; no ranked gap or readiness estimate changes.

* M14: document bounded Page Setup numeric input (#5650)

Update the UI parity note, status and revision history for long input scrolling inside the existing fields. Readiness estimates remain unchanged.

* M14: bound unfocused Page Setup margin values (#5650)

Keep the unfocused DragValue inside an allocated, clipped field without changing numeric parsing, units, speed or accepted values. Regress a finite long paste followed by Tab in all four margins. The actual baseline grew from 354 to 458.53125 pt after Tab; the canonical 13 Page Setup tests and scoped formatting checks pass, and the inspected fixed renders stay 354 pt. Hosted validation remains pending in draft PR #5696.

* M14: record unfocused Page Setup bounds (#5650)

Update the gap and UI parity notes for the accepted-value paste followed by Tab regression. Retain the existing progress history, value ranges and readiness estimates.

* M14: log the Page Setup blur regression (#5650)

Record the bounded unfocused margin display and accepted-value regression. Preserve every upstream progress entry and the previous validation history.

* M14: regress RTL margin re-entry (#5650)

Cover re-editing the visible portion of a wide blurred margin in the real Page Setup dialog under an asserted RTL fixture. Raw pointer events restore editing and accept a normal value; the existing thirteen tests remain unchanged. Fourteen Page Setup tests, package formatting, strict all-target UI Clippy and source-integrity checks passed. No production or dependency changes.
2026-11-08 01:51:01 +01:00
Hamza Naveed b29f3a8a2e Save As suggests a new document's first words as its name (#5795)
Saving a document for the first time suggested Document1.docx whatever
it held. As in Word, a document that was never saved and has no title
of its own is now offered the first words of its first paragraph with
text: up to the first sentence or clause end, without the characters a
file name can't hold, white space collapsed, at most 50 characters cut
at a word's end (Minutes of the board meeting.docx). A path, a file
name or a Title property still name it, and the window title stays
Document1.

Fixes #5793
2026-11-08 01:50:43 +01:00
rabis7575andClaude Opus 5.5 e7668ae53e Tests: run the file commands through the hostile-parameter test (#3719) (#5688)
* #3719: run the file commands through the hostile-parameter test

hostile_params_never_panic skips file.*, insert.picture and
insert.textFromFile, the commands that take a path and parse what is
there. hostile_file_params_never_panic runs them with the same junk
params, and with paths to an empty file, a folder, a missing name and
noise under each extension they open or save. Automation roots hold
them to a temporary folder.

It found no panic, so this adds only the test.

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

* #5688: check the whole working directory, on the sample document

The working-directory check looked only for hostile-relative names, but
an empty path comes out as .pdf or .dotx there. It now compares the
whole folder before and after the runs.

The runs now use the sample document, like hostile_params_never_panic:
on an empty document the export commands had almost nothing to write.
The docs and comments name the three groups of commands, because other
commands also read files.

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

* #5688: a failing file check names the files, not their bytes

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:48:57 +01:00
Hamza Naveed 7aea5f8cab Numbering: Thai numbers are drawn in Thai digits (#5784)
A list level, page number or note mark in Word's thaiNumbers format was
read and saved as such but drawn as decimal (1, 2, 3). It now shows the
decimal number in Thai digits (U+0E50..U+0E59): ๑, ๒ … ๑๐, with the
level's text around it (๑., (๒)). Thai letters and Thai counting still
show decimal until they are drawn too.

Part of #5757 (Thai numerals in automatic numbering).
2026-11-08 01:48:51 +01:00
3b6547de8f ODT: watermark and odd/even page changes to a file are saved (gap 19) (#5729)
* ODT gap 19: a watermark change to a file is saved

A file's own page definitions were written back whatever happened to its
watermark, so one added, changed or removed in WordCraft was gone from the
saved file. The watermark now counts as part of the page setup, so a change
writes the page definitions from the model. A watermark with no text isn't
written, so it isn't a change.

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

* ODT gap 19: Different Odd & Even Pages changed in a file is saved

A file's own page definitions were written back whatever happened to this
setting, so the left pages' header stayed after it was turned off, and turning
it on did nothing. The setting now counts as part of the page setup, as the
watermark does.

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

* ODT tests: say what the watermark and odd/even tests check (gap 19)

Doc comments for CodeRabbit's docstring coverage check on this PR.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 01:36:03 +01:00
Luis e0dd821d4c Release: refresh contributor credits for each release (#5844) (#5845)
About > Contributors still showed an old snapshot, because nothing
regenerated contributors/contributors.json before a release. Opt in to
the shared refresh from craft-common-actions: a read-only credits job
regenerates the snapshot for the release commit, and the shared release
workflow installs it before every source build. The snapshot is not
committed back.
2026-11-08 01:31:33 +01:00
vangel76andClaude Sonnet 5.5 e4310ec5c7 Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 01:17:09 +01:00
7c3a5ccd34 ODT: a table row that doesn't break across pages stays so (gap 19) (#5726)
* ODT gap 19: a table row that doesn't break across pages stays so

fo:keep-together on a table row wasn't read, so rows kept whole came back from
a save free to break across pages in LibreOffice. Read and write it.

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

* ODT: a row follows the default style's keep-together (gap 19)

A row whose style doesn't say took "free to break" instead of the default style's
setting. Found by CodeRabbit on this PR.

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

* ODT: a row free to break stays so over a default that keeps rows together (gap 19)

Nothing was written for it, and the file's default row style stays, so a save took
that away.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 01:07:49 +01:00
vangel76 ecbabc327c Merge remote-tracking branch 'upstream/main' into m1056
# Conflicts:
#	docs/file-format-parity.md
2026-11-08 00:30:11 +01:00
vangel76 34b4caf317 Merge remote-tracking branch 'upstream/main' into m2596
# Conflicts:
#	ROADMAP.md
2026-11-08 00:29:41 +01:00
vangel76 8ab285e61d Merge remote-tracking branch 'upstream/main' into m3195
# Conflicts:
#	ROADMAP.md
2026-11-08 00:27:34 +01:00
vangel76 490437fa87 Merge remote-tracking branch 'upstream/main' into m3197
# Conflicts:
#	ROADMAP.md
2026-11-08 00:27:30 +01:00
Aleksander HelgakerandClaude Opus 5.5 fa153b932c ODT: a heading's text of another size keeps it (gap 19) (#5725)
* ODT gap 19: a heading's text of another size keeps it

Every run in a heading, title or code paragraph lost its size to the style, so
a smaller word in a heading came back at the heading's size in LibreOffice
after a save. Only text at the style's size goes with the style.

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

* ODT: a heading's text is sized against the heading (gap 19)

The comparison used the size outside the paragraph, so text stating the heading's
own size kept it through a style change. A size in percent was of 12 pt, not the
heading's. Found by CodeRabbit on this PR.

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

* ODT: text in a heading style stating no size keeps its own (gap 19)

The run's size was compared with Standard's, so with Standard at 14 pt a 14 pt run lost its size and took WordCraft's Heading 1 size of 20 pt.

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

* ODT: a span stating no size has none in a heading stating none (gap 19)

The size used to work out a percent stayed on a span saying nothing of size, so a bold word in such a heading took Standard's size. It came in with the last commit here.

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

* ODT: a percent in a parentless heading is of the default size (gap 19)

It was of Standard's, which a style with no parent isn't based on: 50% over a 10 pt default and a 14 pt Standard came out 7 pt, not 5.

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

* ODT: a style's percent size is of what is under it (gap 19)

It was of 12 pt, whatever the parent or default: 50% over a 10 pt default made a 6 pt style, and a 5 pt span in it lost its size and showed at 6.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:24:37 +01:00
vangel76 4925e06391 Merge remote-tracking branch 'upstream/main' into m1056
# Conflicts:
#	docs/file-format-parity.md
2026-11-08 00:19:35 +01:00
vangel76 5c2e5ce0d0 Merge remote-tracking branch 'upstream/main' into m2596
# Conflicts:
#	ROADMAP.md
2026-11-08 00:19:30 +01:00
vangel76 274d40fc4d Merge remote-tracking branch 'upstream/main' into m3195
# Conflicts:
#	ROADMAP.md
2026-11-08 00:19:26 +01:00
vangel76 570c39e287 Merge remote-tracking branch 'upstream/main' into m3197
# Conflicts:
#	ROADMAP.md
2026-11-08 00:19:22 +01:00
Aleksander HelgakerandClaude Opus 5.5 595cddaaef ODT: a frame offering several picture formats shows one it can read (gap 19) (#5706)
* ODT gap 19: a frame offering several picture formats shows one it can read

LibreOffice writes its own .svm metafile before a PNG of the same picture.
WordCraft took the first whatever it was, so the picture was missing, and gone
from the file once the paragraph was written from the model. Take the first
it can read, as the frame's order of preference means.

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

* ODT: a replacement picture before its object is kept (gap 19)

A frame's picture WordCraft can't show was dropped before the object came, so a
save from the model lost the object's replacement. Found by CodeRabbit on this PR.

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

* ODT: a readable picture after an unreadable replacement is shown and kept (gap 19)

With an unreadable replacement first, the readable picture after it was skipped: WordCraft drew a placeholder and a save from the model lost the picture. It is now shown and written after the replacement. A placeholder is still not written.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:17:53 +01:00
vangel76andClaude Sonnet 5.5 f1b5f0b4de Layout test: install the pinned fonts before the #4720 chart fallback lookup
The test asked the global font database for a fallback face before any
layout call, so the pinned fonts could no longer be installed.

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:16:20 +01:00
Bra1nZandBra1nZ da914ce674 doc: the CFB guard refuses only more FAT sectors than the file has (#5830)
The #3642 guard refused a file whose DIFAT lists more FAT sectors than
its sectors need: one FAT sector per sector_len / 4 sectors plus one
spare. Real writers reserve FAT for growth, so a real encrypted
package — a compound file of 78 sectors — lists 4 FAT sectors and was
refused: encrypted .docx files stopped opening with "the DIFAT lists
4 FAT sectors in a file of 78 sectors" (#5109, broken since the guard
landed).

Every FAT sector listed is a sector of the file, and none of them
twice (checked next), so a real file lists no more FAT sectors than
the file has. The refusal bound is now that: more than num_sectors is
either duplicates or names cfb refuses anyway, and the FAT cfb builds
(one sector of entries per listed name) stays within about the file's
own size — the #3642 blowup listed 2048 × 127 names in a ~1 MB file
and is still refused (260,096 > 2,048).

Co-authored-by: Bra1nZ <bra1nz@astraoffice.com>
2026-11-07 09:31:35 -05:00
vangel76andClaude Sonnet 5.5 18a26b41ce layout tests: give a #4250 widow test a paragraph-length range that fits the pinned test fonts
With the fixed test fonts the paragraph with a widow needs fewer repeats
than the search range started at.

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 12:16:50 +01:00
vangel76andClaude Sonnet 5.5 1c7c4e66e0 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:38:09 +01:00
vangel76andClaude Sonnet 5.5 2d6da7a326 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:38:09 +01:00
vangel76andClaude Sonnet 5.5 30a439899b Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:37:52 +01:00
vangel76andClaude Sonnet 5.5 875a62043a Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:37:52 +01:00
vangel76andClaude Sonnet 5.5 7ca1dd1802 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:37:52 +01:00
Martin BallandOPUZZLES 729cd0a5fc Typing: avoid repeated scans at protected typing boundaries (#3614) (#4754)
Lands #4754.

Co-Authored-By: OPUZZLES <109076338+OPUZZLES@users.noreply.github.com>
2026-11-06 16:07:28 +01:00
Martin BallandRatkoR 8c46f4fc1b Pictures: a bold insertion cursor while dragging an inline picture (#4195) (#4208)
Lands #4208.

Co-Authored-By: RatkoR <6648783+RatkoR@users.noreply.github.com>
2026-11-06 15:53:31 +01:00
FredApps 2a0a20aec5 Merge pull request #5776 from storytold/house-rules
Copy the shared house rules into AGENTS.md
2026-11-06 14:59:01 +01:00
Martin Ball 247c7feaab i18n: craft-i18n pin follows its main (watermark strings, lv, fr, ar rows) (#5787) 2026-11-06 13:19:38 +01:00
Martin BallandRatkoR 8833c604e0 Layout: a shape dropped just after a page break stays on the new page (#4262) (#4297)
Lands #4297.

Co-Authored-By: RatkoR <6648783+RatkoR@users.noreply.github.com>
2026-11-06 13:12:16 +01:00
Martin BallandD13GOOOO cad8ede1e8 Shapes: pictures and text boxes inside a drawing canvas are selected and moved on their own (#2637) (#2670)
Lands #2670.

Co-Authored-By: D13GOOOO <106814760+D13GOOOO@users.noreply.github.com>
2026-11-06 12:44:48 +01:00
FredApps 565032feb1 docs: preserve local guidance in house-rules rollout 2026-11-06 12:37:35 +01:00
Martin BallandRatkoR beca3f3485 Layout: a float anchored on a later page of a long paragraph is drawn there (#4250) (#4267)
Lands #4267.

Co-Authored-By: RatkoR <6648783+RatkoR@users.noreply.github.com>
2026-11-06 12:13:11 +01:00
FredApps 5c5c35899c Copy the shared house rules into AGENTS.md.
The blocks come from craft-contrib at dde09ea31e2e457f5b4653a6e76f7cdf55944bdf. CLAUDE.md imports AGENTS.md. The house-rules workflow checks the copy.
2026-11-06 11:50:02 +01:00
Martin Ballandmatstrange ef35d79cb8 Control channel: a command that discards unsaved work is refused, not carried out (#3284)
Lands #3284.

Co-Authored-By: matstrange <54028700+matstrange@users.noreply.github.com>
2026-11-06 11:44:29 +01:00
Martin Ballandfinwarman 3d79fff0ad Layout: reserve flow space below bottom paragraph borders (#2437)
Lands #2437.

Co-Authored-By: finwarman <1339254+finwarman@users.noreply.github.com>
2026-11-06 11:29:32 +01:00
Martin Ballandrabis7575 a951642cd0 Shapes: Shape Format › Size shows the interface unit (#4196) (#4212)
Lands #4212.

Co-Authored-By: rabis7575 <340973947+rabis7575@users.noreply.github.com>
2026-11-06 11:16:36 +01:00
Martin BallandMorzilla dc388cfaab Layout: a line of only pictures takes no descent; multiple spacing adds its share (#3984)
Lands #3984.

Co-Authored-By: Morzilla <3956663+Morzilla@users.noreply.github.com>
2026-11-06 10:52:32 +01:00
Martin BallandD13GOOOO ce2961041b Equations: \int + Space waits for its limits, so _x^y decorates the operator (#4917) (#4938)
Lands #4938.

Co-Authored-By: D13GOOOO <106814760+D13GOOOO@users.noreply.github.com>
2026-11-06 10:37:36 +01:00
Martin BallandOPUZZLES aeb2e550ac Mailings: detect CSV delimiters across multiline quoted headers (#4249)
Lands #4249.

Co-Authored-By: OPUZZLES <109076338+OPUZZLES@users.noreply.github.com>
2026-11-06 10:23:39 +01:00
Martin Ballandpakiat 502256d125 Layout: justified lines keep the width of the spaces before their first character (#4307)
Lands #4307.

Co-Authored-By: pakiat <49967840+pakiat@users.noreply.github.com>
2026-11-06 10:07:41 +01:00
Martin Ballandrabis7575 615597bda1 Shapes: the resize arrow over a turned object's handle turns with it (#3932) (#3939)
Lands #3939.

Co-Authored-By: rabis7575 <340973947+rabis7575@users.noreply.github.com>
2026-11-06 09:53:44 +01:00
Martin Ballandfluorine 60535464b2 Font dialog: the preview also shows the font, small caps, super/subscript and hidden (#4058) (#4059)
Lands #4059.

Co-Authored-By: fluorine <1832604+fluorine@users.noreply.github.com>
2026-11-06 09:38:47 +01:00
Martin Ballandfluorine ca21cf0ece Ribbon: a contextual tab that appears glides in so the eye finds it (#3131)
Lands #3131.

Co-Authored-By: fluorine <1832604+fluorine@users.noreply.github.com>
2026-11-06 09:23:50 +01:00
ashandClaude Opus 5.5 035982e34e Printing: the printer list doesn't wait on drivers, and a job stuck on its driver can be cancelled (#5710) (#5732)
* #5710: the Print panel lists printers without asking their drivers

On Windows the printer list waited until every printer's driver said
whether it prints on both sides. A networked printer in power save can
hold that query for a long time, so the list stayed empty and Refresh
stayed unavailable. WordCraft never used the answer.

Discovery now uses craft-print's printer_queues, which lists the same
queues and default without asking any driver (storytold/craft-libs#618).
Printing still checks the job's options with the driver. Only the
craft-print pin moves; craft-ui doesn't depend on it.

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

* #5710: the docs say what is proven, not that the sleeping printer is fixed

Review: the progress log and gap rows stated the reported outcome as
certain, but the sleeping network printer hasn't been retested. They now
say the driver query is removed and the reported printer awaits retest.

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

* #5710: craft-print pin moves to the merge of storytold/craft-libs#618

e653048 is craft-libs main's merge of #618; crates/print is identical
to the reviewed and tested head 81c2488.

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

* #5710: a print job waiting on its driver can be cancelled, and quitting doesn't wait for it

On Windows a print job asks the printer's driver for its capabilities
before it is sent, and a networked printer in power save can hold that
query for a long time. The Print page stayed locked until the driver
answered, and quitting waited for the job before saving preferences
(CodeRabbit on #5732).

Each job now carries a craft-print Cancel token. Cancel printing
(ui.printCancel) stops a job that hasn't started reaching the spooler,
so it is never sent, and frees the Print page with the settings kept;
a job already being sent completes, and the page says to cancel it
from the system's queue. Quitting cancels jobs still waiting and waits
only for jobs already being sent.

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

* #5710: quitting cancels every waiting print job before it waits; Cancel asks the active job

Review: quitting cancelled and waited for jobs one by one, so a job left
waiting while an earlier one was being sent could start sending
meanwhile. Every job is now cancelled first, then only those already
being sent are waited for. Cancel printing asked every unfinished job,
so a job that had just been sent could be reported as cancelled and its
receipt lost; it now asks the job being submitted, which answers that it
was already sent.

The tests cover both orders, junk parameters for ui.printCancel, and the
kept printer list and status message.

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

* #5710: craft-print pin moves to storytold/craft-libs#622 for submit_cancellable

85c8ca1 is #622's head (craft-libs main e653048 plus the Cancel token);
it moves to #622's merge on craft-libs main before this merges.

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

* #5710: craft-print pin follows storytold/craft-libs#622 (230d522)

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

* #5710: pin craft-print to merged cancellation fix

* #5710: allow print cancellation after closing the document

* #5710: correct print-cancellation status date

* #5710: clarify when a print job cannot yet be cancelled in the queue

* #5710: isolate Windows print submissions in cancellable helper processes

* #5710: strengthen helper retry and crash-cleanup regressions

* #5710: erase helper inputs before driver work

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:52:57 +01:00
Martin BallandOPUZZLES ae0692ee66 Layout: avoid repeated text-box lookups during page drawing (#4837)
Lands #4837.

Co-Authored-By: OPUZZLES <109076338+OPUZZLES@users.noreply.github.com>
2026-11-06 02:33:02 +01:00
Martin BallandOPUZZLES 4a48a58496 References: index source lookups during citation refresh (#4890)
Lands #4890.

Co-Authored-By: OPUZZLES <109076338+OPUZZLES@users.noreply.github.com>
2026-11-06 02:18:01 +01:00
Martin Ballandpick2510 9a9246915c Layout: Times and Courier take Times New Roman's and Courier New's line spacing (#3241)
Lands #3241.

Co-Authored-By: pick2510 <4068086+pick2510@users.noreply.github.com>
2026-11-06 02:03:34 +01:00
vangel76andClaude Sonnet 5.5 a5b1518a10 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 01:10:51 +01:00
vangel76andClaude Sonnet 5.5 4e334166e5 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 01:10:42 +01:00
vangel76andClaude Sonnet 5.5 8df01e481f Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 01:10:42 +01:00
vangel76andClaude Sonnet 5.5 4ac7292d62 Merge upstream/main into feat/recent-remove-2716
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 01:09:58 +01:00
vangel76andClaude Sonnet 5.5 67c9b8ac31 Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 01:06:04 +01:00
8c57b0775c Status bar: the live count shows words, characters, or characters without spaces (#3187)
Lands #3187.

Co-Authored-By: vangel76 <271991477+vangel76@users.noreply.github.com>
Co-Authored-By: SlavenDj <53652906+SlavenDj@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-06 00:27:28 +01:00
28925fac8c Linux: a setting chooses the display server (Wayland / X11) (#3189)
Lands #3189.

Co-Authored-By: Yasas-Sri <164534752+Yasas-Sri@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: vangel76 <271991477+vangel76@users.noreply.github.com>
2026-11-06 00:04:18 +01:00
Martin Ballandfinwarman 35e960c850 Align ordinary tabs with paragraph alignment (#3915)
Lands #3915.

Co-Authored-By: finwarman <1339254+finwarman@users.noreply.github.com>
2026-11-05 23:55:58 +01:00
Martin BallandSkaywalker 9d5924c8b7 speech: Read Aloud on Linux falls back to Speech Dispatcher (#4576)
Lands #4576.

Co-Authored-By: Skaywalker <115165402+Skaywalker@users.noreply.github.com>
2026-11-05 23:36:40 +01:00
2fb91eece2 Clipboard: copy and paste RTF (#2630)
Lands #2630.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: dkhlapov <19991457+dkhlapov@users.noreply.github.com>
2026-11-05 23:36:19 +01:00
c21d5af2a6 #4181: a click just outside a floating table skips its turned cell text (#4189)
Lands #4189.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: rabis7575 <340973947+rabis7575@users.noreply.github.com>
2026-11-05 23:35:58 +01:00
Martin Ballandmrzv ed92cb5838 doc: decode numeric XML references in legacy form labels (#3883)
Lands #3883.

Co-Authored-By: mrzv <720816+mrzv@users.noreply.github.com>
2026-11-05 23:35:39 +01:00
Martin BallandMMexicann ec3e14f98b MHT: preserve quoted MIME parameter boundaries (#5604)
Lands #5604.

Co-Authored-By: MMexicann <44814932+MMexicann@users.noreply.github.com>
2026-11-05 23:35:12 +01:00
Martin BallandMMexicann 83bef65a1d CLI: reject malformed conversion format options (#5610)
Lands #5610.

Co-Authored-By: MMexicann <44814932+MMexicann@users.noreply.github.com>
2026-11-05 23:09:30 +01:00
Thân Minh NghĩaandClaude Opus 5.5 e919982fa2 ci: Dependabot groups Cargo minor/patch updates; breaking bumps by crate family (#3395)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:11:03 +01:00
ro-legal-docxandClaude Opus 5.5 2f49c8ad2e Layout: keep a cell's floats laid out in the cell inside it (layoutInCell) (#4545)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 21:56:00 +01:00
NetterSilverandClaude Opus 5.5 334494a39f Cap equation matrix size when writing OMML (#4000)
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-11-05 21:41:27 +01:00
db8d944c5c Fonts: reuse shared MATH construction records (#4660)
Co-Authored-By: OPUZZLES <109076338+OPUZZLES@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 18:50:25 +01:00
Michael Mu 21a4e2702f Tests: the ink lookup and note conversion tests count work steps instead of timing (#5643) 2026-11-05 17:37:24 +01:00
.CooCoo 0c54f94602 Tests: the balloon anchor test counts all of the anchor walk's work (#5622) 2026-11-05 17:22:28 +01:00
FredApps d4bbe8ba9a Merge pull request #4367 from helenkwok/web/host-page
feat(web): page host mode, so a page can host WordCraft in its own document (#4366)
2026-11-05 14:11:02 +01:00
1397669dd5 Table formulas: read the Panamanian balboa B/. in numbers (#3546)
* Table formulas: read the Panamanian balboa B/. in numbers

Word's table formula and sort number reader ignores a currency sign or short currency word around a number; the balboa's B/. was not in the list. Add it as a whitespace-delimited sign, as Word reads it: B/. 1,250.50 and 1,250.50 B/. are numbers, while B/.1,250.50 and B/ 1,250.50 are not. Tested in the locale reader and through =SUM(ABOVE); the localization parity doc records the behaviour.

* Table formulas: read the balboa cell in the document's language

The formula test summed a B/. cell through ev, which supplies no document
or language, so a regression in locale selection could pass. Evaluate
=SUM(ABOVE) with a document whose default text is es-PA and a pl-PL
editing language: 1,250.50 is a number under es-PA and not under pl-PL,
so the assertion fails if the formula stops consulting the cell's
language. The converse case reads B/. 1 250,50 from a pl-PL document with
an en-US fallback, which English alone cannot parse.

* docs: close the parenthesis in the localization-parity change note (#3546)

---------

Co-authored-by: Francisco Herrera <14956584+alosslessdev@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-05 13:17:07 +01:00
Harrison Booth 737bab404f Preserve text frame anchor locks (#5382)
* Preserve text frame anchor locks

* Update lockfile for DOCX test dependency

* Assert frame geometry survives document round trips

* Preserve text frame locks on paragraph updates

* Remove duplicate DOCX test dependency
2026-11-05 11:39:30 +01:00
Harrison Booth 9c5b54a1a1 Preserve paragraph grid indent settings in DOCX (#5384)
* WC6-06: preserve adjustRightInd in DOCX

* WC6-06: honor grid indent defaults and XML order

* Update lockfile for DOCX test dependency

* Format DOCX ordering assertion

* Keep main lockfile for paragraph property PR

* PR 5384: tighten grid indent docs and regression

* PR 5384: match paragraph check formatting
2026-11-05 11:39:25 +01:00
Helen Kwok 5a8e6e4894 wordcraft-web: speak the host messages through craft-web-host
The transports, the message types and the origin checks come from the shared crate (storytold/craft-libs#420); WordCraft keeps the host save and dirty hooks, the saveAs rule, the open size limit and the author.
2026-11-05 17:51:01 +08:00
Helen Kwok 22989a77da web host mode: refuse an oversized host file before copying it, cap the name, and document trust and limits 2026-11-05 17:11:53 +08:00
Helen Kwok 7f63cab7d4 packaging/web: say that ?host=parent takes precedence over data-host="page" 2026-11-05 17:11:53 +08:00
Helen Kwok 5f9bb3f111 feat(web): page host mode, so a page can host WordCraft in its own document (#4366)
data-host="page" on the canvas opts a page that loads the module into its own document into the host bridge. The ?host=parent messages travel as non-bubbling CustomEvents on the canvas; the bridge holds a Transport (Parent or Page) with one send, and the open, save, dirty, ready and failure handling is shared. ?host=parent is checked first and unchanged, including no bridge for an unframed page. The hosting guide documents both modes.
2026-11-05 17:11:53 +08:00
Helen Kwok 7a8819c296 Contributors: credit helenkwok as Helen Kwok 2026-11-05 17:11:53 +08:00
Valentin KleinandClaude Sonnet 5.5 c0ab7e29f9 .doc import: a field's cached result keeps its formatting per run, not per character (#5477) (#5489)
* docbin: a field's cached result keeps its formatting per run, not per character (#5477)

The result was a Vec<(char, CharProps)>: 365 bytes per character, 1.44 GB
at peak for a 4 MB file with one field. It is now a String plus a run
list that gains an entry only when the formatting changes (25 MB peak
for the same file). Output is unchanged.

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

* docbin: show the objects when the field-result test fails

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 09:14:56 +01:00
Michael MuandClaude Opus 5.5 fede86cd1a Tests: comment export discovery counts work steps instead of timing (#5618)
`comment_export_discovery_scales_with_unrelated_markers` timed `notes` on 2,000 and 16,000
markers through /proc/thread-self/schedstat. That count moves only at a scheduler tick or switch,
so a 17 µs baseline read as nothing or a whole tick, and gate failed on linear code (58.68x on
PR #3228's run 41932894607). As #5608 did for the balloon anchors, the test now counts the
characters the walk visits (`wordcraft_doc::work`): 4,010 -> 32,010 steps, 8.0x, every run. A
prefix scan per marker injected into `notes` counts 63.9x and fails it.

`work::steps` becomes public so a crate that walks a paragraph's text itself can count it; outside
tests it is still nothing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 09:14:38 +01:00
ljkx c230e2b13e Alt codes type their character in Find and other text boxes (#4403) (#5405)
* Alt codes type their character in Find and other text boxes (#4403)

Alt+numpad codes worked only on the page. In a text box (Find what,
Replace with, a dialog's field) the digits were typed instead on Linux,
which sends their text with Alt held; on Windows the box got only what
the system composes, a byte for a code above 255.

A pass before the frame is drawn now gives the focused text box the
same codes as the page: the digits' text is dropped, letting go of Alt
types the character the code names in the same code pages, and a
platform's own composed character is not typed twice (up to 255 the
system's replaces ours, as on the page).

* Alt codes in text boxes: letting go of Alt ends the digits' text

The flag that drops a digit's own text stayed set when no text came
for the last digit, so the first text after the release was lost: on
Windows the system's composed character for a code we name no
character for (CodeRabbit), and on Linux whatever followed a frame
whose digits sent no text (CI). A digit's text comes with its key,
before Alt is let go, so the release now clears the flag everywhere.
2026-11-05 09:14:32 +01:00
Valentin KleinandClaude Sonnet 5.5 122c85bd09 .doc import: cap paragraph and cell marks, keep the table context only for table marks (#5476) (#5486)
* docbin: cap paragraph and cell marks, keep the table context only for table marks (#5476)

The walk budget counts characters, not paragraphs, so a file made of
paragraph marks grew memory about 2,000 times faster than its size
(4 MB of marks: 9.4 s, 8.4 GB peak). Marks now draw on one document-wide
cap (2,000,000, all stories together); past it the read returns
DocbinError::Limit. A ParaOut no longer embeds the 296-byte RowInfo of a
table mark: it is an optional box, present only for table marks.

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

* docbin: say what the paragraph cap bounds; name the budget test for what it checks

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

* docbin: refuse an exhausted paragraph budget before assembling a story (review point)

CodeRabbit pointed out that the header, footnote, endnote and text box walks
assembled their partial paragraph lists before the budget check, a transient
memory peak. They now share story_blocks, which checks first.

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

* docbin: test that the paragraph cap is shared by the body and a footnote (review nitpick)

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

* docbin test: review fixes (assert empty pairs, comment wording)

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 09:14:25 +01:00
Valentin KleinandClaude Sonnet 5.5 475ca5420b Drop-down list button: pressing it no longer moves the caret out of the control (#5389) (#5485)
* Content controls: a press on the drop-down list button no longer moves the caret (#5389)

The list button hangs over whatever follows the control. The canvas put the
caret under the pointer first, which left the control, and the list closed in
the frame it opened. A press on the button now skips the caret move.

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

* Content controls: only a press that began on the list button skips the caret move (#5389)

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

* Content controls: a press and release in one frame, and a table tool, no longer hit the button guard (review points)

CodeRabbit pointed out that egui clears press_origin when a press and its
release arrive in the same frame, so the caret still moved off the control and
closed the list; the press is now also read from the frame's events. It also
pointed out that the guard ran before the table pen, so a stroke could not
start over the button; the guard is skipped while a table tool is on.

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

* Content controls: feed the same-frame press and release as one frame in the test

Harness::event runs one frame per event, so the earlier version of the test did
not exercise the single-frame case.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 09:14:16 +01:00
Harrison Booth 12a840f653 Preserve section settings when saving DOCX (#5311)
* DOCX: preserve section print and note settings

* docs: record chapter style byte range

* fix: accept XML integer syntax for tray codes

* fix: preserve automatic footnote columns
2026-11-05 09:14:03 +01:00
NetterSilverandClaude Opus 5.5 94cbdeb4cb Keep wgpu indirect validation for x86_64 builds on Apple silicon (#5244)
Fixes #5199

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 09:13:57 +01:00
107996dd24 REF: preserve paragraph numbers when updating cross-references (#4609)
* M6: preserve numbered REF results during field updates

Adapt and validate the patch published in WordCraft #4533, retaining main's bounded numbering renderer and adding relative-context/cache guards.

Co-authored-by: Alyosha-dot-shop <218732667+Alyosha-dot-shop@users.noreply.github.com>

* M6: reuse bookmark lookup for numbered REF updates

* REF: use explicit imports for the workspace clippy gate

* #4609: adapt synthetic fixtures to shared document data

* #4609: checkpoint review fixes and isolated validation notes

* #4609: retain rendered REF labels and isolate relative context by list

---------

Co-authored-by: Alyosha-dot-shop <218732667+Alyosha-dot-shop@users.noreply.github.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-05 09:13:52 +01:00
7ac4a838c6 Mini toolbar: hovering a formatting button previews it on the selection (#4732)
* Mini toolbar: hovering a formatting button previews it on the selection (#4664)

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

* Keep mini toolbar stable during alignment preview

* Track live mini toolbar anchor during preview

* Mini toolbar: hovering a font, size, highlight or font colour previews it (#4664)

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

* Mini toolbar preview: the page holds its scroll position (#4664)

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

* Mini toolbar: hovering the highlight and font colour buttons previews their colour (#4664)

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

* Mini toolbar: the font list and colour menus mark the choice in effect (#4664)

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

* Skip hover previews in context toolbar

* Pass preview mode in toolbar test

* Font list: a picked family keeps its place until the list next opens (#4664)

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

* Font and size lists close when an entry is picked (#4664)

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

* Mini toolbar: a clicked button holds its preview until the pointer leaves (#4664)

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

* Mini toolbar: a button already on does not preview (#4664)

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

* Alignment buttons follow the paragraph a selection covers; a toggle previews its undo on return (#4664)

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

* Font list: a recent family is marked as chosen in Recently Used Fonts only (#4664)

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Fluorine <joseph.anglada92@gmail.com>
2026-11-05 09:13:42 +01:00
ReesandMartin Ball 96286f0e96 Avoid repeated paragraph scans when picking ink (#3614) (#4763)
* #3614: reuse paragraph-prefix lookups during ink discovery

* Update ink lookup status date (#3614)

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-05 09:13:27 +01:00
FredAppsandClaude Opus 5.5 5a4c45e79a About: let every credits column be dragged and refit changed tables (#5588)
* Let every credits column be dragged, and refit changed tables

The last column was egui's remainder column, which has no drag handle, so
it could only be resized from the edge before it. It is now an ordinary
column with its own handle.

A table keeps its column widths, so switching to a longer name left the
Name column at its old width and clipped the text. The table now goes
back to the opening fit whenever that fit changes: a longer widest cell
or a resized window. Dragged widths are dropped at that point.

fit_widths could overflow the room when one column hit the minimum,
because the other columns did not take up its share. Columns now give
way in proportion to their width above the minimum.

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

* Refit credits tables on what they measure, not the fitted widths

Comparing the fitted widths missed the case it was meant for. Name is the
only growing column, so a longer name takes its extra width from the
spare room and the fit comes out the same: a dragged Name width stayed,
clipping the longer name.

It could also drop dragged widths by accident. The fit comes from the
available width inside the scroll area, which scrolling can nudge, so
scrolling a widened table could reset it.

The table now compares the natural widths and the clip rect's width,
which scrolling does not move.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 04:33:16 +01:00
vangel76andClaude Sonnet 5.5 401e7c9169 Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 04:13:02 +01:00
vangel76andClaude Sonnet 5.5 3daa6b1e78 Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 04:09:53 +01:00
vangel76andClaude Sonnet 5.5 e4ba85346d Merge upstream/main into mcp-insert-table-2242
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 04:06:29 +01:00
vangel76andClaude Sonnet 5.5 ebdca4feb8 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:51:36 +01:00
vangel76andClaude Sonnet 5.5 44eb54eef9 layout: one dev-dependencies table after the merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:36:34 +01:00
vangel76andClaude Sonnet 5.5 75a905802c Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:36:06 +01:00
vangel76andClaude Sonnet 5.5 2c17b04f9a Merge upstream/main into feat/paragraph-tools-5043
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:29:32 +01:00
vangel76andClaude Sonnet 5.5 951b255b80 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:26:18 +01:00
vangel76andClaude Sonnet 5.5 37a627c61e Merge upstream/main into feat/paper-colors-3398
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:23:54 +01:00
vangel76andClaude Sonnet 5.5 0ff06b9d86 backstage: fix Align2 path after merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 03:23:46 +01:00
Dc-xp 6f552abf8e chore: follow craft-libs c5a1cbf for craft-pdf and krilla (#5281)
Same rev the other editors are on; cargo check -p wordcraft-ui-egui is clean.
2026-11-05 02:10:16 +01:00
Dennis Klappe 5f87d7461b packaging: RPM installs on openSUSE (library sonames, no shared directories) (#5369) 2026-11-05 02:10:10 +01:00
Rahulandrahul05ranjan 6a50c1e374 #5289: a section's page start flushes the page before with its own margins and separators (#5291)
The page the section before ends on is finished with its own margins and
footnote separators before the starting section's replace them, so the
footnotes pending on it are placed where the page they sit on says.

Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
2026-11-05 02:10:03 +01:00
entropybeaterandBrian 4123bdc09f docx: keep a paragraph's text alignment (#5498)
* docx: keep a paragraph's text alignment

w:textAlignment (ECMA-376 17.3.1.39) was not read, so a save dropped it. Word sizes a line by it when a
list's bullet is larger than the text: without it, those lines grow and every page after them moves.
ParaProps gains text_alignment, read and carried through styles, and written back after w:jc. Kept for
the file only; the layout does not use it yet.

* contributors: add entropybeater

---------

Co-authored-by: Brian <255903314+entropybeater@users.noreply.github.com>
2026-11-05 02:09:55 +01:00
Harrison BoothandClaude Opus 5.5 b3da7a4d36 Object drag preview: clamp the texture to the GPU limit (#4578)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 02:08:58 +01:00
Valentin KleinandClaude Sonnet 5.5 5e4c92bf20 contributors: set names for vangel76 (#5520)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 02:01:19 +01:00
.CooCoo 61a1808f0f Docs: spelling skips addresses after narrow no-break and figure spaces (#5528)
Spelling as you type now notes that a web or e-mail address after a
no-break, narrow no-break (U+202F), figure (U+2007) or ideographic space
is not marked while Ignore web and file addresses is on, as
address_after_a_wide_space_is_an_internet_token in craft-proof checks.
2026-11-05 02:01:13 +01:00
Joseph AngladaandClaude Sonnet 5.5 208facfb92 SmartArt: custom colours per item from the Text Pane (#3123)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:47:56 +01:00
entropybeaterandBrian 811a847e86 doc, docx: keep the compound border lines (#5499)
* doc, docx: keep the compound border lines

The nine thin and thick compound lines (thinThickSmallGap to thinThickThinLargeGap, ECMA-376 17.18.2)
were read as a single line and written back as one, on paragraphs, tables, cells and pages. BorderStyle
gains them; they are written back by name and drawn as a single line of the border's width, as before.
RTF export and import carry them too (the importer read four of them as double).

* contributors: add entropybeater

---------

Co-authored-by: Brian <255903314+entropybeater@users.noreply.github.com>
2026-11-05 01:40:16 +01:00
Valentin KleinandClaude Sonnet 5.5 c9ecb1a14b Metafiles: read the picture size from the header instead of parsing the whole file (#5481) (#5493)
metafile_size() parsed the whole metafile, decoding every bitmap and building every path, to
read two numbers at picture insert and replace. A new wordcraft_metafile::size reads the frame
from the EMF header or the placeable WMF header, and falls back to the full parse for every
other file (plain WMF, an EMF without a usable frame, a file with no records), so it returns
what parse would. For an EMF with one 8000x5000 1 bpp bitmap the size now costs well under a
microsecond instead of about 280 ms.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:32:30 +01:00
Valentin KleinandClaude Sonnet 5.5 c38cd8c407 Metafiles: decode DIB bitmaps with the bit depth chosen once per bitmap (#5479) (#5492)
decode_clip matched the bit depth, bounds-checked and (for 16 and 32 bpp) divided per channel
for every pixel. The depth, palette and masks are now turned into a row decoder once per
bitmap: a 256-entry colour table for 1/4/8 bpp, plain channel swaps for 24 bpp and default
32 bpp, and precomputed shifts and scale tables for masked 16/32 bpp. Output is byte for byte
the same; the old per-pixel code stays in the tests as the reference.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:32:25 +01:00
Valentin KleinandClaude Sonnet 5.5 991a21dee6 Metafiles: refuse a bitmap whose side no renderer draws before decoding it (#5480) (#5490)
decode_clip only capped width x height at 40 MP. A 32 x 1,250,000 pixel 1 bpp bitmap (about
5 MB in the file) was decoded to 160 MB of RGBA and kept, then dropped by Item::take_pixels
because its side is over MAX_BITMAP_SIDE, while counting against the parse's 256 MB budget.
decode_clip now returns None when the clipped width or height is over the cap, so crops of a
large bitmap that fit still decode.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:32:20 +01:00
Hamza Naveed cbe6a9d5f7 Pin that a comment id past u32 is refused, not truncated (#3677) (#5273)
* Pin that a comment id past u32 is refused, not truncated (#3677)

`validate_comment_id` refuses an `id` that does not fit a `u32`, so the `as
u32` casts behind it can no longer reach another comment -- but nothing pinned
it, and the casts are still there to be reached if the guard moves.

The ids the test sends are the ones that would alias onto real comments if the
truncation came back: 2^32 truncates to 0 (and the first comment is 0), 2^32 +
id to that comment, 4294967301 to 5 as the report describes, and u64::MAX to
u32::MAX. Each of the four commands that take an id is tried with each, then
the comments are checked to be unchanged and unresolved, and a valid id is
accepted afterwards so the guard is shown to be a range check rather than a
refusal.

* Comment ids: the values past u32 go in the existing invalid-id test, refused by the range check
2026-11-05 01:32:15 +01:00
.CooCoo c419985c54 Tests: the balloon anchor test counts work steps instead of timing (#5608)
anchors_walk_dense_unrelated_markers_in_linear_work compared CPU times of
a few microseconds, so cache and timer noise could push the ratio past its
24x limit on a linear walk (43.52x in a CI run).

wordcraft-doc now counts, in tests only, the characters its paragraph
object lookups walk (objects_before, object_offsets and
objects_with_offsets), through a work::count helper behind the existing
test-support feature. The test asserts that 8x the markers take at most
12x the steps, as the layout linear-time tests do: the walk takes 2001 ->
16001 steps (8.0x), and the object_offsets + object_at walk it replaced
takes 2003001 -> 128024001 (63.9x).
2026-11-05 01:32:10 +01:00
Dmitrii KhlapovandClaude Sonnet 5.5 1c7b5013fa Drop Cap follow-ups: whole selection, script fonts, margin gap, menu keeps options (#4077)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:26:57 +01:00
vangel76andClaude Sonnet 5.5 d5495ea4f8 Merge upstream/main into feat/recent-remove-2716
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 00:12:16 +01:00
Dylan M. Taylor 3f555bc85d Pass the Linux signing key to the release workflow (#5614) 2026-11-04 17:44:46 -05:00
Aleksander HelgakerandClaude Opus 5.5 650a97b623 ODT: remove the display settings; one choice for each open case (#5609)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:08:09 +01:00
Dmitrii KhlapovandClaude Sonnet 5.5 78d71fa19c Insert Date & Time and date fields use local time, not UTC (#1782)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 22:43:19 +01:00
RatkoRandClaude Opus 5.5 4932017711 Dialogs are modal, as in Word (#4076)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:31:22 +01:00
NetterSilverandClaude Haiku 5.5 0802b4fe70 Layout: the caret on the empty line after a trailing line break follows the paragraph's alignment (#4293)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 21:57:34 +01:00
mmazzieriandClaude Opus 5.5 75fc50fb77 DOCX: picture brightness, contrast and transparency (VML washout, a:lum, a:alphaModFix) (#1961)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:47:44 +01:00
rabis7575andClaude Opus 5.5 1f026078be Format Shape: the Angle field leaves an angle above 359.9° alone (#3959)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:33:52 +01:00
Joseph AngladaandClaude Opus 5.5 bf7715785c Desktop: a window restored onto no monitor opens on screen (#4742)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 21:19:03 +01:00
FredApps 573b929ad6 Merge pull request #5593 from storytold/fix-5559-followups
Fix #5559 follow-ups: generated list prefix, hidden-text objects, turned clipped lines, kept-markup checks
2026-11-04 18:47:52 +01:00
FredApps 5c427807d8 Give a wrapped list its own paragraph when another list field precedes it 2026-11-04 18:17:20 +01:00
FredApps 006d9e4b5b Rebuild a wrapped list as its own field when another list field precedes it
update_generated now keeps what comes before a wrapped list's field, which can be
another list field; rebuild from the converted field's code instead of the
paragraph's first list field, and let index::rebuild_from take that code.
2026-11-04 18:00:11 +01:00
FredApps 8218dea5bd Fix #5559 follow-ups: generated list prefix, hidden-text objects, turned clipped lines, kept-markup checks once per export
- update_generated keeps what precedes a wrapped list's field (r4468529243)
- from_doc keeps objects in hidden text when hidden text is written (r4469018820)
- clipped cell lines in a turned frame are drawn once, clipped, turned (r4469018900)
- kept::while_writing works out reply map and page fingerprint once per export (r4468529227)
- odt_charts fixture declares its namespaces (r4468529259)
- guard test for character styles based on a missing parent (r4469018843, not a bug)
2026-11-04 17:43:46 +01:00
vangel76andClaude Sonnet 5.5 afc01f8328 Paragraph tools: walk runs once for live characters; stronger tracked-change tests
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 02:14:50 +01:00
vangel76andClaude Sonnet 5.5 9e391e97f4 Paragraph tools: skip tracked-deleted characters; snapshot revisions once per command (review)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 02:04:56 +01:00
vangel76 2113f52db4 Merge upstream/main into feat/paragraph-tools-5043 2026-11-04 02:00:59 +01:00
vangel76 098cf86dc8 Merge upstream/main into feat/paragraph-tools-5043 2026-11-04 01:01:08 +01:00
vangel76andCodex e99e28aebd M8: finish paragraph tools (#5043)
Co-Authored-By: Codex <noreply@openai.com>
2026-11-04 00:53:50 +01:00
vangel76andCodex 4abae255e0 M8: refresh screen paper colors (#3398)
Co-Authored-By: Codex <noreply@openai.com>
2026-11-03 19:57:50 +01:00
vangel76andClaude Sonnet 5.5 0b6e052d73 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 00:01:00 +01:00
vangel76andClaude Sonnet 5.5 45cfc55116 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:54:30 +01:00
vangel76andClaude Sonnet 5.5 a3b50342f9 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:53:36 +01:00
vangel76andClaude Sonnet 5.5 ce4c401c4a Merge upstream/main into ui-text-hinting
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:53:35 +01:00
vangel76andClaude Sonnet 5.5 b8ae10e453 Merge upstream/main into feat/paragraph-tools-5043
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:47:13 +01:00
vangel76andClaude Sonnet 5.5 f4f9d28d99 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:34:13 +01:00
vangel76andClaude Sonnet 5.5 e17ad54b15 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:34:13 +01:00
vangel76andClaude Sonnet 5.5 9e2f6ad330 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:31:57 +01:00
vangel76andClaude Sonnet 5.5 6f125461dd Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:31:57 +01:00
vangel76andClaude Sonnet 5.5 31a37bcf00 Merge upstream/main into feat/322-view-windows
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:30:37 +01:00
vangel76andClaude Sonnet 5.5 2025c48e9b Merge upstream/main into feat/1053-rtf-notes-comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:30:37 +01:00
vangel76andClaude Sonnet 5.5 83b319e7c3 Merge upstream/main into control-mcp-delivery-1326-1331
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:30:37 +01:00
vangel76andClaude Haiku 5.5 ca1f584ae0 Merge upstream/main into zoom-stretch
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-01 14:52:50 +01:00
vangel76andClaude Sonnet 5.5 7378ac5063 Layout tests: install the fixed fonts in mark_text_width too
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 14:50:06 +01:00
vangel76andClaude Sonnet 5.5 7e967d4a3a Layout tests: initialize the fixed fonts centrally in the layout entry points
layout, layout_para and layout_equation call crate::test_fonts() first in test
builds, so no test can reach font lookup through them before the pinned fonts
are installed. Tests and helpers that look up fonts directly (word::resolve,
FontDb::global, the nbsp fixture) call it themselves. Fixes init_test_fonts()
returning false when an uninitialised test ran first in a shared process.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:12:06 +02:00
438 changed files with 29205 additions and 3019 deletions
+20 -1
View File
@@ -7,11 +7,30 @@ updates:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
# Minor and patch updates share one PR. A breaking bump gets its own PR so one API change
# doesn't hold the safe updates back (#2973). For cargo, Dependabot counts a 0.y bump of a
# 0.x crate as major, as Cargo does. A dependency goes to the first group it matches, so the
# crate families that must move together come before the catch-all.
groups:
egui:
patterns:
- "egui"
- "egui_*"
- "eframe"
- "winit"
fonts:
patterns:
- "skrifa"
- "harfrust"
hayro:
patterns:
- "hayro-*"
cargo-dependencies:
patterns:
- "*"
update-types:
- "minor"
- "patch"
commit-message:
prefix: "chore(deps)"
+28
View File
@@ -0,0 +1,28 @@
name: House rules
on:
push:
branches: [main]
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
pull_request:
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: house-rules-${{ github.ref }}
cancel-in-progress: true
jobs:
house-rules:
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
with:
rust: true
+9
View File
@@ -18,7 +18,14 @@ concurrency:
cancel-in-progress: false
jobs:
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
credits:
permissions:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
needs: credits
# Only this job publishes the GitHub release, so only it gets write access.
permissions:
contents: write
@@ -31,6 +38,7 @@ jobs:
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
web-craft-fonts: true
version: ${{ inputs.version }}
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@@ -46,3 +54,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
+57 -4
View File
@@ -1,10 +1,62 @@
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
## House rules
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
10. **Keep secrets and personal details out** of the repository, logs and requests.
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
<!-- craft-house-rules:end -->
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
## Rust Craft repositories
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
## Licence allowlist
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
- `ISC`
- `MIT`
- `BSD-2-Clause`
- `BSD-3-Clause`
- `Apache-2.0`
- `OFL-1.1`
- `Ubuntu-font-1.0`
- `Bitstream-Vera`
- `CC0-1.0`
- `CC-BY-4.0`
- `CC-BY-SA-4.0`
- `LicenseRef-Public-Domain`
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
<!-- craft-house-rules:end -->
# WordCraft — instructions for agents
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. `plan/` is gitignored (local only); if it's missing, start from `ROADMAP.md`, `docs/gaps.md` and `docs/roadmap.md` (Current focus).
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §5). Don't stop to ask unless a decision is genuinely the owner's (licensing, publishing, pushing to new remotes, secrets).
Read [`ROADMAP.md`](ROADMAP.md), [`docs/gaps.md`](docs/gaps.md) and [`docs/roadmap.md`](docs/roadmap.md). `plan/` is an optional gitignored notebook. Do not ask for it and do not stop when it is absent.
## Never crash
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it.
@@ -28,7 +80,7 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
- Never read, disassemble or copy anything inside the Word bundle (file names/listings only). Never commit files produced by Word. Never copy Microsoft wording beyond feature names.
- File formats come from public specs: ECMA-376 / ISO 29500 (OOXML), the published RTF spec, OASIS ODF 1.3. Never copy GPL/LGPL/AGPL code (LibreOffice, AbiWord, pandoc…).
## Architecture (see `plan/architecture.md`)
## Architecture
| Layer | Crate | Job |
|---|---|---|
| L0 | `geom` | units, rects, measurement parsing |
@@ -44,7 +96,6 @@ 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.
- **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`.
- **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`).
## Quality gates
@@ -85,3 +136,5 @@ Progress docs: `ROADMAP.md` (stage, headline numbers, progress log), `docs/targe
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
New workspace crates include `workspace = true` under `[lints]` in `Cargo.toml`. Production crate roots declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
+1 -71
View File
@@ -1,71 +1 @@
# WordCraft — instructions for agents
WordCraft is a clean-room, open-source, Rust-native word processor targeting Microsoft Word parity — and going further on speed, openness and agent control. It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. `plan/` is gitignored (local only); if it's missing, start from `ROADMAP.md`, `docs/gaps.md` and `docs/roadmap.md` (Current focus).
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §5). Don't stop to ask unless a decision is genuinely the owner's (licensing, publishing, pushing to new remotes, secrets).
## Never crash
People trust WordCraft with their writing; a crash loses their work. **This outranks feature work**: never ship a feature through a panic path, and fix a crash before building on top of it.
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or is disabled; it never panics.
- **Input-derived numbers are hostile** (files, commands, MCP/control params): `get()` instead of `[i]`, slice strings only at char boundaries, checked/saturating arithmetic, clamp sizes, cap allocations, bound recursion.
- **Last-resort guard:** `Session::run` turns an escaped panic into an error and restores the document. It's a safety net, not a licence.
- **Prove it:** every crash fix lands with a regression test. `crates/engine/src/tests.rs::hostile_params_never_panic` runs every command with junk params — keep it green.
- Production crate roots carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`; new crates start with it and add `[lints]` with `workspace = true` to their `Cargo.toml`. Tests may `unwrap` (`clippy.toml`).
## Assets: absolutely no Microsoft (or Adobe/Avid/Autodesk) iconography or images
**This rule is non-negotiable.** Breaking it is the worst mistake you can make in this repo.
- Never copy, trace, screenshot-crop or recreate icons, images, templates, themes, clip art, fonts, cursors, sounds or any other asset from Microsoft Word/Office or any Microsoft, Adobe, Avid or Autodesk product. Not even "temporarily".
- Every asset must be **original** (drawn in code — prefer this: `crates/ui-egui/src/icons.rs`), **public domain / CC0**, **OSI open source**, or **Creative Commons that allows redistribution**, or licensed open source by a contributor who made it.
- **Every asset file has a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (author, source, licence). `cargo xtask assets` (part of `cargo xtask ci`) fails otherwise. Add the row in the same commit as the file.
- Fonts: new font files go to [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts), never this repo; it's an optional build input (`CRAFT_FONTS_DIR`). Spelling word lists and hyphenation patterns live in [`storytold/craft-i18n`](https://github.com/storytold/craft-i18n) `language-data/`, never this repo; they are embedded from `CRAFT_I18N_DIR` (pinned by `craft-i18n.rev`, which CI checks out). Without it the app builds, but English spelling falls back to an installed Hunspell English or the built-in Moby word list (with no American/British variant table) and pattern hyphenation is off (Bulgarian, Turkish and Russian still hyphenate by rule), so proofing tests need it. Documents asking for proprietary fonts (Calibri, Aptos, Cambria…) are rendered with installed system fonts or metric-compatible open substitutes (`crates/fonts/src/word.rs`) — never bundle proprietary fonts.
- Templates, sample documents and themes are our own text and colours (`crates/engine/src/sample.rs`, `cmd/design.rs`). Demo images are public domain, with sources recorded.
## Clean room
- Microsoft Word is installed on the dev machine and may be **observed** black-box: run it, use its UI with synthetic documents, take screenshots **of its window only** (by window id — the machine runs other work; never capture the whole desktop) stored only under `plan/word/screenshots/` (gitignored, never committed).
- Never read, disassemble or copy anything inside the Word bundle (file names/listings only). Never commit files produced by Word. Never copy Microsoft wording beyond feature names.
- File formats come from public specs: ECMA-376 / ISO 29500 (OOXML), the published RTF spec, OASIS ODF 1.3. Never copy GPL/LGPL/AGPL code (LibreOffice, AbiWord, pandoc…).
## Architecture (see `plan/architecture.md`)
| Layer | Crate | Job |
|---|---|---|
| L0 | `geom` | units, rects, measurement parsing |
| L1 | `doc`, `fonts` | document model + edit primitives; font DB, shaping, Word font substitution |
| L2 | `layout`, `docx`, `formats` | line breaking, pagination, tables, headers/footers, hit testing; OOXML I/O; TXT/MD/HTML/RTF/ODT |
| L3 | `render`, `pdf` | vello_cpu rasteriser; krilla PDF |
| L4 | `engine` | Session, undo, **command registry**, Word feature catalog, samples, I/O dispatch |
| L5 | `mcp` | MCP server (headless or bridged to the app) |
| L6 | `ui-egui` | Word-style egui front end (swappable) |
| apps | `apps/wordcraft`, `apps/wordcraft-cli`, `apps/wordcraft-web` | binaries |
| external | `craft-proof` (craft-libs, used as `wordcraft-proof`, sits at L1) | proofing: spelling, grammar, hyphenation; its word lists and patterns are craft-i18n's `language-data/` (`CRAFT_I18N_DIR`) |
- **Everything is a command.** User-visible behaviour = a `CommandSpec` in `crates/engine/src/cmd/*.rs` (id, label, ribbon location, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (`ui.*`) live in `crates/ui-egui/src/lib.rs`. The ribbon, shortcuts, command search, CLI, control channel and MCP all dispatch by id. Programmatic calls never open dialogs.
- **Parity is measured:** `crates/engine/src/catalog.rs` lists Word's ribbon/menu features with command ids. `cargo xtask parity` writes `docs/parity-checklist.md`; a test enforces a floor that only rises. Implement missing ids to raise it.
- **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`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
## Quality gates
Before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every arc of work that builds, with a task id in the message (`M2.1: line spacing dialog`).
## Typing behaves like Word
Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's observed behaviour in [`docs/typing-parity.md`](docs/typing-parity.md), pinned by `crates/engine/src/tests/typing.rs` (keys go through the same commands as the keyboard). Change behaviour, doc and tests together; a typing fix lands with a test there. Compare headlessly (`ui_shot`); don't drive GUIs with keystrokes on the shared machine.
## Running and looking at the app
- `cargo run --release -p wordcraft -- --sample --control 7981` (sample document + control channel).
- Drive it with JSON lines on `127.0.0.1:7981`, each with the window's key from `~/.config/wordcraft/control-key.local-7981` (macOS: `~/Library/Application Support/WordCraft/`), e.g. `{"id":1,"key":"…","method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hello"}}}` then `{"id":2,"key":"…","method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`; docs: `docs/control-protocol.md` (Keys).
- **Headless window screenshots** (no focus stealing, works with a locked screen): `cargo run --release -p wordcraft-ui-egui --example ui_shot -- script.jsonl` (see the example's header).
- **For UI work, look at the result** (screenshot, read the PNG) and compare with Word side by side.
- CLI: `wordcraft-cli convert in.docx out.pdf`, `wordcraft-cli run --template sample --cmd 'select.text={"text":"Studio"}' --cmd format.bold --save out.docx`, `wordcraft-cli commands`, `wordcraft-cli parity`.
- MCP: `wordcraft-cli mcp` (headless) or `wordcraft-cli mcp --connect 127.0.0.1:7981` (drives the running app). See `docs/mcp.md`.
- Shell gotcha: `mv`/`cp` may be aliased interactive — use `/bin/mv -f` / `/bin/cp -f`.
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
## Roadmap
Progress docs: `ROADMAP.md` (stage, headline numbers, progress log), `docs/target-app-parity.md` (the assessment), `docs/gaps.md` (ranked work list), `docs/roadmap.md` (milestones, Current focus), `docs/architecture.md`, and the parity checklists `docs/file-format-parity.md`, `docs/layout-parity.md`, `docs/typing-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the gap, the parity doc and the progress log, and bump each touched doc's status line and revision history.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
@AGENTS.md
Generated
+30 -3
View File
@@ -1081,7 +1081,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -1090,9 +1090,10 @@ dependencies = [
[[package]]
name = "craft-print"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=a3e0b5a9440d18f19422a9dca13516747b7d3181#a3e0b5a9440d18f19422a9dca13516747b7d3181"
dependencies = [
"craft-winprint-gdi",
"log",
"miniz_oxide 0.9.1",
"roxmltree",
"tempfile",
@@ -1157,6 +1158,26 @@ dependencies = [
"zip 9.1.0",
]
[[package]]
name = "craft-web-api"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"js-sys",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "craft-window"
version = "0.1.0"
@@ -2960,7 +2981,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -6805,7 +6826,9 @@ dependencies = [
"open",
"pollster 1.0.1",
"rfd",
"serde",
"serde_json",
"tempfile",
"windows",
"winit",
"winresource",
@@ -6815,6 +6838,7 @@ dependencies = [
"wordcraft-engine",
"wordcraft-fonts",
"wordcraft-ui-egui",
"x11rb",
"zbus",
]
@@ -6880,6 +6904,7 @@ dependencies = [
"log",
"proptest",
"quick-xml 0.41.0",
"serde_json",
"sha1 0.11.0",
"sha2 0.11.1",
"thiserror 2.0.21",
@@ -7084,6 +7109,8 @@ dependencies = [
name = "wordcraft-web"
version = "0.15.1"
dependencies = [
"craft-web-api",
"craft-web-host",
"eframe",
"egui",
"js-sys",
+4 -2
View File
@@ -67,7 +67,7 @@ wuff = "0.2.9"
brotli-decompressor = "5"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
quick-xml = "0.41"
@@ -92,6 +92,8 @@ zeroize = "1"
# dependency of other crates.
libc = "0.2"
# time
# ui
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
@@ -132,7 +134,7 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+12 -1
View File
@@ -299,10 +299,21 @@ adapter. The log file records the choice (lines starting with
RISC-V (riscv64): `wordcraft-<ver>-linux-riscv64.tar.gz`, a tarball cross-compiled for glibc 2.39+
(Ubuntu 24.04, Debian 13 and newer).
**Read Aloud** (Review › Read Aloud) speaks through a speech program installed on the system: it
tries `espeak-ng`, then `espeak`, and when neither is installed it talks to Speech Dispatcher.
The `.rpm` package recommends `espeak-ng`; the `.deb` recommends `espeak-ng` or
`speech-dispatcher`, so the package manager installs one of the two by default. With the
AppImage or the tarball, install one yourself if neither is there (`sudo apt install espeak-ng`,
`sudo dnf install espeak-ng`, `sudo pacman -S espeak-ng`). The Flatpak doesn't speak yet: it has
no audio access and doesn't include `espeak-ng`, `espeak` or Speech Dispatcher.
**Wayland:** on a Wayland desktop the app runs through XWayland when it is available, because
winit (the windowing library) supports dragging files into the window only on X11. To use the
native Wayland backend instead (for example for sharper text with fractional scaling, at the cost
of file drag-and-drop), start it with `WORDCRAFT_WAYLAND=1`:
of file drag-and-drop), choose **File › Options › Advanced › Linux display server › Wayland**; it
applies the next time WordCraft starts, and **X11 (XWayland)** or **Automatic** switches back. The
`WORDCRAFT_WAYLAND` environment variable overrides the setting: `1` for native Wayland, `0` for
XWayland:
```sh
WORDCRAFT_WAYLAND=1 wordcraft
+216 -2
View File
File diff suppressed because one or more lines are too long
+23 -38
View File
@@ -68,10 +68,6 @@ Paper: documents created here, and HTML, Markdown and text files (which give no
the system region's paper (A4, or Letter in the US and other Letter regions), read from
LC_ALL / LC_PAPER / LANG or the platform. Set WORDCRAFT_REGION=en-US (or another tag) for the
same output on every machine.
OpenDocument (.odt, .ott, .fodt) files fill in what their standard leaves to the application in
Microsoft Word's style (WORDCRAFT_ODF=word, the default) or LibreOffice's
(WORDCRAFT_ODF=libreoffice); see docs/odf-interpretation.md.
";
/// The subcommands, in `USAGE` order; `--help` or `-h` after any of them prints `USAGE` (#968).
@@ -90,18 +86,11 @@ fn missing_argument() -> Failure {
/// ([`password_from`]). As in the app, a document opened with one is saved with it again, so
/// `convert` does not quietly write a copy the password no longer protects.
fn open(path: &Path, password: Option<&str>) -> Result<Session, String> {
open_in(path, password, odf_profile()?)
}
/// [`open`] with the OpenDocument style `odf`, which the session keeps: a later `file.open` or
/// `file.new` in a `run` script reads files the same way.
fn open_in(path: &Path, password: Option<&str>, odf: wordcraft_doc::odf_profile::OdfProfile) -> Result<Session, String> {
// A file that gives no page size (HTML, Markdown, plain text) opens on the system region's
// paper, as in the app: A4 in Italy, Letter in the US (#1381).
let region = system_region();
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, &region, &odf)?;
let (doc, encrypted) = wordcraft_engine::io::open_path_in(path, password, &region)?;
let mut s = session(doc);
s.prefs.odf = odf;
s.path = Some(path.into());
if encrypted {
s.password = password.map(wordcraft_engine::io::Password::new);
@@ -109,8 +98,8 @@ fn open_in(path: &Path, password: Option<&str>, odf: wordcraft_doc::odf_profile:
Ok(s)
}
/// The commands that open a file, and which of their arguments not starting with `--` it is (as
/// `pos` counts). `run` opens the value of `--file`.
/// The commands that open a file, and which positional argument it is, excluding option values.
/// `run` opens the value of `--file`.
const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0), ("inspect", 0), ("render", 0), ("zotero", 1)];
/// The arguments as text. Only the file the command opens may have a name that isn't valid Unicode,
@@ -119,8 +108,14 @@ const OPENS_FILE: &[(&str, usize)] = &[("convert", 0), ("info", 0), ("text", 0),
fn text_args<S: AsRef<OsStr>>(raw: &[S]) -> Result<(Vec<String>, Option<PathBuf>), String> {
let args: Vec<String> = raw.iter().map(|a| a.as_ref().to_string_lossy().into_owned()).collect();
let cmd = args.first().map(String::as_str).unwrap_or("");
let specs = match cmd {
"convert" => CONVERT_OPTIONS,
"render" => RENDER_OPTIONS,
"zotero" => ZOTERO_OPTIONS,
_ => PASSWORD_OPTIONS,
};
let file_at = match OPENS_FILE.iter().find(|(c, _)| *c == cmd) {
Some((_, n)) => args.iter().enumerate().skip(1).filter(|(_, a)| !a.starts_with("--")).nth(*n).map(|(i, _)| i),
Some((_, n)) => positional_indices(args.get(1..).unwrap_or_default(), specs).get(*n).map(|i| i + 1),
None if cmd == "run" => args.iter().position(|a| a == "--file").map(|i| i + 1),
None => None,
};
@@ -156,8 +151,13 @@ fn password_from(args: &[String]) -> Result<Option<String>, String> {
}
/// The positional arguments, skipping every option's value so an option value is never read as a
/// path. Mirrors [`validate_options`], which has already checked the options are known.
/// path. Uses the same option specifications as [`validate_options`].
fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String> {
positional_indices(args, specs).into_iter().filter_map(|i| args.get(i)).collect()
}
/// Keep the original indices too, so [`text_args`] can open a positional path by its native name.
fn positional_indices(args: &[String], specs: &[OptionSpec]) -> Vec<usize> {
let mut out = Vec::new();
let mut i = 0;
while let Some(arg) = args.get(i) {
@@ -167,7 +167,7 @@ fn positionals<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a String>
i += 2;
} else {
if !arg.starts_with("--") {
out.push(arg);
out.push(i);
}
i += 1;
}
@@ -183,24 +183,6 @@ fn session(doc: wordcraft_doc::Document) -> Session {
s
}
/// How OpenDocument files fill in what their standard leaves open: `WORDCRAFT_ODF` names a preset
/// (`word`, Microsoft Word's style and the default, or `libreoffice`; `wordcraft` is taken too).
fn odf_profile() -> Result<wordcraft_doc::odf_profile::OdfProfile, String> {
odf_from(std::env::var("WORDCRAFT_ODF").ok())
}
/// [`odf_profile`] from `WORDCRAFT_ODF`'s value, if set.
fn odf_from(var: Option<String>) -> Result<wordcraft_doc::odf_profile::OdfProfile, String> {
use wordcraft_doc::odf_profile::{OdfPreset, OdfProfile};
match var.filter(|v| !v.trim().is_empty()) {
None => Ok(OdfProfile::default()),
Some(v) => match OdfPreset::from_id(&v).filter(|p| *p != OdfPreset::Custom) {
Some(p) => Ok(OdfProfile::of(p)),
None => Err(format!("WORDCRAFT_ODF={v}: one of word, libreoffice")),
},
}
}
/// The system region, read as the app reads it (locale variables, else the platform's locale).
fn system_region() -> wordcraft_engine::Region {
wordcraft_engine::Region::from_locale_vars(|name| std::env::var(name).ok(), sys_locale::get_locale)
@@ -459,7 +441,12 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
"convert" => {
validate_options("convert", &rest, CONVERT_OPTIONS)?;
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
let format = arg_value(&rest, "--format");
let format = match find_option_arg(&rest, "--format") {
OptionArg::Absent => None,
OptionArg::Duplicate => return Err("convert: duplicate --format option".into()),
OptionArg::MissingValue => return Err("convert: --format requires a value".into()),
OptionArg::Value(value) => Some(value),
};
let paths = positionals(&rest, CONVERT_OPTIONS);
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
let markup = rest.iter().any(|a| a == "--markup");
@@ -533,8 +520,6 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
// run starts with one open document.
(None, t) => {
let mut s = session(wordcraft_doc::Document::new());
// The script's own `file.open`s read OpenDocument files in `WORDCRAFT_ODF`'s style.
s.prefs.odf = odf_profile()?;
let first = s.document_id();
let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
s.run("file.new", &params).map_err(|e| e.to_string())?;
+21 -18
View File
@@ -75,6 +75,22 @@ fn unknown_options_are_rejected_before_file_access() {
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
}
#[test]
fn convert_rejects_malformed_format_options_before_file_access() {
for (options, error) in [
(vec!["--format"], "convert: --format requires a value"),
(vec!["--format", "--markup"], "convert: --format requires a value"),
(vec!["--format", "flatopc", "--format", "wordml2003"], "convert: duplicate --format option"),
] {
let mut argv = args(&["convert", "missing.docx", "out.xml"]);
argv.extend(args(&options));
assert_eq!(run(&argv), Err(error.into()), "{options:?}");
}
// A valid single value reaches file access, rather than a format-option error.
let error = run(&args(&["convert", "missing.docx", "out.xml", "--format", "flatopc"])).unwrap_err().to_string();
assert!(error.starts_with("missing.docx:"), "{error}");
}
#[test]
fn help_or_h_after_a_subcommand_asks_for_the_usage() {
for cmd in SUBCOMMANDS {
@@ -195,6 +211,10 @@ fn only_the_opened_file_may_be_other_than_unicode() {
let ok = |raw: Vec<OsString>| text_args(&raw).unwrap().1;
assert_eq!(ok(vec!["zotero".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["render".into(), latin1(), "out.png".into(), "--page".into(), "2".into()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["render".into(), "--page".into(), "2".into(), latin1(), "out.png".into()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["convert".into(), "--format".into(), "flatopc".into(), latin1(), "out.xml".into()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["info".into(), "--password".into(), "secret".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["zotero".into(), "--port".into(), "23119".into(), "refresh".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["run".into(), "--print".into(), "--file".into(), latin1()]), Some(PathBuf::from(latin1())));
assert_eq!(ok(vec!["info".into(), "in.docx".into()]), None);
// Every command that opens a file reaches the open with it (no such file here), so
@@ -212,6 +232,7 @@ fn only_the_opened_file_may_be_other_than_unicode() {
for (raw, n) in [
(vec!["zotero".into(), latin1(), "in.docx".into()], 2),
(vec!["render".into(), "in.docx".into(), latin1()], 3),
(vec!["render".into(), "--page".into(), latin1(), "in.docx".into(), "out.png".into()], 3),
(vec!["run".into(), "--save".into(), latin1()], 3),
(vec!["commands".into(), latin1()], 2),
] {
@@ -543,21 +564,3 @@ fn script_parse_resolves_paths_against_the_base() {
assert_eq!(s.save, vec!["o.docx".to_string()]);
assert_eq!(s.commands.len(), 1);
}
/// `WORDCRAFT_ODF` names a display style, and a session opened in one keeps it, so a `run`
/// script's later `file.open` reads OpenDocument files the same way.
#[test]
fn the_odf_style_is_kept_by_the_session() {
use wordcraft_doc::odf_profile::OdfProfile;
assert_eq!(odf_from(None), Ok(OdfProfile::default()));
assert_eq!(odf_from(Some("libreoffice".into())), Ok(OdfProfile::libreoffice()));
assert_eq!(odf_from(Some("word".into())), Ok(OdfProfile::word()));
assert!(odf_from(Some("custom".into())).is_err());
let dir = std::env::temp_dir().join(format!("wc-cli-odf-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("a.txt");
std::fs::write(&path, "Hello").unwrap();
let s = open_in(&path, None, OdfProfile::libreoffice()).unwrap();
assert_eq!(s.prefs.odf, OdfProfile::libreoffice());
let _ = std::fs::remove_dir_all(&dir);
}
@@ -53,6 +53,15 @@ fn the_file_a_command_opens_may_have_any_name() {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn an_option_before_the_input_keeps_its_native_name() {
let (dir, file) = folder("option-first");
let saved = dir.join("out.xml");
ok(&cli(&["convert".into(), "--format".into(), "flatopc".into(), file, saved.clone().into()]));
assert!(std::fs::read_to_string(saved).unwrap().contains("Bonjour"));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn any_other_such_argument_is_an_error_naming_it() {
let (dir, file) = folder("error");
+5 -1
View File
@@ -16,6 +16,10 @@ path = "src/main.rs"
[dependencies]
wordcraft-engine = { workspace = true }
wordcraft-ui-egui = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
# The page command API (storytold/craft-libs#413): window.wordcraft over the control channel.
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
@@ -29,4 +33,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
+61
View File
@@ -0,0 +1,61 @@
//! `window.wordcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
//!
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
//! `screenshot`, as promises with a timeout). This module is WordCraft's part: a [`Request`] that
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
//! reply. WordCraft's control request has no abandon state, so a call the page gave up on still runs
//! when the app reaches it, as it does for a desktop client that timed out.
use std::rc::Rc;
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use craft_web_api::methods::Methods;
use craft_web_api::page::{PageApi, Request};
use serde_json::{Value, json};
use wasm_bindgen_futures::JsFuture;
use wordcraft_ui_egui::ControlRequest;
/// The app id in `window.<app>`.
const APP: &str = "wordcraft";
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
const POLL_MS: i32 = 16;
/// Put `window.wordcraft` on the page and return the queue the app drains each frame (hand it to
/// `WordApp::with_control`). An existing `window.wordcraft` keeps the functions it already has.
pub fn install(ctx: egui::Context) -> Receiver<ControlRequest> {
let (queue, requests) = channel::<ControlRequest>();
let request: Request = Rc::new(move |method, params| {
let (control, reply) = ControlRequest::new(method, params);
if queue.send(control).is_err() {
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
}
ctx.request_repaint();
Box::pin(wait(reply))
});
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
log::warn!("couldn't install window.{APP}: {e:?}");
}
requests
}
/// The app's reply, polled about once a frame.
async fn wait(reply: Receiver<Value>) -> Value {
loop {
match reply.try_recv() {
Ok(reply) => return reply,
Err(TryRecvError::Empty) => sleep(POLL_MS).await,
Err(TryRecvError::Disconnected) => return json!({"ok": false, "error": "the app dropped the request"}),
}
}
}
async fn sleep(ms: i32) {
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
}
});
let _ = JsFuture::from(promise).await;
}
+65 -107
View File
@@ -1,14 +1,20 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
//! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`:
//! Host modes: a page that runs WordCraft (as an `<iframe>`, or in its own document) opens
//! documents in it and stores what it saves instead of the browser downloading. The messages and
//! the two transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the
//! other Craft apps' bridges, with the `wordcraft:` prefix. This module is WordCraft's part of it.
//!
//! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
//! listening; send a file now.
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
//! every file WordCraft would otherwise download. The parent stores it and reports failures
//! itself. `saveAs` says whether the file becomes the document's file:
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
//! - `wordcraft:ready` once the app is running and listening; send a file now. WordCraft reads
//! bytes, so it says `file: false` and a host sends `bytes`.
//! - `wordcraft:open` (from the host): `{ name, bytes }`. It opens like a document picked with
//! File › Open or dropped on the window; a file over [`MAX_OPEN_BYTES`] is refused before it is
//! copied.
//! - `wordcraft:save` (to the host): `{ name, saveAs, bytes }` for every file WordCraft would
//! otherwise download. The host stores it and reports failures itself. `saveAs` says whether
//! the file becomes the document's file:
//! - Save: `false`. It writes the opened file under its own name and format, as the desktop
//! app does. A name the parent never sent (an untitled document, an imported .doc saved as
//! .docx) is a new file: `true`, as for Save As.
@@ -17,70 +23,57 @@
//! is now that file; the next Save sends its name with `false`.
//! - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and
//! Export Page as PNG: `false`. They are copies; the document's file stays the same.
//! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
//! - `wordcraft:dirty` whenever unsaved changes appear or go away, `wordcraft:failed` when the
//! app couldn't start.
//!
//! Only the parent window on the page's own origin is heard or answered. In host mode the parent
//! guards leaving the page, and `?author=` names the user for comments and tracked changes.
//! In both host modes the host sends the document to edit, so no sample opens, saves are handed
//! to the host rather than downloaded, the host guards leaving the page, and `?author=` names the
//! user for comments and tracked changes.
use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use craft_web_host::web::{FileData, Host, Message};
use wordcraft_ui_egui::{Inbox, Services, WordApp};
/// The app id in the host messages (`wordcraft:ready`).
const APP: &str = "wordcraft";
/// The name a document gets when the host sent none (the crate says `document` for a blank name).
const DEFAULT_NAME: &str = "Document.docx";
/// The largest document taken from the host page, refused before it is copied.
const MAX_OPEN_BYTES: u64 = 512 << 20;
/// The longest user name taken from `?author=`.
const MAX_AUTHOR_CHARS: usize = 200;
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
host: Rc<Host>,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
/// on another origin: messages only go to this origin, so the browser would drop every save
/// without an error while the document showed as saved.
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
if origin == "null" {
return None;
}
// Reading another origin's location throws, which is `None` too.
if parent.location().origin().ok()? != origin {
return None;
}
Some(Self { window, parent, origin })
/// The bridge for this page, or `None` when WordCraft isn't hosted: no `?host=parent` and no
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
/// with downloads), or a parent on another origin.
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
}
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
/// Hand saved and exported files to the host instead of downloading them, report unsaved
/// changes to it, and hear the files it sends.
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
self.listen(inbox, ctx);
let bridge = self.clone();
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
// Anything else that would download goes to the parent too.
// Anything else that would download goes to the host too.
let bridge = self.clone();
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
// The browser's save picker is never used, so no save finishes later.
services.async_saves = None;
let bridge = self.clone();
let reported: Rc<Cell<Option<bool>>> = Rc::default();
services.on_dirty = Some(Box::new(move |dirty: bool| {
if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
reported.set(Some(dirty));
}
}));
// The crate does not repeat a state it already reported.
let host = self.host.clone();
services.on_dirty = Some(Box::new(move |dirty: bool| host.post_dirty(dirty)));
}
/// `?author=`: the host's name for the user signs comments and tracked changes.
@@ -92,78 +85,43 @@ impl Bridge {
}
pub fn post_ready(&self) {
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
let _ = self.host.post_failed(error, None);
}
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let message = message(&[
("type", "wordcraft:save".into()),
("name", name.as_str().into()),
("saveAs", save_as.into()),
("bytes", buffer.clone().into()),
])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
self.host.post_save(&name, bytes, None, save_as)
}
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
/// Hear `wordcraft:open` and queue the file like a dropped one.
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
self.host.listen(move |message| {
if let Message::Open { name, data } = message {
deliver_open(&inbox, &ctx, &name, &data);
}
let data = event.data();
if string(&data, "type").as_deref() != Some("wordcraft:open") {
return;
}
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
let Some(bytes) = bytes(&data) else { return };
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
/// Post a message to the parent; false if it couldn't be sent.
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
}
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(array.to_vec())
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(&bytes).to_vec())
} else {
None
/// The file an open message carries, pushed onto the inbox for the next frame, like a dropped
/// file. A host's file is untrusted input: an oversized one is refused before it is copied into
/// the app's memory (the data's own length, not the host's claim). The crate has cleaned the name
/// (and cut it to 255 characters); a blank one comes as `document` and becomes `Document.docx`.
fn deliver_open(inbox: &Inbox, ctx: &egui::Context, name: &str, data: &FileData) {
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
let FileData::Bytes(array) = data else {
log::warn!("host file {name} came as a File; WordCraft reads bytes only; not opened");
return;
};
if data.size() > MAX_OPEN_BYTES as f64 {
log::warn!("host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20);
return;
}
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, array.to_vec()));
ctx.request_repaint();
}
+6 -2
View File
@@ -9,8 +9,10 @@
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
//! comments and tracked changes.
//! document to open and stores what WordCraft saves (`host`). A page can also host WordCraft in
//! its own document (no iframe) with `data-host="page"` on the canvas, talking over DOM
//! `CustomEvent`s on the canvas. `&author=` names the user for comments and tracked changes in
//! either host mode.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
@@ -18,6 +20,8 @@ mod prefs;
#[cfg(any(target_arch = "wasm32", test))]
mod save_fallback;
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(target_arch = "wasm32")]
+8 -4
View File
@@ -28,9 +28,12 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
// what it saves (`host`). That page guards leaving it, so this one doesn't.
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing WordCraft, opens documents in
// it and stores what it saves (`host`); unframed there is no parent, so that URL on its
// own stays the normal app with downloads. `data-host="page"` on the canvas opts a page
// in instead: it runs WordCraft in its own document and talks over DOM `CustomEvent`s
// on the canvas (`host`). The host guards leaving the page, so this one doesn't.
let bridge = crate::host::Bridge::detect(&canvas);
let app_bridge = bridge.clone();
let dirty = Rc::new(Cell::new(false));
let saves = AsyncSaves::default();
@@ -73,7 +76,8 @@ pub fn start() {
if let Some(bridge) = &app_bridge {
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
}
let mut app = WordApp::new(session, services);
let control = crate::api::install(cc.egui_ctx.clone());
let mut app = WordApp::new(session, services).with_control(control);
// Pasted pictures wait apart from dropped files: the app inserts one only when the page is the edit target (#1349).
app.services.pasted = Some(pasted);
app.autosave = false;
+12 -4
View File
@@ -22,6 +22,8 @@ wordcraft-control-key = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11", "links"] }
egui = { workspace = true }
serde_json = { workspace = true }
serde = { workspace = true }
tempfile = "3"
log = { workspace = true }
chrono = { workspace = true }
@@ -38,13 +40,14 @@ fmv-macos-events = "=0.1.0"
# The system's Reduce motion setting (System Settings › Accessibility › Display), read through
# NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard,
# at this version; this adds the `NSAccessibility` bindings and, to fit the title bar around the
# traffic lights, the window, button and view ones.
# traffic lights, the window, button and view ones; and `NSPasteboard` for the clipboard's RTF.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder", "NSPasteboard"] }
objc2 = "0.6"
# A copied equation's MathML goes on the pasteboard as `public.mathml.presentation` beside
# arboard's HTML and text (#1297), through NSPasteboard's safe `setString:forType:`. Already in
# the build through arboard, at this version; this adds only the NSString bindings.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString"] }
# the build through arboard, at this version; this adds only the NSString bindings, and NSData
# for the clipboard's RTF (`public.rtf`).
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSData"] }
# Closing the window must quit through NSApplication rather than by dropping the NSWindow
# inside the AppKit run loop (see `schedule_macos_terminate`).
dispatch2 = "0.3"
@@ -58,6 +61,9 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
# types that arboard doesn't read, and the formats offered. Already in the build through arboard's
# `wayland-data-control` feature, at this version.
wl-clipboard-rs = "0.9"
# Another program's RTF on X11 (`text/rtf`), which arboard doesn't read. Already in the build
# through arboard, at this version.
x11rb = "0.13"
[target.'cfg(target_os = "windows")'.dependencies]
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
@@ -73,7 +79,9 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
clipboard-win = { version = "5.4", features = ["std"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Pinned apart from craft-ui, which doesn't depend on it: `printer_queues`, the printer list that
# asks no driver (#5710, storytold/craft-libs#618), and `submit_cancellable` (storytold/craft-libs#622).
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "a3e0b5a9440d18f19422a9dca13516747b7d3181" }
rfd = { workspace = true }
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
# rfd itself already depends on it.
+139
View File
@@ -0,0 +1,139 @@
//! An AppImage's environment inherited from another program (#5653).
//!
//! The AppImage runtime gives the program it starts `APPIMAGE` (the image's file), `APPDIR` (where
//! it is mounted), `ARGV0` and `OWD`, and whatever that program starts inherits them. So an AppImage
//! app that opens a document through `xdg-open` starts an installed WordCraft with *its* `APPIMAGE`,
//! and the self-update, which trusts that variable, replaces the other app's file with WordCraft.
//!
//! This copy is in the image only when its executable is under `APPDIR`. Otherwise the variables
//! are another program's, and the process starts over without them, before anything reads them.
//! (Removing them in place is `unsafe` under this edition, and `exec` keeps the process id, the
//! arguments and the standard streams.)
use std::ffi::OsStr;
use std::io::Write;
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::Command;
/// What the AppImage runtime sets; the same list as the documented workaround on the issue.
const RUNTIME_VARS: [&str; 4] = ["APPIMAGE", "APPDIR", "ARGV0", "OWD"];
/// Whether the executable runs from the image mounted at `appdir`. Anything unclear counts as not:
/// the update then goes through the package or the plain file this copy really is, and at worst
/// says it can't, where the other answer would overwrite a file that isn't WordCraft's.
fn runs_from_image(exe: Option<&Path>, appdir: Option<&OsStr>) -> bool {
let (Some(exe), Some(appdir)) = (exe, appdir) else { return false };
let appdir = Path::new(appdir);
if !appdir.is_absolute() {
return false;
}
let Ok(appdir) = appdir.canonicalize() else { return false };
// No image mounts at the root, where every program on the machine would count as inside it.
appdir.parent().is_some() && exe.starts_with(&appdir)
}
/// Starts this process over without another program's AppImage variables; returns only when there
/// are none to drop (`None`) or starting over failed. Call it first, before any other thread starts.
pub(crate) fn drop_inherited() -> Option<std::io::Error> {
let appimage = std::env::var_os("APPIMAGE")?;
let exe = std::env::current_exe().ok();
if runs_from_image(exe.as_deref(), std::env::var_os("APPDIR").as_deref()) {
return None;
}
// The logger isn't up yet, and wouldn't survive `exec`. Unlike `eprintln!`, never panics.
let _ = writeln!(std::io::stderr(), "wordcraft: ignoring the AppImage {:?} inherited from another program", Path::new(&appimage));
let mut args = std::env::args_os();
// This file, whatever has happened to its path since it started.
let mut again = Command::new("/proc/self/exe");
if let Some(arg0) = args.next() {
again.arg0(arg0);
}
again.args(args);
for var in RUNTIME_VARS {
again.env_remove(var);
}
Some(again.exec())
}
#[cfg(test)]
mod tests {
use super::runs_from_image;
use std::path::{Path, PathBuf};
/// A folder of its own for one test, removed when it ends.
struct Tmp(PathBuf);
impl Tmp {
/// An empty folder named after `test` and this process, made afresh.
fn new(test: &str) -> Tmp {
let dir = std::env::temp_dir().join(format!("wordcraft-appimage-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Tmp(dir)
}
}
impl Drop for Tmp {
/// Removes the folder and what the test left in it.
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// A mount with the executable inside it, by real path (`/tmp` may be a link).
fn mount(root: &Path, name: &str) -> (PathBuf, PathBuf) {
let dir = root.join(name);
std::fs::create_dir_all(dir.join("usr/bin")).unwrap();
let dir = dir.canonicalize().unwrap();
let exe = dir.join("usr/bin/wordcraft");
(dir, exe)
}
#[test]
fn an_executable_in_the_mount_is_the_image() {
let tmp = Tmp::new("own");
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
assert!(runs_from_image(Some(&exe), Some(dir.as_os_str())));
}
#[test]
fn a_link_to_the_mount_is_the_same_mount() {
let tmp = Tmp::new("link");
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
let link = tmp.0.join("link");
std::os::unix::fs::symlink(&dir, &link).unwrap();
assert!(runs_from_image(Some(&exe), Some(link.as_os_str())));
}
#[test]
fn an_installed_executable_is_not_in_another_programs_mount() {
let tmp = Tmp::new("other");
let (other, _) = mount(&tmp.0, ".mount_other");
assert!(!runs_from_image(Some(Path::new("/usr/bin/wordcraft")), Some(other.as_os_str())));
}
/// Paths compare by component: one mount's name can start with another's.
#[test]
fn a_mount_whose_name_starts_the_same_is_another_mount() {
let tmp = Tmp::new("prefix");
let (other, _) = mount(&tmp.0, ".mount_ab");
let (_, exe) = mount(&tmp.0, ".mount_abc");
assert!(!runs_from_image(Some(&exe), Some(other.as_os_str())));
}
#[test]
fn anything_unclear_is_not_the_image() {
let tmp = Tmp::new("unclear");
let (dir, exe) = mount(&tmp.0, ".mount_WordCr");
// No `APPDIR`, one that's gone (the other program has exited) or empty.
assert!(!runs_from_image(Some(&exe), None));
assert!(!runs_from_image(Some(&exe), Some(tmp.0.join("gone").as_os_str())));
assert!(!runs_from_image(Some(&exe), Some("".as_ref())));
// The root, and a relative path that happens to lead here.
assert!(!runs_from_image(Some(&exe), Some("/".as_ref())));
assert!(!runs_from_image(Some(&exe), Some(".".as_ref())));
// No executable path at all.
assert!(!runs_from_image(None, Some(dir.as_os_str())));
}
}
+3 -2
View File
@@ -36,14 +36,15 @@ impl AppleEvents {
/// Open the documents and act on the quit requests that arrived since the last frame. A document
/// opens like File › Open: beside the open ones, in place of an untouched blank one, or, when it
/// is already open, by showing it. Quitting closes the window, which asks about every document
/// is already open, by showing it; a Word template starts a new document based on it, as Word's
/// default verb for one does (#3433). Quitting closes the window, which asks about every document
/// with unsaved changes.
pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) {
for e in inbox.drain() {
match e {
Event::Open(paths) => {
for p in paths.iter().filter_map(|p| p.to_str()) {
match app.open_file(p, None) {
match app.open_from_platform(p, None) {
// Leave Backstage, as opening from the Open dialog does.
Ok(_) => app.ui.backstage = false,
Err(e) => {
+222 -15
View File
@@ -18,6 +18,11 @@
//! they paste math (learn.microsoft.com/office/math/mathml, "Clipboard support"). Pasting into
//! Word and LibreOffice is not yet verified. Linux and the BSDs have no Word, and arboard can't
//! add MIME types of other names, so there the HTML carries the MathML alone.
//!
//! RTF goes beside them where we can add it: Windows ("Rich Text Format") and macOS
//! (`public.rtf`) after arboard's HTML, Wayland (`text/rtf`) in one offer with the text and HTML.
//! On X11 arboard owns the selection and serves only its own targets, so a copy there carries text
//! and HTML only; reading another program's RTF works on all four.
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
@@ -31,17 +36,24 @@ thread_local! {
static WRITER: std::cell::RefCell<Option<arboard::Clipboard>> = const { std::cell::RefCell::new(None) };
}
/// Put `html` on the clipboard with `text` as its plain-text alternative, and `mathml` (a copy of
/// one equation alone; empty for any other copy) in the platform's MathML formats. `false` when
/// the HTML and text couldn't be set (the caller copies the text alone), including a Wayland
/// session without the data-control protocol, where arboard would fall back to X11 and native
/// Wayland programs might not see it. MathML that can't be added is logged: the HTML still
/// carries it.
pub fn set_html(html: &str, text: &str, mathml: &str) -> bool {
/// Put a copy on the clipboard: `text`, with `html` and `rtf` beside it where the platform takes
/// them (an empty one isn't offered), and `mathml` (a copy of one equation alone; empty for any
/// other copy) in the platform's MathML formats. `false` when the text and HTML couldn't be set
/// (the caller copies the text alone), including a Wayland session without the data-control
/// protocol, where arboard would fall back to X11 and native Wayland programs might not see it.
/// RTF or MathML that can't be added is logged: the HTML still carries the copy.
pub fn set_rich(text: &str, html: &str, rtf: &str, mathml: &str) -> bool {
// Wayland: one offer with the text, HTML and RTF (no MathML formats there; see above).
if platform::set_rich(text, html, rtf) {
return true;
}
if !platform::can_set() {
return false;
}
let set = set_html_text(html, text);
if set && !rtf.is_empty() {
platform::add_rtf(rtf);
}
if set && !mathml.is_empty() && mathml.len() <= MAX_PASTE_BYTES {
platform::add_mathml(mathml);
}
@@ -54,7 +66,8 @@ fn set_html_text(html: &str, text: &str) -> bool {
if slot.is_none() {
*slot = open();
}
match slot.as_mut().map(|c| c.set().html(html, Some(text))) {
let set = slot.as_mut().map(|c| if html.is_empty() { c.set_text(text) } else { c.set().html(html, Some(text)) });
match set {
Some(Ok(())) => true,
Some(Err(e)) => {
log::warn!("clipboard: {e}");
@@ -76,8 +89,8 @@ pub fn html() -> Option<String> {
open()?.get().html().ok().filter(|h| h.len() <= MAX_PASTE_BYTES)
}
/// The clipboard's RTF: Windows ("Rich Text Format") and Wayland (`text/rtf`); not offered by
/// arboard, so read here.
/// The clipboard's RTF: Windows ("Rich Text Format"), macOS (`public.rtf`), Wayland and X11
/// (`text/rtf`); not offered by arboard, so read here.
pub fn rtf() -> Option<String> {
let bytes = platform::rtf()?;
if bytes.len() > MAX_PASTE_BYTES {
@@ -176,15 +189,51 @@ mod platform {
)
}
/// Put the copy on a Wayland clipboard in one offer: the text (with the usual plain-text
/// aliases), HTML and RTF, served from a background thread until something else is copied.
/// `false` outside Wayland (X11 goes through arboard) or without the data-control protocol.
pub fn set_rich(text: &str, html: &str, rtf: &str) -> bool {
use wl_clipboard_rs::copy::{MimeSource, MimeType, Options, Source};
if !wayland() {
return false;
}
let offer = |mime: &str, data: &str| MimeSource { source: Source::Bytes(data.as_bytes().into()), mime_type: MimeType::Specific(mime.into()) };
// The text first: the plain-text aliases serve the first text type offered.
let mut sources = vec![offer("text/plain;charset=utf-8", text)];
for (mime, data) in [("text/html", html), ("text/rtf", rtf)] {
if !data.is_empty() {
sources.push(offer(mime, data));
}
}
match Options::new().copy_multi(sources) {
Ok(()) => true,
Err(e) => {
log::warn!("clipboard: {e}");
false
}
}
}
/// Wayland copies carry their RTF already; X11 ones can't (see the module docs).
pub fn add_rtf(_rtf: &str) {}
/// RTF types a clipboard may offer, best first.
const RTF_MIMES: [&str; 3] = ["text/rtf", "application/rtf", "text/richtext"];
/// The RTF type to ask for among those `offered`.
fn rtf_target(offered: &[String]) -> Option<&'static str> {
RTF_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))
}
/// arboard serves its own MIME types only; Word, which reads the MathML formats, isn't here.
pub fn add_mathml(_mathml: &str) {}
pub fn rtf() -> Option<Vec<u8>> {
if !wayland() {
return None;
return std::env::var_os("DISPLAY").is_some().then(|| x11::rtf(super::MAX_PASTE_BYTES)).flatten();
}
let offered = formats();
let mime = ["text/rtf", "application/rtf", "text/richtext"].into_iter().find(|m| offered.iter().any(|o| o == m))?;
let mime = rtf_target(&formats())?;
read(mime, super::MAX_PASTE_BYTES as u64)
}
@@ -196,6 +245,125 @@ mod platform {
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES)
}
/// Whether a property read of `len` bytes, with `bytes_after` left unread, is the whole value
/// and at most `cap` bytes.
fn complete(len: usize, bytes_after: u32, cap: usize) -> bool {
bytes_after == 0 && len <= cap
}
/// Reading another program's RTF on X11, which arboard doesn't offer: ask the CLIPBOARD
/// selection's owner for its targets, then for its RTF, into a property of a hidden window.
mod x11 {
use std::time::{Duration, Instant};
use x11rb::connection::Connection;
use x11rb::protocol::Event;
use x11rb::protocol::xproto::{Atom, AtomEnum, ConnectionExt as _, CreateWindowAux, GetPropertyReply, Window, WindowClass};
use x11rb::rust_connection::RustConnection;
/// How long a paste waits for the selection's owner, in all.
const TIMEOUT: Duration = Duration::from_secs(1);
/// Most targets looked at.
const MAX_TARGETS: usize = 128;
struct Reader {
conn: RustConnection,
win: Window,
clipboard: Atom,
prop: Atom,
deadline: Instant,
}
fn atom(conn: &RustConnection, name: &str) -> Option<Atom> {
conn.intern_atom(false, name.as_bytes()).ok()?.reply().ok().map(|r| r.atom)
}
impl Reader {
fn new() -> Option<Self> {
let (conn, screen) = x11rb::connect(None).ok()?;
let root = conn.setup().roots.get(screen)?.root;
let win = conn.generate_id().ok()?;
conn.create_window(0, win, root, 0, 0, 1, 1, 0, WindowClass::INPUT_ONLY, x11rb::COPY_FROM_PARENT, &CreateWindowAux::new()).ok()?;
let clipboard = atom(&conn, "CLIPBOARD")?;
let prop = atom(&conn, "WORDCRAFT_PASTE")?;
Some(Self { conn, win, clipboard, prop, deadline: Instant::now() + TIMEOUT })
}
/// The selection converted to `target`, at most `cap` bytes; `None` when the owner
/// refuses or doesn't answer in time. A longer value is `None` when `whole`, else
/// its first `cap` bytes.
fn convert(&self, target: Atom, cap: usize, whole: bool) -> Option<GetPropertyReply> {
self.conn.convert_selection(self.win, self.clipboard, target, self.prop, x11rb::CURRENT_TIME).ok()?;
self.conn.flush().ok()?;
loop {
// Checked every time round, so a stream of other events can't keep us here.
if Instant::now() >= self.deadline {
return None;
}
match self.conn.poll_for_event().ok()? {
Some(Event::SelectionNotify(e)) if e.requestor == self.win => {
if e.property == x11rb::NONE {
return None;
}
break;
}
Some(_) => {}
None => std::thread::sleep(Duration::from_millis(5)),
}
}
// In 32-bit units; for a whole value one more than the cap, so a larger one shows
// as `bytes_after`.
let units = u32::try_from(cap / 4 + usize::from(whole)).unwrap_or(u32::MAX);
let r = self.conn.get_property(true, self.win, self.prop, AtomEnum::ANY, 0, units).ok()?.reply().ok()?;
(!whole || super::complete(r.value.len(), r.bytes_after, cap)).then_some(r)
}
}
impl Drop for Reader {
fn drop(&mut self) {
let _ = self.conn.destroy_window(self.win);
let _ = self.conn.flush();
}
}
/// The CLIPBOARD selection's RTF, at most `cap` bytes. Copies too large to send at once
/// arrive in pieces (INCR), which aren't read: `None` then.
pub fn rtf(cap: usize) -> Option<Vec<u8>> {
let r = Reader::new()?;
// The first targets only: a longer list is cut short, not refused.
let targets = r.convert(atom(&r.conn, "TARGETS")?, MAX_TARGETS.saturating_mul(4), false)?;
let cookies: Vec<_> = targets.value32()?.take(MAX_TARGETS).filter_map(|a| r.conn.get_atom_name(a).ok()).collect();
let names: Vec<String> =
cookies.into_iter().filter_map(|c| c.reply().ok()).map(|n| String::from_utf8_lossy(&n.name).into_owned()).collect();
let target = atom(&r.conn, super::rtf_target(&names)?)?;
let incr = atom(&r.conn, "INCR")?;
let reply = r.convert(target, cap, true)?;
(reply.type_ != incr).then_some(reply.value)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rtf_target_prefers_text_rtf() {
let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(rtf_target(&names(&["TARGETS", "text/richtext", "application/rtf", "text/rtf"])), Some("text/rtf"));
assert_eq!(rtf_target(&names(&["text/richtext", "UTF8_STRING"])), Some("text/richtext"));
assert_eq!(rtf_target(&names(&["text/html", "text/rtf;charset=x"])), None);
assert_eq!(rtf_target(&[]), None);
}
#[test]
fn property_reads_must_be_whole_and_capped() {
assert!(complete(10, 0, 10));
assert!(!complete(11, 0, 10), "over the cap");
assert!(!complete(8, 4, 10), "more left to read");
assert!(complete(0, 0, 0));
}
}
}
#[cfg(target_os = "windows")]
@@ -251,6 +419,23 @@ mod platform {
true
}
/// arboard sets the text and HTML.
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
false
}
/// Add "Rich Text Format" to what arboard just set, NUL-terminated as is usual.
pub fn add_rtf(rtf: &str) {
let Some(format) = raw::register_format("Rich Text Format") else { return };
let Ok(_open) = Clipboard::new_attempts(10) else { return };
let mut bytes = Vec::with_capacity(rtf.len().saturating_add(1));
bytes.extend_from_slice(rtf.as_bytes());
bytes.push(0);
if let Err(e) = raw::set_without_clear(format.get(), &bytes) {
log::warn!("clipboard RTF: {e}");
}
}
/// Add the MathML to what arboard just set, as "MathML" and "MathML Presentation": UTF-16LE
/// text (with its terminating NUL, as Windows text formats have).
pub fn add_mathml(mathml: &str) {
@@ -276,14 +461,18 @@ const MATHML_FORMATS: [&str; 2] = ["MathML", "MathML Presentation"];
#[cfg(target_os = "macos")]
mod platform {
use objc2_app_kit::NSPasteboard;
use objc2_foundation::NSString;
use objc2_foundation::{NSData, NSString};
/// RTF's pasteboard type (`NSPasteboardTypeRTF`, whose extern static needs unsafe to read).
const RTF: &str = "public.rtf";
pub fn formats() -> Vec<String> {
Vec::new()
}
pub fn rtf() -> Option<Vec<u8>> {
None
let data = NSPasteboard::generalPasteboard().dataForType(&NSString::from_str(RTF))?;
(data.len() <= super::MAX_PASTE_BYTES).then(|| data.to_vec())
}
pub fn encoded_picture() -> Option<Vec<u8>> {
@@ -294,6 +483,18 @@ mod platform {
true
}
/// arboard sets the text and HTML.
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
false
}
/// Add RTF to the pasteboard item arboard just wrote (it cleared the pasteboard first).
pub fn add_rtf(rtf: &str) {
if !NSPasteboard::generalPasteboard().setData_forType(Some(&NSData::with_bytes(rtf.as_bytes())), &NSString::from_str(RTF)) {
log::warn!("clipboard: the RTF couldn't be added");
}
}
/// Add the MathML to the pasteboard item arboard just wrote, as the MathML types (W3C MathML 3
/// §6.2.4): presentation markup, and the generic type for readers that only know that one.
pub fn add_mathml(mathml: &str) {
@@ -329,5 +530,11 @@ mod platform {
true
}
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
false
}
pub fn add_rtf(_rtf: &str) {}
pub fn add_mathml(_mathml: &str) {}
}
+183 -11
View File
@@ -9,6 +9,7 @@ use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::{Duration, Instant};
@@ -21,6 +22,38 @@ const MAX_LINE: usize = 1 << 20;
/// A connection that sends no authorised request in this time is closed.
const FIRST_REQUEST: Duration = Duration::from_secs(30);
/// How long a caller waits for the UI to answer a request. A request still queued after this
/// is cancelled and never runs.
const REPLY_WAIT: Duration = Duration::from_secs(60);
/// Connections that have not yet sent an authorised request are limited to this many; more are
/// closed at once. Each one holds a thread until its first request or [`FIRST_REQUEST`].
const MAX_UNAUTHENTICATED: usize = 64;
/// Counts one unauthenticated connection until it authenticates or ends.
struct Admission(Option<Arc<AtomicUsize>>);
impl Admission {
fn enter(count: &Arc<AtomicUsize>, max: usize) -> Option<Self> {
if count.fetch_add(1, Ordering::SeqCst) >= max {
count.fetch_sub(1, Ordering::SeqCst);
return None;
}
Some(Self(Some(count.clone())))
}
fn release(&mut self) {
if let Some(c) = self.0.take() {
c.fetch_sub(1, Ordering::SeqCst);
}
}
}
impl Drop for Admission {
fn drop(&mut self) {
self.release();
}
}
/// The key file of a running control server; [`KeyFile::remove`] it when the window closes.
pub struct KeyFile(PathBuf);
@@ -37,6 +70,15 @@ impl KeyFile {
/// cannot be bound or there is no random key (the control channel is off); the key file is
/// `None` when it could not be written (requests are then refused).
pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
start_with(port, ctx, settings, |listener, ctx, key| serve_on(listener, ctx, key, FIRST_REQUEST))
}
fn start_with(
port: u16,
ctx: egui::Context,
settings: Option<&Path>,
serve: impl FnOnce(TcpListener, egui::Context, String) -> std::io::Result<Receiver<ControlRequest>>,
) -> Option<(Receiver<ControlRequest>, Option<KeyFile>)> {
let listener = match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => l,
Err(e) => {
@@ -67,11 +109,21 @@ pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(
None
}
};
let rx = match serve(listener, ctx, key) {
Ok(rx) => rx,
Err(e) => {
if let Some(f) = &key_file {
f.remove();
}
log::error!("control server off: cannot start the accept thread ({e})");
return None;
}
};
match &key_file {
Some(f) => log::info!("control server listening on 127.0.0.1:{port}, key in {}", f.path().display()),
None => log::info!("control server listening on 127.0.0.1:{port}"),
}
Some((serve_on(listener, ctx, key, FIRST_REQUEST), key_file))
Some((rx, key_file))
}
/// A new key: 32 random bytes from the operating system, as hex.
@@ -103,16 +155,47 @@ fn keys_match(given: &str, key: &str) -> bool {
!b.is_empty() && a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> Receiver<ControlRequest> {
fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Duration) -> std::io::Result<Receiver<ControlRequest>> {
serve_with(listener, ctx, key, Limits { first, wait: REPLY_WAIT, max_unauthenticated: MAX_UNAUTHENTICATED })
}
/// The time and count budgets of a server (small ones in tests).
#[derive(Clone, Copy)]
struct Limits {
first: Duration,
wait: Duration,
max_unauthenticated: usize,
}
fn serve_with(listener: TcpListener, ctx: egui::Context, key: String, limits: Limits) -> std::io::Result<Receiver<ControlRequest>> {
serve_with_spawner(listener, ctx, key, limits, |accept| std::thread::Builder::new().name("control-accept".into()).spawn(accept).map(|_| ()))
}
fn serve_with_spawner(
listener: TcpListener,
ctx: egui::Context,
key: String,
limits: Limits,
spawn: impl FnOnce(Box<dyn FnOnce() + Send>) -> std::io::Result<()>,
) -> std::io::Result<Receiver<ControlRequest>> {
let (tx, rx) = channel::<ControlRequest>();
let key: Arc<str> = key.into();
std::thread::spawn(move || {
let unauthenticated = Arc::new(AtomicUsize::new(0));
let accept = move || {
for stream in listener.incoming().flatten() {
let Some(admission) = Admission::enter(&unauthenticated, limits.max_unauthenticated) else {
log::warn!("control server: too many unauthenticated connections, one closed");
continue;
};
let (tx, ctx, key) = (tx.clone(), ctx.clone(), key.clone());
std::thread::spawn(move || serve(stream, tx, ctx, &key, first));
// A thread that cannot start closes this connection only (the guard releases its slot).
if let Err(e) = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx, &key, limits, admission)) {
log::warn!("control server: no thread for a connection: {e}");
}
}
});
rx
};
spawn(Box::new(accept))?;
Ok(rx)
}
/// The read side of a connection with an optional overall deadline: each read waits only for
@@ -175,11 +258,11 @@ fn discard_rest_of_line(r: &mut BufReader<Timed>) {
}
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, first: Duration) {
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key: &str, limits: Limits, mut admission: Admission) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
// One overall deadline from accept to the first authorised request; after it, none.
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + first) });
let mut reader = BufReader::new(Timed { s: read, deadline: Some(Instant::now() + limits.first) });
let mut authorised = false;
// The key an `auth` request gave this connection.
let mut conn_key: Option<String> = None;
@@ -228,6 +311,7 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
}
if !authorised {
authorised = true;
admission.release();
reader.get_mut().set_deadline(None);
}
if method == "auth" {
@@ -237,12 +321,18 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context, key:
}
continue;
}
let (req, rrx) = ControlRequest::new(method, params);
let (req, rrx, waiter) = ControlRequest::with_waiter(method, params);
if tx.send(req).is_err() {
break;
}
ctx.request_repaint();
let r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
let r = rrx.recv_timeout(limits.wait).unwrap_or_else(|_| {
if waiter.cancel() {
json!({"ok": false, "error": "timeout before execution: the request was not run and never will be"})
} else {
json!({"ok": false, "error": "timeout: outcome unknown, the request had started when the wait ended; inspect the document to check"})
}
});
if !reply(r) {
break;
}
@@ -260,7 +350,7 @@ mod tests {
fn boot_with(first: Duration) -> (u16, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first))
(port, serve_on(listener, egui::Context::default(), KEY.to_string(), first).unwrap())
}
fn boot() -> (u16, Receiver<ControlRequest>) {
@@ -512,6 +602,40 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_accept_thread_spawn_failure_aborts_start_and_removes_the_key() {
let dir = temp_dir("spawn-failure");
let path = wordcraft_control_key::key_path(&dir, 0);
let mut bound = None;
let result = start_with(0, egui::Context::default(), Some(&dir), |listener, ctx, key| {
bound = Some(listener.local_addr().unwrap());
assert_eq!(std::fs::read_to_string(&path).unwrap(), key, "startup wrote the key before spawning");
serve_with_spawner(listener, ctx, key, small(150, 8), |accept| {
drop(accept);
Err(std::io::Error::other("injected accept thread spawn failure"))
})
});
assert!(result.is_none(), "a server without its accept thread must not report success");
assert!(!path.exists(), "failed startup must remove its published key");
let _listener = TcpListener::bind(bound.unwrap()).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_complete_json_request_at_eof_can_reach_the_ui() {
// This is why a bridge write failure before the newline is outcome-unknown.
let (port, rx) = boot();
let mut c = Conn::open(port);
let body = json!({"id": 1, "key": KEY, "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "once"}}});
c.1.write_all(body.to_string().as_bytes()).unwrap();
c.1.shutdown(std::net::Shutdown::Write).unwrap();
let req = answer(&rx);
assert_eq!(req.params["params"]["text"], "once");
assert_eq!(c.recv()["ok"], true);
assert!(c.closed());
assert!(rx.try_recv().is_err());
}
#[test]
fn a_stale_key_file_is_replaced() {
let dir = temp_dir("stale");
@@ -532,4 +656,52 @@ mod tests {
}
let _ = std::fs::remove_dir_all(&dir);
}
fn boot_limits(limits: Limits) -> (u16, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
(port, serve_with(listener, egui::Context::default(), KEY.to_string(), limits).unwrap())
}
fn small(wait_ms: u64, max_unauthenticated: usize) -> Limits {
Limits { first: FIRST_REQUEST, wait: Duration::from_millis(wait_ms), max_unauthenticated }
}
#[test]
fn a_request_that_timed_out_in_the_queue_never_runs() {
let (port, rx) = boot_limits(small(150, 8));
let mut c = Conn::open(port);
let r = c.call(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
assert_eq!(r["ok"], false);
assert!(r["error"].as_str().unwrap_or("").starts_with("timeout before execution"), "{r}");
// The UI gets to it late: it must not run it.
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(!req.start(), "a cancelled request was started");
}
#[test]
fn a_request_that_started_before_the_timeout_reports_an_unknown_outcome() {
let (port, rx) = boot_limits(small(150, 8));
let mut c = Conn::open(port);
c.send(&format!(r#"{{"id":1,"key":"{KEY}","method":"engine.execute","params":{{"command":"text.insert"}}}}"#));
let req = rx.recv_timeout(Duration::from_secs(5)).unwrap();
assert!(req.start());
let r = c.recv();
assert!(r["error"].as_str().unwrap_or("").contains("outcome unknown"), "{r}");
}
#[test]
fn unauthenticated_connections_are_limited() {
let (port, rx) = boot_limits(small(5_000, 2));
let mut idle = [Conn::open(port), Conn::open(port)];
// Give the server time to count both.
std::thread::sleep(Duration::from_millis(200));
assert!(Conn::open(port).closed(), "the third idle connection was admitted");
// One authenticates: its slot is free again.
assert_eq!(idle[0].call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
std::thread::sleep(Duration::from_millis(200));
let mut again = Conn::open(port);
assert_eq!(again.call(&format!(r#"{{"id":1,"method":"auth","key":"{KEY}"}}"#))["ok"], true);
assert!(rx.try_recv().is_err());
}
}
+50 -3
View File
@@ -15,9 +15,25 @@ use eframe::{
/// compiler aborts on them, the device is lost and eframe exits before the window opens. WordCraft
/// issues no indirect draws or dispatches, so Intel Macs skip them; every other GPU keeps wgpu's
/// default. Same fix as PhotoCraft's storytold/PhotoCraft#3171.
///
/// `x86_64` is the build's architecture. An x86_64 build also runs on Apple silicon under Rosetta
/// (#5199), so it skips only when the machine itself isn't Apple silicon.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool {
macos && intel
pub fn skips_indirect_validation(macos: bool, x86_64: bool) -> bool {
// Short-circuits: only x86_64 macOS builds ask the system.
macos && x86_64 && !runs_on_apple_silicon()
}
/// Whether the machine is Apple silicon, also when this process is translated by Rosetta:
/// `hw.optional.arm64` describes the hardware, not the process. Assumes not (an Intel Mac) when
/// the system can't tell.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn runs_on_apple_silicon() -> bool {
cfg!(target_os = "macos")
&& std::process::Command::new("/usr/sbin/sysctl")
.args(["-n", "hw.optional.arm64"])
.output()
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
}
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
@@ -181,7 +197,8 @@ mod tests {
#[test]
fn intel_macs_skip_the_indirect_validation_shaders() {
// #2652: Metal's Intel compiler aborted on them, losing the device before the window opened.
assert!(skips_indirect_validation(true, true));
// An x86_64 build on Apple silicon (Rosetta) isn't an Intel Mac (#5199).
assert_eq!(skips_indirect_validation(true, true), !host_is_apple_silicon());
// Apple silicon, Windows and Linux keep wgpu's validation.
for (macos, intel) in [(true, false), (false, true), (false, false)] {
assert!(!skips_indirect_validation(macos, intel), "macos={macos} intel={intel}");
@@ -205,6 +222,36 @@ mod tests {
}
}
/// Whether the machine running the tests is Apple silicon, whatever this build's architecture.
fn host_is_apple_silicon() -> bool {
cfg!(target_os = "macos")
&& std::process::Command::new("/usr/sbin/sysctl")
.args(["-n", "hw.optional.arm64"])
.output()
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
}
/// #5199: an x86_64 build running under Rosetta on Apple silicon isn't an Intel Mac and keeps
/// wgpu's indirect-call validation.
#[test]
fn x86_64_builds_on_apple_silicon_keep_indirect_validation() {
if !host_is_apple_silicon() {
return;
}
// What main.rs passes from an x86_64 build.
assert!(!skips_indirect_validation(true, true), "Rosetta build on Apple silicon must keep validation");
let mut options = NativeOptions::default();
let WgpuSetup::CreateNew(before) = &options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
let before = before.instance_descriptor.flags;
configure_indirect_validation(&mut options, skips_indirect_validation(true, true));
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert_eq!(create.instance_descriptor.flags, before, "indirect-call validation kept");
}
fn configured_backends(backends: Backends) -> Backends {
let mut options = NativeOptions::default();
configure_backends(&mut options, backends);
+162 -36
View File
@@ -10,6 +10,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod animations;
#[cfg(target_os = "linux")]
mod appimage_env;
#[cfg(target_os = "macos")]
mod apple_events;
mod clipboard;
@@ -27,7 +29,7 @@ mod traffic_lights;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{
Services, UiState, WordApp,
window_geometry::{RawWindowState, WindowGeometry, take_rescue},
window_geometry::{RawWindowState, WindowGeometry, reachable, take_rescue},
};
/// The app, the restored window geometry (or, with none, the default: no position) until the
@@ -54,13 +56,21 @@ impl eframe::App for App {
apple_events::poll(&self.5, &mut self.0, ctx);
self.0.logic(ctx);
self.3.show(frame);
let window = self.4;
let mut window = self.4;
// Only until the off-screen check below has run: it is the one reader.
if self.1.is_some() {
let (on_a_monitor, primary_monitor_rect) = monitor_geometry(frame, ctx.pixels_per_point());
window.on_a_monitor = on_a_monitor;
window.primary_monitor_rect = primary_monitor_rect;
}
// A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit
// there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first
// knows the monitor: the off-screen check below takes the pending geometry then.
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
let b = self.0.window_builder().clone().with_inner_size(size);
self.0.set_window_builder(b);
}
// The geometry saved for the next start is the active window's.
let prev = self.0.ui.window;
@@ -126,6 +136,37 @@ impl eframe::App for App {
}
}
/// Whether a grippable part of the window is on a monitor, and the primary monitor bounds.
/// Winit reports physical pixels; egui positions use points. `None` where the platform cannot
/// report the window or primary monitor (Wayland, headless).
fn monitor_geometry(frame: &eframe::Frame, pixels_per_point: f32) -> (Option<bool>, Option<egui::Rect>) {
let Some(w) = frame.winit_window() else {
return (None, None);
};
if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 {
return (None, None);
}
let Some(pos) = w.outer_position().ok() else {
return (None, None);
};
let size = w.outer_size();
let physical_rect = |(x, y): (i32, i32), (width, height): (u32, u32)| {
egui::Rect::from_min_size(egui::pos2(x as f32, y as f32), egui::vec2(width as f32, height as f32))
};
let monitors: Vec<egui::Rect> =
w.available_monitors().map(|m| physical_rect((m.position().x, m.position().y), (m.size().width, m.size().height))).collect();
let on_a_monitor = (!monitors.is_empty()).then(|| reachable(physical_rect((pos.x, pos.y), (size.width, size.height)), &monitors));
let primary_monitor_rect = w.primary_monitor().map(|m| {
let pos = m.position();
let size = m.size();
egui::Rect::from_min_size(
egui::pos2(pos.x as f32, pos.y as f32) / pixels_per_point,
egui::vec2(size.width as f32, size.height as f32) / pixels_per_point,
)
});
(on_a_monitor, primary_monitor_rect)
}
/// Quit the way Command-Q does, on the next turn of the main queue.
///
/// Called when the window is closing. Doing it on the next turn lets the close
@@ -216,35 +257,66 @@ fn load_user_fonts() {
}
}
/// The saved window geometry, read before the window opens (`load_prefs` runs after).
fn saved_window() -> Option<WindowGeometry> {
/// The saved preferences, read before the window opens for its geometry and, on Linux and the
/// BSDs, the display server (`load_prefs` runs after).
fn saved_ui() -> Option<UiState> {
if !prefs_enabled() {
return None;
}
let bytes = std::fs::read(prefs_path()?).ok()?;
UiState::from_json(&bytes)?.window?.sanitized()
UiState::from_json(&bytes)
}
/// Whether the window runs through XWayland (X11) rather than native Wayland, and the log line
/// saying why. `WORDCRAFT_WAYLAND` wins (`0`, `false`, `no` or `off` asks for X11, any other value
/// for Wayland), then File › Options › Advanced › Linux display server (#2184). Automatic is X11
/// whenever there's an X server: winit has no file drag-and-drop on Wayland, so dropping files from
/// the file manager showed a "no" cursor. X11 is only forced with an X server (`DISPLAY`), and
/// unforced winit picks Wayland when it's there and X11 otherwise, so a choice that isn't
/// available falls back to the other instead of failing to start.
#[cfg(all(unix, not(target_os = "macos")))]
fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Option<std::ffi::OsString>) -> (bool, String) {
use wordcraft_ui_egui::DisplayServer;
let set = |name: &str| env(name).is_some_and(|v| !v.is_empty());
let (wanted, by) = match env("WORDCRAFT_WAYLAND") {
Some(v) if matches!(v.to_string_lossy().trim().to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off") => {
(DisplayServer::X11, "WORDCRAFT_WAYLAND")
}
Some(_) => (DisplayServer::Wayland, "WORDCRAFT_WAYLAND"),
None => (setting, "the setting"),
};
let x11 = wanted != DisplayServer::Wayland && set("DISPLAY");
let runs = if x11 {
"X11"
} else if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
"native Wayland"
} else if set("DISPLAY") {
"X11 (no Wayland)"
} else {
"neither (no Wayland or X server found)"
};
(x11, format!("display: {runs}; {} asks for {}", by, wanted.code()))
}
fn save_prefs(app: &WordApp) {
if let Err(e) = try_save_prefs(app) {
log::warn!("preferences: {e}");
}
}
/// Saves the preferences and says why when it could not. `Ok(false)`: nothing was written
/// because preferences are switched off or there is no settings folder.
fn try_save_prefs(app: &WordApp) -> Result<bool, String> {
if !prefs_enabled() {
return;
return Ok(false);
}
if let Some(p) = prefs_path() {
if let Some(dir) = p.parent()
&& let Err(e) = std::fs::create_dir_all(dir)
{
log::warn!("preferences: cannot create {}: {e}", dir.display());
return;
}
match serde_json::to_vec_pretty(&app.prefs()) {
Ok(bytes) => {
if let Err(e) = write_prefs(&p, &bytes) {
log::warn!("preferences: cannot save {}: {e}", p.display());
}
}
Err(e) => log::warn!("preferences: cannot serialize: {e}"),
}
let Some(p) = prefs_path() else { return Ok(false) };
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
}
let bytes = serde_json::to_vec_pretty(&app.prefs()).map_err(|e| format!("cannot serialize: {e}"))?;
write_prefs(&p, &bytes).map_err(|e| format!("cannot save {}: {e}", p.display()))?;
Ok(true)
}
/// Writes `bytes` to a sibling temporary file and renames it over `path`, so a failed or
@@ -268,6 +340,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
file_dialog: Some(file_dialog),
printers: Some(print::discover()),
print_native: Some(print::submit()),
cancel_print: Some(print::cancel()),
clipboard_picture: Some(Box::new(clipboard::picture)),
make_default_app: default_apps::hook(),
// Personal templates live in `Templates` in the settings folder (#2852), made on first save.
@@ -275,9 +348,10 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
animations_enabled: Some(animations::hook(ctx)),
clipboard_text: Some(Box::new(clipboard::text)),
clipboard_html: Some(Box::new(clipboard::html)),
set_clipboard_html: Some(Box::new(clipboard::set_html)),
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
clipboard_rtf: Some(Box::new(clipboard::rtf)),
clipboard_formats: Some(Box::new(clipboard::formats)),
save_prefs: Some(Box::new(try_save_prefs)),
..Default::default()
}
}
@@ -351,14 +425,15 @@ fn without_launch_args(args: Vec<String>) -> Vec<String> {
/// Open a file whose name isn't valid Unicode (#1440). `file.open` takes its path as text, so it
/// gets the file's bytes under the lossy name, and the document then keeps the real path to save to.
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB.
/// The bytes are read as `file.open` reads a path: only a regular file, and at most 2 GB. A Word
/// template starts a new document instead ([`WordApp::open_from_platform`]), which has no path.
fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
let name = path.to_string_lossy();
let opened = wordcraft_engine::io::read_document(path)
.and_then(|bytes| app.run("file.open", serde_json::json!({"path": name, "data": wordcraft_engine::cmd::insert::base64_encode(&bytes)})));
.and_then(|bytes| app.open_from_platform(&name, Some(&wordcraft_engine::cmd::insert::base64_encode(&bytes))));
match opened {
// Not when it waits for a password: that open names the document itself.
Ok(r) if r.get("pending").is_none() => app.session.path = Some(path.into()),
Ok(r) if r.get("pending").is_none() && !wordcraft_ui_egui::documents::is_word_template(&name) => app.session.path = Some(path.into()),
Ok(_) => {}
Err(e) => {
app.report_open_failure(&name, &e);
@@ -370,14 +445,31 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
/// geometry, opens requested documents or the Start screen, starts the control server when
/// asked, and runs the eframe event loop.
fn main() -> eframe::Result {
#[cfg(windows)]
if let Some(code) = print::isolated::helper_mode() {
std::process::exit(code);
}
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&wordcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// Linux: before anything reads another program's AppImage variables, or a thread starts (#5653).
#[cfg(target_os = "linux")]
let start_over_failed = appimage_env::drop_inherited();
// Still carrying them, this process must never update itself: the update would replace the
// other program's file.
#[cfg(target_os = "linux")]
let updates_unsafe = start_over_failed.is_some();
#[cfg(not(target_os = "linux"))]
let updates_unsafe = false;
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
logging::install_panic_hook();
#[cfg(target_os = "linux")]
if let Some(e) = start_over_failed {
log::error!("couldn't start again without another program's AppImage variables, so the self-update is off in this window: {e}");
}
let mut control_port: Option<u16> = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut sample = false;
@@ -449,25 +541,28 @@ fn main() -> eframe::Result {
// FXC, which needs no DLL of ours (#603; see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652).
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652). An x86_64
// build under Rosetta on Apple silicon keeps them (#5199).
#[cfg(target_os = "macos")]
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
load_user_fonts();
// Windows opened for further documents start like the first one, before its saved place.
let window_builder = options.viewport.clone();
let restored = saved_window();
let saved = saved_ui();
let restored = saved.as_ref().and_then(|ui| ui.window?.sanitized());
if let Some(window) = restored {
options.viewport = window.apply(options.viewport);
}
#[cfg(all(unix, not(target_os = "macos")))]
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; WORDCRAFT_WAYLAND=1
// keeps the native Wayland backend.
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("WORDCRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
{
let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name));
log::info!("{why}");
if x11 {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
}
// Registered before the event loop starts, so it catches the Finder event that launched us as
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
@@ -489,6 +584,10 @@ fn main() -> eframe::Result {
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
app.set_startup_update_check(true);
load_prefs(&mut app);
// After the preferences, so the reason it gives is in the interface language.
if updates_unsafe {
app.disable_self_update();
}
load_user_dictionary(&mut app);
// Language packs from the suite's font store; an Install click is the person's own.
wordcraft_engine::packs::use_font_store(true);
@@ -528,8 +627,9 @@ fn main() -> eframe::Result {
if files.is_empty() && other_files.is_empty() && !sample && control_port.is_none() {
app.start_screen();
}
// A Word template named here starts a new document based on it (#3433).
for f in files {
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
if let Err(e) = app.open_from_platform(&f, None) {
app.report_open_failure(&f, &e);
}
}
@@ -581,6 +681,32 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
/// #2184: `WORDCRAFT_WAYLAND` beats the setting, Automatic keeps XWayland where there's an X
/// server, and a choice that isn't available falls back to the other.
#[test]
fn the_display_server_follows_the_variable_then_the_setting() {
use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11};
let x11 = |setting, vars: &[(&str, &str)]| {
uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v))).0
};
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
assert!(x11(Auto, &both), "Automatic keeps XWayland, for file drag-and-drop");
assert!(x11(X11, &both));
assert!(!x11(Wayland, &both));
for (value, wants_x11) in [("1", false), ("", false), ("yes", false), ("0", true), (" Off ", true), ("FALSE", true), ("no", true)] {
let vars = [both[0], both[1], ("WORDCRAFT_WAYLAND", value)];
for setting in [Auto, Wayland, X11] {
assert_eq!(x11(setting, &vars), wants_x11, "WORDCRAFT_WAYLAND={value:?} beats {setting:?}");
}
}
// Without an X server X11 isn't forced: winit connects to Wayland instead of failing.
let wayland_only = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")];
assert!(!x11(X11, &wayland_only) && !x11(Auto, &wayland_only));
// Without Wayland, Wayland isn't forced either: unforced winit picks X11.
let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()));
assert!(!forced && why.contains("X11 (no Wayland)"), "{why}");
}
/// 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]
+116 -20
View File
@@ -1,13 +1,21 @@
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
#[cfg(any(windows, test))]
pub(crate) mod isolated;
use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
/// Returns a discovery callback that enumerates spooler printers on a named worker thread and
/// sends the list through a channel, keeping spooler calls off the UI thread.
///
/// The Print panel needs only names and the default, so the list comes from
/// `craft_print::printer_queues`, which asks no printer driver anything: on Windows a networked
/// printer in power save can hold a driver's capability query for a long time, and the list must
/// not wait on it (#5710). Submission still checks the job's options with the driver.
pub fn discover() -> Discover {
Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
let printers = craft_print::printers()
let printers = craft_print::printer_queues()
.into_iter()
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
.collect();
@@ -17,22 +25,59 @@ pub fn discover() -> Discover {
})
}
/// A submission worker and how to stop its job: `true` when it will never be sent, `false` when
/// it was already being sent.
struct Job {
worker: std::thread::JoinHandle<()>,
cancel: Box<dyn Fn() -> bool + Send>,
// Acknowledged helper cancellation permits joining it to reap the process and its input PDF.
isolated: bool,
}
// Keep submission workers reachable until normal app shutdown; dropping a JoinHandle would
// let process exit end an in-process Windows job or the CUPS hand-off before the spooler has it.
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
static JOBS: std::sync::Mutex<Vec<Job>> = std::sync::Mutex::new(Vec::new());
fn jobs() -> std::sync::MutexGuard<'static, Vec<Job>> {
JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Waits for the jobs already being sent, so the spooler has them before the process exits. A
/// job still waiting (on a sleeping printer's driver, say: #5710) is cancelled instead, so it is
/// never sent and quitting doesn't wait on the driver.
pub fn finish_pending_jobs() {
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
for worker in workers {
if worker.join().is_err() {
let pending = std::mem::take(&mut *jobs());
// Cancel them all first: a job left waiting while another is joined could start sending.
let finishing: Vec<_> = pending
.into_iter()
.filter(|job| {
let cancelled = (job.cancel)();
job.isolated || !cancelled
})
.collect();
for job in finishing {
if job.worker.join().is_err() {
log::error!("the print worker stopped unexpectedly during shutdown");
}
}
}
/// The Print page's Cancel: stops the job being submitted (the Print page submits one at a
/// time, the latest) unless it has started reaching the spooler. `true` when it will never be
/// sent. A job that has finished sending answers `false`, so its receipt is still shown.
pub fn cancel() -> Box<dyn Fn() -> bool> {
Box::new(|| jobs().last().is_none_or(|job| (job.cancel)()))
}
/// Submit off the UI thread, isolating Windows drivers while retaining CUPS token cancellation.
pub fn submit() -> Submit {
Box::new(|bytes, settings: Settings, collate, options| {
let (sender, receiver) = std::sync::mpsc::channel();
#[cfg(not(windows))]
let cancel = craft_print::Cancel::new();
#[cfg(windows)]
let cancel = isolated::Submission::new();
let token = cancel.clone();
let worker = std::thread::Builder::new().name("print-submit".into()).spawn(move || {
let job = craft_print::Job {
printer: Some(settings.printer),
@@ -48,12 +93,16 @@ pub fn submit() -> Submit {
title: "WordCraft document".into(),
options,
};
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
#[cfg(not(windows))]
let sent = craft_print::submit_cancellable(&bytes, &job, &mut |_, _| {}, &token).map_err(|error| error.to_string());
#[cfg(windows)]
let sent = token.submit(&bytes, &job);
let _ = sender.send(sent);
});
if let Ok(worker) = worker {
let mut jobs = JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
jobs.retain(|job| !job.is_finished());
jobs.push(worker);
let mut jobs = jobs();
jobs.retain(|job| !job.worker.is_finished());
jobs.push(Job { worker, cancel: Box::new(move || cancel.cancel()), isolated: cfg!(windows) });
}
receiver
})
@@ -62,20 +111,67 @@ pub fn submit() -> Submit {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shutdown_waits_for_pending_submission_workers() {
/// A worker that waits until released, then reports that it finished.
fn worker() -> (std::thread::JoinHandle<()>, std::sync::mpsc::Sender<()>, std::sync::mpsc::Receiver<()>) {
let (release, wait) = std::sync::mpsc::channel();
let (completed, completion) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
wait.recv().unwrap();
completed.send(()).unwrap();
let _ = wait.recv();
let _ = completed.send(());
});
JOBS.lock().unwrap().push(worker);
let shutdown = std::thread::spawn(finish_pending_jobs);
assert!(completion.try_recv().is_err(), "the print worker is still pending");
release.send(()).unwrap();
shutdown.join().unwrap();
completion.recv().unwrap();
assert!(JOBS.lock().unwrap().is_empty(), "workers were joined before shutdown returned");
(worker, release, completion)
}
/// A cancel action that records when it was asked and answers `stops`.
fn asked(stops: bool) -> (Box<dyn Fn() -> bool + Send>, std::sync::Arc<std::sync::atomic::AtomicBool>) {
let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let set = flag.clone();
let cancel = Box::new(move || {
set.store(true, std::sync::atomic::Ordering::SeqCst);
stops
});
(cancel, flag)
}
/// Shutdown waits for a job already being sent, so the spooler has it before the process
/// exits, but not for one still waiting on a driver (a sleeping printer, #5710): that one is
/// cancelled, so it is never sent, and quitting doesn't hang on it. Every job is cancelled
/// before any is waited for, so a waiting job can't start sending meanwhile. The Print page's
/// Cancel asks only the latest job. One test, because JOBS is global.
#[test]
fn shutdown_waits_for_jobs_being_sent_and_cancels_the_rest() {
let (sending, release_sending, sent) = worker();
let (stuck, release_stuck, _) = worker();
let (stop_sending, sending_asked) = asked(false);
let (stop_stuck, cancelled) = asked(true);
// The job being sent comes first, so a one-at-a-time shutdown would join it before
// cancelling the waiting one.
*JOBS.lock().unwrap() =
vec![Job { worker: sending, cancel: stop_sending, isolated: false }, Job { worker: stuck, cancel: stop_stuck, isolated: false }];
// Cancel from the Print page asks the latest job only.
assert!(cancel()(), "the latest job was still waiting: never sent");
assert!(!sending_asked.load(std::sync::atomic::Ordering::SeqCst), "an earlier job isn't the one being submitted");
cancelled.store(false, std::sync::atomic::Ordering::SeqCst);
let (finished, shutdown) = std::sync::mpsc::channel();
std::thread::spawn(move || {
finish_pending_jobs();
let _ = finished.send(());
});
let wait = std::time::Duration::from_millis(200);
assert!(shutdown.recv_timeout(wait).is_err(), "shutdown waits for the job being sent");
assert!(cancelled.load(std::sync::atomic::Ordering::SeqCst), "the waiting job was cancelled before the wait");
release_sending.send(()).unwrap();
assert!(shutdown.recv_timeout(std::time::Duration::from_secs(10)).is_ok(), "shutdown doesn't wait for the stuck job");
sent.recv().unwrap();
assert!(JOBS.lock().unwrap().is_empty(), "shutdown took every job");
// A job that has already been sent answers false, so the Print page keeps its receipt.
let (done, release_done, finished) = worker();
release_done.send(()).unwrap();
finished.recv().unwrap();
*JOBS.lock().unwrap() = vec![Job { worker: done, cancel: Box::new(|| false), isolated: false }];
assert!(!cancel()(), "a sent job isn't reported as cancelled");
JOBS.lock().unwrap().clear();
// The stuck worker was left behind without blocking shutdown; let it end.
release_stuck.send(()).unwrap();
}
}
+592
View File
@@ -0,0 +1,592 @@
//! One Windows driver submission per process: a cancelled driver cannot retain another
//! job's provider lock. The child acknowledges its Cancel token before it exits.
use std::{
io::{BufRead, BufReader, Read, Write},
path::Path,
process::{Child, ChildStdin, Command, Stdio},
sync::{Arc, Condvar, Mutex},
time::Duration,
};
use serde::{Deserialize, Serialize};
#[cfg(windows)]
const FLAG: &str = "--wordcraft-print-worker";
const PREFIX: &str = "WORDCRAFT_PRINT:";
const MAX_MESSAGE: u64 = 64 * 1024;
const MAX_PDF: u64 = 512 * 1024 * 1024;
const CANCELLED: &str = "the print job was cancelled before anything was sent to the printer";
#[derive(Serialize, Deserialize)]
enum Duplex {
Off,
LongEdge,
ShortEdge,
}
#[derive(Serialize, Deserialize)]
struct Request {
printer: Option<String>,
copies: u32,
collate: bool,
duplex: Option<Duplex>,
grayscale: bool,
shrink: bool,
title: String,
options: Vec<(String, String)>,
}
impl Request {
/// Preserve all spooler options in a platform-independent wire representation.
fn from_job(job: &craft_print::Job) -> Self {
Self {
printer: job.printer.clone(),
copies: job.copies,
collate: job.collate,
duplex: job.duplex.map(|d| match d {
craft_print::Duplex::Off => Duplex::Off,
craft_print::Duplex::LongEdge => Duplex::LongEdge,
craft_print::Duplex::ShortEdge => Duplex::ShortEdge,
}),
grayscale: job.grayscale,
shrink: job.scaling == craft_print::Scaling::ShrinkToFit,
title: job.title.clone(),
options: job.options.clone(),
}
}
/// Restore the job without substituting printer defaults or changing copy semantics.
fn job(self) -> craft_print::Job {
craft_print::Job {
printer: self.printer,
copies: self.copies,
collate: self.collate,
duplex: self.duplex.map(|d| match d {
Duplex::Off => craft_print::Duplex::Off,
Duplex::LongEdge => craft_print::Duplex::LongEdge,
Duplex::ShortEdge => craft_print::Duplex::ShortEdge,
}),
grayscale: self.grayscale,
scaling: if self.shrink { craft_print::Scaling::ShrinkToFit } else { craft_print::Scaling::Exact },
title: self.title,
options: self.options,
}
}
}
#[derive(Serialize, Deserialize)]
enum Event {
Ready,
Cancelled,
Sending,
Done(Result<String, String>),
}
enum State {
Starting,
Waking(Arc<Mutex<Child>>),
Running(ChildStdin),
Sending(ChildStdin),
Cancelled,
Finished,
}
#[derive(Clone)]
pub(crate) struct Submission(Arc<(Mutex<State>, Condvar)>);
impl Submission {
/// Create one submission whose driver cannot run until the parent grants startup.
pub(crate) fn new() -> Self {
Self(Arc::new((Mutex::new(State::Starting), Condvar::new())))
}
/// Recover the lifecycle state after poisoning without invoking another driver call.
fn state(&self) -> std::sync::MutexGuard<'_, State> {
self.0.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Only an acknowledgement of the child's token can cancel a running helper. A timeout
/// is a refusal, never permission to kill a process that may already be sending.
pub(crate) fn cancel(&self) -> bool {
let mut state = self.state();
match &mut *state {
State::Starting => {
*state = State::Cancelled;
return true;
}
State::Waking(child) => {
let _ = child.lock().unwrap_or_else(std::sync::PoisonError::into_inner).kill();
*state = State::Cancelled;
return true;
}
State::Cancelled => return true,
State::Finished | State::Sending(_) => return false,
State::Running(input) => {
if input.write_all(b"cancel\n").and_then(|_| input.flush()).is_err() {
return false;
}
}
}
let (state, _) = self
.0
.1
.wait_timeout_while(state, Duration::from_secs(1), |s| matches!(s, State::Running(_)))
.unwrap_or_else(std::sync::PoisonError::into_inner);
matches!(*state, State::Cancelled)
}
#[cfg(windows)]
/// Submit through this executable's private helper mode and await its final receipt.
pub(crate) fn submit(&self, pdf: &[u8], job: &craft_print::Job) -> Result<String, String> {
self.submit_with(pdf, job, |directory| {
let mut command = Command::new(std::env::current_exe()?);
command.arg(FLAG).arg(directory);
Ok(command)
})
}
/// Publish a terminal state on every result, waking a cancellation caller awaiting an ack.
fn submit_with(&self, pdf: &[u8], job: &craft_print::Job, command: impl FnOnce(&Path) -> std::io::Result<Command>) -> Result<String, String> {
let result = self.run(pdf, job, command);
let mut state = self.state();
if !matches!(*state, State::Cancelled) {
*state = State::Finished;
}
self.0.1.notify_all();
if matches!(*state, State::Cancelled) { Err(CANCELLED.into()) } else { result }
}
/// Own the private inputs and child until receipt, acknowledged cancellation or failure.
fn run(&self, pdf: &[u8], job: &craft_print::Job, command: impl FnOnce(&Path) -> std::io::Result<Command>) -> Result<String, String> {
if matches!(*self.state(), State::Cancelled) {
return Err(CANCELLED.into());
}
if pdf.len() as u64 > MAX_PDF {
return Err("the print PDF exceeds the 512 MiB helper limit".into());
}
let request = serde_json::to_vec(&Request::from_job(job)).map_err(error)?;
if request.len() as u64 > MAX_MESSAGE {
return Err("the print settings exceed the helper message limit".into());
}
let directory = tempfile::tempdir().map_err(error)?;
std::fs::write(directory.path().join("job.json"), request).map_err(error)?;
std::fs::write(directory.path().join("input.pdf"), pdf).map_err(error)?;
let child =
command(directory.path()).map_err(error)?.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().map_err(error)?;
let child = Arc::new(Mutex::new(child));
let outcome = (|| {
let (input, output) = {
let mut process = child.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
(process.stdin.take().ok_or("print helper has no input pipe")?, process.stdout.take().ok_or("print helper has no output pipe")?)
};
{
let mut state = self.state();
if matches!(*state, State::Cancelled) {
return Err(CANCELLED.into());
}
*state = State::Waking(child.clone());
}
let mut output = BufReader::new(output);
loop {
let message = line(&mut output)?.ok_or("the print helper stopped before it was ready")?;
let Some((_, json)) = message.split_once(PREFIX) else { continue };
if !matches!(serde_json::from_str::<Event>(json).map_err(error)?, Event::Ready) {
return Err("the print helper did not acknowledge startup".into());
}
break;
}
{
let mut state = self.state();
if matches!(*state, State::Cancelled) {
return Err(CANCELLED.into());
}
*state = State::Running(input);
if let State::Running(input) = &mut *state {
input.write_all(b"start\n").and_then(|_| input.flush()).map_err(error)?;
}
}
while let Some(line) = line(&mut output)? {
let Some((_, json)) = line.split_once(PREFIX) else { continue };
match serde_json::from_str::<Event>(json).map_err(error)? {
Event::Ready => return Err("the print helper repeated its startup acknowledgement".into()),
Event::Cancelled => {
*self.state() = State::Cancelled;
self.0.1.notify_all();
return Err(CANCELLED.into());
}
Event::Sending => {
let mut state = self.state();
let previous = std::mem::replace(&mut *state, State::Finished);
*state = match previous {
State::Running(input) | State::Sending(input) => State::Sending(input),
other => other,
};
self.0.1.notify_all();
}
Event::Done(result) => return result,
}
}
Err("the print helper stopped without a receipt".into())
})();
// A cancelled child has acknowledged that it cannot send. Other outcomes are terminal
// receipts or a broken protocol/pipe; reap the process and its private input files.
if outcome.is_err() {
let _ = child.lock().unwrap_or_else(std::sync::PoisonError::into_inner).kill();
}
let status = child.lock().unwrap_or_else(std::sync::PoisonError::into_inner).wait().map_err(error)?;
if outcome.is_ok() && !status.success() {
return Err("the print helper failed after its receipt".into());
}
outcome
}
}
/// Keep helper transport failures distinct from a printer's own error message.
fn error(error: impl std::fmt::Display) -> String {
format!("print helper: {error}")
}
/// Read at most one byte beyond the cap so oversized files fail without unbounded allocation.
fn bounded(mut input: impl Read, limit: u64) -> Result<Vec<u8>, String> {
let mut bytes = Vec::new();
input.by_ref().take(limit.saturating_add(1)).read_to_end(&mut bytes).map_err(error)?;
if bytes.len() as u64 > limit { Err("print helper input exceeds its limit".into()) } else { Ok(bytes) }
}
/// Read one bounded UTF-8 protocol record, distinguishing EOF from an oversized message.
fn line(input: &mut impl BufRead) -> Result<Option<String>, String> {
let mut bytes = Vec::new();
input.take(MAX_MESSAGE + 1).read_until(b'\n', &mut bytes).map_err(error)?;
if bytes.len() as u64 > MAX_MESSAGE {
return Err("print helper message exceeds its limit".into());
}
if bytes.is_empty() { Ok(None) } else { String::from_utf8(bytes).map(|s| Some(s.trim_end().to_owned())).map_err(error) }
}
/// Serialize complete events under stdout's lock so the driver and listener cannot interleave.
fn emit(event: &Event) -> Result<(), String> {
let mut output = std::io::stdout().lock();
write!(output, "{PREFIX}").map_err(error)?;
serde_json::to_writer(&mut output, event).map_err(error)?;
writeln!(output).and_then(|_| output.flush()).map_err(error)
}
/// Remove both plaintext inputs even when one fails; already-removed inputs count as success.
fn remove_inputs(directory: &Path) -> Result<(), String> {
let mut first_error = None;
for name in ["job.json", "input.pdf"] {
if let Err(failure) = std::fs::remove_file(directory.join(name))
&& failure.kind() != std::io::ErrorKind::NotFound
{
first_error.get_or_insert_with(|| error(format!("could not remove {name}: {failure}")));
}
}
first_error.map_or(Ok(()), Err)
}
/// Load and erase the private inputs before driver work, while an independent listener can cancel.
fn helper(
directory: &Path,
cancel_request: impl Fn(&craft_print::Cancel) -> bool + Send + 'static,
submit: impl FnOnce(&[u8], &craft_print::Job, &craft_print::Cancel) -> Result<String, String>,
) -> Result<(), String> {
let mut input = BufReader::new(std::io::stdin());
emit(&Event::Ready)?;
if line(&mut input)?.as_deref() != Some("start") {
return Err("print helper requires a start acknowledgement".into());
}
let cancel = craft_print::Cancel::new();
let listener = cancel.clone();
std::thread::Builder::new()
.name("print-cancel".into())
.spawn(move || {
loop {
match line(&mut input) {
Ok(Some(command)) if command == "cancel" => {
if cancel_request(&listener) {
let _ = emit(&Event::Cancelled);
std::process::exit(0);
}
if emit(&Event::Sending).is_err() {
break;
}
}
_ => break,
}
}
// If the parent disappears, never leave a waiting driver process behind. Sending
// jobs finish normally so the OS gets their pages even if the UI has gone away.
if cancel_request(&listener) {
std::process::exit(0);
}
})
.map_err(error)?;
let request: Request =
serde_json::from_slice(&bounded(std::fs::File::open(directory.join("job.json")).map_err(error)?, MAX_MESSAGE)?).map_err(error)?;
let pdf = bounded(std::fs::File::open(directory.join("input.pdf")).map_err(error)?, MAX_PDF)?;
remove_inputs(directory)?;
let result = submit(&pdf, &request.job(), &cancel);
emit(&Event::Done(result))
}
#[cfg(windows)]
/// Handle only the private first-argument flag, before logging or GUI initialization.
pub(crate) fn helper_mode() -> Option<i32> {
let mut args = std::env::args_os().skip(1);
if args.next().as_deref() != Some(std::ffi::OsStr::new(FLAG)) {
return None;
}
let result = args.next().ok_or_else(|| "print helper directory missing".to_owned()).and_then(|dir| {
helper(Path::new(&dir), craft_print::Cancel::cancel, |pdf, job, cancel| {
craft_print::submit_cancellable(pdf, job, &mut |_, _| {}, cancel).map_err(error)
})
});
if let Err(message) = &result {
let _ = emit(&Event::Done(Err(message.clone())));
}
Some(i32::from(result.is_err()))
}
#[cfg(test)]
mod tests {
use super::*;
/// A real test child exercises production pipes, token handling and process cleanup.
#[test]
#[ignore = "launched by the process protocol tests"]
fn helper_process() {
let Some(directory) = std::env::var_os("WORDCRAFT_TEST_PRINT_DIR") else { return };
if std::env::var("WORDCRAFT_TEST_PRINT_MODE").as_deref() == Ok("waking") {
std::fs::write(Path::new(&directory).join("waking"), b"starting").unwrap();
loop {
std::thread::park();
}
}
let result = helper(
Path::new(&directory),
|token| {
if std::env::var("WORDCRAFT_TEST_PRINT_MODE").as_deref() == Ok("sending") { false } else { token.cancel() }
},
|pdf, job, _token| {
assert_eq!(pdf, b"synthetic PDF");
assert_eq!(job.printer.as_deref(), Some("test queue"));
match std::env::var("WORDCRAFT_TEST_PRINT_MODE").as_deref() {
Ok("held") => {
let _provider = Mutex::new(());
let _held = _provider.lock().unwrap();
std::fs::write(Path::new(&directory).join("held"), b"driver entered").unwrap();
loop {
std::thread::park();
}
}
Ok("crash") => std::process::exit(7),
Ok("sending") => {
std::fs::write(Path::new(&directory).join("sending"), b"sending").unwrap();
while !Path::new(&directory).join("release").exists() {
std::thread::sleep(Duration::from_millis(5));
}
Ok("spooled synthetic job".into())
}
_ => Ok("spooled synthetic job".into()),
}
},
);
std::process::exit(i32::from(result.is_err()));
}
/// Launch just the ignored helper entry, without invoking printer hardware.
fn command(directory: &Path, mode: &str) -> std::io::Result<Command> {
let mut command = Command::new(std::env::current_exe()?);
let name = module_path!().split_once("::").map_or(module_path!(), |(_, name)| name);
command.args(["--ignored", "--exact", &format!("{name}::helper_process"), "--nocapture"]);
command.env("WORDCRAFT_TEST_PRINT_DIR", directory).env("WORDCRAFT_TEST_PRINT_MODE", mode);
Ok(command)
}
/// Give the child a literal printer identity to verify transport.
fn job() -> craft_print::Job {
craft_print::Job { printer: Some("test queue".into()), ..Default::default() }
}
#[test]
/// Cancelling before submission cannot launch a driver process.
fn cancelled_start_never_launches_a_helper() {
let submission = Submission::new();
assert!(submission.cancel());
let result = submission.submit_with(b"synthetic PDF", &job(), |_| Err(std::io::Error::other("must not launch")));
assert_eq!(result, Err(CANCELLED.into()));
}
#[test]
/// A cancellation during creation withholds the start grant and removes private inputs.
fn cancellation_during_startup_never_starts_the_driver() {
let submission = Submission::new();
let worker = submission.clone();
let (started, starting) = std::sync::mpsc::channel();
let (release, wait) = std::sync::mpsc::channel();
let running = std::thread::spawn(move || {
worker.submit_with(b"synthetic PDF", &job(), |directory| {
started.send(directory.to_path_buf()).unwrap();
wait.recv().unwrap();
command(directory, "held")
})
});
let directory = starting.recv_timeout(Duration::from_secs(10)).unwrap();
assert!(submission.cancel());
release.send(()).unwrap();
assert_eq!(running.join().unwrap(), Err(CANCELLED.into()));
assert!(!directory.exists(), "startup cancellation reaps the child and removes its inputs");
}
#[test]
/// A hung startup has no permission to send and can be killed without a token ack.
fn an_unresponsive_starting_process_can_be_stopped_without_an_acknowledgement() {
let submission = Submission::new();
let worker = submission.clone();
let (path_tx, path_rx) = std::sync::mpsc::channel();
let running = std::thread::spawn(move || {
worker.submit_with(b"synthetic PDF", &job(), |dir| {
path_tx.send(dir.to_path_buf()).unwrap();
command(dir, "waking")
})
});
let directory = path_rx.recv_timeout(Duration::from_secs(10)).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !directory.join("waking").exists() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert!(directory.join("waking").exists());
assert!(submission.cancel(), "a process without a start grant cannot send anything");
assert_eq!(running.join().unwrap(), Err(CANCELLED.into()));
assert!(!directory.exists());
}
#[test]
/// Job transport preserves every explicit setting, including Unicode and duplex direction.
fn job_options_survive_the_process_boundary() {
let expected = craft_print::Job {
printer: Some("Office λ".into()),
copies: 3,
collate: false,
duplex: Some(craft_print::Duplex::ShortEdge),
grayscale: true,
scaling: craft_print::Scaling::ShrinkToFit,
title: "A document".into(),
options: vec![("media".into(), "iso_a4_210x297mm".into())],
};
let bytes = serde_json::to_vec(&Request::from_job(&expected)).unwrap();
let received: Request = serde_json::from_slice(&bytes).unwrap();
assert_eq!(received.job(), expected);
}
#[test]
/// Retry succeeds immediately after cancellation, before joining the old worker.
fn cancelled_driver_does_not_block_the_next_process() {
let submission = Submission::new();
let worker = submission.clone();
let (path_tx, path_rx) = std::sync::mpsc::channel();
let running = std::thread::spawn(move || {
worker.submit_with(b"synthetic PDF", &job(), |dir| {
path_tx.send(dir.to_path_buf()).unwrap();
command(dir, "held")
})
});
let directory = path_rx.recv_timeout(Duration::from_secs(10)).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !directory.join("held").exists() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert!(directory.join("held").exists(), "the child is held inside its driver");
assert!(!directory.join("input.pdf").exists(), "the stalled driver retains no plaintext PDF");
assert!(!directory.join("job.json").exists(), "loaded settings were erased before driver work");
assert!(submission.cancel(), "a stalled driver still acknowledges cancellation");
let next = Submission::new();
assert_eq!(next.submit_with(b"synthetic PDF", &job(), |dir| command(dir, "ready")), Ok("spooled synthetic job".into()));
assert_eq!(running.join().unwrap(), Err(CANCELLED.into()));
assert!(!directory.exists(), "the reaped helper's PDF and settings were removed");
assert!(!next.cancel(), "a completed job keeps its receipt");
}
#[test]
/// A refused cancellation keeps the sending process alive until its receipt.
fn cancellation_refusal_preserves_the_sending_job() {
let submission = Submission::new();
let worker = submission.clone();
let (path_tx, path_rx) = std::sync::mpsc::channel();
let running = std::thread::spawn(move || {
worker.submit_with(b"synthetic PDF", &job(), |dir| {
path_tx.send(dir.to_path_buf()).unwrap();
command(dir, "sending")
})
});
let directory = path_rx.recv_timeout(Duration::from_secs(10)).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !directory.join("sending").exists() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert!(directory.join("sending").exists());
assert!(!submission.cancel(), "a sending job refuses cancellation");
assert!(matches!(*submission.state(), State::Sending(_)));
assert!(!running.is_finished(), "the child is retained until it finishes sending");
std::fs::write(directory.join("release"), b"continue").unwrap();
assert_eq!(running.join().unwrap(), Ok("spooled synthetic job".into()));
assert!(!directory.exists());
}
#[test]
/// Closing the parent's pipe stops a waiting driver and permits parent-side cleanup.
fn parent_disconnect_stops_a_waiting_driver() {
let submission = Submission::new();
let worker = submission.clone();
let (path_tx, path_rx) = std::sync::mpsc::channel();
let running = std::thread::spawn(move || {
worker.submit_with(b"synthetic PDF", &job(), |dir| {
path_tx.send(dir.to_path_buf()).unwrap();
command(dir, "held")
})
});
let directory = path_rx.recv_timeout(Duration::from_secs(10)).unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !directory.join("held").exists() && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(5));
}
assert!(directory.join("held").exists());
*submission.state() = State::Finished; // Drops the parent's pipe as on process exit.
assert_eq!(running.join().unwrap(), Err("the print helper stopped without a receipt".into()));
assert!(!directory.exists());
}
#[test]
/// Helper failure removes its input directory; input limits reject oversize data.
fn helper_crashes_are_errors_and_inputs_are_bounded() {
let submission = Submission::new();
let mut inputs = None;
assert_eq!(
submission.submit_with(b"synthetic PDF", &job(), |dir| {
inputs = Some(dir.to_path_buf());
command(dir, "crash")
}),
Err("the print helper stopped without a receipt".into())
);
assert!(!inputs.unwrap().exists(), "a helper crash removes the PDF and settings");
assert_eq!(bounded(&b"abcd"[..], 3), Err("print helper input exceeds its limit".into()));
assert_eq!(bounded(&b"abc"[..], 3), Ok(b"abc".to_vec()));
}
/// Cleanup is repeatable and reports real failures while still attempting both inputs.
#[test]
fn input_cleanup_tolerates_missing_files_and_reports_failures() {
let directory = tempfile::tempdir().unwrap();
std::fs::write(directory.path().join("job.json"), b"settings").unwrap();
std::fs::write(directory.path().join("input.pdf"), b"private PDF").unwrap();
assert_eq!(remove_inputs(directory.path()), Ok(()));
assert_eq!(remove_inputs(directory.path()), Ok(()));
assert!(!directory.path().join("input.pdf").exists());
std::fs::create_dir(directory.path().join("job.json")).unwrap();
std::fs::write(directory.path().join("input.pdf"), b"private PDF").unwrap();
let error = remove_inputs(directory.path()).unwrap_err();
assert!(error.contains("could not remove job.json"), "{error}");
assert!(!directory.path().join("input.pdf").exists(), "a settings cleanup failure does not retain the PDF");
}
}
+99
View File
@@ -0,0 +1,99 @@
//! A WordCraft started by an AppImage app inherits that app's `APPIMAGE`, which the self-update
//! would take for this copy's file and replace (#5653). The real program, started both ways.
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::time::{Duration, Instant};
const NOTICE: &str = "ignoring the AppImage";
/// A folder of its own for one test, removed when it ends.
struct Tmp(PathBuf);
impl Tmp {
/// An empty folder named after `test` and this process, made afresh.
fn new(test: &str) -> Tmp {
let dir = std::env::temp_dir().join(format!("wordcraft-appimage-it-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Tmp(dir)
}
}
impl Drop for Tmp {
/// Removes the folder and what the test left in it.
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Runs the real app with `args`, as an AppImage app with `appimage` mounted at `appdir` would
/// start it, and waits up to 30 s for it to exit (with no display it can't open a window).
fn app(args: &[OsString], appimage: &Path, appdir: &Path) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_wordcraft"))
.args(args)
.env("APPIMAGE", appimage)
.env("APPDIR", appdir)
.env("ARGV0", "other-app")
.env("OWD", "/")
// With no display, an app that tries to open a window fails at once instead.
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
.env_remove("WAYLAND_SOCKET")
.env_remove("WORDCRAFT_CONTROL_PORT")
.env("WORDCRAFT_NO_PREFS", "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
// A start-over that kept the variables would start over for ever, and end here.
let deadline = Instant::now() + Duration::from_secs(30);
while child.try_wait().unwrap().is_none() {
if Instant::now() > deadline {
let _ = child.kill();
panic!("wordcraft {args:?} was still running after 30 s");
}
std::thread::sleep(Duration::from_millis(50));
}
child.wait_with_output().unwrap()
}
/// The notice comes once: the program it started over as no longer sees the variables.
#[test]
fn another_programs_variables_are_dropped_once_and_the_program_carries_on() {
let tmp = Tmp::new("dropped");
let other = tmp.0.join("other-app.AppImage");
std::fs::write(&other, b"another program").unwrap();
let out = app(&["--version".into()], &other, &tmp.0);
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}");
assert_eq!(stderr.matches(NOTICE).count(), 1, "stderr: {stderr}");
assert!(stderr.contains("other-app.AppImage"), "stderr: {stderr}");
assert_eq!(std::fs::read(&other).unwrap(), b"another program");
}
/// Arguments reach the program it starts over as byte for byte, even one that isn't Unicode.
#[test]
fn starting_over_keeps_the_arguments() {
let tmp = Tmp::new("args");
let out = app(&["--sample".into(), "--control".into(), OsString::from_vec(b"79\xe981".to_vec())], &tmp.0.join("gone"), &tmp.0);
let stderr = String::from_utf8_lossy(&out.stderr);
assert_eq!(out.status.code(), Some(2), "stderr: {stderr}");
assert!(stderr.contains("argument 3 is not valid Unicode: 79\u{FFFD}81"), "stderr: {stderr}");
}
/// Its own image's variables stay, or the self-update couldn't find the file to replace.
#[test]
fn its_own_images_variables_stay() {
let exe = Path::new(env!("CARGO_BIN_EXE_wordcraft")).canonicalize().unwrap();
let mount = exe.parent().unwrap();
let out = app(&["--version".into()], &mount.join("WordCraft.AppImage"), mount);
let (stdout, stderr) = (String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert!(out.status.success(), "status {:?}, stderr: {stderr}", out.status);
assert!(stdout.starts_with("wordcraft "), "stdout: {stdout}");
assert!(!stderr.contains(NOTICE), "stderr: {stderr}");
}
+2
View File
@@ -1,3 +1,5 @@
#[cfg(target_os = "linux")]
mod appimage_env;
mod control_arg;
mod help;
#[cfg(unix)]
+18 -1
View File
@@ -14,6 +14,10 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.SenuraMalaka]
real_name = "Senura Dissanayake"
sync_github_name = true
[people.divyanshu2074]
real_name = "Divyanshu Gupta"
sync_github_name = true
@@ -35,7 +39,9 @@ sync_github_name = false
real_name = "Saeed Azarehgar"
[people.vangel76]
sync_github_name = true
real_name = "Valentin Klein"
display_name = "vangel76"
sync_github_name = false
[people.dkhlapov]
real_name = "Dmitrii Khlapov"
@@ -160,6 +166,8 @@ sync_github_name = true
[people.maxm11]
real_name = "Max Murphy"
display_name = "Max Murphy"
sync_github_name = false
[people.SlavenDj]
sync_github_name = true
@@ -187,3 +195,12 @@ sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.entropybeater]
display_name = "entropybeater"
[people.helenkwok]
real_name = "Helen Kwok"
[people.ashokram-si]
real_name = "Ashok Ram"
+1 -1
View File
@@ -1 +1 @@
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
1e515422afdda2f317af430c1f9d68a5f83935b4
+3 -2
View File
@@ -10,8 +10,9 @@ workspace = true
[features]
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`,
# `zip_guard::zip64_entry_count_sample`); turned on only as a
# dev-dependency, so never built into the app.
# `zip_guard::zip64_entry_count_sample`) and the step count of
# `work::count`; turned on only as a dev-dependency, so never built into
# the app.
test-support = []
[dependencies]
+19 -10
View File
@@ -7,8 +7,9 @@
/// makes it allocate gigabytes. Here the DIFAT is walked the way `cfb` walks it (header entries,
/// then the DIFAT chain, at most one step per sector; trailing zero and free entries trimmed
/// the same way) and the file is refused when it lists one FAT sector twice, or more FAT sectors
/// than its sectors need (one FAT sector covers `sector_len / 4` of them, plus one spare), so the
/// FAT `cfb` builds is about the size the file needs. Anything else that is wrong is left for
/// than the file has sectors — every FAT sector is a sector of the file, so a longer list is
/// duplicates or names `cfb` refuses anyway, and the FAT `cfb` builds (one sector of entries per
/// listed name) stays within about the file's own size. Anything else that is wrong is left for
/// `cfb` to report.
pub fn check(bytes: &[u8]) -> Result<(), String> {
const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
@@ -52,12 +53,14 @@ pub fn check(bytes: &[u8]) -> Result<(), String> {
while difat.last() == Some(&FREE) {
difat.pop();
}
// Each listed FAT sector adds a whole sector of entries. A FAT sector holds the entries of
// `sector_len / 4` sectors, so a file needs no more FAT sectors than that covers (one spare),
// and none of them twice. A free entry in the middle is left for `cfb` to refuse.
// Each listed FAT sector adds a whole sector of entries. A real file lists no more FAT
// sectors than the file has — every FAT sector is a sector of the file, and none of them
// twice (checked below) — so the FAT `cfb` builds stays within about the file's own size.
// The #3642 blowup listed 2048 × 127 names in a ~1 MB file. A tighter bound (one FAT sector
// per `sector_len / 4` sectors plus a spare) refused real files, whose writers reserve FAT
// for growth: an encrypted package of 78 sectors lists 4 and didn't open (#5109).
let listed: Vec<u32> = difat.into_iter().filter(|&s| s != FREE).collect();
let needed = num_sectors.div_ceil(sector_len / 4).saturating_add(1);
if listed.len() > needed {
if listed.len() > num_sectors {
return Err(format!("the DIFAT lists {} FAT sectors in a file of {num_sectors} sectors", listed.len()));
}
let mut seen = std::collections::HashSet::with_capacity(listed.len());
@@ -123,10 +126,16 @@ mod tests {
}
#[test]
fn a_fat_sector_listed_twice_or_more_than_the_file_needs_is_refused() {
// Each list is shorter than the file has sectors, which the guard used to accept.
fn a_fat_sector_listed_twice_or_more_than_the_file_has_is_refused() {
// A duplicate, and a list longer than the file has sectors: every FAT sector is a
// sector of the file, so a longer list is one of the two.
assert!(super::check(&header_difat(10, &[0, 0])).is_err_and(|e| e.contains("FAT sector 0 twice")));
assert!(super::check(&header_difat(10, &[0, 1, 2])).is_err_and(|e| e.contains("3 FAT sectors")));
assert!(
super::check(&header_difat(10, &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])).is_err_and(|e| e.contains("11 FAT sectors in a file of 10 sectors"))
);
// A writer may reserve FAT for growth: a real encrypted package of 78 sectors lists
// 4 FAT sectors and didn't open since the #3642 guard landed (#5109).
assert!(super::check(&header_difat(78, &[0, 1, 2, 3])).is_ok());
assert!(super::check(&header_difat(10, &[0])).is_ok());
assert!(super::check(&header_difat(200, &[0, 1])).is_ok());
}
+2
View File
@@ -213,7 +213,9 @@ impl Document {
if !parent.len().is_multiple_of(3) {
return None;
}
crate::work::steps(1);
for ch in parent.chunks(3) {
crate::work::steps(1);
let [b, r, c] = ch else { return None };
let Block::Table(t) = &**bl.get(*b as usize)? else { return None };
bl = &t.rows.get(*r as usize)?.cells.get(*c as usize)?.blocks;
+11 -2
View File
@@ -609,8 +609,10 @@ impl Document {
let mut at = at.into_iter().peekable();
let mut start = 0usize;
for r in &mut p.runs {
crate::work::steps(1);
let end = start.saturating_add(r.len);
while at.peek().is_some_and(|&o| o < start) {
crate::work::steps(1);
at.next();
}
if at.peek().is_some_and(|&o| o < end) {
@@ -770,6 +772,10 @@ impl Document {
if a.path.parent() == b.path.parent() {
// Same container: trim the ends, drop the blocks between, join.
let (ia, ib) = (a.path.last(), b.path.last());
// Removing an already-empty paragraph must not impose its style or mark formatting
// on the following content. Test before trimming: deleting actual heading text is
// still an ordinary merge that keeps the first paragraph's properties.
let empty_start = self.para(a.story, &a.path).is_some_and(Paragraph::is_empty);
// Kept markers of the paragraphs in between move to the join.
let mut moved: Vec<(InlineObject, CharProps)> = Vec::new();
// `kept` is sorted: one walk of each paragraph's objects and runs (#3614).
@@ -817,15 +823,18 @@ impl Document {
if ib < bl.len() && ia < ib {
bl.drain(ia + 1..=ib);
}
tail.props = ParaProps::default();
// The deleted marks include `a`'s, so a section break there goes too: the joined
// paragraph ends where `b`'s mark did, and its text takes the following section's
// setup, as in Word (#2127).
let sect = tail.section.take();
let pa = self.para_mut(a.story, &a.path)?;
let keep_props = pa.props.clone();
let keep_props = if empty_start { tail.props.clone() } else { pa.props.clone() };
let keep_mark = empty_start.then(|| tail.mark.clone());
pa.append(tail);
pa.props = keep_props;
if let Some(mark) = keep_mark {
pa.mark = mark;
}
let before = std::mem::replace(&mut pa.section, sect);
if a.story == crate::StoryRef::Body && a.path.0.len() == 1 {
// Nearest first, as "link to previous" resolved them before the delete.
+90 -4
View File
@@ -263,7 +263,9 @@ impl KeptXml {
}
/// Whether `doc`'s page setup is still as it was read.
pub fn pages_as_read(&self, doc: &crate::Document) -> bool {
self.pages_fp.is_some_and(|fp| fp == pages_fingerprint(doc))
let Some(fp) = self.pages_fp else { return false };
let now = in_scope(doc, |sc| *sc.pages.get_or_insert_with(|| pages_fingerprint(doc))).unwrap_or_else(|| pages_fingerprint(doc));
fp == now
}
/// Whether `doc`'s style definitions are still as they were read.
pub fn styles_as_read(&self, doc: &crate::Document) -> bool {
@@ -384,11 +386,62 @@ const MAX_REPLIES: usize = 10_000;
/// What comment `root` is with its replies (and theirs): each one's author, date, state and
/// text, and how many they are.
pub fn comments_fingerprint(doc: &crate::Document, root: u32) -> (u64, usize) {
family_fingerprint(doc, root, &replies(doc))
family_fingerprint(doc, root, &scoped_replies(doc))
}
/// What a writer asks of a document's kept markup for many paragraphs, worked out once while
/// [`while_writing`] it: each comment's replies, and its page setup's fingerprint. Without it a
/// document of C comments took C² steps (and its page setup serialized once per note) to save.
struct Scope {
/// The document's address: it can't move or change while borrowed for the writing.
doc: usize,
replies: Option<std::rc::Rc<HashMap<u32, Vec<u32>>>>,
pages: Option<u64>,
}
thread_local! {
static SCOPE: std::cell::RefCell<Option<Scope>> = const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
thread_local! {
/// How many times [`replies`] was built (tests).
static REPLY_BUILDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Puts the scope before it back when dropped (when `f` returns or unwinds).
struct Restore(Option<Scope>);
impl Drop for Restore {
fn drop(&mut self) {
let prev = self.0.take();
SCOPE.with(|s| *s.borrow_mut() = prev);
}
}
/// Run `f`, which writes `doc`, with what the kept markup asks of `doc` worked out at most once.
pub fn while_writing<R>(doc: &crate::Document, f: impl FnOnce() -> R) -> R {
let key = std::ptr::from_ref(doc) as usize;
let prev = SCOPE.with(|s| s.replace(Some(Scope { doc: key, replies: None, pages: None })));
let _restore = Restore(prev);
f()
}
/// The scope for `doc`, if it is being written: `f` of it.
fn in_scope<R>(doc: &crate::Document, f: impl FnOnce(&mut Scope) -> R) -> Option<R> {
let key = std::ptr::from_ref(doc) as usize;
SCOPE.with(|s| s.borrow_mut().as_mut().filter(|sc| sc.doc == key).map(f))
}
/// [`replies`], built once while `doc` is being written.
fn scoped_replies(doc: &crate::Document) -> std::rc::Rc<HashMap<u32, Vec<u32>>> {
in_scope(doc, |sc| sc.replies.get_or_insert_with(|| std::rc::Rc::new(replies(doc))).clone()).unwrap_or_else(|| std::rc::Rc::new(replies(doc)))
}
/// Each comment's replies, by the comment's id.
fn replies(doc: &crate::Document) -> HashMap<u32, Vec<u32>> {
#[cfg(test)]
REPLY_BUILDS.with(|n| n.set(n.get() + 1));
let mut out: HashMap<u32, Vec<u32>> = HashMap::new();
for (id, c) in &doc.comments {
if let Some(p) = c.parent {
@@ -425,8 +478,9 @@ fn family_fingerprint(doc: &crate::Document, root: u32, replies: &HashMap<u32, V
}
/// What `doc`'s page setup is: each section's setup (where it starts, its size, margins,
/// columns, headers and footers), the headers' and footers' content, the page colour and
/// picture, and the notes' numbering.
/// columns, headers and footers), the headers' and footers' content, whether odd and even pages
/// differ, the page colour and picture, the watermark (one with no text isn't written), and the
/// notes' numbering.
pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
let mut sections: Vec<&crate::SectionProps> = doc.body.iter().filter_map(|b| b.as_para()).filter_map(|p| p.section.as_deref()).collect();
sections.push(&doc.last_section);
@@ -437,8 +491,10 @@ pub fn pages_fingerprint(doc: &crate::Document) -> u64 {
match serde_json::to_vec(&(
sections,
parts,
s.even_odd_headers,
&s.page_color,
&s.page_picture,
s.watermark.as_ref().filter(|w| !w.text.trim().is_empty()),
(&s.footnote_format, &s.endnote_format, &s.footnote_pr, &s.endnote_pr),
(&doc.footnote_separators, &doc.endnote_separators),
)) {
@@ -694,4 +750,34 @@ mod tests {
assert_eq!(k.group(p), Some((1, 1)));
assert_eq!(k.story_fps.get(&id), Some(&story_fingerprint(&doc, id)));
}
/// Writing a document checks each kept paragraph's comments against what was read: the
/// comments' replies are looked up once for the whole document, not once per comment.
#[test]
fn writing_many_comments_looks_their_replies_up_once() {
use crate::para::InlineObject;
let n = 50u32;
let text = (0..n).map(|i| format!("Para {i}")).collect::<Vec<_>>().join("\n");
let mut doc = crate::Document::from_text(&text);
for i in 0..n {
let part = doc.add_part(crate::PartKind::Comment, vec![crate::para_block(Paragraph::with_text("Note", Default::default()))]);
doc.comments.insert(i, crate::Comment { parent: None, part, ..Default::default() });
doc.para_mut(crate::StoryRef::Body, &crate::Path::top(i as usize))
.unwrap()
.insert_object(0, InlineObject::CommentStart { id: i }, &Default::default())
.unwrap();
}
let mut k = KeptXml { format: "odt".into(), ..Default::default() };
for i in 0..n {
k.comment_fps.insert(i, comments_fingerprint(&doc, i).0);
}
let xml = "<text:p><office:annotation>x</office:annotation></text:p>";
let before = REPLY_BUILDS.with(std::cell::Cell::get);
let all = while_writing(&doc, || doc.body.iter().filter_map(|b| b.as_para()).all(|p| k.comments_as_read(&doc, p, xml)));
assert!(all, "every paragraph's comment is as read");
assert_eq!(REPLY_BUILDS.with(std::cell::Cell::get) - before, 1);
// Outside a writing, nothing is kept between calls.
assert!(doc.body.first().and_then(|b| b.as_para()).is_some_and(|p| k.comments_as_read(&doc, p, xml)));
assert_eq!(REPLY_BUILDS.with(std::cell::Cell::get) - before, 2);
}
}
+45 -2
View File
@@ -120,14 +120,57 @@ fn attr_in(tag: &str) -> Option<String> {
Some(unescape(rest.get(..end)?))
}
fn unescape(s: &str) -> String {
s.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
fn unescape(mut s: &str) -> String {
let mut out = String::with_capacity(s.len());
while let Some((text, rest)) = s.split_once('&') {
out.push_str(text);
if let Some(end) = rest.find(['&', ';'])
&& rest.as_bytes()[end] == b';'
{
let entity = &rest[..end];
let decoded = match entity {
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"amp" => Some('&'),
_ => entity.strip_prefix('#').and_then(|n| {
let value = if let Some(hex) = n.strip_prefix('x') {
(!hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit())).then(|| u32::from_str_radix(hex, 16).ok()).flatten()
} else {
(!n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())).then(|| n.parse::<u32>().ok()).flatten()
}?;
char::from_u32(value)
.filter(|c| matches!(*c, '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}'))
}),
};
if let Some(c) = decoded {
out.push(c);
s = &rest[end + 1..];
continue;
}
}
out.push('&');
s = rest;
}
out.push_str(s);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn drop_down_entities_decode_once_including_xml_whitespace() {
let xml = r#"<w:ffData><w:name w:val="N&#x61;me"/><w:ddList><w:listEntry w:val="A&#10;B&#9;C&#13;D&#x1F600;&amp;#10;&lt;&gt;&quot;&apos;"/></w:ddList></w:ffData>"#;
let f = LegacyForm::parse(xml).unwrap();
assert_eq!(f.name(), "Name");
assert_eq!(f.shown_text(), Some("A\nB\tC\rD😀&#10;<>\"'"));
let malformed = r#"<w:ffData><w:ddList><w:listEntry w:val="&#0;&#xD800;&#x110000;&#-1;&#xZZ;&unknown;&amp"/></w:ddList></w:ffData>"#;
assert_eq!(LegacyForm::parse(malformed).unwrap().shown_text(), Some("&#0;&#xD800;&#x110000;&#-1;&#xZZ;&unknown;&amp"));
}
#[test]
fn check_boxes_text_fields_and_drop_downs() {
let cb = |inner: &str| format!(r#"<w:ffData><w:name w:val="Box1"/><w:enabled/><w:checkBox>{inner}</w:checkBox></w:ffData>"#);
+4 -3
View File
@@ -39,8 +39,8 @@ pub mod math_latex;
pub mod math_linear;
pub mod math_symbols;
pub mod numbering;
pub mod odf_profile;
pub mod para;
pub mod picfx;
pub mod props;
pub mod resolve;
pub mod section;
@@ -49,6 +49,7 @@ pub mod styles;
pub mod symbol_fonts;
pub mod table;
pub mod wordart;
pub mod work;
pub mod wrap;
pub mod zip_guard;
@@ -86,11 +87,11 @@ pub use error::{DocError, Result};
pub use parts::{MAX_LINKED_BOXES, MAX_TEXT_BOX_INSET, NoteSeparators, Part, PartKind, PermRange, TEXT_BOX_INSET, TextBody, free_id};
pub use position::{Path, Pos, StoryRef};
pub use settings::{
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, PagePicture, Settings, Source,
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source,
THEME_COLORS, Watermark, Zoom, ZoomFit,
};
pub use text_walk::count_words;
pub(crate) use text_walk::{PARA_WALK_DEPTH, walk_between};
pub use text_walk::{count_chars, count_chars_no_spaces, count_words};
/// A document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+11
View File
@@ -19,6 +19,12 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
/// refused too.
pub fn is_safe_href(url: &str) -> bool {
// Exporters drop other control characters and noncharacters when they write the link
// (`file\u{85}:` comes out `file:`), so a link holding one is refused, not judged as it
// stands and written as something else.
if url.chars().any(|c| (c.is_control() && !c.is_ascii_control()) || matches!(c, '\u{FFFE}' | '\u{FFFF}')) {
return false;
}
let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
// `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
if cleaned.replace('\\', "/").starts_with("//") {
@@ -125,6 +131,7 @@ mod tests {
("HYPERLINK\t\"http://example.com\"", Some("http://example.com")),
(r#"HYPERLINK"http://example.com""#, Some("http://example.com")),
(r#"HYPERLINK "http://example.com/path\latest""#, Some("http://example.com/pathlatest")),
(r#"HYPERLINK "x \* Upper""#, Some("x * Upper")),
(r#"HYPERLINK "C:\\Docs\\My file.docx""#, Some(r"C:\Docs\My file.docx")),
(r#"HYPERLINK "say \"hi\"""#, Some(r#"say "hi""#)),
(r#"HYPERLINK " http://example.com ""#, Some("http://example.com")),
@@ -199,6 +206,10 @@ mod tests {
"\\\\attacker.example\\share\\x",
"/\\attacker.example\\x",
" //attacker.example/x",
// Characters the exporters drop on writing don't hide a scheme.
"file\u{85}:///etc/passwd",
"java\u{9F}script:alert(1)",
"/\u{FFFE}/attacker.example/x",
"",
] {
assert!(!is_safe_href(bad), "{bad:?}");
+51 -2
View File
@@ -22,10 +22,29 @@ pub fn decimal_separator(lang: Option<&str>) -> char {
const SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\u{2009}'];
const CURRENCY: &[char] = &['$', '€', '£', '¥', '¢', '₽', '₴', '₺', '₹', '₩', '₪', '₫', '฿', '₱', '₸', '₼', '₾'];
/// Currency signs spelled with more than one character: the Panamanian balboa (`B/.`).
const CURRENCY_SYMBOLS: &[&str] = &["B/."];
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`) from either end.
/// Trim spaces and single-character currency signs from either end.
fn trim_currency(s: &str) -> &str {
s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c))
}
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`, `B/.`) from either end.
/// As Word does, a multi-character sign is a word of its own (`B/. 1,250.50`, `1,250.50 B/.`);
/// `B/.1,250.50` and `B/ 1,250.50` are not numbers.
fn strip_currency(s: &str) -> &str {
let mut s = s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c));
let mut s = trim_currency(s);
if let Some(rest) =
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_prefix(sym).filter(|rest| rest.chars().next().is_some_and(char::is_whitespace)))
{
s = trim_currency(rest);
}
if let Some(rest) =
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_suffix(sym).filter(|rest| rest.chars().next_back().is_some_and(char::is_whitespace)))
{
s = trim_currency(rest);
}
if let Some((head, word)) = s.rsplit_once(SPACES)
&& (1..=3).contains(&word.chars().count())
&& word.chars().all(|c| c.is_alphabetic() || c == '.')
@@ -137,6 +156,7 @@ mod tests {
assert_eq!(parse_number("1.5", pl), Some(1.5));
assert_eq!(parse_number("-2,25", pl), Some(-2.25));
assert_eq!(parse_number("PLN 12", pl), Some(12.0));
assert_eq!(parse_number("-B/. 1 250,50", pl), Some(-1250.5));
assert_eq!(parse_number("abc", pl), None);
assert_eq!(parse_number("1,2,3", pl), None);
// From review: ordinals, a leading zero, percentages and an en dash minus.
@@ -174,11 +194,40 @@ mod tests {
assert_eq!(format_number(5.75, Some("es-ES")), "5,75");
}
#[test]
fn balboa_numbers() {
let en = Some("en-US");
// Word 16.0 reads `B/.` as a word of its own, before or after the number.
assert_eq!(parse_number("B/. 1,234.50", en), Some(1234.5));
assert_eq!(parse_number("1,234.50 B/.", en), Some(1234.5));
assert_eq!(parse_number("B/.\u{a0}1,234.50", en), Some(1234.5), "a no-break space after the sign");
assert_eq!(parse_number("B/. 1,234.50 B/.", en), Some(1234.5), "the sign at both ends");
assert_eq!(parse_number("-B/. 1,234.50", en), Some(-1234.5), "a minus before the sign");
assert_eq!(parse_number("B/. 1,234.50", Some("es-PA")), Some(1234.5), "the balboa's own region");
// The sign has no space of its own in Word: touching the number is text.
assert_eq!(parse_number("B/.1,234.50", en), None);
assert_eq!(parse_number("1,234.50B/.", en), None);
assert_eq!(parse_number("B/ 1,234.50", en), None, "the balboa is B/. in Word");
assert_eq!(parse_number("B/.", en), None);
// The slash and the full stop make `B/.` ambiguous with a fraction: the sign is matched at
// the ends of the text only, so a fraction is never read as a balboa amount and a sign in
// the middle is not a sign.
assert_eq!(parse_number("1/.5", en), None, "a fraction is not a price");
assert_eq!(parse_number("1/.5", Some("es-PA")), None);
assert_eq!(parse_number("1/.5 B/.", en), None, "the sign at the end does not rescue a fraction");
assert_eq!(parse_number("B/. 1/.5", en), None);
assert_eq!(parse_number("2 B/. 1,234.50", en), None, "the sign sits before the number");
assert_eq!(parse_number("1,234.50 B/. 1,234.50", en), None, "the sign sits between two numbers");
assert_eq!(parse_number("1,234.50/2", en), None, "a division is not a balboa amount");
assert_eq!(parse_number("B/. 1,234.50,50", en), None, "a second decimal separator");
}
#[test]
fn english_numbers() {
let en = Some("en-US");
assert_eq!(parse_number("1,250.50", en), Some(1250.5));
assert_eq!(parse_number("$1,250", en), Some(1250.0));
assert_eq!(parse_number("PAB 1,250.50", en), Some(1250.5), "the ISO code");
assert_eq!(parse_number("1.5", en), Some(1.5));
assert_eq!(parse_number("12", None), Some(12.0));
assert_eq!(parse_number(".5", None), Some(0.5));
+6
View File
@@ -16,6 +16,12 @@ pub type Arg = Vec<MNode>;
/// Deepest structure nesting kept (parsers stop here; layout draws nothing deeper).
pub const MAX_DEPTH: usize = 64;
/// Most matrix columns kept (writers pad every row to the widest, so this bounds a row's cost).
pub const MAX_MATRIX_COLS: usize = 64;
/// Most matrix rows kept.
pub const MAX_MATRIX_ROWS: usize = 1000;
/// An equation's structure, plus what's needed to write it back.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
pub struct Math {
+46
View File
@@ -769,6 +769,30 @@ pub fn autocorrect(root: &mut Arg, pos: &MathPos) -> Option<MathPos> {
Some(MathPos { path: pos.path, off: pos.off - name.chars().count() })
}
/// The character just before the caret is an n-ary operator (∫, ∑…) in plain math text, as
/// AutoCorrect leaves `\int` or `\sum`.
pub fn nary_before(root: &Arg, pos: &MathPos) -> bool {
let pos = clamp(root, pos);
let Some(a) = arg_at(root, &pos.path) else { return false };
let (i, k) = match locate(a, pos.off) {
Loc::Inside(i, k) => (i, k),
Loc::Before(i) => match i.checked_sub(1).and_then(|i| a.get(i).map(|n| (i, node_units(n)))) {
Some(at) => at,
None => return false,
},
};
let Some(MNode::Run(r)) = a.get(i) else { return false };
!r.nor && !r.lit && k.checked_sub(1).and_then(|k| r.text.chars().nth(k)).is_some_and(crate::math::is_nary_char)
}
/// The node just before the caret is a built n-ary operator.
pub fn nary_node_before(root: &Arg, pos: &MathPos) -> bool {
let pos = clamp(root, pos);
let Some(a) = arg_at(root, &pos.path) else { return false };
let Loc::Before(i) = locate(a, pos.off) else { return false };
i.checked_sub(1).and_then(|i| a.get(i)).is_some_and(|n| matches!(n, MNode::Nary { .. }))
}
/// Characters that make typed linear text build up into structures.
fn builds(s: &str) -> bool {
s.chars().any(|c| {
@@ -872,6 +896,28 @@ pub fn build_up(root: &mut Arg, pos: &MathPos, latex: bool) -> Option<MathPos> {
Some(clamp(root, &out))
}
/// Typing that just built an n-ary operator without its operand (`∫_0^1` then Space) goes on in
/// the empty operand, as after inserting one from the gallery, so what is typed next is
/// integrated or summed (#4917). An operator whose operand ends with another (`∫∫`) leads to the
/// innermost one. Other carets, and moves or Enter that build up, stay put.
pub fn into_empty_operand(root: &Arg, pos: &MathPos) -> MathPos {
let mut path = pos.path.clone();
let mut off = pos.off;
while path.len() < MAX_DEPTH
&& let Some(a) = arg_at(root, &path)
&& let Loc::Before(i) = locate(a, off)
&& let Some(k) = i.checked_sub(1)
&& let Some(MNode::Nary { e, .. }) = a.get(k)
{
path.push((k, 2));
if e.is_empty() {
return MathPos { path, off: 0 };
}
off = units(e);
}
pos.clone()
}
/// A top-level `#` numbers the equation: wrap it as a one-row equation array.
pub fn number_equation(root: &mut Arg) -> bool {
if has_number_mark(root) && !matches!(root.as_slice(), [MNode::EqArr { .. }]) {
+167 -35
View File
@@ -228,7 +228,7 @@ impl Numbering {
/// Whether a paragraph in list `num` at `level` gets a label ([`Counters::next_label`] gives
/// one), without counting it.
pub fn shows_label(&self, num: u32, level: u8) -> bool {
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some_and(|d| d.format != NumFormat::None)
self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some()
}
pub fn level(&self, num: u32, level: u8) -> Option<&Level> {
if let Some((_, l)) = self.num(num)?.level_overrides.iter().find(|(i, _)| *i == level) {
@@ -294,6 +294,14 @@ pub struct Counters {
/// 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]>,
/// The last explicitly rendered label and template at each abstract-list level.
labels: HashMap<(u32, u8), CountedLabel>,
}
#[derive(Debug, Clone)]
struct CountedLabel {
label: Option<String>,
levels: Vec<usize>,
}
/// Whether a level has counted yet, and the value its next item takes if a start override says so.
@@ -317,11 +325,9 @@ impl Counters {
let lv = (level as usize).min(8);
// This list's own definition of a level wins over its abstract list's.
let level_def = |k: usize| numbering.level(num, k as u8);
// A "none" level (Word's numFmt none) still counts and restarts the levels below it; only
// its number reads as nothing, so its literal text ("RESPONSE:") shows (#4985).
let def = level_def(lv)?.clone();
// A "none" level shows no label at all (Word's numFmt none) — not even its text.
if def.format == NumFormat::None {
return None;
}
let st = self.state.entry(abs.id).or_insert([0; 9]);
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
@@ -366,42 +372,108 @@ impl Counters {
if restarts {
*deeper = 0;
*f = LevelFlags::default();
self.labels.remove(&(abs.id, d as u8));
}
}
let vals = *st;
let started = *fl;
if def.format == NumFormat::Bullet {
return Some((def.text.clone(), def));
}
let mut label = String::new();
// Word's level text is short; .docx and the lists command already cap it at 64.
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
{
chars.next();
let k = (d as usize).saturating_sub(1).min(8);
let val = vals.get(k).copied().unwrap_or(0);
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
// format (二 of a Chinese outline becomes 2 in "2.1").
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
NumFormat::DecimalZero => NumFormat::DecimalZero,
_ if def.legal => NumFormat::Decimal,
f => f,
};
label.push_str(&fmt.format(val));
if label.len() > MAX_LABEL_BYTES {
break;
}
} else {
label.push(c);
}
}
let numeric = def.format != NumFormat::Bullet;
let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() };
// A "none" level shows its literal text, but has no number of its own for a reference to
// show (#4985): it is kept as one that shows none.
let counted = if def.format == NumFormat::None {
CountedLabel { label: None, levels: Vec::new() }
} else {
CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) }
};
self.labels.insert((abs.id, lv as u8), counted);
Some((label, def))
}
/// Which levels of `num`'s list have counted a paragraph yet (a level never used has no
/// number of its own to show in a reference).
pub fn started(&self, numbering: &Numbering, num: u32) -> Option<[bool; 9]> {
let abs = numbering.abstract_of(num)?;
Some(std::array::from_fn(|k| self.labels.get(&(abs.id, k as u8)).is_some_and(|last| last.label.is_some())))
}
/// The label `level` of `num`'s list shows at this point of the walk, without counting: what
/// the last paragraph at that level showed (`"14."` while walking clause 14's sub-clauses).
/// `None` for a bullet or "none" level, or a level the list doesn't define.
pub fn current_label(&self, numbering: &Numbering, num: u32, level: u8) -> Option<String> {
let abs = numbering.abstract_of(num)?;
if let Some(last) = self.labels.get(&(abs.id, level.min(8))) {
return last.label.clone();
}
// A deeper item can count a level implicitly without rendering a paragraph there.
let def = numbering.level(num, level.min(8))?;
if matches!(def.format, NumFormat::None | NumFormat::Bullet) {
return None;
}
let vals = self.state.get(&abs.id).copied().unwrap_or([0; 9]);
let started = self.flags.get(&abs.id).copied().unwrap_or_default();
Some(render_label(numbering, num, def, &vals, &started))
}
/// Levels shown by the last rendered label, or the current definition for an implicit level.
pub fn current_template_levels(&self, numbering: &Numbering, num: u32, level: u8) -> Vec<usize> {
if let Some(abs) = numbering.abstract_of(num)
&& let Some(last) = self.labels.get(&(abs.id, level.min(8)))
{
return last.levels.clone();
}
numbering.level(num, level.min(8)).map(|def| template_levels(&def.text)).unwrap_or_default()
}
}
/// The zero-based levels a label template shows (`"%1.%2"` → `[0, 1]`, `"(%3)"` → `[2]`).
pub fn template_levels(text: &str) -> Vec<usize> {
let mut out = Vec::new();
let mut chars = text.chars().take(MAX_LEVEL_TEXT).peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
{
chars.next();
let k = (d as usize).saturating_sub(1).min(8);
if !out.contains(&k) {
out.push(k);
}
}
}
out
}
/// Render a list label without advancing counters, retaining the existing hostile-input caps.
fn render_label(numbering: &Numbering, num: u32, def: &Level, vals: &[u32; 9], started: &[LevelFlags; 9]) -> String {
let level_def = |k: usize| numbering.level(num, k as u8);
let mut label = String::new();
// Word's level text is short; .docx and the lists command already cap it at 64.
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
{
chars.next();
let k = (d as usize).saturating_sub(1).min(8);
let val = vals.get(k).copied().unwrap_or(0);
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
// format (二 of a Chinese outline becomes 2 in "2.1"); a "none" level stays empty.
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
f @ (NumFormat::DecimalZero | NumFormat::None) => f,
_ if def.legal => NumFormat::Decimal,
f => f,
};
label.push_str(&fmt.format(val));
if label.len() > MAX_LABEL_BYTES {
break;
}
} else {
label.push(c);
}
}
label
}
/// Longest level text (in characters) expanded into a list label.
@@ -413,6 +485,45 @@ const MAX_LABEL_BYTES: usize = 256;
mod tests {
use super::*;
/// Read-only counter APIs retain the renderer's bounds and never advance a list.
#[test]
fn reference_labels_are_bounded_and_do_not_advance_counters() {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.level_overrides = vec![(0, Level { text: "%1".repeat(30_000), ..Level::default() })];
}
let mut c = Counters::default();
let label = c.next_label(&n, id, 0).unwrap().0;
let before = c.values(&n, id);
assert_eq!(c.current_label(&n, id, 0).as_deref(), Some(label.as_str()));
assert_eq!(c.values(&n, id), before);
assert!(label.len() <= 512);
assert_eq!(template_levels(&"%1%9".repeat(30_000)), [0, 8]);
assert!(c.started(&n, id).unwrap()[0]);
}
#[test]
fn last_ancestor_label_keeps_the_instance_that_rendered_it() {
let mut numbering = Numbering::default();
let first = numbering.add_list(ListKind::Numbered);
numbering.abstracts[0].levels[0].text = "Clause %1.".into();
let mut second = numbering.num(first).unwrap().clone();
second.id = first + 1;
second.level_overrides = vec![
(0, Level { text: "Part %1.%2.".into(), ..Level::default() }),
(1, Level { format: NumFormat::LowerLetter, text: "(%2)".into(), ..Level::default() }),
];
let second_id = second.id;
numbering.nums.push(second);
let mut counters = Counters::default();
assert_eq!(counters.next_label(&numbering, first, 0).unwrap().0, "Clause 1.");
assert_eq!(counters.next_label(&numbering, second_id, 1).unwrap().0, "(a)");
assert_eq!(counters.current_label(&numbering, second_id, 0).as_deref(), Some("Clause 1."));
assert_eq!(counters.current_template_levels(&numbering, second_id, 0), vec![0]);
assert_eq!(counters.started(&numbering, second_id).unwrap()[..2], [true, true]);
}
#[test]
fn a_huge_level_text_gives_a_bounded_label() {
let mut n = Numbering::default();
@@ -449,6 +560,27 @@ mod tests {
assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero));
}
#[test]
fn a_none_level_shows_its_text_and_still_counts() {
// As Word shows them (#4985): a "none" level's literal text is its label, its own number
// reads as nothing (also in legal numbering), and it restarts the levels below it.
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
let lvl = |format, text: &str| Level { format, text: text.into(), ..Level::default() };
let legal = Level { legal: true, ..lvl(NumFormat::Decimal, "%2.%3") };
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.level_overrides = vec![(0, lvl(NumFormat::None, "RESPONSE:")), (1, lvl(NumFormat::None, "[%2]")), (2, legal)];
}
assert!(n.shows_label(id, 0));
let mut c = Counters::default();
assert_eq!(c.next_label(&n, id, 0).unwrap().0, "RESPONSE:");
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]");
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1");
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".2");
assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]");
assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1");
}
#[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
-255
View File
@@ -1,255 +0,0 @@
//! How WordCraft fills in what the OpenDocument standard leaves to the application.
//!
//! Where ODF 1.3 defines a value (directly, or by referring to XSL for an `fo:` property), that
//! is the rule and nothing here changes it. Where it doesn't (ODF 1.3 Part 3 §16.2: "in all
//! remaining cases an implementation-dependent value is used"), each application chooses, and
//! documents from one application can look different in another. An [`OdfProfile`] is that
//! choice for every such case: Microsoft Word's style ([`OdfPreset::Word`], the default: as
//! WordCraft lays out Word documents), LibreOffice's style ([`OdfPreset::LibreOffice`]), or the
//! user's own ([`OdfPreset::Custom`]). `docs/odf-interpretation.md` lists every case.
use serde::{Deserialize, Serialize};
use crate::props::Rgb;
/// Whose choices fill in what the standard leaves open.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OdfPreset {
/// Microsoft Word's style: the behaviour WordCraft has for its own and Word documents.
/// (`wordcraft`, its earlier id, is still taken.)
#[default]
#[serde(alias = "wordcraft")]
Word,
/// LibreOffice's (and Collabora's, which share its engine).
LibreOffice,
/// The user's own choice for each case ([`OdfProfile`]'s values).
Custom,
}
impl OdfPreset {
pub const ALL: [OdfPreset; 3] = [OdfPreset::Word, OdfPreset::LibreOffice, OdfPreset::Custom];
pub fn id(self) -> &'static str {
match self {
OdfPreset::Word => "word",
OdfPreset::LibreOffice => "libreoffice",
OdfPreset::Custom => "custom",
}
}
/// The preset named `id` (any case): its id, or `wordcraft` / `msword` for Word's.
pub fn from_id(id: &str) -> Option<OdfPreset> {
let id = id.trim();
if ["wordcraft", "msword", "ms-word"].iter().any(|a| a.eq_ignore_ascii_case(id)) {
return Some(OdfPreset::Word);
}
OdfPreset::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id))
}
/// The two styles a document can be shown in, as the user chooses between them (Custom
/// aside).
pub const STYLES: [OdfPreset; 2] = [OdfPreset::Word, OdfPreset::LibreOffice];
/// Its name as the user sees it (File › Options, English; the UI translates it).
pub fn label(self) -> &'static str {
match self {
OdfPreset::Word => "Microsoft Word style",
OdfPreset::LibreOffice => "LibreOffice style",
OdfPreset::Custom => "Custom",
}
}
}
/// The most a length in a profile may be, points (22 inches).
pub const MAX_LENGTH: f32 = 1584.0;
/// The choice for each case the standard leaves open (see the module docs). With a preset other
/// than [`OdfPreset::Custom`], the preset's values apply whatever these hold
/// ([`OdfProfile::effective`]).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct OdfProfile {
pub preset: OdfPreset,
/// A page margin a page layout doesn't state, points. §20.206–20.209 refer to XSL's margins
/// (§7.10), whose initial value is 0; whether that initial value comes with the reference
/// the standard doesn't say (LibreOffice uses 2 cm).
pub page_margin: f32,
/// The height of a header or footer whose style states none (§20.212.2 `fo:min-height`
/// has no default), points.
pub header_min_height: f32,
/// A header's or footer's minimum height takes in its spacing from the body (`fo:margin-*`
/// of `style:header-footer-properties`; the standard doesn't say), rather than the spacing
/// coming after it.
pub header_height_includes_spacing: bool,
/// What a header or footer whose style doesn't say does with its spacing when its content
/// is taller than its height (§20.264 `style:dynamic-spacing` has no default): grows into it
/// (`true`), or keeps it (`false`, both presets: with no height stated, growing into it would
/// drop the spacing the file states).
pub dynamic_spacing: bool,
/// Paragraph tab stops are measured from the paragraph's indent rather than the margin when
/// the document doesn't say (§19.512.3 `style:position` doesn't say from where; §19.861 `text:relative-tab-stop-position` says so only for indexes; LibreOffice's own
/// `TabsRelativeToIndent` setting, in `settings.xml`, says so when present).
pub tabs_from_indent: bool,
/// The gap between columns whose `style:columns` states neither one nor each column
/// (§19.233 `fo:column-gap` has no default), points.
pub column_gap: f32,
/// A drawing shape's fill colour when no style gives one (§20.119 `draw:fill-color` has no
/// default).
pub shape_fill: Rgb,
/// A drawing shape's line colour when no style gives one (§20.408 `svg:stroke-color` has no default).
pub shape_line: Rgb,
/// A drawing shape's line width when no style gives one, and what a width of 0 draws at
/// (§20.410 `svg:stroke-width` gives neither), points.
pub hairline: f32,
/// A page-anchored frame placed against a paragraph (a combination §20.398 and §20.299 don't
/// define) is placed against the page (`true`), else against the page's text area.
pub page_frame_paragraph_is_page: bool,
/// An in-line frame placed against something an in-line frame can't be (a paragraph or a
/// page, §20.398 Table 23) is placed against the baseline (`true`), else stands on it.
pub inline_invalid_relation_is_baseline: bool,
/// A line holding only in-line pictures is as tall as they are (`true`), else as tall as
/// its text would be too (the standard defines no line-height algorithm).
pub picture_lines_fit_pictures: bool,
/// A columned section that doesn't say whether its columns are balanced (§20.428
/// `text:dont-balance-text-columns` has no default, and its value descriptions contradict
/// its name) ends with balanced columns at the end of the document too (`true`), else only
/// when text follows on the same page, as in Word.
pub balance_section_columns: bool,
/// The space between two paragraphs is the first's space after plus the second's space
/// before (`true`), else the larger of the two, when the document doesn't say (the
/// standard doesn't; LibreOffice's own `AddParaTableSpacing` setting does when present).
pub sum_paragraph_spacing: bool,
/// Widow and orphan control when no style states `fo:widows` or `fo:orphans` (§20.312,
/// §20.244 refer to XSL's, whose initial value is 2 lines; whether that initial value comes
/// with the reference the standard doesn't say, and LibreOffice reads none stated as none).
pub widow_control: bool,
}
impl Default for OdfProfile {
fn default() -> Self {
OdfProfile::word()
}
}
impl OdfProfile {
/// Microsoft Word's style: as WordCraft lays out its own and Word documents.
pub fn word() -> OdfProfile {
OdfProfile {
preset: OdfPreset::Word,
page_margin: 0.0,
header_min_height: 0.0,
header_height_includes_spacing: false,
dynamic_spacing: false,
tabs_from_indent: false,
column_gap: 36.0,
shape_fill: Rgb(0x15, 0x60, 0x82),
shape_line: Rgb(0x0E, 0x40, 0x5A),
hairline: 0.25,
page_frame_paragraph_is_page: false,
inline_invalid_relation_is_baseline: false,
picture_lines_fit_pictures: false,
balance_section_columns: false,
sum_paragraph_spacing: false,
widow_control: true,
}
}
/// LibreOffice's choices, measured in LibreOffice 24 where it doesn't document them.
pub fn libreoffice() -> OdfProfile {
OdfProfile {
preset: OdfPreset::LibreOffice,
page_margin: 2.0 * 72.0 / 2.54,
header_min_height: 0.5 * 72.0 / 2.54,
header_height_includes_spacing: true,
dynamic_spacing: false,
tabs_from_indent: true,
column_gap: 0.0,
shape_fill: Rgb(0x72, 0x9F, 0xCF),
shape_line: Rgb(0x34, 0x65, 0xA4),
hairline: 0.5,
page_frame_paragraph_is_page: true,
inline_invalid_relation_is_baseline: true,
picture_lines_fit_pictures: true,
balance_section_columns: true,
sum_paragraph_spacing: true,
widow_control: false,
}
}
/// A preset's values (a custom profile starts from Word's style).
pub fn of(preset: OdfPreset) -> OdfProfile {
match preset {
OdfPreset::Word | OdfPreset::Custom => OdfProfile { preset, ..OdfProfile::word() },
OdfPreset::LibreOffice => OdfProfile::libreoffice(),
}
}
/// The values that apply: a preset's own, or, for a custom profile, these (lengths made
/// finite and bounded).
pub fn effective(&self) -> OdfProfile {
let length = |v: f32, least: f32| if v.is_finite() { v.clamp(least, MAX_LENGTH) } else { least };
match self.preset {
OdfPreset::Custom => OdfProfile {
page_margin: length(self.page_margin, 0.0),
header_min_height: length(self.header_min_height, 0.0),
column_gap: length(self.column_gap, 0.0),
hairline: length(self.hairline, 0.05),
..self.clone()
},
p => OdfProfile::of(p),
}
}
/// What the layout needs to know of it for a document opened from ODF.
pub fn layout(&self) -> OdfLayout {
let e = self.effective();
OdfLayout { picture_lines_fit_pictures: e.picture_lines_fit_pictures, tabs_from_indent: e.tabs_from_indent }
}
}
/// A document opened from ODF, for the layout: rules of the standard that differ from Word's
/// (a paragraph's padding takes room on a side without a border, XSL's box model) apply, and
/// the profile's choices that are made as the document is laid out.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct OdfLayout {
/// See [`OdfProfile::picture_lines_fit_pictures`].
pub picture_lines_fit_pictures: bool,
/// Tab stops are measured from the paragraph's left indent, default ones too (LibreOffice's
/// `TabsRelativeToIndent`, else [`OdfProfile::tabs_from_indent`]). The document's explicit
/// stops are held from the margin either way; the default ones are counted from the indent.
pub tabs_from_indent: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn presets_and_custom_values() {
assert_eq!(OdfProfile::default().preset, OdfPreset::Word);
assert_eq!(OdfPreset::from_id(" LibreOffice "), Some(OdfPreset::LibreOffice));
assert_eq!(OdfPreset::from_id("google"), None);
// Word's style by its id, and by the id it had before.
for id in ["word", "Word", "wordcraft", "MSWord", "ms-word"] {
assert_eq!(OdfPreset::from_id(id), Some(OdfPreset::Word), "{id}");
}
assert_eq!(serde_json::from_value::<OdfPreset>(serde_json::json!("wordcraft")).unwrap(), OdfPreset::Word);
assert_eq!(serde_json::from_value::<OdfPreset>(serde_json::json!("word")).unwrap(), OdfPreset::Word);
assert_eq!(OdfPreset::STYLES.map(OdfPreset::label), ["Microsoft Word style", "LibreOffice style"]);
// Saved and taken by `file.options` as its id.
for p in OdfPreset::ALL {
assert_eq!(serde_json::to_value(p).unwrap(), serde_json::json!(p.id()));
}
// A preset's values apply whatever the profile holds.
let lo = OdfProfile { column_gap: 99.0, ..OdfProfile::libreoffice() };
assert_eq!(lo.effective(), OdfProfile::libreoffice());
// A custom profile's own, bounded.
let custom = OdfProfile { preset: OdfPreset::Custom, column_gap: f32::NAN, header_min_height: 1e9, ..OdfProfile::word() };
let e = custom.effective();
assert_eq!((e.column_gap, e.header_min_height), (0.0, MAX_LENGTH));
assert!(OdfProfile::libreoffice().layout().picture_lines_fit_pictures);
assert!(!OdfProfile::word().layout().picture_lines_fit_pictures);
}
}
+3 -3
View File
@@ -231,7 +231,7 @@ impl Paragraph {
/// Number of objects in `text[..off]`.
fn objects_before(&self, off: usize) -> usize {
self.text.get(..off).map(|t| t.chars().filter(|c| *c == OBJ).count()).unwrap_or(0)
self.text.get(..off).map(|t| t.chars().inspect(|_| crate::work::steps(1)).filter(|c| *c == OBJ).count()).unwrap_or(0)
}
/// The object at byte `off` (which must hold U+FFFC).
@@ -250,13 +250,13 @@ impl Paragraph {
}
/// Byte offsets of all objects, in order.
pub fn object_offsets(&self) -> Vec<usize> {
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
}
/// (byte offset, object) for every object, in text order, in one pass. Prefer it to
/// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before
/// each offset again, so a loop over a paragraph's objects is quadratic (#3614).
pub fn objects_with_offsets(&self) -> impl Iterator<Item = (usize, &InlineObject)> + '_ {
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
}
/// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length.
+23
View File
@@ -186,6 +186,11 @@ pub struct Float {
/// top. 0 = unknown, drawn in document order (objects inserted here, or from other formats).
#[serde(skip_serializing_if = "is_zero_u32")]
pub z: u32,
/// Turned, but the file keeps no room for the turn (`wp:effectExtent` missing or zero, as
/// files from other tools have): Word then keeps an inline one's line to its frame and lets
/// the turned object overhang it (see [`Float::line_pad`]). Cleared by [`Float::set_spin`].
#[serde(skip_serializing_if = "is_false")]
pub bare_turn: bool,
}
/// Longest alternative text kept (characters): hostile files and commands are capped.
@@ -241,6 +246,7 @@ impl Float {
self.rot = wordcraft_geom::normalize_degrees(s.deg);
self.flip_h = s.flip_h;
self.flip_v = s.flip_v;
self.bare_turn = false;
}
/// How far a `w` × `h` frame's rotated bounds reach past it on each side (x, y): the room a
/// rotated object takes beyond its frame (zero unrotated; negative where the turned object
@@ -250,6 +256,23 @@ impl Float {
let (bw, bh) = self.spin().extent(w, h);
((bw - w) / 2.0, (bh - h) / 2.0)
}
/// A [`Float::bare_turn`] still inline and without effects room: made floating, or given room
/// for a shadow or glow since, it saves, and so lays out, with the turn's room again.
pub fn is_bare_turn(&self) -> bool {
self.bare_turn && self.wrap == Wrap::Inline && self.effect_extent() == [0.0; 4]
}
/// The room an inline `w` × `h` object's line keeps past its frame on each side (x, y) for its
/// turn: its rotated bounds ([`Float::spin_pad`]) — or, for a [`Float::is_bare_turn`], none, except
/// that Word 2013+ layout (`modern`) exchanges the frame's width and height from 45° up to 135°
/// and from 225° up to 315°.
pub fn line_pad(&self, w: f32, h: f32, modern: bool) -> (f32, f32) {
if !self.is_bare_turn() {
return self.spin_pad(w, h);
}
let (w, h) = (wordcraft_geom::finite(w).clamp(0.0, 4000.0), wordcraft_geom::finite(h).clamp(0.0, 4000.0));
let deg = wordcraft_geom::normalize_degrees(self.rot);
if modern && ((45.0..135.0).contains(&deg) || (225.0..315.0).contains(&deg)) { ((h - w) / 2.0, (w - h) / 2.0) } else { (0.0, 0.0) }
}
/// The effect extents, finite and clamped (left, top, right, bottom).
pub fn effect_extent(&self) -> [f32; 4] {
self.effect.map(|v| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 })
+22 -5
View File
@@ -19,6 +19,9 @@ pub enum InlineObject {
/// Crop (left, top, right, bottom) fractions 0..1.
#[serde(default)]
crop: [f32; 4],
/// Brightness, contrast and transparency read from the file (see [`crate::picfx`]).
#[serde(default, skip_serializing_if = "crate::picfx::PicFx::is_identity")]
fx: crate::picfx::PicFx,
/// The OLE object (embedded or linked file) or Word signature line this picture shows, as
/// read from a file: saving writes the object back, not only its picture. See
/// [`crate::graphic::Embedded`].
@@ -313,20 +316,34 @@ impl InlineObject {
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
/// (x, y, w, h) and the member. Empty for anything else.
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
let InlineObject::Group { children, .. } = self else { return Vec::new() };
self.member_rects(x, y, w, h).into_iter().filter_map(|(i, r)| Some((r, &children.get(i)?.obj))).collect()
}
/// [`InlineObject::group_rects`] with each member's index in `children` instead of the member.
pub fn member_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<(usize, [f32; 4])> {
let InlineObject::Group { children, .. } = self else { return Vec::new() };
let (sx, sy) = self.group_scale(w, h);
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
children
.iter()
.take(MAX_GROUP_CHILDREN)
.filter_map(|c| {
.enumerate()
.filter_map(|(i, c)| {
let (cw, chh, _) = c.obj.frame()?;
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
(!matches!(c.obj, InlineObject::Group { .. })).then_some((i, r))
})
.collect()
}
/// How much a group drawn `w` × `h` scales its members' space (x, y): 1 for a canvas, whose
/// members keep their size.
pub fn group_scale(&self, w: f32, h: f32) -> (f32, f32) {
let InlineObject::Group { ch_w, ch_h, .. } = self else { return (1.0, 1.0) };
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
(sx, sy)
}
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
/// the `w:ffData` it keeps; `None` for anything else.
pub fn legacy_form(&self) -> Option<crate::legacy_form::LegacyForm> {
+190
View File
@@ -0,0 +1,190 @@
//! Picture colour corrections read from a file: brightness, contrast and transparency
//! (DrawingML `a:lum` and `a:alphaModFix` inside `a:blip`, ECMA-376 §20.1.8.42 and §20.1.8.4;
//! VML `v:imagedata` `gain` and `blacklevel`, which Word's picture watermark "Washout" sets).
//!
//! Values are kept in OOXML units: thousandths of a percent, so 100 000 is 100 %. All zero is
//! the picture as it is.
//!
//! The pixel formula was measured on Word (#1711, #1766): Word bakes the corrected pixels into
//! the picture it embeds in a PDF export, so a synthetic grey and colour ramp exported with
//! 20 `a:lum` combinations and 6 VML `gain`/`blacklevel` pairs gives the curve. Every channel is
//! mapped on its own (no hue or luminance coupling) by one straight line, clamped to 0..=255
//! only at the end:
//!
//! `y = s·(x − 127.5) + 127.5 + 127.5·b·(1 + s)`, with `b = bright / 100 000`, `c = contrast /
//! 100 000`, `s = 1 + c` when `c ≤ 0` and `s = 1 / (1 − c)` when `c > 0`
//!
//! (as if half the brightness shift were applied before the contrast and half after). Measured
//! slope and intercept of Word's output for grey input `x` (PNG pictures, exact to ±1 level):
//! bright 20 000 → 1.00, +51; bright −40 000 → 1.00, −103; contrast 80 000 → 5.00, −510;
//! contrast −80 000 → 0.20, +101.4; (40 000, −40 000) → 0.60, +132.2; (80 000, −80 000) →
//! 0.20, +223.8 (also the #1766 header picture: fitted 0.197..0.201, +223.6..224.0);
//! (−40 000, 40 000) → 1.667, −221.7; (30 000, 60 000) → 2.50, −57.8.
//!
//! VML: `gain` is the slope itself and `blacklevel` half the brightness — Word draws `gain` 0.3,
//! `blacklevel` 0.35 (Washout) as 0.30, +205.3, the same as `a:lum` (70 000, −70 000), and
//! `gain` 1, `blacklevel` 0.2 as the very same picture as `bright="40000"`.
use serde::{Deserialize, Serialize};
/// The largest magnitude of a correction (100 %).
pub const FX_MAX: i32 = 100_000;
/// Brightness, contrast and transparency of a picture (see the module docs).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct PicFx {
/// −100 000 (black) ..= 100 000 (white).
pub bright: i32,
/// −100 000 (flat grey) ..= 100 000 (hard threshold).
pub contrast: i32,
/// 0 (opaque) ..= 100 000 (invisible).
pub transparency: i32,
}
impl PicFx {
/// Corrections clamped to their ranges.
pub fn new(bright: i32, contrast: i32, transparency: i32) -> PicFx {
PicFx { bright: bright.clamp(-FX_MAX, FX_MAX), contrast: contrast.clamp(-FX_MAX, FX_MAX), transparency: transparency.clamp(0, FX_MAX) }
}
/// The same corrections, each clamped to its range (for values that came from anywhere).
pub fn clamped(self) -> PicFx {
PicFx::new(self.bright, self.contrast, self.transparency)
}
/// No correction at all.
pub fn is_identity(&self) -> bool {
*self == PicFx::default()
}
/// Brightness or contrast changes the pixels (transparency alone doesn't).
pub fn changes_pixels(&self) -> bool {
let f = self.clamped();
f.bright != 0 || f.contrast != 0
}
/// The picture's opacity, 0..=1 (1 − transparency).
pub fn opacity(&self) -> f32 {
1.0 - self.transparency.clamp(0, FX_MAX) as f32 / FX_MAX as f32
}
/// The corrections a VML `v:imagedata` gives with `gain` and `blacklevel` (already parsed to
/// plain numbers, 1 and 0 when missing): contrast from the slope `gain`, brightness twice the
/// black level (see the module docs). Non-finite values count as missing.
pub fn from_vml(gain: f64, blacklevel: f64) -> PicFx {
let gain = if gain.is_finite() { gain.clamp(0.0, 1.0e6) } else { 1.0 };
let black = if blacklevel.is_finite() { blacklevel.clamp(-0.5, 0.5) } else { 0.0 };
// s = 1 + c (c ≤ 0) or 1 / (1 − c) (c > 0).
let c = if gain <= 1.0 { gain - 1.0 } else { 1.0 - 1.0 / gain };
let fx = |v: f64| (v * FX_MAX as f64).round().clamp(-(FX_MAX as f64), FX_MAX as f64) as i32;
PicFx::new(fx(2.0 * black), fx(c), 0)
}
/// `gain` and `blacklevel` for VML (the inverse of [`PicFx::from_vml`]).
pub fn to_vml(&self) -> (f64, f64) {
(self.slope(), self.clamped().bright as f64 / FX_MAX as f64 / 2.0)
}
/// The contrast slope `s` (capped at 255: contrast 100 % is a threshold).
fn slope(&self) -> f64 {
let c = self.contrast.clamp(-FX_MAX, FX_MAX) as f64 / FX_MAX as f64;
if c <= 0.0 { 1.0 + c } else { (1.0 / (1.0 - c).max(1.0 / 255.0)).min(255.0) }
}
/// The lookup table every colour channel goes through.
pub fn table(&self) -> [u8; 256] {
let s = self.slope();
let b = self.bright.clamp(-FX_MAX, FX_MAX) as f64 / FX_MAX as f64;
let mut t = [0u8; 256];
for (x, slot) in t.iter_mut().enumerate() {
let y = s * (x as f64 - 127.5) + 127.5 + 127.5 * b * (1.0 + s);
*slot = if y.is_finite() { y.round().clamp(0.0, 255.0) as u8 } else { 0 };
}
t
}
/// Apply brightness and contrast to straight (not premultiplied) RGBA8 pixels in place. The
/// alpha byte is left alone (transparency is drawn as an opacity, see [`PicFx::opacity`]); a
/// trailing partial pixel is ignored.
pub fn apply_rgba(&self, rgba: &mut [u8]) {
if !self.changes_pixels() {
return;
}
let t = self.table();
for px in rgba.as_chunks_mut::<4>().0 {
for c in px.iter_mut().take(3) {
*c = t.get(usize::from(*c)).copied().unwrap_or(*c);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Word's own output for grey input 0, 128 and 255 (PNG pictures in a PDF export).
#[test]
fn matches_word() {
let cases: [(PicFx, [u8; 3]); 6] = [
(PicFx::new(40_000, -40_000, 0), [132, 209, 255]),
(PicFx::new(80_000, -80_000, 0), [224, 249, 255]),
(PicFx::new(-40_000, 40_000, 0), [0, 0, 203]),
(PicFx::new(0, 80_000, 0), [0, 130, 255]),
(PicFx::new(20_000, 0, 0), [51, 179, 255]),
(PicFx::from_vml(19661.0 / 65536.0, 22938.0 / 65536.0), [205, 244, 255]),
];
for (fx, want) in cases {
let t = fx.table();
for (x, w) in [0usize, 128, 255].into_iter().zip(want) {
let got = t[x];
assert!(got.abs_diff(w) <= 2, "{fx:?}: {x} → {got}, Word {w}");
}
}
}
#[test]
fn channels_apart_alpha_kept() {
let fx = PicFx::new(80_000, -80_000, 0);
let mut px = [255, 0, 0, 77, 0, 128, 255, 255];
fx.apply_rgba(&mut px);
// Word: red (255, 0, 0) → (255, 224, 224); 128 → 249 (the formula gives 250.0).
assert_eq!(px, [255, 224, 224, 77, 224, 250, 255, 255]);
let mut odd = [0u8; 6];
fx.apply_rgba(&mut odd);
assert_eq!(odd[4..], [0, 0]);
}
#[test]
fn vml_mapping() {
// 16.16 fixed point: 19661f and 22938f are 0.3 and 0.35 to within 1e-5.
let washout = PicFx::from_vml(19661.0 / 65536.0, 22938.0 / 65536.0);
assert!((washout.bright - 70_000).abs() <= 2 && (washout.contrast + 70_000).abs() <= 2, "{washout:?}");
assert_eq!(PicFx::from_vml(1.0, 0.2), PicFx::new(40_000, 0, 0));
assert_eq!(PicFx::from_vml(2.0, 0.0), PicFx::new(0, 50_000, 0));
assert!(PicFx::from_vml(1.0, 0.0).is_identity());
for (g, b) in [(f64::NAN, f64::INFINITY), (-1.0e30, 1.0e30), (1.0e300, -7.0), (0.0, 0.0)] {
let fx = PicFx::from_vml(g, b);
assert_eq!(fx, fx.clamped(), "{g} {b}");
let _ = fx.table();
}
}
#[test]
fn hostile_values_clamp() {
for v in [i32::MIN, -100_001, 0, 100_001, i32::MAX] {
let fx = PicFx { bright: v, contrast: v, transparency: v };
let _ = fx.table();
assert!((0.0..=1.0).contains(&fx.opacity()));
let mut px = [1u8, 2, 3, 4];
fx.apply_rgba(&mut px);
assert_eq!(px[3], 4);
}
assert_eq!(PicFx::new(0, 0, 25_000).opacity(), 0.75);
assert!(!PicFx::new(0, 0, 25_000).changes_pixels());
// Contrast 100 %: a threshold at mid-grey, no NaN.
let t = PicFx::new(0, FX_MAX, 0).table();
assert_eq!((t[0], t[127], t[128], t[255]), (0, 0, 255, 255));
}
}
+4 -1
View File
@@ -26,7 +26,10 @@ pub use char_props::{
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, EmphasisMark, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
};
pub use color::{Rgb, TextColor};
pub use para_props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextFrame};
pub use para_props::{
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, TAB_POS_EPS, TabAlign, TabLeader, TabStop,
TextAlignment, TextFrame,
};
pub use table_props::{
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
anchor_ooxml, valign_from_anchor,
+141 -2
View File
@@ -26,6 +26,101 @@ impl Align {
}
}
/// Paragraph-level text flow (`w:pPr/w:textDirection`, ECMA-376 §17.3.1.41).
///
/// Kept separate from [`super::TextDirection`], whose three values model the table-cell direction
/// control. These values preserve the exact OOXML direction through JSON and DOCX saves.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ParagraphTextDirection {
LrTb,
LrTbV,
Tb,
TbRl,
TbRlV,
TbLr,
TbLrV,
BtLr,
TbV,
Rl,
RlV,
Lr,
LrV,
}
impl ParagraphTextDirection {
pub fn from_ooxml(value: &str) -> Option<Self> {
match value {
"lrTb" => Some(Self::LrTb),
"lrTbV" => Some(Self::LrTbV),
"tb" => Some(Self::Tb),
"tbRl" => Some(Self::TbRl),
"tbRlV" => Some(Self::TbRlV),
"tbLr" => Some(Self::TbLr),
"tbLrV" => Some(Self::TbLrV),
"btLr" => Some(Self::BtLr),
"tbV" => Some(Self::TbV),
"rl" => Some(Self::Rl),
"rlV" => Some(Self::RlV),
"lr" => Some(Self::Lr),
"lrV" => Some(Self::LrV),
_ => None,
}
}
pub fn ooxml(self) -> &'static str {
match self {
Self::LrTb => "lrTb",
Self::LrTbV => "lrTbV",
Self::Tb => "tb",
Self::TbRl => "tbRl",
Self::TbRlV => "tbRlV",
Self::TbLr => "tbLr",
Self::TbLrV => "tbLrV",
Self::BtLr => "btLr",
Self::TbV => "tbV",
Self::Rl => "rl",
Self::RlV => "rlV",
Self::Lr => "lr",
Self::LrV => "lrV",
}
}
}
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
/// ECMA-376 §17.3.1.39).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TextAlignment {
Auto,
Top,
Center,
Baseline,
Bottom,
}
impl TextAlignment {
pub fn ooxml(self) -> &'static str {
match self {
TextAlignment::Auto => "auto",
TextAlignment::Top => "top",
TextAlignment::Center => "center",
TextAlignment::Baseline => "baseline",
TextAlignment::Bottom => "bottom",
}
}
pub fn from_ooxml(s: &str) -> Option<TextAlignment> {
match s {
"auto" => Some(TextAlignment::Auto),
"top" => Some(TextAlignment::Top),
"center" => Some(TextAlignment::Center),
"baseline" => Some(TextAlignment::Baseline),
"bottom" => Some(TextAlignment::Bottom),
_ => None,
}
}
}
/// Line spacing rule.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "rule", content = "value")]
@@ -107,6 +202,18 @@ pub enum BorderStyle {
Triple,
DotDash,
Wave,
/// The compound lines of thin and thick strokes (`thinThickSmallGap` to
/// `thinThickThinLargeGap`), kept so a file writes back the line it read; drawn as a single
/// line of the border's width.
ThinThickSmallGap,
ThickThinSmallGap,
ThinThickThinSmallGap,
ThinThickMediumGap,
ThickThinMediumGap,
ThinThickThinMediumGap,
ThinThickLargeGap,
ThickThinLargeGap,
ThinThickThinLargeGap,
}
impl BorderStyle {
@@ -121,6 +228,15 @@ impl BorderStyle {
BorderStyle::Triple => "triple",
BorderStyle::DotDash => "dotDash",
BorderStyle::Wave => "wave",
BorderStyle::ThinThickSmallGap => "thinThickSmallGap",
BorderStyle::ThickThinSmallGap => "thickThinSmallGap",
BorderStyle::ThinThickThinSmallGap => "thinThickThinSmallGap",
BorderStyle::ThinThickMediumGap => "thinThickMediumGap",
BorderStyle::ThickThinMediumGap => "thickThinMediumGap",
BorderStyle::ThinThickThinMediumGap => "thinThickThinMediumGap",
BorderStyle::ThinThickLargeGap => "thinThickLargeGap",
BorderStyle::ThickThinLargeGap => "thickThinLargeGap",
BorderStyle::ThinThickThinLargeGap => "thinThickThinLargeGap",
}
}
pub fn from_ooxml(s: &str) -> BorderStyle {
@@ -133,6 +249,15 @@ impl BorderStyle {
"triple" => BorderStyle::Triple,
"dotDash" | "dotDotDash" => BorderStyle::DotDash,
"wave" | "doubleWave" => BorderStyle::Wave,
"thinThickSmallGap" => BorderStyle::ThinThickSmallGap,
"thickThinSmallGap" => BorderStyle::ThickThinSmallGap,
"thinThickThinSmallGap" => BorderStyle::ThinThickThinSmallGap,
"thinThickMediumGap" => BorderStyle::ThinThickMediumGap,
"thickThinMediumGap" => BorderStyle::ThickThinMediumGap,
"thinThickThinMediumGap" => BorderStyle::ThinThickThinMediumGap,
"thinThickLargeGap" => BorderStyle::ThinThickLargeGap,
"thickThinLargeGap" => BorderStyle::ThickThinLargeGap,
"thinThickThinLargeGap" => BorderStyle::ThinThickThinLargeGap,
_ => BorderStyle::Single,
}
}
@@ -241,6 +366,9 @@ pub struct TextFrame {
pub v_anchor: String,
/// `around`, `auto`, `notBeside`, `none`, `tight` or `through`.
pub wrap: String,
/// Whether the frame stays fixed to its page anchor when its paragraph moves.
#[serde(skip_serializing_if = "Option::is_none")]
pub anchor_lock: Option<bool>,
/// Distance from the text left and right, and above and below.
pub h_space: f32,
pub v_space: f32,
@@ -295,6 +423,9 @@ pub struct ParaProps {
/// outside ones, so they swap sides on even (left-hand) pages.
#[serde(skip_serializing_if = "Option::is_none")]
pub mirror_indents: Option<bool>,
/// `w:adjustRightInd`: automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
#[serde(skip_serializing_if = "Option::is_none")]
pub adjust_right_indent: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keep_next: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -321,6 +452,9 @@ pub struct ParaProps {
pub suppress_line_numbers: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bidi: Option<bool>,
/// Paragraph text direction (`w:textDirection`); `None` inherits from the paragraph style.
#[serde(skip_serializing_if = "Option::is_none")]
pub text_direction: Option<ParagraphTextDirection>,
/// Drop cap: lines to drop (0 = none) — applies to the first character(s).
#[serde(skip_serializing_if = "Option::is_none")]
pub drop_cap: Option<u8>,
@@ -368,6 +502,10 @@ pub struct ParaProps {
/// `w:snapToGrid`: lines snap to the section's document grid. Unset = on.
#[serde(skip_serializing_if = "Option::is_none")]
pub snap_to_grid: Option<bool>,
/// `w:textAlignment`: how characters of different sizes line up on a line. Kept for the
/// file (not laid out yet); Word sizes a line by it, so dropping it changes Word's pages.
#[serde(skip_serializing_if = "Option::is_none")]
pub text_alignment: Option<TextAlignment>,
}
impl ParaProps {
@@ -379,7 +517,7 @@ impl ParaProps {
fn overlay_layout(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars,
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders);
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, adjust_right_indent, tabs, shading, borders);
}
fn overlay_pagination(&mut self, patch: &ParaProps) {
@@ -388,7 +526,8 @@ impl ParaProps {
}
fn overlay_script_layout(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
overlay_fields!(self, patch; bidi, text_direction, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment);
}
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
self.overlay(patch);
+39 -2
View File
@@ -137,9 +137,12 @@ impl ResolvedChar {
pub fn uses_complex(&self, c: char) -> bool {
self.rtl || self.cs || crate::bidi::is_complex_script(c)
}
/// The size glyphs are drawn at (super/subscript shrink to ~2/3).
/// The size glyphs are drawn and measured at. Word sets superscript and subscript text at
/// 0.65 of the run's size rounded to the half point, a font size the format can store
/// (measured in Word for Mac from digit advances: 8 pt → 5, 9 → 6, 11 → 7, 12 → 8,
/// 16 → 10.5, 24 → 15.5, 36 → 23.5).
pub fn draw_size(&self) -> f32 {
if self.vert_align == VertAlign::Baseline { self.size } else { self.size * 0.65 }
if self.vert_align == VertAlign::Baseline { self.size } else { (self.size * 0.65 * 2.0).round() / 2.0 }
}
/// Baseline shift, points (positive = up).
pub fn baseline_shift(&self) -> f32 {
@@ -153,6 +156,10 @@ impl ResolvedChar {
}
/// Concrete paragraph formatting.
fn default_adjust_right_indent() -> bool {
true
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedPara {
@@ -173,6 +180,9 @@ pub struct ResolvedPara {
pub contextual_spacing: bool,
/// Left/right indents are inside/outside: they swap on even pages.
pub mirror_indents: bool,
/// Automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
#[serde(default = "default_adjust_right_indent")]
pub adjust_right_indent: bool,
pub keep_next: bool,
pub keep_lines: bool,
pub page_break_before: bool,
@@ -264,6 +274,7 @@ impl ResolvedPara {
},
contextual_spacing: p.contextual_spacing.unwrap_or(false),
mirror_indents: p.mirror_indents.unwrap_or(false),
adjust_right_indent: p.adjust_right_indent.unwrap_or(true),
keep_next: p.keep_next.unwrap_or(false),
keep_lines: p.keep_lines.unwrap_or(false),
page_break_before: p.page_break_before.unwrap_or(false),
@@ -445,6 +456,32 @@ fn diff_patch<T: Serialize + serde::de::DeserializeOwned + Default>(with: &T, wi
mod tests {
use super::*;
/// Superscript and subscript sizes as Word for Mac sets them (measured from digit advances in
/// its PDF export): 0.65 of the size, rounded to the half point.
#[test]
fn raised_and_lowered_text_is_sized_to_the_half_point() {
for (size, want) in [
(8.0, 5.0),
(9.0, 6.0),
(10.0, 6.5),
(11.0, 7.0),
(12.0, 8.0),
(13.0, 8.5),
(14.0, 9.0),
(16.0, 10.5),
(20.0, 13.0),
(24.0, 15.5),
(36.0, 23.5),
] {
for vert_align in [VertAlign::Superscript, VertAlign::Subscript] {
let rc = ResolvedChar { size, vert_align, ..StyleSheet::builtin().resolve_char(None, &CharProps::default()) };
assert_eq!(rc.draw_size(), want, "{size} pt {vert_align:?}");
}
}
let rc = StyleSheet::builtin().resolve_char(None, &CharProps { size: Some(11.0), ..Default::default() });
assert_eq!(rc.draw_size(), 11.0, "baseline text keeps its size");
}
/// #2036: only a character itself (direct formatting) has a symbol font of its own; a
/// style never gives one to its text.
#[test]
+71 -4
View File
@@ -115,6 +115,40 @@ pub enum NumFormat {
DollarText,
}
/// Chapter separator for page numbers (`w:chapSep`, ECMA-376 §17.18.12).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ChapterSep {
Hyphen,
Period,
Colon,
EmDash,
EnDash,
}
impl ChapterSep {
pub fn from_ooxml(value: &str) -> Option<Self> {
match value {
"hyphen" => Some(Self::Hyphen),
"period" => Some(Self::Period),
"colon" => Some(Self::Colon),
"emDash" => Some(Self::EmDash),
"enDash" => Some(Self::EnDash),
_ => None,
}
}
pub const fn ooxml(self) -> &'static str {
match self {
Self::Hyphen => "hyphen",
Self::Period => "period",
Self::Colon => "colon",
Self::EmDash => "emDash",
Self::EnDash => "enDash",
}
}
}
impl NumFormat {
/// Every format, in the order ST_NumberFormat lists them (which is also the order of the
/// binary .doc number format codes up to 0x27).
@@ -255,13 +289,14 @@ impl NumFormat {
pub fn from_ooxml(s: &str) -> NumFormat {
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
}
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai,
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
/// (the 21st item of ①…⑳).
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai letters
/// and counting, Vietnamese, text amounts) show decimal, as do values past a sequence's end
/// (the 21st item of ①…⑳). Thai numbers are the decimal number in Thai digits (๑, ๒ … ๑๐).
pub fn format(self, n: u32) -> String {
use crate::cjk_numbers as cjk;
match self {
NumFormat::DecimalZero => format!("{n:02}"),
NumFormat::ThaiNumbers => thai_digits(n),
NumFormat::UpperRoman => roman(n).to_uppercase(),
NumFormat::LowerRoman => roman(n),
NumFormat::UpperLetter => letters(n).to_uppercase(),
@@ -315,7 +350,6 @@ impl NumFormat {
| NumFormat::HindiNumbers
| NumFormat::HindiCounting
| NumFormat::ThaiLetters
| NumFormat::ThaiNumbers
| NumFormat::ThaiCounting
| NumFormat::BahtText
| NumFormat::DollarText => n.to_string(),
@@ -323,6 +357,11 @@ impl NumFormat {
}
}
/// `n` in Thai digits, ๐ (U+0E50) to ๙ (#5757).
fn thai_digits(n: u32) -> String {
n.to_string().chars().filter_map(|c| c.to_digit(10).and_then(|d| char::from_u32(0x0E50 + d))).collect()
}
/// Chicago Manual of Style note marks: *, †, ‡, §, then doubled (**, ††, …) and so on.
fn chicago(n: u32) -> String {
const MARKS: [char; 4] = ['*', '\u{2020}', '\u{2021}', '\u{a7}'];
@@ -749,6 +788,9 @@ pub struct SectionProps {
pub title_page: bool,
pub page_num_start: Option<u32>,
pub page_num_format: NumFormat,
/// Chapter heading level and separator in `w:pgNumType`.
pub chapter_style: Option<u8>,
pub chapter_sep: Option<ChapterSep>,
/// The file carries a `w:pgNumType` element that sets nothing else (no start, decimal
/// format), so saving writes it back rather than dropping it.
#[serde(skip_serializing_if = "std::ops::Not::not")]
@@ -779,6 +821,13 @@ pub struct SectionProps {
pub footnote_pr: NoteProps,
#[serde(skip_serializing_if = "NoteProps::is_empty")]
pub endnote_pr: NoteProps,
/// First-page and other-page printer trays (`w:paperSrc`).
pub paper_src_first: Option<u16>,
pub paper_src_other: Option<u16>,
/// Section-specific suppression of endnotes (`w:noEndnote`).
pub no_endnote: Option<bool>,
/// Independent footnote column count (`w15:footnoteColumns`).
pub footnote_columns: Option<u32>,
/// Whether its columns end balanced (an ODF section's `text:dont-balance-text-columns`):
/// `None` is Word's rule, balanced when a continuous section follows; `Some(true)` also at
/// the end of the document, `Some(false)` never.
@@ -809,6 +858,8 @@ impl Default for SectionProps {
title_page: false,
page_num_start: None,
page_num_format: NumFormat::Decimal,
chapter_style: None,
chapter_sep: None,
page_num_set: false,
line_numbers: None,
valign: PageVAlign::Top,
@@ -821,6 +872,10 @@ impl Default for SectionProps {
doc_grid: None,
footnote_pr: NoteProps::default(),
endnote_pr: NoteProps::default(),
paper_src_first: None,
paper_src_other: None,
no_endnote: None,
footnote_columns: None,
balance_columns: None,
}
}
@@ -949,6 +1004,18 @@ mod tests {
assert_eq!(NumFormat::from_ooxml("klingon"), NumFormat::Decimal, "unknown formats are decimal");
}
/// #5757: Thai numbers are the decimal number in Thai digits, every digit, at any length.
#[test]
fn thai_numbers_use_thai_digits() {
let labels: Vec<String> = [1, 2, 3, 9, 10, 11, 20, 105, 2569].iter().map(|n| NumFormat::ThaiNumbers.format(*n)).collect();
assert_eq!(labels.join(" "), "๑ ๒ ๓ ๙ ๑๐ ๑๑ ๒๐ ๑๐๕ ๒๕๖๙");
assert_eq!(NumFormat::ThaiNumbers.format(0), "๐");
assert_eq!(NumFormat::ThaiNumbers.format(u32::MAX), "๔๒๙๔๙๖๗๒๙๕");
assert_eq!(NumFormat::from_ooxml("thaiNumbers"), NumFormat::ThaiNumbers);
// Formats not drawn yet still show decimal.
assert_eq!(NumFormat::ThaiLetters.format(3), "3");
}
#[test]
fn russian_letters_follow_word() {
// Word's own labels for a russianLower list: no ё, й, ъ or ь, then doubled letters.
+15 -3
View File
@@ -167,10 +167,22 @@ pub struct Settings {
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub remove_date_time: bool,
/// The document was opened from ODF: the standard's rules that differ from Word's apply as
/// it is laid out, with the choices it leaves open made as the user's profile made them
/// (see [`crate::odf_profile`]).
/// it is laid out.
#[serde(skip_serializing_if = "Option::is_none")]
pub odf: Option<crate::odf_profile::OdfLayout>,
pub odf: Option<OdfLayout>,
}
/// A document opened from ODF, for the layout: rules of the standard that differ from Word's (a
/// paragraph's padding takes room on a side without a border, XSL's box model) apply, and what
/// the file says about laying it out.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct OdfLayout {
/// Tab stops are measured from the paragraph's left indent, default ones too (LibreOffice's
/// `TabsRelativeToIndent` in the file; from the margin when it doesn't say). The document's
/// explicit stops are held from the margin either way; the default ones are counted from the
/// indent.
pub tabs_from_indent: bool,
}
/// A saved zoom: a percentage, or a fit mode (the percentage it last came to).
+35 -1
View File
@@ -12,6 +12,8 @@
use serde::{Deserialize, Serialize};
use crate::props::Rgb;
/// Most items in a SmartArt graphic.
pub const MAX_ITEMS: usize = 100;
/// Longest item text, characters.
@@ -198,11 +200,14 @@ pub struct SmartArtItem {
pub text: String,
#[serde(default)]
pub level: u8,
/// Fill of the item's shape in place of the style's colour.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<Rgb>,
}
impl SmartArtItem {
pub fn new(text: impl Into<String>, level: u8) -> SmartArtItem {
SmartArtItem { text: text.into(), level }
SmartArtItem { text: text.into(), level, color: None }
}
}
@@ -257,6 +262,35 @@ impl SmartArtSpec {
}
}
/// Whether any item has its own colour.
pub fn has_custom_colors(&self) -> bool {
self.items.iter().any(|it| it.color.is_some())
}
/// Give every item the style's colour again.
pub fn clear_custom_colors(&mut self) {
for it in &mut self.items {
it.color = None;
}
}
/// The theme accent (1–6) the style gives item `i`; `None` when the item has no shape of
/// its own (it is text inside another item's shape).
pub fn style_accent(&self, i: usize) -> Option<usize> {
let it = self.items.get(i)?;
let tops = || self.items.iter().take(i).filter(|o| o.level == 0).count();
match self.layout {
SmartArtLayout::Hierarchy => Some(self.colors.accent(i)),
SmartArtLayout::Radial if i == 0 => Some(self.colors.accent(0)),
SmartArtLayout::Radial => {
let parents = self.parents();
let spoke = |j: usize| j > 0 && matches!(parents.get(j), Some(Some(0)) | Some(None));
spoke(i).then(|| self.colors.accent((1..i).filter(|j| spoke(*j)).count() + 1))
}
_ => (it.level == 0).then(|| self.colors.accent(tops())),
}
}
/// The graphic after `sanitize`.
pub fn sanitized(mut self) -> SmartArtSpec {
self.sanitize();
+25
View File
@@ -966,6 +966,31 @@ fn table_styles() -> Vec<Style> {
#[cfg(test)]
mod tests {
use super::*;
use crate::resolve::ResolvedPara;
#[test]
fn adjust_right_indent_inherits_and_can_be_overridden() {
let resolved = StyleSheet::empty().resolve_para(&ParaProps::default());
assert!(resolved.adjust_right_indent);
let mut older_json = serde_json::to_value(&resolved).unwrap();
older_json.as_object_mut().unwrap().remove("adjustRightIndent");
assert!(serde_json::from_value::<ResolvedPara>(older_json).unwrap().adjust_right_indent);
let mut s = StyleSheet::empty();
s.styles.push(Style {
id: "Normal".into(),
name: "Normal".into(),
para: ParaProps { adjust_right_indent: Some(true), ..Default::default() },
..Default::default()
});
assert!(s.resolve_para(&ParaProps::default()).adjust_right_indent);
assert!(!s.resolve_para(&ParaProps { adjust_right_indent: Some(false), ..Default::default() }).adjust_right_indent);
let json = serde_json::to_value(ParaProps { adjust_right_indent: Some(false), ..Default::default() }).unwrap();
assert_eq!(json["adjustRightIndent"], false);
assert_eq!(serde_json::from_value::<ParaProps>(json).unwrap().adjust_right_indent, Some(false));
}
#[test]
fn builtins_present() {
+7 -2
View File
@@ -19,8 +19,8 @@
//!
//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent.
/// The symbol fonts this module knows, by lowercase family name.
fn table(family: &str) -> Option<&'static [u32; 224]> {
/// The symbol table for a single family name, by lowercase family name.
fn single_table(family: &str) -> Option<&'static [u32; 224]> {
let f = family.trim();
if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") {
Some(&SYMBOL)
@@ -37,6 +37,11 @@ fn table(family: &str) -> Option<&'static [u32; 224]> {
}
}
/// The symbol fonts this module knows, by lowercase family name.
fn table(family: &str) -> Option<&'static [u32; 224]> {
if family.contains(';') { family.split(';').map(str::trim).filter(|s| !s.is_empty()).find_map(single_table) } else { single_table(family) }
}
/// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3,
/// Webdings), whose characters [`symbol_char`] maps.
pub fn is_symbol_font(family: &str) -> bool {
+19
View File
@@ -19,6 +19,25 @@ fn word_count() {
assert_eq!(count_words(" -- "), 0);
}
/// #2797: the status bar's character counts, worded like the Word Count dialog's: characters
/// with spaces leave the paragraph breaks out, characters without spaces leave every
/// whitespace character out.
#[test]
fn character_counts() {
assert_eq!(count_chars(""), 0);
assert_eq!(count_chars("Hello"), 5);
// Paragraph breaks join the paragraphs but aren't characters.
assert_eq!(count_chars("Hello\nworld\r\n"), 10);
assert_eq!(count_chars("a\tb"), 3, "a tab is a character");
// Every kind of whitespace goes, spaces, tabs and (non-ASCII) spaces between words.
assert_eq!(count_chars_no_spaces("Hello world"), 10);
assert_eq!(count_chars_no_spaces("a\tb c\u{a0}d"), 4, "including a non-breaking space");
assert_eq!(count_chars_no_spaces(" \n\r\t "), 0);
// Accented letters and emoji count as one character each, as they are.
assert_eq!(count_chars("café 🚀"), 6);
assert_eq!(count_chars_no_spaces("café 🚀"), 5);
}
/// #4547: each Han ideograph and kana counts as a word; punctuation and Hangul spacing don't change.
#[test]
fn word_count_counts_each_chinese_and_japanese_character() {
+9 -26
View File
@@ -102,18 +102,6 @@ fn dense(n: usize) -> Document {
d
}
// Thread CPU excludes scheduler delays. Missing or nonadvancing counters provide no timing
// evidence; the semantic assertions still run on those platforms.
fn cpu_cost(f: impl FnOnce()) -> Option<f64> {
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
let before = ran();
f();
match (before, ran()) {
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
_ => None,
}
}
#[test]
fn conversion_work_scales_with_reference_and_formatting_runs() {
let (small, big) = (dense(2_000), dense(16_000));
@@ -128,18 +116,13 @@ fn conversion_work_scales_with_reference_and_formatting_runs() {
};
convert(&small);
convert(&big);
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
for _ in 0..3 {
let a = cpu_cost(|| (0..8).for_each(|_| convert(&small)));
let b = cpu_cost(|| convert(&big));
if let (Some(a), Some(b)) = (a, b) {
t1 = t1.min(a / 8.0);
t8 = t8.min(b);
}
}
if t1.is_finite() && t8.is_finite() {
let ratio = t8 / t1;
eprintln!("note conversion CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
assert!(ratio < 24.0, "8x the references and runs cost {ratio:.2}x CPU");
}
// The characters and runs the conversion walks (`crate::work`), not a timing: about 8x for 8x
// the references and runs when they are walked together, about 64x when each run rescans the
// references.
let s1 = crate::work::count(|| convert(&small));
let s8 = crate::work::count(|| convert(&big));
let ratio = s8 as f64 / s1.max(1) as f64;
eprintln!("note conversion steps: {s1} -> {s8}, {ratio:.1}x for 8x references and runs");
assert!(s1 > 0, "no steps counted, so the walk this test watches no longer counts them (crate::work)");
assert!(ratio <= 12.0, "8x the references and runs cost {ratio:.1}x the steps ({s1} -> {s8})");
}
+13
View File
@@ -35,6 +35,18 @@ fn is_cjk_word(c: char) -> bool {
) && c.is_alphanumeric()
}
/// Characters in `s`, as the Word Count dialog counts them ("Characters (with spaces)"):
/// paragraph breaks aren't characters (#2797).
pub fn count_chars(s: &str) -> usize {
s.chars().filter(|c| *c != '\n' && *c != '\r').count()
}
/// Characters in `s` ignoring every whitespace character, as the Word Count dialog counts them
/// ("Characters (no spaces)"; #2797).
pub fn count_chars_no_spaces(s: &str) -> usize {
s.chars().filter(|c| !c.is_whitespace()).count()
}
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
pub(crate) const PARA_WALK_DEPTH: usize = 16;
@@ -51,6 +63,7 @@ pub(crate) fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, dept
return;
}
for (i, b) in bl.iter().enumerate() {
crate::work::steps(1);
prefix.push(i as u32);
match &**b {
Block::Para(_) => out.push(Path(prefix.clone())),
+30
View File
@@ -0,0 +1,30 @@
//! A count of the characters a paragraph's object lookups walk, the blocks
//! [`crate::Document::para_paths`] visits, and each block lookup by path with the table levels it
//! descends, for the linear-time tests of the crates that use them: an input 8x the size must cost
//! about 8x the steps, which a busy machine can't skew the way it skews a timing. Counted only in
//! tests (this crate's, and other crates' through the `test-support` feature, which they turn on
//! as a dev-dependency); in the app the count is nothing.
#[cfg(any(test, feature = "test-support"))]
thread_local! {
/// The steps counted on this thread.
static STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// Count `n` steps of a walk (nothing outside tests). Public for the walks other crates do over a
/// paragraph's text themselves (Markdown and HTML comment export, for one).
#[inline(always)]
pub fn steps(n: usize) {
#[cfg(any(test, feature = "test-support"))]
STEPS.with(|s| s.set(s.get().saturating_add(n as u64)));
#[cfg(not(any(test, feature = "test-support")))]
let _ = n;
}
/// The steps `f` takes.
#[cfg(any(test, feature = "test-support"))]
pub fn count(f: impl FnOnce()) -> u64 {
let before = STEPS.with(std::cell::Cell::get);
f();
STEPS.with(std::cell::Cell::get).saturating_sub(before)
}
+158
View File
@@ -0,0 +1,158 @@
use std::sync::Arc;
use wordcraft_doc::props::{Align, CharProps, ParaProps};
use wordcraft_doc::{Block, Document, Paragraph, Path, Pos, StoryRef, Table, para_block};
/// Build an empty heading followed by explicitly formatted, mixed-run body text.
fn fixture(first: &str, style: Option<&str>) -> Document {
let mut d = Document::from_text(&format!("{first}\nBody paragraph."));
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.props.style = Some("Heading1".into());
p.props.outline_level = Some(0);
p.mark = CharProps { size: Some(40.0), bold: Some(true), ..Default::default() };
let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
p.props = ParaProps {
style: style.map(str::to_string),
align: Some(Align::Right),
indent_left: Some(18.0),
space_after: Some(7.0),
keep_next: Some(false),
..Default::default()
};
p.mark = CharProps { size: Some(11.0), bold: Some(false), lang: Some("en-GB".into()), ..Default::default() };
p.format(0, 4, &|c| c.italic = Some(true)).unwrap();
d
}
/// Compare paragraph, mark and inline-run formatting independently of text.
fn assert_format(p: &Paragraph, expected: &Paragraph) {
assert_eq!(p.props, expected.props, "the following paragraph keeps all its properties");
assert_eq!(p.mark, expected.mark, "the empty heading's mark must not replace the following mark");
assert_eq!(serde_json::to_value(&p.runs).unwrap(), serde_json::to_value(&expected.runs).unwrap());
}
/// Deleting an empty paragraph preserves the following style, including implicit Normal.
#[test]
fn deleting_an_empty_paragraph_keeps_the_following_format() {
for first_style in ["Heading1", "Heading3", "Normal"] {
for style in [None, Some("Normal"), Some("Heading2")] {
let mut d = fixture("", style);
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.style = Some(first_style.into());
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
assert_eq!(d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap(), Pos::body(0, 0));
let p = d.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.text, expected.text);
assert_format(p, &expected);
}
}
}
/// A selection spanning several paragraphs keeps the surviving tail format in both directions.
#[test]
fn an_empty_start_preserves_the_last_paragraph_over_a_multi_paragraph_selection() {
for backwards in [false, true] {
let mut d = fixture("", None);
d.body.insert(1, para_block(Paragraph::with_text("Removed middle paragraph", Default::default()).styled("Heading2")));
let mut expected = d.para_at(&Pos::body(2, 0)).unwrap().clone();
expected.delete(0, 5).unwrap();
let (a, b) = (Pos::body(0, 0), Pos::body(2, 5));
d.delete_range(if backwards { &b } else { &a }, if backwards { &a } else { &b }).unwrap();
assert_eq!(d.body.len(), 1);
let p = d.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.text, "paragraph.");
assert_format(p, &expected);
}
}
/// A control whose closing marker survives must move across the join without replacing tail formatting.
#[test]
fn an_empty_start_keeps_a_control_from_a_deleted_middle_paragraph() {
use wordcraft_doc::control::ContentControl;
use wordcraft_doc::{InlineObject, Revision, RevisionKind};
let mut d = fixture("", Some("Normal"));
d.body.insert(1, para_block(Paragraph::with_text("Removed", Default::default())));
d.body.push(para_block(Paragraph::with_text("After", Default::default())));
d.revisions.push(Revision { kind: RevisionKind::Insert, ..Default::default() });
d.revisions.push(Revision { kind: RevisionKind::Delete, ..Default::default() });
let p = d.para_mut(StoryRef::Body, &Path::top(2)).unwrap();
p.mark.ins = Some(0);
p.mark.del = Some(1);
let expected = p.clone();
let marker = CharProps { bold: Some(true), ins: Some(0), ..Default::default() };
d.insert_object(&Pos::body(1, 0), InlineObject::ControlStart { control: Box::new(ContentControl::default()) }, &marker).unwrap();
d.insert_object(&Pos::body(3, 5), InlineObject::ControlEnd, &Default::default()).unwrap();
assert_eq!(d.control_ranges(StoryRef::Body).len(), 1);
d.delete_range(&Pos::body(0, 0), &Pos::body(2, 0)).unwrap();
let p = d.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props, expected.props);
assert_eq!(p.mark, expected.mark, "tail revision IDs and formatting survive the moved marker");
assert_eq!(p.props_of_char(0), &marker);
assert!(matches!(p.objects.as_slice(), [InlineObject::ControlStart { .. }]));
assert_eq!(p.text, format!("\u{fffc}{}", expected.text));
let ranges = d.control_ranges(StoryRef::Body);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].start, Pos::body(0, 0));
assert_eq!(ranges[0].end, Pos::body(1, 5));
assert!(!d.has_unbalanced_controls());
}
/// The empty-paragraph rule applies in header stories and table-cell containers.
#[test]
fn empty_paragraph_merges_work_in_a_header_and_a_table_cell() {
for cell in [false, true] {
let mut d = fixture("", Some("Normal"));
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
let (story, a, b) = if cell {
let mut table = Table::new(1, 1, 300.0);
table.rows[0].cells[0].blocks = d.body.clone();
d.body = vec![Arc::new(Block::Table(table)), para_block(Paragraph::new())];
(StoryRef::Body, Path(vec![0, 0, 0, 0]), Path(vec![0, 0, 0, 1]))
} else {
let id = d.add_part(wordcraft_doc::PartKind::Header, d.body.clone());
(StoryRef::Part(id), Path::top(0), Path::top(1))
};
d.delete_range(&Pos { story, path: a.clone(), off: 0 }, &Pos { story, path: b, off: 0 }).unwrap();
assert_format(d.para(story, &a).unwrap(), &expected);
}
}
/// Absorbing an empty section keeps the following format and inherited header.
#[test]
fn deleting_an_empty_section_paragraph_keeps_format_and_linked_headers() {
let mut d = fixture("", None);
let expected = d.para_at(&Pos::body(1, 0)).unwrap().clone();
let header = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(Paragraph::with_text("Header", Default::default()))]);
let mut section = d.last_section.clone();
section.headers.default = Some(header);
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(section));
d.last_section.margin_left = 96.0;
d.delete_range(&Pos::body(0, 0), &Pos::body(1, 0)).unwrap();
assert_format(d.para_at(&Pos::body(0, 0)).unwrap(), &expected);
assert!(d.para_at(&Pos::body(0, 0)).unwrap().section.is_none());
assert_eq!(d.last_section.headers.default, Some(header));
assert_eq!(d.last_section.margin_left, 96.0);
}
/// Deleting actual text continues to use the first paragraph merge rule.
#[test]
fn joining_nonempty_paragraphs_keeps_the_existing_first_paragraph_rule() {
for off in [0, 7] {
let mut d = fixture("Heading", None);
let expected = d.para_at(&Pos::body(0, 0)).unwrap().props.clone();
d.delete_range(&Pos::body(0, off), &Pos::body(1, 0)).unwrap();
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
}
}
/// Removing a soft line break must not change paragraph formatting.
#[test]
fn a_soft_break_is_not_an_empty_paragraph_merge() {
let mut d = fixture("", None);
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.insert_text(0, "\nHeading", &Default::default()).unwrap();
let expected = p.props.clone();
d.delete_range(&Pos::body(0, 0), &Pos::body(0, 1)).unwrap();
assert_eq!(d.body.len(), 2);
assert_eq!(d.para_at(&Pos::body(0, 0)).unwrap().props, expected);
}
+6 -2
View File
@@ -185,7 +185,7 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
let p = &sp.props;
if let Some(pib) = p.pib {
let media = pics.bse(pib, b.bstore, b.delay)?;
return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, ole: None });
return Some(InlineObject::Image { media, w, h, alt: p.alt.clone(), float, crop: p.crop, fx: Default::default(), ole: None });
}
let story = b.stories.get(&sp.spid).copied().filter(|_| sp.textbox || sp.spt == 202);
let mut kind = match sp.spt {
@@ -211,7 +211,11 @@ fn leaf(sp: &Sp, w: f32, h: f32, float: Float, b: &Build, pics: &mut Pictures) -
kind = ShapeKind::TextBox;
}
let line = kind == ShapeKind::Line;
let fill = if line || p.filled == Some(false) { None } else { Some(Fill::solid(p.fill_color.unwrap_or(Rgb::WHITE))) };
// Brackets and braces (85 to 88, bracket and brace pairs 185, 186) are unfilled unless
// `fFilled` says otherwise, as Word's VML shapetypes for them say (`filled="f"`). They are
// drawn as their box here, which a default fill would make an opaque block over the text.
let unfilled = p.filled == Some(false) || (matches!(sp.spt, 85..=88 | 185 | 186) && p.filled != Some(true));
let fill = if line || unfilled { 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)) };
// 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));
+52 -13
View File
@@ -7,9 +7,26 @@ use crate::DocbinError;
/// Largest bin table (entries) we accept.
const MAX_BINS: usize = 100_000;
/// A Chpx grpprl and the FCs `[lo, hi)` for which it is the one [`Bins::chpx`] returns.
#[derive(Clone, Copy)]
pub(crate) struct ChpxRun<'w> {
pub(crate) grpprl: &'w [u8],
lo: u32,
hi: u32,
}
impl ChpxRun<'_> {
pub(crate) fn contains(&self, fc: u32) -> bool {
self.lo <= fc && fc < self.hi
}
}
/// A parsed `PlcBte*`: FC breakpoints and the FKP page each range lives in.
pub(crate) struct Bins {
fcs: Vec<u32>,
/// `fcs` ascends, so the bin of an FC holds every FC up to the next breakpoint (a corrupt
/// table that does not gets no cached ranges, [`ChpxRun`]).
sorted: bool,
/// Page number (× 512 = byte offset in the WordDocument stream).
pns: Vec<u32>,
}
@@ -20,7 +37,7 @@ impl Bins {
let fc = fc as usize;
let lcb = lcb as usize;
if lcb < 4 || !(lcb - 4).is_multiple_of(8) {
return Ok(Bins { fcs: Vec::new(), pns: Vec::new() });
return Ok(Bins { fcs: Vec::new(), sorted: true, pns: Vec::new() });
}
let end = fc.checked_add(lcb).ok_or_else(|| DocbinError::Malformed("bin table overflow".into()))?;
let plc = table.get(fc..end).ok_or_else(|| DocbinError::Malformed("bin table outside the Table stream".into()))?;
@@ -38,36 +55,58 @@ impl Bins {
// PnBte* keeps the page number in its low bits; the rest must be zero.
pns.push(at(n + 1 + i).ok_or_else(|| DocbinError::Malformed("truncated bin aPn".into()))? & 0x003F_FFFF);
}
Ok(Bins { fcs, pns })
let sorted = fcs.windows(2).all(|w| w.first() <= w.get(1));
Ok(Bins { fcs, sorted, pns })
}
/// The page containing `fc`, as a slice of the WordDocument stream.
fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<&'w [u8]> {
/// The page containing `fc`, as a slice of the WordDocument stream, and the FC range
/// `[lo, hi)` of its bin (`hi` is `u32::MAX` for the last; an empty range when the table
/// is not ascending, so nothing is cached from it).
fn page<'w>(&self, word: &'w [u8], fc: u32) -> Option<(&'w [u8], u32, u32)> {
let i = self.fcs.partition_point(|&f| f <= fc).checked_sub(1)?;
let pn = *self.pns.get(i)?;
let at = pn.checked_mul(512)? as usize;
word.get(at..at.checked_add(512)?)
let page = word.get(at..at.checked_add(512)?)?;
if !self.sorted {
return Some((page, 1, 0));
}
let lo = self.fcs.get(i).copied().unwrap_or(0);
let hi = self.fcs.get(i + 1).copied().unwrap_or(u32::MAX);
Some((page, lo, hi))
}
/// The grpprl of the Chpx covering `fc` ([MS-DOC] ChpxFkp: rgfc u32s, rgb offsets ×2,
/// crun in the last byte).
pub(crate) fn chpx<'w>(&self, word: &'w [u8], fc: u32) -> &'w [u8] {
let Some(page) = self.page(word, fc) else { return &[] };
let Some(&crun) = page.get(511) else { return &[] };
self.chpx_run(word, fc).grpprl
}
/// [`Bins::chpx`] with the FC range over which the same grpprl is the answer, so a walk
/// over consecutive characters looks it up once per run, not once per character.
pub(crate) fn chpx_run<'w>(&self, word: &'w [u8], fc: u32) -> ChpxRun<'w> {
let none = ChpxRun { grpprl: &[], lo: 1, hi: 0 };
let Some((page, page_lo, page_hi)) = self.page(word, fc) else { return none };
let Some(&crun) = page.get(511) else { return none };
let crun = crun.min(0x65) as usize;
let rgfc = |i: usize| -> Option<u32> { page.get(i * 4..i * 4 + 4).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) };
// rgb array sits right after rgfc (crun+1 u32s); each byte is a half-offset into the
// region between rgb's end and crun.
let rgb_at = (crun + 1) * 4;
// The last run starting at or before `fc`. It stays the answer for every FC from its own
// start up to the smallest start after it (also in a page whose starts do not ascend).
let j = (0..crun).rev().find(|&j| rgfc(j).is_some_and(|f| f <= fc));
let Some(j) = j else { return &[] };
let Some(&off) = page.get(rgb_at + j) else { return &[] };
let first_after = j.map_or(0, |j| j + 1);
let hi = (first_after..crun).filter_map(rgfc).min().map_or(page_hi, |m| m.min(page_hi));
let lo = j.and_then(rgfc).map_or(page_lo, |f| f.max(page_lo));
let run = |grpprl: &'w [u8]| ChpxRun { grpprl, lo, hi };
let Some(j) = j else { return run(&[]) };
let Some(&off) = page.get(rgb_at + j) else { return run(&[]) };
if off == 0 {
return &[];
return run(&[]);
}
let at = off as usize * 2;
let Some(&cb) = page.get(at) else { return &[] };
page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[])
let Some(&cb) = page.get(at) else { return run(&[]) };
run(page.get(at + 1..at + 1 + cb as usize).unwrap_or(&[]))
}
/// The (istd, grpprl) of the Papx of the paragraph whose mark is just before `fc`
@@ -75,7 +114,7 @@ impl Bins {
/// entries (1 offset byte + 12 PHE bytes), crun in the last byte; the PAPX itself is a
/// PapxInFkp with the cb/cb' size quirk). Query with the FC of the character after the mark.
pub(crate) fn papx<'w>(&self, word: &'w [u8], fc: u32) -> (u16, &'w [u8]) {
let Some(page) = self.page(word, fc) else { return (0, &[]) };
let Some((page, _, _)) = self.page(word, fc) else { return (0, &[]) };
let Some(&crun) = page.get(511) else { return (0, &[]) };
// BX is 13 bytes; cap crun at what fits before byte 511.
let max_run = (511usize.saturating_sub((crun as usize + 1) * 4)) / 13;
+191 -61
View File
@@ -51,6 +51,14 @@ const MAX_PARTS: usize = 50_000;
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
const MAX_TOTAL_CHARS: u64 = 64 << 20;
/// Most paragraph and cell marks across every story walk of one document. The character
/// budget does not see them: a mark is one byte of the file but about 1 KB of document, so a
/// file of nothing but marks would grow memory about a thousand times faster than its size.
/// The cap bounds a flood to about 2 GB while reading (about 4 GB at the very cap, when the
/// blocks are assembled). Past it the read fails with [`DocbinError::Limit`]. Real documents stay far below it:
/// 50 paragraphs on each of 10,000 pages is 500,000, and a 10,000-page one-column table (46
/// rows, each a cell mark plus a row mark) is about 920,000.
const MAX_PARAGRAPHS: usize = 2_000_000;
/// Bytes of repeated work per document beyond the text itself: piece Prm grpprls (Prcs, up
/// to 64 KB each) interpreted (#3692), hyperlink targets (#3708), picture alt text (#3709)
/// and floating objects (#3710) copied into every run or anchor. Past it Prms are skipped,
@@ -195,6 +203,12 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
/// verifier ([`DocbinError::WrongPassword`] when it does not match) and then decrypted. The
/// password is ignored for an unprotected file.
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Document, DocbinError> {
read_limited(bytes, password, MAX_PARAGRAPHS)
}
/// [`read_with_password`] with the paragraph cap as a parameter, so tests need not build a
/// file of [`MAX_PARAGRAPHS`] marks.
fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) -> Result<Document, DocbinError> {
let mut comp = open_container(bytes)?;
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
if word.len() as u64 > MAX_STREAM {
@@ -246,6 +260,9 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
};
let mut doc = Document::new();
// Word opens Word 97-2003 files in compatibility mode 11 (Word 2003); the model's default
// 15 would apply Word 2013+ justification and line-breaking rules to them.
doc.settings.compat_mode = 11;
if let Some(core) = metadata::read(&mut comp) {
doc.core = core;
}
@@ -270,6 +287,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
// tight against aliasing — and never past the absolute ceiling.
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
let work = Work::new(MAX_WORK);
let paragraphs = ParaBudget { left: std::cell::Cell::new(max_paragraphs), exceeded: std::cell::Cell::new(false) };
let ctx = WalkCtx {
word: &word,
@@ -285,6 +303,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
drawn: &[],
sect_ends: &[],
budget: &budget,
paragraphs: &paragraphs,
work: &work,
};
@@ -304,7 +323,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
if doc.parts.len() >= MAX_PARTS || tb_stories.contains_key(&spid) {
continue;
}
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
tb_stories.insert(spid, doc.add_part(PartKind::TextBox, blocks));
}
}
@@ -329,7 +348,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
0 | 1 | 4 => PartKind::Header,
_ => PartKind::Footer,
};
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
if !blocks.is_empty() {
let id = doc.add_part(kind, blocks);
hf_ids.push((si, k, id));
@@ -342,7 +361,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
let mut note_links: Vec<(u32, NoteKind, u32)> = Vec::new();
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_FND_REF, fib::pair::PLCF_FND_TXT) {
let Some((a, b)) = story(ftn_base, fib.ccp.ftn, ab) else { continue };
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
let id = doc.add_part(PartKind::Footnote, blocks);
note_links.push((cp, NoteKind::Footnote, id));
@@ -350,7 +369,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
}
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_END_REF, fib::pair::PLCF_END_TXT) {
let Some((a, b)) = story(edn_base, fib.ccp.edn, ab) else { continue };
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
let id = doc.add_part(PartKind::Endnote, blocks);
note_links.push((cp, NoteKind::Endnote, id));
@@ -393,6 +412,9 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
doc.last_section = l;
}
if paragraphs.exceeded.get() {
return Err(paragraph_limit(max_paragraphs));
}
doc.media.extend(pics.media);
doc.body = table::assemble(paras);
doc.ensure_nonempty();
@@ -400,6 +422,20 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
Ok(doc)
}
fn paragraph_limit(max_paragraphs: usize) -> DocbinError {
DocbinError::Limit(format!("the document has more than {max_paragraphs} paragraphs and table cells"))
}
/// Walks one story into blocks. A walk that spent the shared paragraph budget is refused before
/// its partial list is assembled, so a flood does not build blocks it will throw away.
fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, max_paragraphs: usize) -> Result<wordcraft_doc::Blocks, DocbinError> {
let paras = walk(ctx, a, b, pics);
if ctx.paragraphs.exceeded.get() {
return Err(paragraph_limit(max_paragraphs));
}
Ok(table::assemble(paras))
}
/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len`
/// CPs, as absolute CPs clamped to that subdocument; `None` when empty.
fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> {
@@ -433,10 +469,35 @@ struct WalkCtx<'a> {
/// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of
/// them could otherwise materialise the same text thousands of times; this bounds the total.
budget: &'a std::cell::Cell<u64>,
/// Paragraph and cell marks still allowed across every story walk ([`MAX_PARAGRAPHS`]).
paragraphs: &'a ParaBudget,
/// Repeated-work bytes left ([`MAX_WORK`]), and what they left out.
work: &'a Work,
}
/// The paragraph and cell marks still allowed in this document, and whether a walk ran into
/// the end of them (the read then fails).
struct ParaBudget {
left: std::cell::Cell<usize>,
exceeded: std::cell::Cell<bool>,
}
impl ParaBudget {
/// Spends one mark; false (and the overrun noted) when none are left.
fn spend(&self) -> bool {
match self.left.get().checked_sub(1) {
Some(left) => {
self.left.set(left);
true
}
None => {
self.exceeded.set(true);
false
}
}
}
}
/// State while assembling one paragraph from per-CP formatting.
#[derive(Default)]
struct ParaBuild {
@@ -444,29 +505,37 @@ struct ParaBuild {
runs: Vec<Run>,
props: wordcraft_doc::props::ParaProps,
mark: CharProps,
/// The current run's properties, so runs only break when formatting changes.
cur: Option<CharProps>,
/// Which resolved formatting ([`FmtState::generation`]) the last character came from, while
/// the last run is still the one it extended: the same generation means equal properties.
run_gen: Option<u64>,
/// Inline objects, each anchored at a U+FFFC pushed into `text`.
objects: Vec<InlineObject>,
}
impl ParaBuild {
fn push(&mut self, c: char, props: CharProps) {
self.run_gen = None;
let len = c.len_utf8();
match &self.cur {
Some(p) if *p == props => {
if let Some(r) = self.runs.last_mut() {
r.len += len;
}
}
_ => {
self.runs.push(Run { len, props: props.clone() });
self.cur = Some(props);
}
match self.runs.last_mut() {
Some(r) if r.props == props => r.len += len,
_ => self.runs.push(Run { len, props }),
}
self.text.push(c);
}
/// [`ParaBuild::push`] for properties that are resolved once and shared by many characters:
/// they are cloned only when a run starts, and not even compared while `generation` stays
/// the same.
fn push_shared(&mut self, c: char, props: &CharProps, generation: u64) {
let len = c.len_utf8();
match self.runs.last_mut() {
Some(r) if self.run_gen == Some(generation) || r.props == *props => r.len += len,
_ => self.runs.push(Run { len, props: props.clone() }),
}
self.run_gen = Some(generation);
self.text.push(c);
}
/// Append `c` to the current run, whose properties the caller knows it matches.
fn extend(&mut self, c: char) {
if let Some(r) = self.runs.last_mut() {
@@ -494,7 +563,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
// Field assembly state: instruction characters are swallowed, the result is kept until
// the end character, then emitted as one object (hyperlinks keep their result text).
let mut field: Option<FieldBuild> = None;
let mut memo: Option<FmtMemo<'a>> = None;
let mut fmt = FmtState::default();
while cp < cp_end {
// Bookmark boundaries at this CP (main-document walks only; subdocument walks get
// an empty table and `cp` never matches the main-document CPs).
@@ -527,6 +596,9 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
let section_mark = c == '\u{C}' && ctx.sect_ends.binary_search(&cp.saturating_add(1)).is_ok();
match c {
'\r' | '\u{7}' | '\u{C}' if c != '\u{C}' || section_mark => {
if !ctx.paragraphs.spend() {
break;
}
// Paragraph mark: its PAPX covers the FC range of the paragraph it ends, so
// querying at the mark's own FC finds that paragraph's properties.
let fc_mark = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
@@ -536,6 +608,9 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
}
let mut row = table::decode(papx);
row.cell_mark = c == '\u{7}';
// Only table marks keep their table context; a plain paragraph would carry
// a `RowInfo` (about 300 bytes) it never uses.
let row = row.is_table().then(|| Box::new(row));
let mut ilvl = 0u8;
let mut ilfo = 0i32;
for prl in sprm::iter(papx) {
@@ -589,7 +664,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
&& f.depth == 1
&& f.sep.is_none()
{
f.sep = Some(Vec::new());
f.sep = Some(FieldResult::default());
}
cp = cp.saturating_add(1);
continue;
@@ -623,6 +698,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
alt,
float: Default::default(),
crop: p.crop,
fx: Default::default(),
ole: None,
});
}
@@ -650,16 +726,16 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() });
}
}
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut memo),
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut memo),
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut memo),
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut memo),
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut memo),
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut memo),
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut fmt),
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut fmt),
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut fmt),
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut fmt),
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut fmt),
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut fmt),
// Object anchors, comment references and other special characters are dropped
// (or handled by later passes).
c if (c as u32) < 0x20 => {}
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut memo),
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut fmt),
}
cp = cp.saturating_add(units);
}
@@ -668,7 +744,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
out.push(ParaOut {
para: Paragraph { text: done.text, runs: done.runs, props: done.props, mark: done.mark, objects: done.objects, ..Default::default() },
end_cp: cp_end,
row: table::RowInfo::default(),
row: None,
});
}
out
@@ -679,7 +755,28 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
struct FieldBuild {
depth: u32,
instr: String,
sep: Option<Vec<(char, CharProps)>>,
sep: Option<FieldResult>,
}
/// A field's cached result: its text and, as runs, the formatting of that text. A run is added
/// only when the formatting changes, so a result of millions of equally formatted characters
/// costs one `CharProps`, not one per character (365 bytes each).
#[derive(Default)]
struct FieldResult {
text: String,
/// (byte length in `text`, formatting) in order; adjacent runs differ.
runs: Vec<(usize, CharProps)>,
}
impl FieldResult {
fn push(&mut self, c: char, props: CharProps) {
let len = c.len_utf8();
match self.runs.last_mut() {
Some((n, last)) if *last == props => *n += len,
_ => self.runs.push((len, props)),
}
self.text.push(c);
}
}
/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all
@@ -704,24 +801,26 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
let result = f.sep.unwrap_or_default();
if let Some(target) = hyperlink_target(&instr) {
let cost = target.len() as u64;
let mut run: Option<CharProps> = None;
for (c, props) in result {
if run.as_ref() == Some(&props) {
pb.extend(c);
continue;
}
let mut linked = props.clone();
let mut chars = result.text.chars();
for (len, props) in result.runs {
let mut linked = props;
if work.charge(cost, Cost::LinkTarget) {
linked.link = Some(target.clone());
}
pb.push(c, linked);
run = Some(props);
let mut left = len;
let Some(first) = chars.next() else { break };
pb.push(first, linked);
left = left.saturating_sub(first.len_utf8());
while left > 0 {
let Some(c) = chars.next() else { break };
pb.extend(c);
left = left.saturating_sub(c.len_utf8());
}
}
return;
}
let result_text: String = result.iter().map(|(c, _)| *c).collect();
pb.push('\u{FFFC}', CharProps::default());
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
pb.objects.push(InlineObject::Field { instr, result: result.text, locked: false, dirty: false, code: false, form: String::new() });
}
/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream.
@@ -766,34 +865,53 @@ fn num_of(ilfo: i32) -> Option<u32> {
/// plus the piece's Prm, over the paragraph style's base character props. Inside a field,
/// the character is recorded into the instruction or the cached result instead of (or as
/// well as) the visible text.
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, memo: &mut Option<FmtMemo<'a>>) {
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
let grpprl = ctx.chpx_bins.chpx(ctx.word, fc);
let prm = ctx.pieces.prm_of(cp);
let style = pb.props.style.as_deref();
let mut props = match memo.as_ref().filter(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style) {
Some(m) => m.props.clone(),
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, st: &mut FmtState<'a>) {
// The piece and the CHPX run of the previous character usually cover this one too.
let span = match st.span.filter(|s| s.contains(cp)) {
Some(s) => Some(s),
None => {
let default;
let base = match style.and_then(|id| ctx.sheet.get(id)) {
Some(st) => &st.chr,
None => {
default = CharProps::default();
&default
}
};
let mut props = char_props(grpprl, ctx.fonts, base);
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
*memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props: props.clone() });
props
st.span = ctx.pieces.span(cp);
st.span
}
};
let fc = span.and_then(|s| s.fc_at(cp)).unwrap_or(0);
let run = match st.chpx.filter(|r| r.contains(fc)) {
Some(r) => r,
None => {
let r = ctx.chpx_bins.chpx_run(ctx.word, fc);
st.chpx = Some(r);
r
}
};
let grpprl = run.grpprl;
let prm = span.map_or(0, |s| s.prm());
let style = pb.props.style.as_deref();
let hit = st.memo.as_ref().is_some_and(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style);
if !hit {
let default;
let base = match style.and_then(|id| ctx.sheet.get(id)) {
Some(sty) => &sty.chr,
None => {
default = CharProps::default();
&default
}
};
let mut props = char_props(grpprl, ctx.fonts, base);
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
st.memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props });
st.generation += 1;
}
let Some(m) = st.memo.as_ref() else { return };
// 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 symbol = if c == '(' { symbol_operand(grpprl) } else { None };
if field.is_none() && symbol.is_none() {
pb.push_shared(c, &m.props, st.generation);
return;
}
let mut props = m.props.clone();
let mut c = c;
if c == '('
&& let Some((ftc, xchar)) = symbol_operand(grpprl)
{
if let Some((ftc, xchar)) = symbol {
if let Some(font) = ctx.fonts.get(usize::from(ftc)) {
props.font = Some(font.clone());
}
@@ -805,13 +923,25 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f
Some(f) => match f.sep.as_mut() {
// Inside a field, characters are recorded for the field object instead of shown;
// `finish_field` emits the visible text (hyperlink runs or one object anchor).
Some(result) => result.push((c, props)),
Some(result) => result.push(c, props),
None => f.instr.push(c),
},
None => pb.push(c, props),
}
}
/// What the walk carries from one character to the next while it resolves formatting: the piece
/// and the CHPX run the last character was in (so most characters need no search), and the last
/// formatting resolved.
#[derive(Default)]
struct FmtState<'a> {
span: Option<piece::PieceSpan<'a>>,
chpx: Option<fkp::ChpxRun<'a>>,
memo: Option<FmtMemo<'a>>,
/// Counts the memo's replacements, so equal values mean the same properties.
generation: u64,
}
/// The last character formatting resolved, reused while the CHPX, the piece's Prm and the
/// paragraph style stay the same, so a Prm grpprl (up to 64 KB) is interpreted once per
/// formatting change, not once per character (#3692).
+36 -6
View File
@@ -117,12 +117,6 @@ impl PieceTable {
}
}
/// The raw Prm of the piece holding `cp` (0, no properties, when there is none): two CPs
/// with the same value get the same [`PrmRef`].
pub(crate) fn prm_of(&self, cp: u32) -> u16 {
self.piece_of(cp).map_or(0, |p| p.prm)
}
fn piece_of(&self, cp: u32) -> Option<&Pcd> {
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
if i >= self.pieces.len() {
@@ -143,6 +137,17 @@ impl PieceTable {
if p.compressed { p.fc.checked_add(step)?.checked_div(2) } else { p.fc.checked_add(step) }
}
/// The piece holding `cp`, with the CP range it covers: for every CP in that range the
/// answers of [`PieceTable::fc_of_cp`] and the piece's Prm come from this span alone,
/// so a sequential walk searches the piece table once per piece.
pub(crate) fn span(&self, cp: u32) -> Option<PieceSpan<'_>> {
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
let pcd = self.pieces.get(i)?;
let cp_lo = *self.cps.get(i)?;
let cp_hi = *self.cps.get(i + 1)?;
Some(PieceSpan { cp_lo, cp_hi, pcd })
}
/// The CP just past the last piece: no text exists at or beyond it.
pub(crate) fn cp_end(&self) -> u32 {
self.cps.last().copied().unwrap_or(0)
@@ -187,6 +192,31 @@ impl PieceTable {
}
}
/// One piece and the CPs `[cp_lo, cp_hi)` it covers ([`PieceTable::span`]).
#[derive(Clone, Copy)]
pub(crate) struct PieceSpan<'a> {
cp_lo: u32,
cp_hi: u32,
pcd: &'a Pcd,
}
impl PieceSpan<'_> {
pub(crate) fn contains(&self, cp: u32) -> bool {
self.cp_lo <= cp && cp < self.cp_hi
}
/// [`PieceTable::fc_of_cp`] for a CP inside the span.
pub(crate) fn fc_at(&self, cp: u32) -> Option<u32> {
let step = cp.checked_sub(self.cp_lo)?.checked_mul(2)?;
if self.pcd.compressed { self.pcd.fc.checked_add(step)?.checked_div(2) } else { self.pcd.fc.checked_add(step) }
}
/// The raw Prm of the piece: two CPs with the same value get the same [`PrmRef`].
pub(crate) fn prm(&self) -> u16 {
self.pcd.prm
}
}
/// What a piece's `Prm` refers to.
pub(crate) enum PrmRef<'a> {
None,
+9 -6
View File
@@ -83,6 +83,11 @@ pub(crate) struct RowInfo {
/// Read the table context out of a paragraph mark's PAPX grpprl.
impl RowInfo {
/// Does the mark belong to a table: inside one, ending a row, or ending a cell?
pub(crate) fn is_table(&self) -> bool {
self.in_table || self.ttp || self.cell_mark
}
/// The row height from `sprmTDyaRowHeight`, for tests.
#[cfg(test)]
pub(crate) fn height(&self) -> Option<f32> {
@@ -263,8 +268,8 @@ pub(crate) struct ParaOut {
pub(crate) para: Paragraph,
/// CP just past the terminating mark.
pub(crate) end_cp: u32,
/// Table context from the mark's PAPX (empty default = not in a table).
pub(crate) row: RowInfo,
/// Table context from the mark's PAPX; `None` for a paragraph outside a table.
pub(crate) row: Option<Box<RowInfo>>,
}
/// Group consecutive in-table paragraphs into [`Table`] blocks; the rest pass through.
@@ -369,9 +374,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
}
for p in paras {
let info = p.row;
let is_table = info.in_table || info.ttp || info.cell_mark;
if !is_table {
let Some(info) = p.row else {
if open {
flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);
grid.clear();
@@ -386,7 +389,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
}
out.push(para_block(p.para));
continue;
}
};
if !open {
open = true;
}
+295 -3
View File
@@ -116,6 +116,12 @@ fn piece_bytes(p: &Piece) -> Vec<u8> {
/// A document whose main text is the given pieces (extra subdocument text may follow).
fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec<u8> {
text_doc_with(main, extra, FibSpec { ccp_hdd, ..Default::default() }, &[])
}
/// [`text_doc`] with the FIB knobs given (`ccp_text` is filled in from `main`; `pairs` must be empty) and extra
/// Table-stream data placed after the piece table: `(pair index, bytes)` for each.
fn text_doc_with(main: &[Piece], extra: &[Piece], spec: FibSpec, table_data: &[(usize, &[u8])]) -> Vec<u8> {
let all: Vec<Piece> = main.iter().chain(extra.iter()).cloned().collect();
let ccp_text: u32 = main.iter().map(|p| p.text.chars().count() as u32).sum();
let mut word = vec![0u8; TEXT_FC as usize];
@@ -128,7 +134,13 @@ fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec<u8> {
clx.extend_from_slice(&plc);
let mut table = vec![0u8; CLX_AT as usize];
table.extend_from_slice(&clx);
let spec = FibSpec { ccp_text, ccp_hdd, pairs: vec![(33, CLX_AT, clx.len() as u32)], ..Default::default() };
let mut pairs = vec![(33, CLX_AT, clx.len() as u32)];
for (pair, bytes) in table_data {
pairs.push((*pair, table.len() as u32, bytes.len() as u32));
table.extend_from_slice(bytes);
}
debug_assert!(spec.pairs.is_empty(), "text_doc_with sets the pairs itself");
let spec = FibSpec { ccp_text, pairs, ..spec };
word[..fib_bytes(&spec).len()].copy_from_slice(&fib_bytes(&spec));
cfb_file(&[("WordDocument", &word), ("0Table", &table)])
}
@@ -247,6 +259,77 @@ fn empty_paragraphs_kept() {
assert_eq!(body_texts(&doc), ["a", "", "b"]);
}
/// A document of `n` copies of `mark` as one compressed piece.
fn marks_doc(mark: char, n: usize) -> Vec<u8> {
text_doc(&[Piece { compressed: true, text: mark.to_string().repeat(n) }], &[], 0)
}
/// Paragraph marks are one byte each in the file but about 1 KB each in memory, so they have
/// their own cap ([`super::MAX_PARAGRAPHS`]); a file past it is refused instead of growing
/// until the machine runs out. A document exactly at the cap still opens.
#[test]
fn a_flood_of_paragraph_marks_is_refused() {
let at_cap = super::read_limited(&marks_doc('\r', 1000), None, 1000).expect("opens");
assert_eq!(at_cap.body.len(), 1000);
let over = super::read_limited(&marks_doc('\r', 1001), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
}
/// The paragraph cap is one budget for the whole document, not one per story: marks in the
/// body and in a footnote add up. (A walk that got its own budget would open both over-cap files.)
#[test]
fn the_paragraph_cap_is_shared_by_the_body_and_a_footnote() {
// One footnote at CP 0 whose text is `note` marks, stored right after the body in the stream.
let doc_of = |body: usize, note: usize| {
let cps = |v: &[u32]| v.iter().flat_map(|c| c.to_le_bytes()).collect::<Vec<u8>>();
let mut refs = cps(&[0, 1]);
refs.extend_from_slice(&[0, 0]); // the one reference's data
let txt = cps(&[0, note as u32, note as u32]); // story 0..note, then the limit
let spec = FibSpec { ccp_ftn: note as u32, ..Default::default() };
text_doc_with(
&[Piece { compressed: true, text: "\r".repeat(body) }],
&[Piece { compressed: true, text: "\r".repeat(note) }],
spec,
&[(2, &refs), (3, &txt)],
)
};
let at_cap = super::read_limited(&doc_of(600, 400), None, 1000).expect("opens");
assert_eq!(at_cap.body.len(), 600);
assert_eq!(at_cap.parts.len(), 1);
let over = super::read_limited(&doc_of(600, 401), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
let over = super::read_limited(&doc_of(601, 400), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
}
/// Cell marks are paragraph marks too: a never-closed row of them is refused the same way.
#[test]
fn a_flood_of_cell_marks_is_refused() {
let over = super::read_limited(&marks_doc('\u{7}', 5000), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
}
/// The budget counts down across spends and notes the overrun (`walk` receives one shared by
/// every story of the document).
#[test]
fn the_paragraph_budget_counts_down_and_notes_the_overrun() {
let budget = super::ParaBudget { left: std::cell::Cell::new(2), exceeded: std::cell::Cell::new(false) };
assert!(budget.spend() && budget.spend());
assert!(!budget.exceeded.get());
assert!(!budget.spend());
assert!(budget.exceeded.get());
}
/// The cap is far above any real document (10,000 pages of 50 paragraphs is 500,000), and a
/// paragraph outside a table no longer carries a table context (a `ParaOut` was 1,224 bytes).
#[test]
fn the_paragraph_cap_leaves_room_and_a_paragraph_stays_small() {
const { assert!(super::MAX_PARAGRAPHS >= 1_000_000) };
assert!(std::mem::size_of::<crate::table::ParaOut>() < 1000, "{}", std::mem::size_of::<crate::table::ParaOut>());
let doc = read(&marks_doc('\r', 10)).expect("opens");
assert_eq!(doc.body.len(), 10);
}
#[test]
fn empty_document_gets_a_paragraph() {
let f = text_doc(&[Piece { compressed: false, text: "".into() }], &[], 0);
@@ -254,6 +337,15 @@ fn empty_document_gets_a_paragraph() {
assert_eq!(doc.body.len(), 1);
}
#[test]
fn a_doc_opens_in_word_97_2003_compatibility_mode() {
let f = text_doc(&[Piece { compressed: false, text: "body\r".into() }], &[], 0);
let doc = read(&f).expect("opens");
// Word 97-2003 files are mode 11 in Word 2013+; mode 15 would change justification and
// line breaking.
assert_eq!(doc.settings.compat_mode, 11);
}
#[test]
fn header_text_stays_out_of_body() {
let f = text_doc(
@@ -2660,7 +2752,8 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
assert_eq!(linked.first().map(|r| r.len), Some(n));
// One copy of the target for the one run, not one per character (~2.5 s here): counted by
// what the copies charge to the work budget, which a busy machine can't skew.
let result = vec![('y', wordcraft_doc::props::CharProps::default()); n];
let mut result = super::FieldResult::default();
(0..n).for_each(|_| result.push('y', wordcraft_doc::props::CharProps::default()));
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) };
let work = super::Work::new(u64::MAX);
super::finish_field(&mut super::ParaBuild::default(), f, &work);
@@ -2673,7 +2766,8 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
fn hyperlink_target_copies_are_charged_to_the_work_budget() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let result: Vec<(char, CharProps)> = (0..1000).map(|i| ('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })).collect();
let mut result = super::FieldResult::default();
(0..1000).for_each(|i| result.push('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() }));
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) };
let mut pb = super::ParaBuild::default();
let budget = super::Work::new(10_000);
@@ -2747,3 +2841,201 @@ fn prm_grpprls_past_the_work_budget_are_skipped() {
assert_eq!(work.left.get(), 1);
assert_eq!(work.dropped(), [super::Cost::Formatting]);
}
/// A field's cached result keeps one `CharProps` per formatting run, not one per character
/// (365 bytes each: 4 million result characters took 1.4 GB).
#[test]
fn a_field_result_stores_one_props_per_run() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut r = super::FieldResult::default();
for _ in 0..200_000 {
r.push('x', CharProps::default());
}
assert_eq!(r.runs.len(), 1);
assert_eq!(r.runs.first().map(|(n, _)| *n), Some(200_000));
r.push('\u{e9}', bold.clone());
r.push('y', bold);
r.push('z', CharProps::default());
assert_eq!(r.runs.iter().map(|(n, _)| *n).collect::<Vec<_>>(), [200_000, 3, 1], "a two-byte char counts its bytes");
assert_eq!(r.text.len(), 200_004);
}
/// A hyperlink whose result changes formatting, with multi-byte text, keeps every run's text and
/// links all of it; a plain field keeps its result text whole.
#[test]
fn field_results_keep_their_text_and_runs() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut result = super::FieldResult::default();
for (c, p) in
[('a', CharProps::default()), ('\u{e9}', CharProps::default()), ('\u{20ac}', bold.clone()), ('b', bold), ('c', CharProps::default())]
{
result.push(c, p);
}
let f = super::FieldBuild { depth: 1, instr: "HYPERLINK \"http://x\"".into(), sep: Some(result) };
let mut pb = super::ParaBuild::default();
super::finish_field(&mut pb, f, &super::Work::new(u64::MAX));
assert_eq!(pb.text, "a\u{e9}\u{20ac}bc");
assert_eq!(pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(), [3, 4, 1]);
assert!(pb.runs.iter().all(|r| r.props.link.as_deref() == Some("http://x")));
let doc = read(&text_doc(&[Piece { compressed: false, text: "\u{13} PAGE \u{14}1\u{e9}2\u{15}\r".into() }], &[], 0)).expect("opens");
let p = doc.body.first().and_then(|b| b.as_para()).expect("paragraph");
assert!(
p.objects
.iter()
.any(|o| matches!(o, wordcraft_doc::para::InlineObject::Field { instr, result, .. } if instr == "PAGE" && result == "1\u{e9}2")),
"{:?}",
p.objects
);
}
/// A one-page CHPX bin table over `page`: FCs 0..`end` live in page 1 of the returned stream.
fn one_page_bins(page: &[u8], end: u32) -> (Vec<u8>, crate::fkp::Bins) {
let mut word = vec![0u8; 512];
word.extend_from_slice(page);
let plc = [0u32.to_le_bytes().as_slice(), end.to_le_bytes().as_slice(), 1u32.to_le_bytes().as_slice()].concat();
let bins = crate::fkp::Bins::parse(&plc, 0, 12).expect("bins");
(word, bins)
}
/// The CHPX run a lookup reports covers exactly the FCs that give the same grpprl: the walk
/// reuses it for the following characters without asking again.
#[test]
fn a_chpx_run_covers_the_fcs_with_the_same_answer() {
let runs = [(0u32, grpprl(&[(0x0835, &[1])])), (5, Vec::new()), (9, grpprl(&[(0x0836, &[1])])), (30, grpprl(&[(0x0835, &[1])]))];
let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000);
for fc in (fc_of(0) - 4)..(fc_of(70)) {
let run = bins.chpx_run(&word, fc);
assert_eq!(run.grpprl, bins.chpx(&word, fc), "fc {fc}");
for other in (fc_of(0) - 4)..(fc_of(70)) {
if run.contains(other) {
assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}");
}
}
}
assert!(bins.chpx_run(&word, fc_of(6)).contains(fc_of(8)) && !bins.chpx_run(&word, fc_of(6)).contains(fc_of(9)));
}
/// A page whose run starts do not ascend (a corrupt file) gives the same answers as before and
/// a range that never claims an FC with a different answer.
#[test]
fn a_chpx_run_is_sound_on_a_page_whose_starts_do_not_ascend() {
let runs = [(10u32, grpprl(&[(0x0835, &[1])])), (3, grpprl(&[(0x0836, &[1])])), (20, Vec::new()), (7, grpprl(&[(0x0835, &[1])]))];
let (word, bins) = one_page_bins(&chpx_fkp(&runs, 60), 0x2000);
for fc in (fc_of(0) - 4)..(fc_of(70)) {
let run = bins.chpx_run(&word, fc);
assert_eq!(run.grpprl, bins.chpx(&word, fc));
for other in (fc_of(0) - 4)..(fc_of(70)) {
if run.contains(other) {
assert_eq!(bins.chpx(&word, other), run.grpprl, "fc {fc} run reused at {other}");
}
}
}
}
/// A piece span answers exactly what the per-CP lookup answers, in every piece (compressed
/// and UTF-16) and past the last.
#[test]
fn a_piece_span_matches_the_per_cp_lookups() {
let doc = text_doc(
&[
Piece { compressed: true, text: "abc".into() },
Piece { compressed: false, text: "d\u{e9}fgh".into() },
Piece { compressed: true, text: "ij".into() },
],
&[],
0,
);
let mut comp = crate::open_container(&doc).expect("container");
let table = crate::stream(&mut comp, "0Table").expect("table");
let pieces = crate::piece::PieceTable::parse(&table, CLX_AT, table.len() as u32 - CLX_AT).expect("pieces");
for cp in 0..14 {
let span = pieces.span(cp);
assert_eq!(span.and_then(|s| s.fc_at(cp)), pieces.fc_of_cp(cp), "cp {cp}");
for other in 0..14 {
if span.is_some_and(|s| s.contains(other)) {
assert_eq!(pieces.span(other).and_then(|s| s.fc_at(other)), pieces.fc_of_cp(other));
}
}
}
assert!(pieces.span(10).is_none(), "past the last piece");
}
/// Characters of one formatting share one `CharProps`: it is cloned when a run starts, and not
/// even compared while the formatting stays the same.
#[test]
fn shared_props_start_one_run_per_formatting() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut pb = super::ParaBuild::default();
for _ in 0..100_000 {
pb.push_shared('a', &bold, 1);
}
pb.push_shared('b', &CharProps::default(), 2);
pb.push_shared('c', &bold, 3);
pb.push_shared('d', &bold, 4);
assert_eq!(pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(), [100_000, 1, 2], "equal props under a new generation join the run");
pb.push('e', bold.clone());
pb.push_shared('f', &bold, 4);
assert_eq!(
pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(),
[100_000, 1, 4],
"a plain push forgets the generation but still joins an equal run"
);
assert_eq!(pb.text.len(), 100_005);
}
/// Several bins, equal and descending breakpoints, a missing page and the last FC: wherever a
/// run is reported as valid it gives the answer a fresh lookup gives.
#[test]
fn chpx_runs_are_sound_across_bins() {
let page_a = chpx_fkp(&[(0, grpprl(&[(0x0835, &[1])])), (8, grpprl(&[(0x0836, &[1])]))], 20);
let page_b = chpx_fkp(&[(20, Vec::new()), (26, grpprl(&[(0x0835, &[1])]))], 40);
let mut word = vec![0u8; 512];
word.extend_from_slice(&page_a);
word.extend_from_slice(&page_b);
let (a, b, end) = (fc_of(20), fc_of(40), fc_of(60));
let plc = |fcs: &[u32], pns: &[u32]| {
[fcs.iter().flat_map(|f| f.to_le_bytes()).collect::<Vec<u8>>(), pns.iter().flat_map(|p| p.to_le_bytes()).collect()].concat()
};
let tables = [
("two bins", plc(&[0, a, end], &[1, 2])),
("an empty bin", plc(&[0, a, a, end], &[1, 2, 2])),
("a missing page", plc(&[0, a, end], &[1, 9])),
("descending breakpoints", plc(&[0, b, a, end], &[1, 2, 1])),
("fcs up to the maximum", plc(&[0, a, u32::MAX], &[1, 2])),
];
for (what, t) in &tables {
let bins = crate::fkp::Bins::parse(t, 0, t.len() as u32).expect("bins");
let probes: Vec<u32> = (0..fc_of(50)).step_by(2).chain([u32::MAX - 1, u32::MAX]).collect();
for &fc in &probes {
let run = bins.chpx_run(&word, fc);
assert_eq!(run.grpprl, bins.chpx(&word, fc), "{what}: fc {fc}");
for &other in &probes {
if run.contains(other) {
assert_eq!(bins.chpx(&word, other), run.grpprl, "{what}: fc {fc} run reused at {other}");
}
}
}
}
}
/// The same formatting changes through a whole read: runs break exactly where the CHPX changes
/// (with a field and a symbol character in between), and nowhere else.
#[test]
fn formatting_runs_break_where_the_chpx_changes() {
let spec = RichSpec {
text: "aaaabbbb\u{13}PAGE\u{14}12\u{15}ccdd(ee\r",
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
fonts: vec!["Times New Roman", "Arial"],
chpx_runs: vec![(0, grpprl(&[(0x0835, &[1])])), (4, Vec::new()), (8, grpprl(&[(0x0835, &[1])])), (20, grpprl(&[(0x0836, &[1])]))],
papx_entries: vec![(30, 0, Vec::new())],
};
let doc = read(&rich_doc(&spec)).expect("opens");
let p = para0(&doc);
let shape: Vec<(usize, Option<bool>, Option<bool>)> = p.runs.iter().map(|r| (r.len, r.props.bold, r.props.italic)).collect();
// "aaaa" bold, "bbbb" plain plus the field's anchor (3 bytes, default formatting), "ccd" bold
// (the run from the field to CP 20), "d(ee" italic.
assert_eq!(shape, [(4, Some(true), None), (7, None, None), (3, Some(true), None), (4, None, Some(true))], "{shape:?}");
}
+36
View File
@@ -183,6 +183,41 @@ fn shapes_keep_kind_colours_wrap_and_text_box_text() {
assert_eq!(part.blocks.first().and_then(|b| b.as_para()).map(|p| p.text.as_str()), Some("Inside"));
}
#[test]
fn braces_and_brackets_are_unfilled_unless_filled_is_set() {
// A right brace (88) in front of the text with no fill properties, and a left bracket (85)
// whose fFilled is set: only the bracket is filled. A plain rectangle (1) keeps its default
// white fill.
let brace = sp(&[fsp(88, 1026, 0xA00)]);
let bracket = sp(&[fsp(85, 1027, 0xA00), fopt(&[(0x01BF, (1 << 20) | (1 << 4))], &[])]);
let rect = sp(&[fsp(1, 1028, 0xA00)]);
let art = art_content(&[], &[brace, bracket, rect]);
let flags = spa_flags(0, 0, 3, false);
let spas =
plc(&[1, 2, 3, 5], &[spa(1026, [0, 0, 240, 1140], flags), spa(1027, [0, 0, 240, 1140], flags), spa(1028, [0, 0, 240, 1140], flags)].concat());
let text = "A\u{8}\u{8}\u{8}B\r";
let ccp = text.chars().count() as u32;
let spec = StructSpec { text, extra_pairs: vec![(40, spas), (50, art)], ..structured_spec(text, ccp) };
let doc = read(&struct_doc(&spec)).expect("opens");
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
let fills: Vec<_> = p0
.objects
.iter()
.map(|o| match o {
InlineObject::Shape { fill, stroke, w, h, .. } => (fill.clone(), *stroke, *w, *h),
other => panic!("{other:?}"),
})
.collect();
assert_eq!(
fills,
[
(None, Some(Rgb::BLACK), 12.0, 57.0),
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
(Some(Fill::solid(Rgb::WHITE)), Some(Rgb::BLACK), 12.0, 57.0),
]
);
}
#[test]
fn text_boxes_pointing_at_the_same_story_text_read_it_once() {
// Two text boxes whose PlcftxbxTxt stories are both [0, 7) (the table steps back to CP 0
@@ -391,6 +426,7 @@ fn a_floating_copy_is_charged_its_alt_text() {
alt,
float: Default::default(),
crop: Default::default(),
fx: Default::default(),
ole: None,
};
let bare = crate::object_cost(&image(String::new()));
+1
View File
@@ -32,6 +32,7 @@ getrandom = { workspace = true, features = ["wasm_js"] }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
serde_json = { workspace = true }
proptest = { workspace = true }
image = { workspace = true }
zip = { workspace = true }
+20 -1
View File
@@ -350,6 +350,7 @@ impl Package {
let path = resolve(dir, target);
self.canonical(&path).map_or(path, str::to_string)
};
rels.ids.entry(id.to_string()).or_insert(rels.list.len());
rels.list.push(Rel { id: id.to_string(), kind: r.attr("Type").unwrap_or("").to_string(), target: resolved, external });
}
rels
@@ -405,11 +406,13 @@ pub struct Rel {
#[derive(Clone, Debug, Default)]
pub struct Rels {
pub list: Vec<Rel>,
/// Each id's first relationship in `list`, so a lookup doesn't scan every relationship.
ids: HashMap<String, usize>,
}
impl Rels {
pub fn by_id(&self, id: &str) -> Option<&Rel> {
self.list.iter().find(|r| r.id == id)
self.ids.get(id).and_then(|&i| self.list.get(i))
}
pub fn by_type(&self, full: &str) -> Option<&Rel> {
self.list.iter().find(|r| rel_is(&r.kind, full))
@@ -547,6 +550,22 @@ mod tests {
assert_eq!(crate::package_title(std::io::Cursor::new(z), 1 << 20).as_deref(), Some("Plan"));
}
#[test]
fn relationships_are_looked_up_by_id_the_first_one_winning() {
let mut rels = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
for i in 0..20_000 {
rels.push_str(&format!(r#"<Relationship Id="rId{i}" Type="hyperlink" Target="https://example.com/{i}" TargetMode="External"/>"#));
}
rels.push_str(r#"<Relationship Id="rId7" Type="hyperlink" Target="https://example.com/dup" TargetMode="External"/></Relationships>"#);
let z = zip_of(&[("word/_rels/document.xml.rels", rels.into_bytes())]);
let r = Package::open(&z).unwrap().rels("word/document.xml");
assert_eq!(r.by_id("rId7").map(|r| r.target.as_str()), Some("https://example.com/7"), "the first rId7 wins");
assert_eq!(r.by_id("rId19999").map(|r| r.target.as_str()), Some("https://example.com/19999"));
assert!(r.by_id("rId20000").is_none());
// Hyperlink-style lookups, one per relationship, are each a map lookup.
assert_eq!((0..20_000).filter(|i| r.by_id(&format!("rId{i}")).is_some()).count(), 20_000);
}
#[test]
fn decompression_bomb_is_rejected() {
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
+3
View File
@@ -66,6 +66,9 @@ pub(super) struct Axis {
pub(super) struct Series {
pub(super) name: String,
/// The series' `c:idx` (an `xsd:unsignedInt`), which picks its automatic colour; `None`
/// when missing or not a valid number.
pub(super) idx: Option<usize>,
pub(super) color: Option<Rgb>,
pub(super) line_w: f32,
/// Whether the series is drawn as a line (scatter series without one get markers).
+6 -2
View File
@@ -91,8 +91,11 @@ impl Chart {
self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK)
}
/// Colour of series `j`: its own, else the accent its `c:idx` picks (as Word does; `c:order`
/// doesn't matter), else the one its place in the file picks.
pub(super) fn series_colour(&self, j: usize) -> Rgb {
self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j))
let s = self.series.get(j);
s.and_then(|s| s.color).unwrap_or_else(|| self.accent(s.and_then(|s| s.idx).unwrap_or(j)))
}
/// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or
@@ -199,6 +202,7 @@ pub(super) fn text_size(space: &El) -> f32 {
impl Series {
fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series {
let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default();
let idx = ser.child("c:idx").and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::<u32>().ok()).and_then(|v| usize::try_from(v).ok());
let sp = ser.child("c:spPr");
let ln = sp.and_then(|p| p.child("a:ln"));
let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme));
@@ -219,7 +223,7 @@ impl Series {
}
}
let labels = show_values(ser.child("c:dLbls"));
Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
Series { name, idx, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
}
}
+62
View File
@@ -528,3 +528,65 @@ fn negative_sections_and_scientific_numbers() {
assert_eq!(format_value(0.0, "0.00E+00", 1.0), "0.00E+00");
assert_eq!(format_value(-12345.0, "0.0E-00", 1.0), "-1.2E04");
}
// An automatic series colour follows the series' `c:idx`, not its place in the file (#5963), as
// measured in Word: `idx` 3 then 0 are accent4 then accent1, a lone `idx` 2 is accent3, and
// `c:order` changes nothing.
/// The fill colours of the bars of a column chart of `sers`, and of its legend swatches.
fn column_colours(sers: &[String]) -> (Vec<Rgb>, Vec<Rgb>) {
let plot = format!(
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:varyColors val="0"/>{}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
sers.concat()
);
let items = with_legend(&plot, "r", 300.0, 200.0);
let bars = filled(&items).iter().map(|b| b.0).collect();
let keys = items
.iter()
.filter_map(|it| if let GraphicItem::Shape { rect, fill, .. } = it { (rect[2] == SWATCH).then_some(*fill).flatten() } else { None })
.collect();
(bars, keys)
}
/// `ser` with its `c:idx` / `c:order` replaced by `idx_order` (an empty string drops them).
fn renumbered(ser: &str, idx_order: &str) -> String {
let start = ser.find("<c:idx").expect("ser writes c:idx");
let end = ser.find("<c:tx>").expect("ser writes c:tx");
format!("{}{idx_order}{}", &ser[..start], &ser[end..])
}
#[test]
fn automatic_series_colours_follow_c_idx() {
let accent = |k: usize| theme()[4 + k];
let a = ser(0, "A", None, &["Q1"], &["1"]);
let b = ser(1, "B", None, &["Q1"], &["2"]);
// idx 3 first, idx 0 second: accent4, accent1, in the bars and the legend.
let sers = [renumbered(&a, r#"<c:idx val="3"/><c:order val="0"/>"#), renumbered(&b, r#"<c:idx val="0"/><c:order val="1"/>"#)];
assert_eq!(column_colours(&sers), (vec![accent(3), accent(0)], vec![accent(3), accent(0)]));
// A lone series with idx 2 is accent3.
let lone = [renumbered(&a, r#"<c:idx val="2"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&lone).0, [accent(2)]);
// c:order doesn't matter: idx 0 and 1 stay accent1 and accent2 with their orders swapped.
let swapped = [renumbered(&a, r#"<c:idx val="0"/><c:order val="1"/>"#), renumbered(&b, r#"<c:idx val="1"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&swapped).0, [accent(0), accent(1)]);
// A series' own colour still wins.
let own = [renumbered(&ser(0, "A", Some("FF0000"), &["Q1"], &["1"]), r#"<c:idx val="3"/><c:order val="0"/>"#)];
assert_eq!(column_colours(&own).0, [Rgb(0xFF, 0, 0)]);
}
#[test]
fn series_without_a_usable_c_idx_take_their_place() {
let accent = |k: usize| theme()[4 + k];
let a = ser(0, "A", None, &["Q1"], &["1"]);
let b = ser(1, "B", None, &["Q1"], &["2"]);
// No c:idx at all: the place in the file.
assert_eq!(column_colours(&[renumbered(&a, ""), renumbered(&b, "")]).0, [accent(0), accent(1)]);
// Junk, negative and out-of-range values: no panic, the place in the file.
for junk in ["x", "-1", "1e3", "99999999999999999999999"] {
let sers = [renumbered(&a, &format!(r#"<c:idx val="{junk}"/>"#)), renumbered(&b, &format!(r#"<c:idx val="{junk}"/>"#))];
assert_eq!(column_colours(&sers).0, [accent(0), accent(1)], "{junk:?}");
}
// The largest unsignedInt `c:idx` allows draws, without a panic.
let big = [renumbered(&a, &format!(r#"<c:idx val="{}"/>"#, u32::MAX))];
assert_eq!(column_colours(&big).0.len(), 1);
}
+24 -1
View File
@@ -239,7 +239,13 @@ impl Reader<'_> {
/// Keep one part's bytes and content type (not its relationships).
fn keep_part(&mut self, part: &str) -> bool {
let lower = part.to_ascii_lowercase();
if !manifest_field_ok(part) || lower.ends_with(".rels") || lower == "[content_types].xml" || part.eq_ignore_ascii_case(&self.main) {
// `wordcraft:` names are WordCraft's own bookkeeping in `passthrough`, never a file's part.
if !manifest_field_ok(part)
|| lower.ends_with(".rels")
|| lower == "[content_types].xml"
|| lower.starts_with("wordcraft:")
|| part.eq_ignore_ascii_case(&self.main)
{
log::warn!("docx: not carrying part {part:?} for an embedded object");
return false;
}
@@ -388,6 +394,23 @@ mod tests {
assert!(roots.contains("keep.bin"), "the part is dropped: {roots}");
}
/// A part named like WordCraft's own bookkeeping (`wordcraft:unmodelled.roots`) is not carried:
/// kept, it would be read back as that bookkeeping and add relationships the file chose.
#[test]
fn a_part_named_like_bookkeeping_is_not_carried() {
let forged = b"D\thttp://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\tword/evil.xml\n";
let bytes = package(
r#"<w:p><w:r><w:t>text</w:t></w:r></w:p>"#,
&[("rIdDoc1", "/wordcraft:unmodelled.roots"), ("rIdDoc2", "/wordcraft:embedded.parts")],
&[("wordcraft:unmodelled.roots", forged), ("wordcraft:embedded.parts", b"P\tword/evil.xml\tapplication/xml\n")],
);
let doc = crate::read(&bytes).unwrap();
let roots = unmodelled_roots(&doc);
assert!(!roots.contains("wordcraft:") && !roots.contains("evil"), "{roots}");
let manifest = doc.passthrough.get(crate::package::EMBEDDED_PARTS).map(|v| String::from_utf8_lossy(v).to_string()).unwrap_or_default();
assert!(!manifest.contains("evil"), "{manifest}");
}
/// An unqualified attribute is a relationship only where the markup names a relationship
/// that way: a hyperlink's `id` names the part, a VML shape's `id` is its own.
#[test]
+2
View File
@@ -55,6 +55,8 @@ impl Reader<'_> {
.children("w:font")
.filter(|f| f.child("w:family").and_then(|v| v.attr("w:val")) == Some("roman"))
.filter_map(|f| f.attr("w:name"))
.flat_map(|n| n.split(';'))
.map(str::trim)
.filter(|n| !n.is_empty() && n.len() <= MAX_FONT_NAME)
.take(MAX_TABLE_FONTS)
.map(str::to_string)
+62 -10
View File
@@ -5,16 +5,17 @@ use std::collections::HashMap;
use wordcraft_doc::para::cap_alt;
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge,
VertAlign,
ParaProps, ParagraphTextDirection, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor,
TextDirection, Underline, VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
PageVAlign, SectionProps, SectionStart,
};
use super::MAX_FONT_NAME;
use super::drawing_color::adjust_lum;
use crate::units::{int, line_multiple, measure, on_off, tw, u32_of};
use crate::units::{int, line_multiple, measure, on_off, tw, tw_i16, tw_u16, u32_of};
/// A text frame (`w:framePr` without a drop cap, ECMA-376 §17.3.1.11), sizes and offsets in
/// points (clamped), names kept as OOXML writes them.
@@ -33,6 +34,7 @@ fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame {
h_anchor: word("w:hAnchor", &["text", "margin", "page"], "page"),
v_anchor: word("w:vAnchor", &["text", "margin", "page"], "page"),
wrap: word("w:wrap", &["around", "auto", "notBeside", "none", "tight", "through"], "auto"),
anchor_lock: k.attr("w:anchorLock").map(|v| !matches!(v.trim(), "0" | "false" | "off" | "False" | "FALSE")),
h_space: len("w:hSpace").unwrap_or(0.0).max(0.0),
v_space: len("w:vSpace").unwrap_or(0.0).max(0.0),
}
@@ -144,6 +146,29 @@ pub(crate) fn resolve_level_only(sheet: &wordcraft_doc::styles::StyleSheet, p: &
}
}
/// A `w:rFonts` value as the document keeps it: `None` when it names no family, else its leading
/// families up to [`MAX_FONT_NAME`] bytes, cut at a `;` so no name is split. A LibreOffice file
/// names its Latin font as a semicolon-separated fallback list, and a real list names a few
/// families; a longer value is corrupt or hostile, and every piece of text drawn in it would pay
/// for its whole length as the list is classified and resolved. Keeping the leading families keeps
/// the family such a list picks, and the rest of the value is dropped like `w:cs`/`w:eastAsia`
/// drop an over-long name.
fn bounded_font_list(v: &str) -> Option<String> {
if v.len() <= MAX_FONT_NAME {
return (!v.is_empty()).then(|| v.to_string());
}
let mut end = 0usize;
for part in v.split_inclusive(';') {
let next = end + part.len();
if next > MAX_FONT_NAME {
break;
}
end = next;
}
let kept = v.get(..end)?.trim_end_matches(';');
(!kept.is_empty()).then(|| kept.to_string())
}
impl PropCtx {
pub fn style_id(&self, id: &str) -> String {
self.alias.get(id).cloned().unwrap_or_else(|| id.to_string())
@@ -225,6 +250,8 @@ impl PropCtx {
shd_fill(e)
}
/// The character properties `e` (a `w:rPr`) sets: direct formatting, plus the fonts, size,
/// weight and posture of the complex-script and East Asian text beside them.
pub fn rpr(&self, e: &El) -> CharProps {
let mut c = CharProps::default();
for k in e.els() {
@@ -233,7 +260,11 @@ impl PropCtx {
"w:rFonts" => {
// A theme font supersedes the fixed font beside it (`w:asciiTheme` over
// `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`).
let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string);
// A LibreOffice file names the Latin font as a semicolon-separated fallback
// list. Names are bounded like `w:cs`/`w:eastAsia` below: a longer one is
// corrupt or hostile, and every piece of text drawn in it would pay for its
// whole length as the list is classified (see `bounded_font_list`).
let named = |a: &str| k.attr(a).and_then(bounded_font_list);
let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t));
// `w:eastAsia` and `w:cs` name fonts for other scripts and stay in `font_ea` /
// `font_cs`: `w:hint` only picks between them for ambiguous characters, never for
@@ -333,9 +364,11 @@ impl PropCtx {
"w:widowControl" => p.widow_control = Some(on_off(k)),
"w:contextualSpacing" => p.contextual_spacing = Some(on_off(k)),
"w:mirrorIndents" => p.mirror_indents = Some(on_off(k)),
"w:adjustRightInd" => p.adjust_right_indent = Some(on_off(k)),
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
"w:bidi" => p.bidi = Some(on_off(k)),
"w:textDirection" => p.text_direction = k.attr("w:val").and_then(ParagraphTextDirection::from_ooxml),
"w:kinsoku" => p.kinsoku = Some(on_off(k)),
"w:wordWrap" => p.word_wrap = Some(on_off(k)),
"w:overflowPunct" => p.overflow_punct = Some(on_off(k)),
@@ -343,6 +376,7 @@ impl PropCtx {
"w:autoSpaceDE" => p.auto_space_de = Some(on_off(k)),
"w:autoSpaceDN" => p.auto_space_dn = Some(on_off(k)),
"w:snapToGrid" => p.snap_to_grid = Some(on_off(k)),
"w:textAlignment" => p.text_alignment = k.attr("w:val").and_then(TextAlignment::from_ooxml),
"w:framePr" => {
if let Some(kind @ ("drop" | "margin")) = k.attr("w:dropCap") {
p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8);
@@ -373,10 +407,10 @@ impl PropCtx {
p.tabs = Some(tabs);
}
"w:spacing" => {
if let Some(v) = tw(k, "w:before") {
if let Some(v) = tw_u16(k, "w:before") {
p.space_before = Some(v.max(0.0));
}
if let Some(v) = tw(k, "w:after") {
if let Some(v) = tw_u16(k, "w:after") {
p.space_after = Some(v.max(0.0));
}
// HTML-style automatic spacing; "0" switches an inherited one off.
@@ -399,15 +433,15 @@ impl PropCtx {
}
}
"w:ind" => {
if let Some(v) = tw(k, "w:left").or_else(|| tw(k, "w:start")) {
if let Some(v) = tw_i16(k, "w:left").or_else(|| tw_i16(k, "w:start")) {
p.indent_left = Some(v);
}
if let Some(v) = tw(k, "w:right").or_else(|| tw(k, "w:end")) {
if let Some(v) = tw_i16(k, "w:right").or_else(|| tw_i16(k, "w:end")) {
p.indent_right = Some(v);
}
if let Some(v) = tw(k, "w:hanging") {
if let Some(v) = tw_i16(k, "w:hanging") {
p.indent_first = Some(-v);
} else if let Some(v) = tw(k, "w:firstLine") {
} else if let Some(v) = tw_i16(k, "w:firstLine") {
p.indent_first = Some(v);
}
// Character units (hundredths of a character), which win when non-zero.
@@ -762,8 +796,26 @@ pub fn sectpr(pc: &PropCtx, e: &El) -> (SectionProps, Vec<HfRef>) {
if let Some(f) = k.attr("w:fmt") {
s.page_num_format = NumFormat::from_ooxml(f);
}
s.chapter_style = k.attr("w:chapStyle").and_then(u32_of).filter(|v| *v <= u8::MAX as u32).map(|v| v as u8);
s.chapter_sep = k.attr("w:chapSep").and_then(wordcraft_doc::section::ChapterSep::from_ooxml);
s.page_num_set = s.page_num_start.is_none() && s.page_num_format == NumFormat::Decimal;
}
"w:paperSrc" => {
let tray = |name| {
let value = k.attr(name)?.trim();
let digits = value.strip_prefix('+').unwrap_or(value);
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
u32_of(digits).filter(|v| *v <= u16::MAX as u32).map(|v| v as u16)
};
s.paper_src_first = tray("w:first");
s.paper_src_other = tray("w:other");
}
"w:noEndnote" => s.no_endnote = Some(on_off(k)),
"w15:footnoteColumns" => {
s.footnote_columns = k.attr("w:val").and_then(u32_of).filter(|v| (1..=45).contains(v));
}
"w:lnNumType" => {
let mut l = LineNumbering::default();
if let Some(v) = k.attr("w:countBy").and_then(u32_of) {
+1 -1
View File
@@ -31,7 +31,7 @@ impl Reader<'_> {
let cap_font = first.runs.first().and_then(|r| r.props.font.clone());
let text_font = p.runs.first().and_then(|r| r.props.font.clone());
if cap_font.is_some() && cap_font != text_font {
for r in &mut first.runs {
for r in first.runs.iter_mut().filter(|r| r.props.font == cap_font) {
r.props.font = text_font.clone();
}
props.drop_cap_font = cap_font;
+38 -6
View File
@@ -41,6 +41,7 @@ impl Reader<'_> {
}
let source = self.keep_embedded(c.child("a:graphic")?, rels, &[])?;
float.set_spin(xfrm_spin(content.child("w14:xfrm")));
float.bare_turn = !anchored && float.rot != 0.0 && float.effect == [0.0; 4];
let (px, py) = float.spin_pad(w, h);
for (e, p) in float.effect.iter_mut().zip([px, py, px, py]) {
if *e != 0.0 {
@@ -106,7 +107,11 @@ impl Reader<'_> {
self.set_shape_id(&mut obj, c.child("wp:docPr").and_then(|p| p.attr("id")));
// Word's effect extent also covers a rotated object's overhang; the model keeps that
// apart (it follows from the angle), so take it back out.
// Shadow or glow room the file didn't record: saving records it (and the turn's room).
let fx_room = matches!(&obj, InlineObject::Shape { effects, .. } if effects.extent() != [0.0; 4]);
if let Some(f) = obj.float_mut() {
// None recorded: Word keeps an inline one's line to the frame (see `Float::bare_turn`).
f.bare_turn = !anchored && !fx_room && f.rot != 0.0 && f.effect == [0.0; 4];
let (px, py) = f.spin_pad(w, h);
for (e, p) in f.effect.iter_mut().zip([px, py, px, py]) {
// Zero adds no effects room when rotation contracts this axis.
@@ -118,9 +123,33 @@ impl Reader<'_> {
Some(obj)
}
/// The hyperlink Word keeps on a drawing itself: its `wp:docPr`'s `a:hlinkClick` (ECMA-376
/// Part 1 §20.4.2.5, §21.1.2.3.5) naming an external hyperlink relationship. Word stores a
/// picture's link there rather than in a `w:hyperlink` around its run (#5267). Its `#name`
/// part, when the address has one, stays part of the address.
pub(super) fn drawing_link(d: &El, rels: &Rels) -> Option<String> {
let frame = d.child("wp:inline").or_else(|| d.child("wp:anchor"))?;
let id = frame.child("wp:docPr")?.child("a:hlinkClick")?.attr("r:id")?;
let rel = rels.by_id(id).filter(|r| r.external && crate::package::rel_is(&r.kind, rt::HYPERLINK))?;
Some(rel.target.trim().to_string()).filter(|u| !u.is_empty())
}
/// Native ink or an Office 2016 chart kept with its fallback (`mc:AlternateContent`), and the
/// [`Self::drawing_link`] of the choice it was read from, else of its fallback: another
/// choice's link is not this object's.
pub(super) fn read_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<(InlineObject, Option<String>)> {
let (object, choice) = match self.read_ink_alternate(sc, ac, rels) {
Some(o) => (o, children_of_choice(ac).filter(|e| e.name == "mc:Choice")),
None => (self.read_chartex_alternate(sc, ac, rels)?, chartex_choice(ac)),
};
let link =
[choice, ac.child("mc:Fallback")].into_iter().flatten().filter_map(|e| e.child("w:drawing")).find_map(|d| Self::drawing_link(d, rels));
Some((object, link))
}
/// Keep the selected native-ink branch and its picture fallback as one object. Reading only
/// the fallback flattens the ink; reading only the choice loses its preview on the next save.
pub(super) fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
fn read_ink_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
let choice = children_of_choice(ac).filter(|e| e.name == "mc:Choice")?;
let drawing = choice.child("w:drawing")?;
if drawing.find("a:graphicData")?.attr("uri") != Some(INK_URI) {
@@ -149,10 +178,8 @@ impl Reader<'_> {
/// An Office 2016 chart (`cx:chart`: waterfall, sunburst, box & whisker…) drawn from its
/// fallback picture, keeping both branches so saving writes the chart back rather than only
/// the picture. `None` (read the fallback alone) when it can't be kept whole.
pub(super) fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
let choice = ac
.children("mc:Choice")
.find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))?;
fn read_chartex_alternate(&mut self, sc: &mut StoryCtx, ac: &El, rels: &Rels) -> Option<InlineObject> {
let choice = chartex_choice(ac)?;
// A requirement in a namespace we can't name couldn't be written back.
let requires = choice.attr("Requires").unwrap_or("");
if requires.split_whitespace().next().is_none() || requires.split_whitespace().any(|p| p == "?") {
@@ -263,7 +290,7 @@ impl Reader<'_> {
}
}
}
Some(InlineObject::Image { media, w, h, alt, float, crop, ole: None })
Some(InlineObject::Image { media, w, h, alt, float, crop, fx: crate::units::blip_fx(blip), ole: None })
}
/// A `wpg:wgp` group (`w` × `h`): its pictures, shapes and text boxes, nested groups
@@ -563,6 +590,11 @@ impl Reader<'_> {
}
}
/// The `mc:Choice` holding an Office 2016 chart (`cx:chart`) in an `mc:AlternateContent`.
fn chartex_choice(ac: &El) -> Option<&El> {
ac.children("mc:Choice").find(|c| c.child("w:drawing").and_then(|d| d.find("a:graphicData")).and_then(|g| g.attr("uri")) == Some(CHARTEX_URI))
}
/// The alternative text of a `wp:docPr`, `pic:cNvPr` or `wps:cNvPr` (ECMA-376
/// `CT_NonVisualDrawingProps`): the description (the title when there is none, as before), and
/// the title and decorative mark (the `adec:decorative` extension of [MS-ODRAWXML]).
+13 -5
View File
@@ -98,9 +98,9 @@ impl Reader<'_> {
}
}
"mc:AlternateContent" => {
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
if let Some((o, link)) = self.read_alternate(sc, k, rels) {
let props = self.run_props_none(ctx);
self.emit_obj(sc, pb, o, &props);
self.emit_linked_obj(sc, pb, o, &props, link);
} else if let Some(c) = children_of_choice(k) {
self.read_inline_children(sc, pb, c, rels, ctx, depth + 1);
}
@@ -237,7 +237,7 @@ impl Reader<'_> {
},
"w:drawing" => {
if let Some(o) = self.read_drawing(sc, k, rels) {
self.emit_obj(sc, pb, o, props);
self.emit_linked_obj(sc, pb, o, props, Self::drawing_link(k, rels));
}
}
"w:pict" => {
@@ -298,8 +298,8 @@ impl Reader<'_> {
),
"w:ruby" => self.read_ruby(sc, pb, k, props),
"mc:AlternateContent" => {
if let Some(o) = self.read_ink_alternate(sc, k, rels).or_else(|| self.read_chartex_alternate(sc, k, rels)) {
self.emit_obj(sc, pb, o, props);
if let Some((o, link)) = self.read_alternate(sc, k, rels) {
self.emit_linked_obj(sc, pb, o, props, link);
} else if let Some(c) = children_of_choice(k) {
for x in c.els() {
self.read_run_child(sc, pb, x, (props, rels), note, depth + 1);
@@ -376,6 +376,14 @@ impl Reader<'_> {
}
}
/// [`Self::emit_obj`] for a drawing that carries its own `link`: a link around the run wins.
fn emit_linked_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps, link: Option<String>) {
match link.filter(|_| props.link.is_none()) {
Some(link) => self.emit_obj(sc, pb, o, &CharProps { link: Some(link), ..props.clone() }),
None => self.emit_obj(sc, pb, o, props),
}
}
pub(super) fn emit_obj(&mut self, sc: &mut StoryCtx, pb: &mut PB, o: InlineObject, props: &CharProps) {
match sink(sc) {
Some(f) => {
+13 -1
View File
@@ -134,7 +134,10 @@ impl Reader<'_> {
let id = img.attr("r:id").or_else(|| img.attr("r:pict"))?;
let media = self.media_for(rels, id)?;
let alt = shape.attr("alt").or_else(|| img.attr("o:title")).unwrap_or("").to_string();
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], ole: None });
// Brightness and contrast (Word's picture watermark "Washout" sets them, #1711).
let frac = |k: &str| img.attr(k).and_then(crate::units::vml_number);
let fx = wordcraft_doc::picfx::PicFx::from_vml(frac("gain").unwrap_or(1.0), frac("blacklevel").unwrap_or(0.0));
return Some(InlineObject::Image { media, w, h, alt, float, crop: [0.0; 4], fx, ole: None });
}
// The shapetype the shape is drawn from (its `type`, ECMA-376 Part 4 VML), when written in
// the same picture: what the shape leaves out, it takes from there.
@@ -221,6 +224,15 @@ impl Reader<'_> {
{
let id = self.read_textbox(sc, t, rels);
if let Some(part) = self.doc.parts.get_mut(&id) {
// Text direction (#5193), as Word reads it: `mso-layout-flow-alt:bottom-to-top` is
// `vert270` whatever `layout-flow` says; otherwise `layout-flow:vertical…` (also
// `vertical-ideographic`, our `eaVert`) is `vert`, and anything else horizontal.
let tb_style = tb.attr("style").unwrap_or("");
if vml_style(tb_style, "mso-layout-flow-alt").is_some_and(|v| v.eq_ignore_ascii_case("bottom-to-top")) {
part.body.vert = wordcraft_doc::props::TextVert::Vert270;
} else if vml_style(tb_style, "layout-flow").is_some_and(|v| v.to_ascii_lowercase().starts_with("vertical")) {
part.body.vert = wordcraft_doc::props::TextVert::Vert;
}
// Internal margins `inset="l,t,r,b"` (ECMA-376 Part 4 §19.1.2.22), each one left
// out keeping Word's default; a bare number is in EMUs, VML's base unit.
// `o:insetmode="auto"` leaves them to the application.
+4 -3
View File
@@ -2,6 +2,7 @@ use super::{
AbstractNum, DocxError, El, Level, LevelSuffix, Num, NumFormat, PartFormat, Reader, Style, StyleKind, StyleSheet, TablePart, TableStyleParts,
VAlign, flag, int, props, tw, u32_of,
};
use crate::units::tw_i16;
use std::sync::Arc;
/// Built-in style names Word stores in lower case, and how the UI shows them.
@@ -317,12 +318,12 @@ impl Reader<'_> {
lv.align = props::align(j);
}
if let Some(ind) = l.child("w:pPr").and_then(|p| p.child("w:ind")) {
if let Some(v) = tw(ind, "w:left").or_else(|| tw(ind, "w:start")) {
if let Some(v) = tw_i16(ind, "w:left").or_else(|| tw_i16(ind, "w:start")) {
lv.indent = v;
}
if let Some(h) = tw(ind, "w:hanging") {
if let Some(h) = tw_i16(ind, "w:hanging") {
lv.hanging = h;
} else if let Some(f) = tw(ind, "w:firstLine") {
} else if let Some(f) = tw_i16(ind, "w:firstLine") {
lv.hanging = -f;
}
}
+4 -3
View File
@@ -83,13 +83,14 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
}
// An empty paragraph opening the control takes the field. It may end a section, as above
// entries in columns (#3889).
if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
let empty_heading = engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty());
if empty_heading {
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;
*body = if empty_heading { toc } else { engine };
}
}
@@ -97,7 +98,7 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
pub(super) fn toc_field_to_heading(body: &mut Blocks) {
for i in 1..body.len() {
if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
if body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
field_to_heading(body, i);
}
}
+83 -19
View File
@@ -86,6 +86,8 @@ enum Paint {
None,
/// Theme accent `n` (1–6), lightened to `tint` (thousandths of a percent kept) when set.
Accent(usize, Option<u32>),
/// A colour of its own, lightened to `tint` when set.
Rgb(Rgb, Option<u32>),
/// The theme's light background colour.
Light,
}
@@ -150,6 +152,22 @@ impl Sh {
}
}
/// Accent fills and outlines in item `i`'s own colour, when it has one. Light outlines
/// keep the theme's background colour so adjacent nodes remain separated.
fn recolor(&mut self, spec: &SmartArtSpec, i: usize) {
let Some(c) = spec.items.get(i).and_then(|it| it.color) else { return };
for p in [&mut self.fill, &mut self.line] {
if let Paint::Accent(_, tint) = *p {
*p = Paint::Rgb(c, tint);
}
}
}
fn item(mut self, spec: &SmartArtSpec, i: usize) -> Sh {
self.recolor(spec, i);
self
}
fn with_text(mut self, text: Vec<(String, bool)>) -> Sh {
self.text = text;
self
@@ -226,7 +244,7 @@ fn basic_list(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let in_row = if r + 1 == rows { n - r * cols } else { cols };
let x0 = (w - (in_row as f32 * bw + (in_row - 1) as f32 * gx)) / 2.0;
let b = [x0 + c as f32 * (bw + gx), y0 + r as f32 * (bh + gy), bw, bh];
out.push(Sh::node("rect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
out.push(Sh::node("rect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
}
out
}
@@ -247,11 +265,14 @@ fn process(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let mut a = Sh::node("rightArrow", [gx + (gap - aw) / 2.0, h / 2.0 - ah / 2.0, aw, ah], spec.colors.accent(k - 1));
a.fill = Paint::Accent(spec.colors.accent(k - 1), Some(60_000));
a.line = Paint::None;
if let Some(&i) = tops.get(k - 1) {
a.recolor(spec, i);
}
out.push(a);
}
for (k, &i) in tops.iter().enumerate() {
let b = [k as f32 * (nw + gap), y, nw, nh];
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
}
out
}
@@ -294,6 +315,9 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
a.rot = (mid + 90.0).rem_euclid(360.0);
a.fill = Paint::Accent(spec.colors.accent(k), Some(60_000));
a.line = Paint::None;
if let Some(&i) = tops.get(k) {
a.recolor(spec, i);
}
out.push(a);
}
}
@@ -301,7 +325,7 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
for (k, &i) in tops.iter().enumerate() {
let (x, y) = if n == 1 { (cx, cy) } else { polar(cx, cy, r, angle(k, n)) };
let b = [x - d / 2.0, y - d / 2.0, d, d];
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
}
out
}
@@ -360,19 +384,19 @@ fn hierarchy(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let (px, pb) = (cx(i), top(i) + nh);
let ct = ks.first().map_or(pb, |&k| top(k));
let mid = (pb + ct) / 2.0;
out.push(Sh::line(px, pb, px, mid, accent));
out.push(Sh::line(px, pb, px, mid, accent).item(spec, i));
let xs: Vec<f32> = ks.iter().map(|&k| cx(k)).collect();
let (lo, hi) = xs.iter().fold((px, px), |(lo, hi), &x| (lo.min(x), hi.max(x)));
if hi - lo > 0.5 {
out.push(Sh::line(lo, mid, hi, mid, accent));
out.push(Sh::line(lo, mid, hi, mid, accent).item(spec, i));
}
for x in xs {
out.push(Sh::line(x, mid, x, ct, accent));
out.push(Sh::line(x, mid, x, ct, accent).item(spec, i));
}
}
for i in 0..n {
let b = [cx(i) - nw / 2.0, top(i), nw, nh];
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).with_text(paras(spec, i, false)));
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).item(spec, i).with_text(paras(spec, i, false)));
}
out
}
@@ -400,7 +424,7 @@ fn pyramid(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let mid_w = (top_w + bot_w) / 2.0 * 0.8;
s.text_box([w / 2.0 - mid_w / 2.0, b[1] + bh * 0.08, mid_w, bh * 0.84])
};
out.push(sh.with_text(paras(spec, i, true)));
out.push(sh.item(spec, i).with_text(paras(spec, i, true)));
}
out
}
@@ -418,7 +442,7 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
if n == 0 {
let d = m * 0.5;
let b = [cx - d / 2.0, cy - d / 2.0, d, d];
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
return out;
}
let step = if n == 1 { 1.0 } else { (std::f32::consts::PI / n as f32).sin() };
@@ -432,14 +456,14 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let at = |k: usize| polar(cx, cy, r, angle(k, n));
for k in 0..n {
let (x, y) = at(k);
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)));
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)).item(spec, 0));
}
let bc = [cx - dc / 2.0, cy - dc / 2.0, dc, dc];
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
for (k, &i) in spokes.iter().enumerate() {
let (x, y) = at(k);
let b = [x - ds / 2.0, y - ds / 2.0, ds, ds];
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
}
out
}
@@ -457,7 +481,7 @@ fn matrix(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
for (k, &i) in tops.iter().enumerate() {
let (r, c) = (k / cols, k % cols);
let b = [x0 + c as f32 * cell + gap / 2.0, y0 + r as f32 * cell + gap / 2.0, cell - gap, cell - gap];
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
}
out
}
@@ -478,22 +502,22 @@ fn venn(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
(x, y, deg)
};
let mut out = Vec::new();
for (j, _) in tops.iter().enumerate() {
for (j, &i) in tops.iter().enumerate() {
let (x, y, _) = place(j);
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
s.fill = Paint::Accent(spec.colors.accent(j), Some(40_000));
s.alpha = Some(60_000);
s.line = Paint::None;
out.push(s);
out.push(s.item(spec, i));
}
// Every outline over every fill, so each circle shows whole.
for (j, _) in tops.iter().enumerate() {
for (j, &i) in tops.iter().enumerate() {
let (x, y, _) = place(j);
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
s.fill = Paint::None;
s.line = Paint::Accent(spec.colors.accent(j), None);
s.line_w = 1.5;
out.push(s);
out.push(s.item(spec, i));
}
for (j, &i) in tops.iter().enumerate() {
let (x, y, deg) = place(j);
@@ -554,6 +578,23 @@ fn paint(w: &mut W, p: Paint, alpha: Option<u32>) {
w.empty("a:schemeClr", &[("val", "lt1")]);
w.close("a:solidFill");
}
Paint::Rgb(c, tint) => {
w.open("a:solidFill", &[]);
let val = c.hex();
if tint.is_none() && alpha.is_none() {
w.empty("a:srgbClr", &[("val", &val)]);
} else {
w.open("a:srgbClr", &[("val", &val)]);
if let Some(t) = tint {
w.empty("a:tint", &[("val", &t.to_string())]);
}
if let Some(a) = alpha {
w.empty("a:alpha", &[("val", &a.to_string())]);
}
w.close("a:srgbClr");
}
w.close("a:solidFill");
}
Paint::Accent(n, tint) => {
w.open("a:solidFill", &[]);
let val = format!("accent{}", n.clamp(1, 6));
@@ -743,7 +784,16 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String {
let (node, cxn, par, sib) = item_ids(i);
w.open("dgm:pt", &[("modelId", &node)]);
w.empty("dgm:prSet", &[]);
w.empty("dgm:spPr", &[]);
match it.color {
Some(c) => {
w.open("dgm:spPr", &[]);
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
w.close("a:solidFill");
w.close("dgm:spPr");
}
None => w.empty("dgm:spPr", &[]),
}
w.open("dgm:t", &[]);
w.empty("a:bodyPr", &[]);
w.empty("a:lstStyle", &[]);
@@ -1062,7 +1112,9 @@ pub(crate) fn spec_of(data: &El) -> Option<SmartArtSpec> {
.zip(&levels)
.map(|(p, l)| {
let text: String = p.child("dgm:t")?.child("a:p")?.children("a:r").filter_map(|r| r.child("a:t")).map(|t| t.text()).collect();
Some(SmartArtItem { text, level: (*l)? })
let color =
p.child("dgm:spPr").and_then(|s| s.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| Rgb::parse(c.attr("val")?));
Some(SmartArtItem { text, level: (*l)?, color })
})
.collect::<Option<Vec<_>>>()?;
let spec = SmartArtSpec { layout, items, colors }.sanitized();
@@ -1138,6 +1190,18 @@ mod tests {
}
}
#[test]
fn an_items_own_colour_is_saved_drawn_and_read_back() {
let mut spec = SmartArtSpec::sample(SmartArtLayout::Process);
spec.items[1].color = Some(Rgb(0x12, 0x34, 0x56));
let spec = spec.sanitized();
assert_eq!(spec_of(&parse(&data_xml(&spec, "rId9"))).as_ref(), Some(&spec));
let drawing = drawing_xml(&spec, 400.0, 250.0);
assert_eq!(drawing.matches("<a:srgbClr val=\"123456\"").count(), 2, "the step and the arrow after it");
let plain = drawing_xml(&SmartArtSpec::sample(SmartArtLayout::Process), 400.0, 250.0);
assert!(!plain.contains("srgbClr"));
}
#[test]
fn a_diagram_with_more_than_the_model_stays_read_only() {
let spec = SmartArtSpec::sample(SmartArtLayout::Process);
+62
View File
@@ -84,6 +84,29 @@ pub fn int(s: &str) -> Option<i64> {
v.is_finite().then(|| v.round().clamp(i64::MIN as f64, i64::MAX as f64) as i64)
}
/// A VML number (`gain`, `blacklevel`), unclamped: 16.16 fixed point with an `f` suffix
/// (`19661f` is 0.3) or a plain decimal (`0.3`). None for anything else or non-finite.
pub fn vml_number(s: &str) -> Option<f64> {
let t = s.trim();
if t.is_empty() || t.len() > 40 {
return None;
}
let v = match t.strip_suffix('f') {
Some(fixed) => fixed.trim().parse::<f64>().ok()? / 65_536.0,
None => t.parse::<f64>().ok()?,
};
v.is_finite().then_some(v)
}
/// A picture's brightness, contrast and transparency: `a:lum` (ECMA-376 §20.1.8.42) and
/// `a:alphaModFix` (§20.1.8.4) in its `a:blip` (#1766).
pub fn blip_fx(blip: &El) -> wordcraft_doc::picfx::PicFx {
let val = |el: Option<&El>, k: &str| el.and_then(|e| e.attr(k)).and_then(int).map(|v| v.clamp(-1_000_000, 1_000_000) as i32);
let lum = blip.child("a:lum");
let amt = val(blip.child("a:alphaModFix"), "amt").unwrap_or(100_000).clamp(0, 100_000);
wordcraft_doc::picfx::PicFx::new(val(lum, "bright").unwrap_or(0), val(lum, "contrast").unwrap_or(0), 100_000 - amt)
}
pub fn u32_of(s: &str) -> Option<u32> {
int(s).map(|v| v.clamp(0, u32::MAX as i64) as u32)
}
@@ -93,6 +116,27 @@ pub fn tw(e: &El, attr: &str) -> Option<f32> {
e.attr(attr).and_then(|v| measure(v, 20.0))
}
/// Signed twips Word keeps in 16 bits (paragraph and list indents, ECMA-376 Part 1 §17.3.1.12):
/// a plain integer outside `i16` keeps its low 16 bits, two's complement, as Word reads it
/// (66256 → 720). Values with a unit or a fraction read as [`tw`].
pub fn tw_i16(e: &El, attr: &str) -> Option<f32> {
let v = e.attr(attr)?;
match v.trim().parse::<i64>() {
Ok(i) => Some(f32::from(i as i16) / 20.0),
Err(_) => measure(v, 20.0),
}
}
/// Unsigned twips Word keeps in 16 bits (paragraph spacing, §17.3.1.33): a plain integer above
/// 65535 keeps its low 16 bits (65537 → 1). Other values read as [`tw`].
pub fn tw_u16(e: &El, attr: &str) -> Option<f32> {
let v = e.attr(attr)?;
match v.trim().parse::<i64>() {
Ok(i) if i > 0xFFFF => Some(f32::from((i & 0xFFFF) as u16) / 20.0),
_ => measure(v, 20.0),
}
}
/// ST_OnOff: absent `w:val` = on; `0`, `false`, `off` = off.
pub fn on_off(e: &El) -> bool {
match e.attr("w:val") {
@@ -125,6 +169,24 @@ pub fn emu(pt: f32) -> String {
mod tests {
use super::*;
#[test]
fn twips_beyond_16_bits_wrap() {
let el = |a: &str| El { attrs: vec![("w:v".into(), a.into())], ..El::default() };
let s = |a: &str| tw_i16(&el(a), "w:v");
let u = |a: &str| tw_u16(&el(a), "w:v");
assert_eq!(s("66256"), Some(36.0));
assert_eq!(s("-65516"), Some(1.0));
assert_eq!(s("98304"), Some(-1638.4));
assert_eq!(s("32767"), Some(1638.35));
assert_eq!(s("-32768"), Some(-1638.4));
assert_eq!(s("1in"), Some(72.0));
assert_eq!(s("x"), None);
assert_eq!(u("65537"), Some(0.05));
assert_eq!(u("65535"), Some(3276.75));
assert_eq!(u("12pt"), Some(12.0));
assert_eq!(u("-20"), Some(-1.0));
}
#[test]
fn measures() {
assert_eq!(measure("1440", 20.0), Some(72.0));
+15 -2
View File
@@ -317,7 +317,8 @@ impl<'a> Conv<'a> {
/// A DrawingML picture as a `w:pict` (inline, at its extent); any other drawing is dropped.
fn picture(&mut self, drawing: &El, rels: &Rels) -> Vec<Node> {
let Some(id) = drawing.find("a:blip").and_then(|b| b.attr("r:embed")) else { return Vec::new() };
let Some(blip) = drawing.find("a:blip") else { return Vec::new() };
let Some(id) = blip.attr("r:embed") else { return Vec::new() };
let Some(target) = rels.by_id(id).filter(|r| !r.external).map(|r| r.target.clone()) else { return Vec::new() };
let Some(src) = self.bin_for(&target) else { return Vec::new() };
let pt = |k: &str| {
@@ -329,7 +330,19 @@ impl<'a> Conv<'a> {
let shape_id = format!("_x0000_i{}", self.next_shape);
self.next_shape = self.next_shape.saturating_add(1);
let mut kids: Vec<Node> = std::mem::take(&mut self.bins).into_iter().map(Node::El).collect();
kids.push(Node::El(self.shape_frame(&shape_id, &style, &src, &alt)));
let mut shape = self.shape_frame(&shape_id, &style, &src, &alt);
// Brightness and contrast as VML `gain` and `blacklevel` (16.16 fixed point, #1711).
// `v:imagedata` has no opacity, so a picture's transparency (`a:alphaModFix`) is dropped.
let fx = crate::units::blip_fx(blip);
if fx.changes_pixels()
&& let Some(Node::El(data)) = shape.kids.iter_mut().find(|k| matches!(k, Node::El(e) if e.name == "v:imagedata"))
{
let (gain, black) = fx.to_vml();
let fixed = |v: f64| format!("{}f", (v * 65_536.0).round().clamp(-1.0e12, 1.0e12) as i64);
data.attrs.push(("gain".into(), fixed(gain)));
data.attrs.push(("blacklevel".into(), fixed(black)));
}
kids.push(Node::El(shape));
vec![Node::El(plain("w:pict", kids))]
}
+28 -7
View File
@@ -353,11 +353,11 @@ impl super::Writer<'_> {
};
self.embeds.markup_bytes = left;
let mut root = xml::parse(src.xml.as_bytes()).ok()?;
let mut used = Vec::new();
let mut used = HashSet::new();
rel_attr_values(&root, &mut used, 0);
// Every relationship the markup uses must be writable, or the object would be broken.
let writable = |id: &String| src.refs.iter().any(|r| r.id == *id && (r.external || self.embeds.available(&r.target)));
if !used.iter().all(writable) {
let writable: HashSet<&str> = src.refs.iter().filter(|r| r.external || self.embeds.available(&r.target)).map(|r| r.id.as_str()).collect();
if !used.iter().all(|id| writable.contains(id.as_str())) {
log::warn!("docx: an embedded object's parts are missing; it is left out");
return None;
}
@@ -499,8 +499,9 @@ impl super::Writer<'_> {
}
}
"wp:effectExtent" => {
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
let (px, py) = float.spin_pad(w, h);
// Word's effect extent also covers a rotated object's overhang (its rotated bounds),
// unless the file it came from kept none.
let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(w, h) };
let [el, et, er, eb] = float.effect_extent();
let ext = [el + px, et + py, er + px, eb + py].map(emu);
e.attrs.retain(|(k, _)| !matches!(k.as_str(), "l" | "t" | "r" | "b"));
@@ -637,13 +638,15 @@ fn set_attr(e: &mut El, k: &str, v: &str) {
}
}
fn rel_attr_values(e: &El, out: &mut Vec<String>, depth: usize) {
/// The distinct relationship ids `e` uses: a set, so a large tree (a native document's markup
/// never went through the reader's id limit) costs no scan per attribute.
fn rel_attr_values(e: &El, out: &mut HashSet<String>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for (k, v) in &e.attrs {
if xml::is_rel_attr(&e.name, k) && !out.contains(v) {
out.push(v.clone());
out.insert(v.clone());
}
}
for c in e.els() {
@@ -851,6 +854,24 @@ fn valid_part_name(name: &str) -> String {
mod tests {
use super::*;
#[test]
fn relationship_ids_are_collected_once_each() {
// A native document's embedded markup skips the reader's id limit: 40,000 distinct ids
// (each named twice) were a scan of every id collected so far, per attribute.
let mut xml = String::from(
r#"<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
);
for i in 0..40_000 {
xml.push_str(&format!(r#"<w:r r:id="rId{i}"/><w:r r:id="rId{i}"/>"#));
}
xml.push_str("</w:p>");
let root = xml::parse(xml.as_bytes()).unwrap();
let mut used = HashSet::new();
rel_attr_values(&root, &mut used, 0);
assert_eq!(used.len(), 40_000);
assert!(used.contains("rId39999"));
}
#[test]
fn charts_cut_to_the_limits_on_save_say_so_once() {
// A model deserialised as is can hold more than a chart part keeps; the save left the rest
+6 -2
View File
@@ -1,7 +1,8 @@
//! [`wordcraft_doc::math`] tree → Office Math (OMML).
use wordcraft_doc::math::{
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, parse_linear,
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MAX_MATRIX_COLS, MAX_MATRIX_ROWS, MNode, MRun, MScr, MSty, Math, MathJc, MathProps,
ScriptKind, parse_linear,
};
use wordcraft_doc::props::Rgb;
@@ -181,7 +182,10 @@ fn char_prop(w: &mut W, name: &str, c: Option<char>) {
fn write_matrix(w: &mut W, n: &MNode, depth: usize) {
let MNode::Matrix { rows, col_jc } = n else { return };
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
// Every row is padded to the widest one, so a ragged matrix is clamped to the reader's
// caps rather than written as rows × widest cells (#3643).
let rows = rows.get(..rows.len().min(MAX_MATRIX_ROWS)).unwrap_or_default();
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).clamp(1, MAX_MATRIX_COLS);
w.open("m:m", &[]);
w.open("m:mPr", &[]);
w.open("m:mcs", &[]);
+10
View File
@@ -287,6 +287,12 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
if let Some(a) = p.align {
w.val("w:jc", align_val(a));
}
if let Some(direction) = p.text_direction {
w.val("w:textDirection", direction.ooxml());
}
if let Some(t) = p.text_alignment {
w.val("w:textAlignment", t.ooxml());
}
if let Some(l) = p.outline_level {
w.val("w:outlineLvl", &n(l.min(9) as i64));
}
@@ -323,6 +329,9 @@ fn write_frame(w: &mut W, p: &ParaProps, framed: bool) {
a.push(("w:vSpace", &vs));
}
a.push(("w:wrap", &f.wrap));
if let Some(anchor_lock) = f.anchor_lock {
a.push(("w:anchorLock", if anchor_lock { "1" } else { "0" }));
}
a.push(("w:vAnchor", &f.v_anchor));
a.push(("w:hAnchor", &f.h_anchor));
match &f.x_align {
@@ -370,6 +379,7 @@ fn write_text_flow(w: &mut W, p: &ParaProps) {
toggle(w, "w:autoSpaceDE", p.auto_space_de);
toggle(w, "w:autoSpaceDN", p.auto_space_dn);
toggle(w, "w:bidi", p.bidi);
toggle(w, "w:adjustRightInd", p.adjust_right_indent);
toggle(w, "w:snapToGrid", p.snap_to_grid);
}
+18 -1
View File
@@ -86,11 +86,28 @@ pub fn plan(bl: &Blocks) -> Plan<'_> {
&& (0..off).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlStart { .. })))
})
};
// Bookmark, comment and permission markers after the end may stay there: a body-level
// bookmark after a block control (Word's `_GoBack`) is read onto the end of its paragraph,
// and the control still covers the whole paragraph.
let at_end = |b: usize, off: usize| {
use wordcraft_doc::InlineObject as O;
para(b).is_some_and(|p| {
let from = off + OBJ.len_utf8();
p.text.get(from..).is_some_and(|t| t.chars().all(|c| c == OBJ))
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlEnd)))
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| {
matches!(
p.object_at(o),
Some(
O::ControlEnd
| O::BookmarkStart { .. }
| O::BookmarkEnd { .. }
| O::CommentStart { .. }
| O::CommentEnd { .. }
| O::PermStart { .. }
| O::PermEnd { .. }
)
)
})
})
};
let mut mode = vec![Mode::Inline; ranges.len()];
+47 -16
View File
@@ -10,23 +10,47 @@ pub(super) struct DrawingInfo<'a> {
pub docpr: &'a str,
pub name: &'a str,
pub alt: &'a str,
/// The relationship id of the drawing's hyperlink (`a:hlinkClick`), see [`Writer::drawing_link`].
pub link: Option<&'a str>,
}
impl Writer<'_> {
/// The relationship of the hyperlink a drawing in a run with `props` carries on itself, as
/// Word writes a picture's link (`wp:docPr/a:hlinkClick`, #5267): a web or file address. A
/// link to a place in the document (`#name`) is only the `w:hyperlink` around the run.
pub(super) fn drawing_link(props: &wordcraft_doc::CharProps, rels: &mut PartRels) -> Option<String> {
let url = props.link.as_deref().filter(|l| !l.is_empty() && !l.starts_with('#'))?;
Some(rels.add(rt::HYPERLINK, url, true))
}
/// A picture's `pic:pic` (the image in `media/{file}`), at `off` in its group (or 0, 0).
pub(super) fn pic(&mut self, w: &mut W, o: &InlineObject, file: &str, off: (f32, f32), rels: &mut PartRels) {
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, .. } = o else { return };
let InlineObject::Image { media, w: iw, h: ih, alt, crop, float, fx, .. } = o else { return };
let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false);
self.used_media.insert(media.clone());
w.open("pic:pic", &[]);
w.open("pic:nvPicPr", &[]);
nv_props(w, "pic:cNvPr", "0", file, alt, &float.alt, float.hidden);
nv_props(w, "pic:cNvPr", ("0", file), alt, &float.alt, float.hidden, None);
w.open("pic:cNvPicPr", &[]);
w.empty("a:picLocks", &[("noChangeAspect", "1"), ("noChangeArrowheads", "1")]);
w.close("pic:cNvPicPr");
w.close("pic:nvPicPr");
w.open("pic:blipFill", &[]);
w.empty("a:blip", &[("r:embed", &rid)]);
// Brightness, contrast and transparency (#1711, #1766) as `a:blip` effects (ECMA-376
// §20.1.8.13 CT_Blip: an unbounded choice of effects, in any order).
let fx = fx.clamped();
if fx.is_identity() {
w.empty("a:blip", &[("r:embed", &rid)]);
} else {
w.open("a:blip", &[("r:embed", &rid)]);
if fx.transparency != 0 {
w.empty("a:alphaModFix", &[("amt", &n(i64::from(100_000 - fx.transparency)))]);
}
if fx.bright != 0 || fx.contrast != 0 {
w.empty("a:lum", &[("bright", &n(i64::from(fx.bright))), ("contrast", &n(i64::from(fx.contrast)))]);
}
w.close("a:blip");
}
if crop.iter().any(|c| *c > 0.0) {
let c = |v: f32| n((wordcraft_geom::finite(v).clamp(0.0, 1.0) * 100_000.0).round() as i64);
w.empty("a:srcRect", &[("l", &c(crop[0])), ("t", &c(crop[1])), ("r", &c(crop[2])), ("b", &c(crop[3]))]);
@@ -52,7 +76,7 @@ impl Writer<'_> {
w.open("wps:wsp", &[]);
if let Some(id) = id {
let name = shape_name(*kind, freeform.as_deref(), id);
nv_props(w, "wps:cNvPr", id, &name, &float.alt.text, &float.alt, float.hidden);
nv_props(w, "wps:cNvPr", (id, &name), &float.alt.text, &float.alt, float.hidden, None);
}
if kind.is_connector() {
// A connector's glued ends (ECMA-376 §20.1.2.2.8 `cNvCxnSpPr`).
@@ -221,7 +245,7 @@ impl Writer<'_> {
}
pub(super) fn drawing_open(&mut self, w: &mut W, info: DrawingInfo<'_>) {
let DrawingInfo { float, size: (iw, ih), docpr, name, alt } = info;
let DrawingInfo { float, size: (iw, ih), docpr, name, alt, link } = info;
let (cx, cy) = (emu(iw.max(0.0)), emu(ih.max(0.0)));
if float.wrap == Wrap::Inline {
w.open("wp:inline", &[("distT", "0"), ("distB", "0"), ("distL", "0"), ("distR", "0")]);
@@ -274,8 +298,9 @@ impl Writer<'_> {
}
}
w.empty("wp:extent", &[("cx", &cx), ("cy", &cy)]);
// Word's effect extent also covers a rotated object's overhang (its rotated bounds).
let (px, py) = float.spin_pad(iw, ih);
// Word's effect extent also covers a rotated object's overhang (its rotated bounds), unless
// the file it came from kept none.
let (px, py) = if float.is_bare_turn() { (0.0, 0.0) } else { float.spin_pad(iw, ih) };
let [el, et, er, eb] = float.effect_extent();
let [el, et, er, eb] = [el + px, et + py, er + px, eb + py].map(emu);
w.empty("wp:effectExtent", &[("l", &el), ("t", &et), ("r", &er), ("b", &eb)]);
@@ -302,7 +327,7 @@ impl Writer<'_> {
Wrap::TopAndBottom => w.empty("wp:wrapTopAndBottom", &[]),
Wrap::BehindText | Wrap::InFrontOfText => w.empty("wp:wrapNone", &[]),
}
nv_props(w, "wp:docPr", docpr, name, alt, &float.alt, float.hidden);
nv_props(w, "wp:docPr", (docpr, name), alt, &float.alt, float.hidden, link);
}
}
@@ -488,8 +513,9 @@ pub(in super::super) fn spin_attrs(spin: wordcraft_geom::Spin) -> Vec<(&'static
/// A drawing's `wp:docPr` (or a group member's `pic:cNvPr`/`wps:cNvPr`, ECMA-376
/// `CT_NonVisualDrawingProps`) with its alt text `descr`, its title, and the decorative mark as
/// the `adec:decorative` extension ([MS-ODRAWXML]). A description that is only the title (read
/// from a file with no description) is not written twice.
pub(in super::super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, descr: &str, alt: &AltText, hidden: bool) {
/// from a file with no description) is not written twice. `link` is the relationship id of the
/// drawing's hyperlink (`a:hlinkClick`, the first child the schema allows).
pub(in super::super) fn nv_props(w: &mut W, tag: &str, (id, name): (&str, &str), descr: &str, alt: &AltText, hidden: bool, link: Option<&str>) {
let mut attrs = vec![("id", id), ("name", name)];
if descr != alt.title || descr.is_empty() {
attrs.push(("descr", descr));
@@ -500,16 +526,21 @@ pub(in super::super) fn nv_props(w: &mut W, tag: &str, id: &str, name: &str, des
if hidden {
attrs.push(("hidden", "1"));
}
if !alt.decorative {
if !alt.decorative && link.is_none() {
w.empty(tag, &attrs);
return;
}
w.open(tag, &attrs);
w.open("a:extLst", &[]);
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
w.close("a:ext");
w.close("a:extLst");
if let Some(rid) = link {
w.empty("a:hlinkClick", &[("r:id", rid)]);
}
if alt.decorative {
w.open("a:extLst", &[]);
w.open("a:ext", &[("uri", crate::xml::DECORATIVE_EXT)]);
w.empty("adec:decorative", &[("xmlns:adec", crate::xml::DECORATIVE_NS), ("val", "1")]);
w.close("a:ext");
w.close("a:extLst");
}
w.close(tag);
}
+17 -5
View File
@@ -220,13 +220,18 @@ impl Writer<'_> {
w.open("mc:Choice", &[("Requires", requires)]);
}
let docpr = self.next_docpr();
let link = Self::drawing_link(props, rels);
let name = match graphic.kind {
wordcraft_doc::graphic::GraphicKind::Chart => format!("Chart {docpr}"),
wordcraft_doc::graphic::GraphicKind::Diagram => format!("Diagram {docpr}"),
wordcraft_doc::graphic::GraphicKind::Ink => format!("Ink {docpr}"),
};
let z = self.z;
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, native.unwrap_or(&root));
self.graphic_frame(
w,
DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt, link: link.as_deref() },
native.unwrap_or(&root),
);
if let Some(fallback) = fallback {
w.close("mc:Choice");
match picture {
@@ -234,7 +239,11 @@ impl Writer<'_> {
// Both branches are one object: same id and stacking order.
self.z = z;
w.open("mc:Fallback", &[]);
self.graphic_frame(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt }, picture);
self.graphic_frame(
w,
DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt, link: link.as_deref() },
picture,
);
w.close("mc:Fallback");
}
None => w.raw(&fallback.to_xml()),
@@ -266,8 +275,9 @@ impl Writer<'_> {
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let link = Self::drawing_link(props, rels);
let name = format!("Picture {docpr}");
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt });
self.drawing_open(w, DrawingInfo { float, size: (*iw, *ih), docpr: &docpr, name: &name, alt, link: link.as_deref() });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.openxmlformats.org/drawingml/2006/picture")]);
@@ -355,6 +365,7 @@ impl Writer<'_> {
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.shape_docpr(extra.id);
let link = Self::drawing_link(props, rels);
// Ink is told apart by its name (and its pen), which reading looks for.
let name = shape_name(*kind, freeform.as_deref(), &docpr);
// The effect extent leaves room for the shadow and glow.
@@ -369,7 +380,7 @@ impl Writer<'_> {
.map(std::sync::Arc::new);
}
let float = &float;
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text() });
self.drawing_open(w, DrawingInfo { float, size: (*sw, *sh), docpr: &docpr, name: &name, alt: o.alt_text(), link: link.as_deref() });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
w.open("a:graphicData", &[("uri", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape")]);
@@ -389,8 +400,9 @@ impl Writer<'_> {
self.rpr(w, props);
w.open("w:drawing", &[]);
let docpr = self.next_docpr();
let link = Self::drawing_link(props, rels);
let name = format!("{} {docpr}", if canvas.is_some() { "Canvas" } else { "Group" });
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text() });
self.drawing_open(w, DrawingInfo { float, size: (*gw, *gh), docpr: &docpr, name: &name, alt: o.alt_text(), link: link.as_deref() });
w.empty("wp:cNvGraphicFramePr", &[]);
w.open("a:graphic", &[]);
let outer = match canvas {
+45 -8
View File
@@ -87,6 +87,7 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
("w:gutter", &twips(s.gutter.max(0.0))),
],
);
paper_source(w, s);
if let Some(b) = &s.page_borders {
borders(w, "w:pgBorders", b, None, &[("w:offsetFrom", "text")]);
}
@@ -107,14 +108,7 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:lnNumType", &refs);
}
if s.page_num_set || s.page_num_start.is_some() || s.page_num_format != wordcraft_doc::section::NumFormat::Decimal {
let mut a = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
if let Some(st) = s.page_num_start {
a.push(("w:start", st.to_string()));
}
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:pgNumType", &refs);
}
page_number_type(w, s);
let c = &s.columns;
let count = c.count.clamp(1, 45);
let mut ca = vec![("w:space", twips(c.space.max(0.0))), ("w:num", n(count as i64))];
@@ -134,6 +128,9 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
let refs: Vec<(&str, &str)> = ca.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:cols", &refs);
}
if let Some(count) = s.footnote_columns.filter(|count| *count > 0) {
w.empty("w15:footnoteColumns", &[("w:val", &count.clamp(1, 45).to_string())]);
}
if let Some(on) = s.form_prot {
w.empty("w:formProt", if on { &[] } else { &[("w:val", "0")] });
}
@@ -143,6 +140,9 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
wordcraft_doc::section::PageVAlign::Justify => w.val("w:vAlign", "both"),
wordcraft_doc::section::PageVAlign::Bottom => w.val("w:vAlign", "bottom"),
}
if let Some(on) = s.no_endnote {
w.empty("w:noEndnote", if on { &[] } else { &[("w:val", "0")] });
}
if s.title_page {
w.empty("w:titlePg", &[]);
}
@@ -167,6 +167,43 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
}
}
fn paper_source(w: &mut W, s: &SectionProps) {
let mut attrs = Vec::new();
if let Some(first) = s.paper_src_first {
attrs.push(("w:first", first.to_string()));
}
if let Some(other) = s.paper_src_other {
attrs.push(("w:other", other.to_string()));
}
if !attrs.is_empty() {
let refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:paperSrc", &refs);
}
}
fn page_number_type(w: &mut W, s: &SectionProps) {
if !(s.page_num_set
|| s.page_num_start.is_some()
|| s.page_num_format != wordcraft_doc::section::NumFormat::Decimal
|| s.chapter_style.is_some()
|| s.chapter_sep.is_some())
{
return;
}
let mut attrs = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
if let Some(start) = s.page_num_start {
attrs.push(("w:start", start.to_string()));
}
if let Some(style) = s.chapter_style {
attrs.push(("w:chapStyle", style.to_string()));
}
if let Some(separator) = s.chapter_sep {
attrs.push(("w:chapSep", separator.ooxml().to_string()));
}
let refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:pgNumType", &refs);
}
/// The children of a `w:footnotePr` / `w:endnotePr` in schema order (`w:pos`, `w:numFmt`,
/// `w:numStart`, `w:numRestart`; ECMA-376 §17.11.11), for the fields that are set.
pub(in super::super) fn note_pr_inner(w: &mut W, pr: &NoteProps) {
+114 -6
View File
@@ -94,6 +94,49 @@ fn prefix_for(uri: &[u8], extra: &[(&'static str, &str)]) -> &'static str {
"?"
}
/// Word's 2006 namespace (`wne`). A Strict document from Word keeps a text box's content in
/// `wne:txbxContent`; a Transitional one uses `w:txbxContent`.
const WNE: &[u8] = b"http://schemas.microsoft.com/office/word/2006/wordml";
/// The canonical prefix of an element named `local` in namespace `uri`, inside an element
/// named `parent` (canonical, `None` at the root). This is [`prefix_for`] with two exceptions
/// for Strict documents (#3548):
///
/// - Shapes, groups and canvases (`wsp`, `wgp`, `wpc`) and their parts are elements of the
/// WordprocessingDrawing namespace in ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`,
/// Strict and Transitional alike). A Strict document from Word puts them there; a Transitional
/// one uses Word 2010's own namespaces, whose names the reader knows. So they read as `wps:`,
/// `wpg:` and `wpc:` (see [`drawing_prefix`]).
/// - A shape's text box content reads as `w:txbxContent` from the drawing namespace (the
/// schema's name) and from Word's `wne:txbxContent`.
fn element_prefix(uri: &[u8], local: &str, parent: Option<&str>, extra: &[(&'static str, &str)]) -> &'static str {
match prefix_for(uri, extra) {
"wp" => drawing_prefix(local, parent),
"?" if uri == WNE && local == "txbxContent" && parent == Some("wps:txbx") => "w",
p => p,
}
}
/// The prefix for `local`, an element of the WordprocessingDrawing namespace inside `parent`.
/// Word 2010's namespaces split what this one holds: a shape and its parts are `wps:`, a group
/// (`wgp`, a nested `grpSp`) and a graphic frame in it are `wpg:`, and a canvas is `wpc:`. Its
/// members are named for what they are. A text box's content is `w:txbxContent`, and anything
/// else (`inline`, `anchor`, `docPr`…) stays `wp:`.
fn drawing_prefix(local: &str, parent: Option<&str>) -> &'static str {
match (parent, local) {
(_, "wsp") => "wps",
(_, "wgp" | "grpSp" | "graphicFrame") => "wpg",
(_, "wpc") => "wpc",
(Some("wps:wsp"), "cNvPr" | "cNvSpPr" | "cNvCnPr" | "spPr" | "style" | "extLst" | "txbx" | "linkedTxbx" | "bodyPr") => "wps",
(Some("wps:txbx"), "txbxContent") => "w",
(Some("wps:txbx" | "wps:linkedTxbx"), "extLst") => "wps",
(Some("wpg:wgp" | "wpg:grpSp"), "cNvPr" | "cNvGrpSpPr" | "grpSpPr" | "extLst") => "wpg",
(Some("wpg:graphicFrame"), "cNvPr" | "cNvFrPr" | "xfrm" | "extLst") => "wpg",
(Some("wpc:wpc"), "bg" | "whole" | "extLst") => "wpc",
_ => "wp",
}
}
/// Compatibility feature namespaces whose elements are not otherwise handled by this tree.
/// Keep the reader's existing feature set when canonicalizing `mc:Choice/@Requires` aliases.
fn requirement_prefix(uri: &[u8]) -> &'static str {
@@ -342,8 +385,8 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
loop {
buf.clear();
let (res, ev) = r.read_resolved_event_into(&mut buf).map_err(|e| DocxError::Xml(e.to_string()))?;
let el_prefix = match &res {
ResolveResult::Bound(ns) => Some(prefix_for(ns.as_ref(), extra)),
let el_ns = match &res {
ResolveResult::Bound(ns) => Some(ns.as_ref()),
_ => None,
};
match ev {
@@ -356,8 +399,8 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
return Err(DocxError::Limit("too many XML elements".into()));
}
let local = String::from_utf8_lossy(e.local_name().as_ref()).into_owned();
let name = match el_prefix {
Some(p) => format!("{p}:{local}"),
let name = match el_ns {
Some(ns) => format!("{}:{local}", element_prefix(ns, &local, stack.last().map(|p| p.name.as_str()), extra)),
None => local,
};
let mut el = El { name, attrs: Vec::new(), kids: Vec::new() };
@@ -419,15 +462,22 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
attach(&mut stack, &mut root, el);
}
}
// Line ends are normalised as XML 1.0 says (§2.11): CR LF and a lone CR are read as
// LF, so a file saved with Windows line ends has no CR in its text. A CR written as
// `&#13;` is a character reference and stays.
Event::Text(t) => {
if let Some(top) = stack.last_mut() {
let s = t.decode().map_err(|e| DocxError::Xml(e.to_string()))?;
let s = t.xml_content(XmlVersion::Explicit1_0).map_err(|e| DocxError::Xml(e.to_string()))?;
push_text(top, &s);
}
}
Event::CData(t) => {
if let Some(top) = stack.last_mut() {
let s = String::from_utf8_lossy(&t).into_owned();
// CDATA that isn't UTF-8 is decoded lossily, with its line ends normalised the same way.
let s = t
.xml_content(XmlVersion::Explicit1_0)
.map(|s| s.into_owned())
.unwrap_or_else(|_| String::from_utf8_lossy(&t).replace("\r\n", "\n").replace('\r', "\n"));
push_text(top, &s);
}
}
@@ -593,6 +643,42 @@ mod tests {
assert!(parse(b"<a></a><b></b>").is_ok(), "lenient parsing still stops at the root");
}
/// Every element name in `e`, depth first.
fn names(e: &El, out: &mut Vec<String>) {
out.push(e.name.clone());
for c in e.els() {
names(c, out);
}
}
#[test]
fn strict_drawing_names_read_as_word_2010_names() {
// A Strict shape with a text box, a group with a nested group and a graphic frame, and
// a canvas, each in the one Strict WordprocessingDrawing namespace (#3548).
let xml = br#"<w:body xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:d="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml">
<d:anchor><d:extent/><d:docPr/><d:cNvGraphicFramePr/><a:graphic><a:graphicData>
<d:wsp><d:cNvPr/><d:cNvSpPr/><d:spPr/><d:style/><d:extLst/><d:txbx><wne:txbxContent><w:p/></wne:txbxContent></d:txbx><d:bodyPr/></d:wsp>
<d:wgp><d:cNvPr/><d:cNvGrpSpPr/><d:grpSpPr/><d:grpSp><d:cNvGrpSpPr/><d:grpSpPr/><d:wsp><d:cNvCnPr/><d:spPr/><d:linkedTxbx/><d:bodyPr/></d:wsp></d:grpSp>
<d:graphicFrame><d:cNvPr/><d:cNvFrPr/><d:xfrm/><a:graphic/><d:extLst/></d:graphicFrame><d:extLst/></d:wgp>
<d:wpc><d:bg/><d:whole/><d:wsp><d:spPr/><d:txbx><d:txbxContent/><d:extLst/></d:txbx></d:wsp><d:wgp><d:grpSpPr/></d:wgp><d:extLst/></d:wpc>
</a:graphicData></a:graphic></d:anchor>
<wne:txbxContent/><d:inline><d:docPr/><d:bodyPr/><d:bg/></d:inline></w:body>"#;
let mut got = Vec::new();
names(&parse(xml).unwrap(), &mut got);
let want = "w:body wp:anchor wp:extent wp:docPr wp:cNvGraphicFramePr a:graphic a:graphicData \
wps:wsp wps:cNvPr wps:cNvSpPr wps:spPr wps:style wps:extLst wps:txbx w:txbxContent w:p wps:bodyPr \
wpg:wgp wpg:cNvPr wpg:cNvGrpSpPr wpg:grpSpPr wpg:grpSp wpg:cNvGrpSpPr wpg:grpSpPr wps:wsp wps:cNvCnPr wps:spPr wps:linkedTxbx wps:bodyPr \
wpg:graphicFrame wpg:cNvPr wpg:cNvFrPr wpg:xfrm a:graphic wpg:extLst wpg:extLst \
wpc:wpc wpc:bg wpc:whole wps:wsp wps:spPr wps:txbx w:txbxContent wps:extLst wpg:wgp wpg:grpSpPr wpc:extLst \
?:txbxContent wp:inline wp:docPr wp:bodyPr wp:bg";
assert_eq!(got.join(" "), want);
// The Transitional namespaces read as before.
let xml = br#"<w:body xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"><wp:inline><wp:docPr/></wp:inline><wps:wsp><wps:txbx><w:txbxContent/></wps:txbx></wps:wsp><wne:toolbars/></w:body>"#;
let mut got = Vec::new();
names(&parse(xml).unwrap(), &mut got);
assert_eq!(got.join(" "), "w:body wp:inline wp:docPr wps:wsp wps:txbx w:txbxContent ?:toolbars");
}
#[test]
fn a_relationship_attribute_is_the_namespaced_one_or_the_alias_of_one() {
// The relationship namespace, and VML's `o:relid`.
@@ -645,6 +731,28 @@ mod tests {
assert_eq!(t.attr("xml:space"), Some("preserve"));
}
/// CDATA that isn't UTF-8 is decoded lossily, and its line ends are still normalised.
#[test]
fn line_ends_in_cdata_that_isnt_utf8_are_normalised() {
let x =
b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t><![CDATA[a\xff\r\nb\rc]]></x:t></x:document>";
assert_eq!(parse(x).unwrap().child("w:t").unwrap().text(), "a\u{FFFD}\nb\nc");
}
/// XML 1.0 §2.11: CR LF and a lone CR in text and CDATA are read as LF; `&#13;` stays a CR.
#[test]
fn line_ends_in_text_are_normalised_but_a_cr_reference_stays() {
let x = b"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>a\r\nb\rc&#13;d<![CDATA[e\r\nf]]></x:t></x:document>";
let e = parse(x).unwrap();
assert_eq!(e.child("w:t").unwrap().text(), "a\nb\nc\rde\nf");
// The writer escapes a CR, so a CR in the text survives a save.
let x = format!(
"<x:document xmlns:x=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><x:t>{}</x:t></x:document>",
esc("a\r\nb\rc")
);
assert_eq!(parse(x.as_bytes()).unwrap().child("w:t").unwrap().text(), "a\r\nb\rc");
}
#[test]
fn choice_requirements_resolve_namespace_aliases_and_rebindings() {
let x = br#"<mc:AlternateContent xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:pen="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"><mc:Choice Requires="pen"/><mc:Choice xmlns:pen="urn:future" Requires="pen"/><mc:Choice Requires="undeclared"/></mc:AlternateContent>"#;
+10 -1
View File
@@ -317,7 +317,16 @@ fn a_chunks_pictures_come_with_it() {
let mut d = Document::new();
let key = d.add_media(vec![1, 2, 3], "png");
let mut para = wordcraft_doc::Paragraph::new();
let image = InlineObject::Image { media: key, w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None };
let image = InlineObject::Image {
media: key,
w: 10.0,
h: 10.0,
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
ole: None,
fx: Default::default(),
};
para.insert_object(0, image, &Default::default()).unwrap();
d.body = vec![std::sync::Arc::new(Block::Para(para))];
Some(d)
+76
View File
@@ -0,0 +1,76 @@
//! Compound border lines of thin and thick strokes (`thinThickSmallGap` to
//! `thinThickThinLargeGap`, ECMA-376 Part 1 §17.18.2 ST_Border) on paragraphs, table cells and
//! pages: written back as read, not as a single line. Fixtures are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::BorderStyle;
const LINES: [(&str, BorderStyle); 9] = [
("thinThickSmallGap", BorderStyle::ThinThickSmallGap),
("thickThinSmallGap", BorderStyle::ThickThinSmallGap),
("thinThickThinSmallGap", BorderStyle::ThinThickThinSmallGap),
("thinThickMediumGap", BorderStyle::ThinThickMediumGap),
("thickThinMediumGap", BorderStyle::ThickThinMediumGap),
("thinThickThinMediumGap", BorderStyle::ThinThickThinMediumGap),
("thinThickLargeGap", BorderStyle::ThinThickLargeGap),
("thickThinLargeGap", BorderStyle::ThickThinLargeGap),
("thinThickThinLargeGap", BorderStyle::ThinThickThinLargeGap),
];
fn docx(body: &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 = 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 zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
fn side(name: &str, val: &str) -> String {
format!(r#"<w:{name} w:val="{val}" w:sz="36" w:space="1" w:color="2F5496"/>"#)
}
#[test]
fn compound_paragraph_borders_are_written_back_as_read() {
let body: String = LINES
.iter()
.map(|(val, _)| {
format!(r#"<w:p><w:pPr><w:pBdr>{}{}</w:pBdr></w:pPr><w:r><w:t>{val}</w:t></w:r></w:p>"#, side("top", val), side("bottom", val))
})
.collect();
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let got: Vec<Option<BorderStyle>> =
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style)).collect();
assert_eq!(got, LINES.iter().map(|(_, s)| Some(*s)).collect::<Vec<_>>());
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
for (val, _) in LINES {
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 2, "{val}: {xml}");
}
assert!(!xml.contains(r#"w:val="single""#), "{xml}");
}
#[test]
fn compound_cell_and_page_borders_are_written_back_as_read() {
let body = format!(
r#"<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders>{}</w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:tcBorders>{}</w:tcBorders></w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p/><w:sectPr><w:pgBorders w:offsetFrom="text">{}</w:pgBorders></w:sectPr>"#,
side("top", "thickThinLargeGap"),
side("left", "thinThickSmallGap"),
side("right", "thinThickThinMediumGap")
);
let xml = document_xml(&wordcraft_docx::write(&wordcraft_docx::read(&docx(&body)).unwrap()).unwrap());
for val in ["thickThinLargeGap", "thinThickSmallGap", "thinThickThinMediumGap"] {
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 1, "{val}: {xml}");
}
}
+29
View File
@@ -80,3 +80,32 @@ fn automatic_spacing_reads_and_round_trips() {
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(props(&again)[..3], p[..3]);
}
#[test]
fn adjust_right_indent_is_preserved() {
let body = r#"<w:p><w:pPr><w:adjustRightInd w:val="0"/><w:snapToGrid w:val="0"/><w:spacing w:after="120"/><w:ind w:right="120"/></w:pPr><w:r><w:t>false</w:t></w:r></w:p>
<w:p><w:pPr><w:adjustRightInd w:val="1"/></w:pPr><w:r><w:t>true</w:t></w:r></w:p>
<w:p><w:r><w:t>absent</w:t></w:r></w:p>"#;
let mut d = wordcraft_docx::read(&docx(body)).unwrap();
assert_eq!(props(&d).iter().map(|p| p.adjust_right_indent).collect::<Vec<_>>(), [Some(false), Some(true), None]);
for _ in 0..2 {
let out = wordcraft_docx::write(&d).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(out.clone())).unwrap();
let mut xml = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
assert!(xml.contains(r#"<w:adjustRightInd w:val="0""#), "explicit false was lost: {xml}");
assert!(xml.contains("<w:adjustRightInd/>"), "explicit true was lost: {xml}");
let tags = ["<w:adjustRightInd", "<w:snapToGrid", "<w:spacing", "<w:ind"];
let pprs = xml.split("<w:pPr>").skip(1).filter_map(|rest| rest.split("</w:pPr>").next()).collect::<Vec<_>>();
let first = pprs.first().expect("paragraph properties must be present");
for &tag in &tags {
assert!(first.contains(tag), "{tag} missing: {xml}");
}
for ppr in pprs {
let order = tags.iter().filter_map(|tag| ppr.find(*tag)).collect::<Vec<_>>();
assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "paragraph properties are out of schema order: {xml}");
}
d = wordcraft_docx::read(&out).unwrap();
assert_eq!(props(&d).iter().map(|p| p.adjust_right_indent).collect::<Vec<_>>(), [Some(false), Some(true), None]);
}
}
+54
View File
@@ -342,3 +342,57 @@ fn toc_gallery_around_a_toc_whose_heading_is_in_a_block_control_round_trips() {
assert!(xml.rfind("</w:sdt>").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}");
}
}
#[test]
fn word_toc_gallery_with_field_end_after_entries_round_trips_without_update() {
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 content = format!(
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>",
style("TOCHeading", &t("Contents")),
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
style("TOC2", &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(), 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}");
}
}
#[test]
fn range_toc_without_control_stays_unwrapped_and_section_break_toc_control_hands_over() {
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>"#;
// A range TOC that was never in a content control (with FieldEnd in its own paragraph) must
// not gain a Table of Contents w:sdt on save.
let bare = format!(
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>{}",
style("TOCHeading", &t("Contents")),
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
style("TOC2", &t("Section\t2")),
p(&t("Chapter One")),
);
let d_bare = read(&package(&bare)).unwrap();
let xml_bare = document_xml(&write(&d_bare).unwrap());
assert_eq!(xml_bare.matches("<w:sdt>").count(), 0, "{xml_bare}");
// #3889: a Table of Contents control opening on an empty paragraph that ends a section above
// the entries hands its control over on read so Update Table reuses the section-break paragraph.
let sect_toc = format!(
r#"<w:p><w:pPr><w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr></w:pPr></w:p>{}{}<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#,
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
style("TOC2", &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 d_sect = read(&package(&format!("{}{}", sdt(pr, &sect_toc), p(&t("Chapter One"))))).unwrap();
assert!(controls(&d_sect).is_empty());
assert!(d_sect.body[0].as_para().unwrap().section.is_some());
let xml_sect = document_xml(&write(&d_sect).unwrap());
assert!(xml_sect.contains("Chapter One") && xml_sect.contains("Section"), "{xml_sect}");
}
+1
View File
@@ -22,6 +22,7 @@ fn picture() -> InlineObject {
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
fx: Default::default(),
ole: None,
}
}
+338 -3
View File
@@ -247,6 +247,46 @@ fn frame_zero_side_is_automatic() {
}
}
#[test]
fn locked_frame_anchor_survives_two_saves() {
for (lock, expected) in [("", None), (" w:anchorLock=\"1\"", Some(true)), (" w:anchorLock=\"0\"", Some(false))] {
let mut document = read_body(&format!(r#"<w:p><w:pPr><w:framePr w:w="1440" w:h="720"{lock}/></w:pPr><w:r><w:t>Locked</w:t></w:r></w:p>"#));
let paragraph = paras(&document)[0];
assert_eq!(paragraph.props.frame.as_ref().and_then(|f| f.anchor_lock), expected);
if expected == Some(true) {
let Some(wordcraft_doc::Block::Para(paragraph)) = document.body.get_mut(0).map(std::sync::Arc::make_mut) else {
panic!("first block is the framed paragraph");
};
let frame = paragraph.props.frame.as_mut().unwrap();
frame.x = 36.0;
frame.y = 72.0;
frame.w = Some(108.0);
}
let json = serde_json::to_vec(&document).unwrap();
document = serde_json::from_slice(&json).unwrap();
if expected == Some(true) {
let frame = paras(&document)[0].props.frame.as_ref().unwrap();
assert_eq!((frame.x, frame.y, frame.w), (36.0, 72.0, Some(108.0)));
}
for pass in 0..2 {
let bytes = wordcraft_docx::write(&document).unwrap();
let xml = text(&unzip(&bytes), "word/document.xml");
let expected_xml = match expected {
Some(true) => "w:anchorLock=\"1\"",
Some(false) => "w:anchorLock=\"0\"",
None => "w:anchorLock=",
};
assert_eq!(xml.contains(expected_xml), expected.is_some(), "lock changed on save {}: {xml}", pass + 1);
document = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(paras(&document)[0].props.frame.as_ref().and_then(|f| f.anchor_lock), expected);
if expected == Some(true) {
let frame = paras(&document)[0].props.frame.as_ref().unwrap();
assert_eq!((frame.x, frame.y, frame.w), (36.0, 72.0, Some(108.0)));
}
}
}
}
#[test]
fn omitted_paragraph_defaults_can_be_edited() {
let styles =
@@ -1726,12 +1766,27 @@ const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml";
/// A package holding one box & whisker chart the way Word writes it: the `cx:chart` in an
/// `mc:Choice` requiring `requires` (bound to `requires_ns`), a picture of it in the `mc:Fallback`.
fn chartex_docx(requires: &str, requires_ns: &str) -> Vec<u8> {
let frame = r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1"/><wp:cNvGraphicFramePr/>"#;
chartex_docx_linked(requires, requires_ns, false)
}
/// [`chartex_docx`], its drawing linked to a web address (`wp:docPr/a:hlinkClick`) when `link`,
/// after a first choice linked elsewhere, which needs a namespace no reader knows, so a reader
/// takes the chart's choice.
fn chartex_docx_linked(requires: &str, requires_ns: &str, link: bool) -> Vec<u8> {
let click = if link { r#"<a:hlinkClick r:id="rIdWeb"/>"# } else { "" };
let other = if link {
r#"<mc:Choice xmlns:w99="urn:example:unsupported" Requires="w99"><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="254000" cy="254000"/><wp:docPr id="9" name="Other"><a:hlinkClick r:id="rIdOther"/></wp:docPr><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice>"#
} else {
""
};
let frame = format!(
r#"<wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1">{click}</wp:docPr><wp:cNvGraphicFramePr/>"#
);
let body = format!(
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent><mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
r#"<w:p><w:r><w:t>Spread</w:t></w:r><w:r><mc:AlternateContent>{other}<mc:Choice xmlns:{requires}="{requires_ns}" Requires="{requires}"><w:drawing>{frame}<a:graphic><a:graphicData uri="{CHARTEX_NS}"><cx:chart xmlns:cx="{CHARTEX_NS}" r:id="rIdCx"/></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Choice><mc:Fallback><w:drawing>{frame}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="Chart 1"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2743200" cy="1371600"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></mc:Fallback></mc:AlternateContent></w:r></w:p>"#
);
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/></Relationships>"#;
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdCx" Type="http://schemas.microsoft.com/office/2014/relationships/chartEx" Target="charts/chartEx1.xml"/><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/><Relationship Id="rIdWeb" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/chart" TargetMode="External"/><Relationship Id="rIdOther" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/other" TargetMode="External"/></Relationships>"#;
let chart_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet.xlsx"/><Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2011/relationships/chartColorStyle" Target="colors1.xml"/></Relationships>"#;
let types = format!(
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="png" ContentType="image/png"/><Default Extension="xlsx" ContentType="{CT_XLSX}"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/charts/chartEx1.xml" ContentType="{CT_CHARTEX}"/><Override PartName="/word/charts/colors1.xml" ContentType="application/vnd.ms-office.chartcolorstyle+xml"/></Types>"#
@@ -1787,6 +1842,25 @@ fn office_2016_chart_keeps_its_chart_part_and_fallback_through_saves() {
}
}
/// #5267: a link on an Office 2016 chart's drawing is the chart's link (not an earlier choice's),
/// and saving keeps it on both branches (the writer rebuilds their frames).
#[test]
fn a_link_on_an_office_2016_charts_drawing_is_kept() {
let mut d = wordcraft_docx::read(&chartex_docx_linked("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex", true)).unwrap();
for _ in 0..2 {
let p = d.body[0].as_para().unwrap();
let off = p.object_offsets().into_iter().find(|o| matches!(p.object_at(*o), Some(InlineObject::Graphic { .. }))).unwrap();
assert_eq!(p.props_of_char(off).link.as_deref(), Some("https://example.com/chart"));
let files = unzip(&wordcraft_docx::write(&d).unwrap());
let body = text(&files, "word/document.xml");
let ids = attr_values(&body, "a:hlinkClick", "r:id");
assert_eq!(ids.len(), 2, "on the chart and its fallback picture: {body}");
assert_eq!(rel(&text(&files, "word/_rels/document.xml.rels"), &ids[0]).1, "https://example.com/chart");
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
d = wordcraft_docx::read(&zip(&entries)).unwrap();
}
}
#[test]
fn resized_office_2016_chart_resizes_its_fallback_too() {
let mut d = wordcraft_docx::read(&chartex_docx("cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex")).unwrap();
@@ -2422,6 +2496,31 @@ fn east_asian_number_formats_read_label_and_round_trip() {
assert_eq!(formats(&back), want);
}
/// #4985: a level with numFmt none and literal text (`RESPONSE:`) labels its paragraph with that
/// text, as in Word, and keeps both through a save.
#[test]
fn a_none_level_s_literal_text_is_its_label() {
use wordcraft_doc::numbering::Counters;
use wordcraft_doc::section::NumFormat;
let numbering = format!(
r#"<w:numbering {W_NS}><w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="none"/><w:lvlText w:val="RESPONSE:"/><w:pPr><w:ind w:left="1440" w:hanging="1440"/></w:pPr></w:lvl></w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>"#
);
let body = r#"<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>X</w:t></w:r></w:p>"#;
let bytes = docx(body, &[("rId1", "numbering", "numbering.xml")], &[("word/numbering.xml", &numbering)]);
let label = |d: &Document| {
let n = paras(d).first().and_then(|p| p.props.numbering)?;
Counters::default().next_label(&d.numbering, n.num, n.level).map(|l| l.0)
};
let d = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(label(&d).as_deref(), Some("RESPONSE:"));
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(
back.numbering.level(1, 0).map(|l| (l.format, l.text.as_str(), l.indent, l.hanging)),
Some((NumFormat::None, "RESPONSE:", 72.0, 72.0))
);
assert_eq!(label(&back).as_deref(), Some("RESPONSE:"));
}
/// #464: a bookmark whose end follows the document's final table keeps its end (it goes into
/// the table's last cell) and is saved as a matched pair.
#[test]
@@ -3848,6 +3947,33 @@ fn bidi_theme_slots_follow_the_theme_font_language() {
}
}
/// A `w:rFonts` fallback list longer than the reader keeps is cut at the last complete family
/// within the bound, so the family a real list would pick survives while a hostile list cannot
/// cost its whole length in every piece of text drawn in it.
#[test]
fn a_hostile_fallback_list_keeps_its_leading_families() {
let candidates: Vec<String> = (0..500).map(|i| format!("MissingCandidateFont{i}")).collect();
let list = format!("{};Source Serif 4", candidates.join(";"));
let body = format!(r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="{list}" w:hAnsi="{list}"/></w:rPr><w:t>x</w:t></w:r></w:p>"#);
let d = read_body(&body);
let c = &d.body[0].as_para().unwrap().runs[0].props;
let kept = c.font.as_deref().expect("the leading families are kept");
assert!(kept.len() <= 256, "{} bytes kept", kept.len());
assert!(kept.starts_with("MissingCandidateFont0"), "{kept}");
// Every kept entry is a whole family name: the cut lands on a `;`, never inside a name.
assert!(kept.split(';').all(|f| f.starts_with("MissingCandidateFont") || f.contains("Source Serif")), "{kept}");
assert!(!kept.ends_with(';'), "no dangling separator: {kept}");
}
/// A real LibreOffice fallback list is read whole: a few families, well under any bound.
#[test]
fn a_real_fallback_list_is_read_whole() {
let body = r#"<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans;Arial" w:hAnsi="Liberation Sans;Arial"/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
let d = read_body(body);
let c = &d.body[0].as_para().unwrap().runs[0].props;
assert_eq!(c.font.as_deref(), Some("Liberation Sans;Arial"));
}
/// The font table's family classes: Word draws a missing roman font in a serif face.
#[test]
fn roman_fonts_are_read_from_the_font_table() {
@@ -3857,6 +3983,15 @@ fn roman_fonts_are_read_from_the_font_table() {
assert_eq!(d.settings.roman_fonts, ["Brand Serif"]);
}
/// Semicolon-separated roman font fallback lists in `fontTable.xml` register each candidate family.
#[test]
fn roman_fonts_split_fallback_lists() {
let fonts = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:font w:name="Liberation Serif;Times New Roman"><w:family w:val="roman"/></w:font></w:fonts>"#;
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable", "fontTable.xml")];
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/fontTable.xml", fonts)])).unwrap();
assert_eq!(d.settings.roman_fonts, ["Liberation Serif", "Times New Roman"]);
}
/// `w:doNotUseHTMLParagraphAutoSpacing` in the settings' `w:compat`: paragraph spacing adds up.
#[test]
fn html_auto_spacing_off_is_read_and_kept() {
@@ -3941,6 +4076,144 @@ fn hyperlink_with_empty_target_is_no_link() {
assert_eq!(p.props_of_char(5).link.as_deref(), Some("mailto:a@example.com"));
}
/// Picture brightness, contrast and transparency (#1711, #1766): VML `gain`/`blacklevel` (Word's
/// watermark "Washout"), DrawingML `a:lum` and `a:alphaModFix`; saved as DrawingML and read back.
#[test]
fn picture_brightness_contrast_transparency() {
use wordcraft_doc::picfx::PicFx;
let png: &[u8] = b"\x89PNG\r\n\x1a\nnot really a png";
let vml =
|attrs: &str| format!(r#"<w:r><w:pict><v:shape style="width:1in;height:36pt"><v:imagedata r:id="rIdImg" {attrs}/></v:shape></w:pict></w:r>"#);
let dml = |blip: &str| {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="1270000" cy="635000"/><wp:docPr id="3" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rIdImg">{blip}</a:blip></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
};
let cases: Vec<(String, Option<PicFx>)> = vec![
// Word's Washout: 0.3 and 0.35 in 16.16 fixed point.
(vml(r#"gain="19661f" blacklevel="22938f""#), Some(PicFx::new(70_000, -70_000, 0))),
(vml(r#"gain="0.5" blacklevel="0.25""#), Some(PicFx::new(50_000, -50_000, 0))),
(vml(r#"gain="131072f""#), Some(PicFx::new(0, 50_000, 0))),
(vml(""), Some(PicFx::default())),
(dml(r#"<a:lum bright="80000" contrast="-80000"/>"#), Some(PicFx::new(80_000, -80_000, 0))),
(dml(r#"<a:alphaModFix amt="40000"/><a:lum bright="-20000"/>"#), Some(PicFx::new(-20_000, 0, 60_000))),
(dml(""), Some(PicFx::default())),
// Hostile values: clamped, never a panic.
(vml(r#"gain="NaNf" blacklevel="infinity""#), None),
(vml(r#"gain="-99999999f" blacklevel="99999999999f""#), None),
(vml(r#"gain="1e308" blacklevel="-1e308""#), None),
(vml(r#"gain="f" blacklevel="--3""#), None),
(dml(r#"<a:lum bright="999999999" contrast="-99999999999999999999"/><a:alphaModFix amt="-5"/>"#), None),
(dml(r#"<a:alphaModFix amt="250000"/><a:lum bright="x" contrast=""/>"#), None),
];
let body: String = cases.iter().map(|(x, _)| format!("<w:p>{x}</w:p>")).collect();
let doc = format!(r#"<w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
let r = rels(&[("rIdImg", "image", "media/pic.png")]);
let bytes = zip(&[
("_rels/.rels", ROOT_RELS.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/_rels/document.xml.rels", r.as_bytes()),
("word/media/pic.png", png),
]);
let d = wordcraft_docx::read(&bytes).unwrap();
let fx_of = |d: &Document| -> Vec<PicFx> {
paras(d)
.iter()
.map(|p| match p.objects.first() {
Some(InlineObject::Image { fx, .. }) => *fx,
o => panic!("{o:?}"),
})
.collect()
};
let read = fx_of(&d);
assert_eq!(read.len(), cases.len());
for ((x, want), got) in cases.iter().zip(&read) {
assert_eq!(*got, got.clamped(), "{x}");
if let Some(want) = want {
let near = |a: i32, b: i32| (a - b).abs() <= 2;
assert!(near(got.bright, want.bright) && near(got.contrast, want.contrast) && got.transparency == want.transparency, "{x}: {got:?}");
}
}
// Clamped hostile values.
assert_eq!(read[11], PicFx::new(100_000, -100_000, 100_000));
assert_eq!(read[12], PicFx::default());
// Saved as DrawingML (VML pictures too) and read back unchanged.
let out = wordcraft_docx::write(&d).unwrap();
let back = wordcraft_docx::read(&out).unwrap();
assert_eq!(fx_of(&back), read);
}
/// Word's picture watermark with Washout (#1711): a VML picture in a header's Watermarks
/// building block keeps its brightness and contrast.
#[test]
fn washout_picture_watermark_in_header() {
let hdr = format!(
r##"<w:hdr {W_NS}><w:sdt><w:sdtPr><w:docPartObj><w:docPartGallery w:val="Watermarks"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent><w:p><w:r><w:pict><v:shape id="WordPictureWatermark1" type="#_x0000_t75" style="position:absolute;margin-left:0;margin-top:0;width:200pt;height:200pt;z-index:-251657216;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical:center;mso-position-vertical-relative:margin"><v:imagedata r:id="rImg" o:title="wm" gain="19661f" blacklevel="22938f"/></v:shape></w:pict></w:r></w:p></w:sdtContent></w:sdt></w:hdr>"##
);
let hrels = rels(&[("rImg", "image", "media/wm.png")]);
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rHdr"/></w:sectPr>"#;
let d = wordcraft_docx::read(&docx(
body,
&[("rHdr", "header", "header1.xml")],
&[("word/header1.xml", &hdr), ("word/_rels/header1.xml.rels", &hrels), ("word/media/wm.png", "not really a png")],
))
.unwrap();
let id = d.last_section.headers.default.unwrap();
let fx = d.parts[&id]
.blocks
.iter()
.filter_map(|b| b.as_para())
.flat_map(|p| p.objects.iter())
.find_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None })
.expect("watermark picture");
assert!((fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2, "{fx:?}");
}
/// A faded DrawingML picture behind a header's text (#1766; #1961 review): `a:alphaModFix` reads
/// and is written back once, as in the body.
#[test]
fn faded_picture_behind_text_in_header() {
let hdr = format!(
r#"<w:hdr {W_NS}><w:p><w:r><w:drawing><wp:anchor behindDoc="1" distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="1" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="margin"><wp:align>center</wp:align></wp:positionH><wp:positionV relativeFrom="margin"><wp:align>center</wp:align></wp:positionV><wp:extent cx="2540000" cy="2540000"/><wp:wrapNone/><wp:docPr id="7" name="Faded"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rImg"><a:alphaModFix amt="20000"/></a:blip></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:anchor></w:drawing></w:r></w:p></w:hdr>"#
);
let hrels = rels(&[("rImg", "image", "media/wm.png")]);
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rHdr"/></w:sectPr>"#;
let header_picture = |d: &wordcraft_doc::Document| {
let id = d.last_section.headers.default.unwrap();
d.parts[&id]
.blocks
.iter()
.filter_map(|b| b.as_para())
.flat_map(|p| p.objects.iter())
.find_map(|o| if let InlineObject::Image { fx, float, .. } = o { Some((*fx, float.wrap)) } else { None })
.expect("header picture")
};
let d = wordcraft_docx::read(&docx(
body,
&[("rHdr", "header", "header1.xml")],
&[("word/header1.xml", &hdr), ("word/_rels/header1.xml.rels", &hrels), ("word/media/wm.png", "not really a png")],
))
.unwrap();
let (fx, wrap) = header_picture(&d);
assert_eq!(fx, wordcraft_doc::picfx::PicFx::new(0, 0, 80_000));
assert_eq!(wrap, wordcraft_doc::para::Wrap::BehindText);
let saved = wordcraft_docx::write(&d).unwrap();
let again = wordcraft_docx::read(&saved).unwrap();
assert_eq!(header_picture(&again), (fx, wrap), "the same picture after saving");
}
/// An OLE object's preview keeps the corrections of its picture (#1961 review).
#[test]
fn ole_preview_keeps_its_corrections() {
let body = r##"<w:p><w:r><w:object w:dxaOrig="1440" w:dyaOrig="720"><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:72pt;height:36pt" o:ole=""><v:imagedata r:id="rIdImg" gain="19661f" blacklevel="22938f"/></v:shape></w:object></w:r></w:p>"##;
let d = wordcraft_docx::read(&docx(body, &[("rIdImg", "image", "media/p.png")], &[("word/media/p.png", "not really a png")])).unwrap();
let fx = d.body[0]
.as_para()
.and_then(|p| p.objects.iter().find_map(|o| if let InlineObject::Image { fx, .. } = o { Some(*fx) } else { None }))
.expect("the preview");
assert!((fx.bright - 70_000).abs() <= 2 && (fx.contrast + 70_000).abs() <= 2, "{fx:?}");
}
#[test]
fn row_table_borders_round_trip() {
let d = read_body(
@@ -4117,6 +4390,54 @@ fn zero_effects_stay_zero_rotated() {
}
}
/// A turned picture whose file keeps no room for the turn (no or a zero `wp:effectExtent`) is
/// marked so its line keeps to its frame as in Word, and saves that way; one with the room keeps
/// its rotated bounds.
#[test]
fn bare_turns_are_kept_through_a_save() {
let pic = |effects: &str| {
format!(
r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="1524000" cy="762000"/>{effects}<wp:docPr id="1" name="Synthetic picture"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rId1"/></pic:blipFill><pic:spPr><a:xfrm rot="2700000"><a:off x="0" y="0"/><a:ext cx="1524000" cy="762000"/></a:xfrm></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#
)
};
let read =
|body: &str| wordcraft_docx::read(&docx(body, &[("rId1", "image", "media/marker.png")], &[("word/media/marker.png", "synthetic")])).unwrap();
let bare = |d: &Document| paras(d)[0].objects[0].frame().unwrap().2.bare_turn;
for effects in ["", r#"<wp:effectExtent l="0" t="0" r="0" b="0"/>"#] {
let d = read(&pic(effects));
assert!(bare(&d), "{effects:?}");
let out = wordcraft_docx::write(&d).unwrap();
let body = text(&unzip(&out), "word/document.xml");
assert_eq!(attr_values(&body, "wp:effectExtent", "t"), ["0"]);
assert!(bare(&wordcraft_docx::read(&out).unwrap()));
}
// 120 × 60 pt at 45°: Word's rotated-bounds room, 3.64 pt wider and 33.64 pt taller each side.
let d = read(&pic(r#"<wp:effectExtent l="46210" t="427210" r="46210" b="427210"/>"#));
assert!(!bare(&d));
let mut d = read(&pic(""));
let f = first_object(&mut d).float_mut().unwrap();
f.set_spin(wordcraft_geom::Spin::new(30.0, false, false));
assert!(!f.bare_turn, "turned here: the rotated bounds again");
// A shadow's room added later: saved, and so laid out, with the turn's room again.
let mut d = read(&pic(""));
let f = first_object(&mut d).float_mut().unwrap();
f.effect = [2.0; 4];
assert!(!f.is_bare_turn());
let out = wordcraft_docx::write(&d).unwrap();
assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]);
// Made floating: saved with the turn's room, so it opens again as an ordinary turn.
let mut d = read(&pic(""));
let f = first_object(&mut d).float_mut().unwrap();
f.wrap = wordcraft_doc::para::Wrap::Square;
assert!(!f.is_bare_turn());
let out = wordcraft_docx::write(&d).unwrap();
assert_ne!(attr_values(&text(&unzip(&out), "word/document.xml"), "wp:effectExtent", "t"), ["0"]);
assert!(!bare(&wordcraft_docx::read(&out).unwrap()));
// Only inline ones: a floating one keeps text clear of its rotated bounds as before.
let anchored = pic("").replace("<wp:inline>", "<wp:anchor>").replace("</wp:inline>", "</wp:anchor>");
assert!(!bare(&read(&anchored)));
}
/// The math namespace, declared on the math element itself (the fixture root doesn't).
const M_NS: &str = r#"xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math""#;
@@ -4376,6 +4697,20 @@ fn vml_textbox_style_anchor_and_fit() {
assert!(tb.body.auto_fit);
}
/// #2601: a drop-cap frame of two letters in two fonts. The first letter's font is the cap's; only
/// the runs in that font get the text's font back, the other letter keeps its own.
#[test]
fn drop_cap_frame_keeps_other_fonts() {
let body = r#"<w:p><w:pPr><w:framePr w:dropCap="drop" w:lines="3" w:wrap="around" w:vAnchor="text" w:hAnchor="text"/></w:pPr><w:r><w:rPr><w:rFonts w:ascii="Liberation Mono" w:hAnsi="Liberation Mono"/></w:rPr><w:t>A</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans" w:hAnsi="Liberation Sans"/></w:rPr><w:t>b</w:t></w:r></w:p>
<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Serif" w:hAnsi="Liberation Serif"/></w:rPr><w:t>c tale</w:t></w:r></w:p>"#;
let d = read_body(body);
let p = paras(&d);
assert_eq!(p.len(), 1, "the frame merges into its paragraph");
assert_eq!(p[0].props.drop_cap_font.as_deref(), Some("Liberation Mono"));
assert_eq!(p[0].props_of_char(0).font.as_deref(), Some("Liberation Serif"), "the cap letter gets the text's font");
assert_eq!(p[0].props_of_char(1).font.as_deref(), Some("Liberation Sans"), "the other letter keeps its own");
}
/// A VML horizontal rule (`o:hr`, a web page's `<hr>` pasted into Word) is read as one: the whole
/// line when it has no width and no `o:hrpct`, a share of it (`o:hrpct`, tenths of a percent), or
/// its own width (`o:hrpct="0"`), placed by `o:hralign`. It saves back as the same VML rule.
+7
View File
@@ -1,6 +1,7 @@
mod alt_chunks;
mod bidi;
mod bidi_runs;
mod border_lines;
mod cell_margin_inheritance;
mod char_units;
mod citations;
@@ -20,20 +21,26 @@ mod heading_levels;
mod legacy_forms;
mod ligatures;
mod malformed;
mod nested_block_controls;
mod note_comments;
mod passthrough;
mod picture_links;
mod positional_tabs;
mod privacy;
mod roundtrip;
mod row_property_exceptions;
mod run_props;
mod section_form_protection;
mod section_metadata_preservation;
mod section_page_number_type;
mod section_text_direction;
mod section_valign;
mod settings_merge;
mod strict_drawings;
mod text_alignment;
mod text_boxes;
mod toc_ranges;
mod twips_wrap;
mod undefined_style;
mod vml_shapes;
mod word_xml;
@@ -0,0 +1,180 @@
//! A body-level content control nested directly in another body-level one stays body-level when
//! saved, even when it holds a single paragraph (ECMA-376 §17.5.2.29-30; [MS-DOCX] `w15`
//! repeating sections, which must wrap whole paragraphs or rows). All markup here is synthetic.
use std::io::Write;
use wordcraft_doc::{ContentControl, Document, StoryRef};
use wordcraft_docx::{read, write};
const NS: &str =
r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml""#;
const ROOT_RELS: &str = r#"<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>"#;
fn package(body: &str) -> Vec<u8> {
let doc = format!(r#"<w:document {NS}><w:body>{body}</w:body></w:document>"#);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(docx: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
let mut s = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap();
s
}
fn t(s: &str) -> String {
format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#)
}
fn p(inner: &str) -> String {
format!("<w:p>{inner}</w:p>")
}
fn sdt(pr: &str, content: &str) -> String {
format!("<w:sdt><w:sdtPr>{pr}</w:sdtPr><w:sdtContent>{content}</w:sdtContent></w:sdt>")
}
fn controls(d: &Document) -> Vec<ContentControl> {
let mut out = Vec::new();
wordcraft_doc::control::walk_controls(&d.body, &mut |c| out.push(c.clone()));
out
}
/// How many `w:p` elements are open at byte `at` of `xml`.
fn open_paragraphs(xml: &str, at: usize) -> usize {
let head = &xml[..at];
let opened = head.matches("<w:p>").count() + head.matches("<w:p ").count();
opened - head.matches("</w:p>").count()
}
/// Paragraph depth at the start of the `w:sdt` whose `w:sdtPr` holds `marker`.
fn depth_of_control(xml: &str, marker: &str) -> usize {
let at = xml.find(marker).unwrap_or_else(|| panic!("{marker} not written:\n{xml}"));
let start = xml[..at].rfind("<w:sdt>").expect("control start");
open_paragraphs(xml, start)
}
fn round_trip(body: &str) -> (Document, Document, String) {
let d = read(&package(body)).unwrap();
let bytes = write(&d).unwrap();
let back = read(&bytes).unwrap();
(d, back, document_xml(&bytes))
}
fn repeating_section() -> String {
let field = sdt(r#"<w:alias w:val="note"/><w:id w:val="3"/><w:text/>"#, &t("note 1"));
let item = sdt(r#"<w:alias w:val="item"/><w:id w:val="2"/><w15:repeatingSectionItem/>"#, &p(&format!("{field}{}", t(" "))));
let section = sdt(r#"<w:alias w:val="notes"/><w:id w:val="1"/><w15:repeatingSection/>"#, &item);
format!("{}{section}{}", p(&t("Notes")), p(&t("End")))
}
#[test]
fn a_repeating_section_around_one_paragraph_stays_around_the_paragraph() {
let (_, _, xml) = round_trip(&repeating_section());
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
// The text control stays inside the paragraph, and the item holds that paragraph.
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1);
assert!(xml.contains("<w15:repeatingSectionItem/></w:sdtPr><w:sdtContent><w:p"), "{xml}");
}
#[test]
fn a_repeating_section_round_trips_its_controls_and_text() {
let (d, back, _) = round_trip(&repeating_section());
assert_eq!(controls(&back), controls(&d));
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
assert_eq!(back.body.len(), d.body.len());
}
#[test]
fn plain_nested_block_controls_around_one_paragraph_stay_block_level() {
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="11"/>"#, &p(&t("Only paragraph")));
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="10"/>"#, &inner);
let (d, back, xml) = round_trip(&format!("{outer}{}", p(&t("After"))));
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
assert_eq!(controls(&back), controls(&d));
}
#[test]
fn nested_block_controls_in_a_table_cell_stay_block_level() {
let inner = sdt(r#"<w:alias w:val="cellInner"/><w:id w:val="21"/>"#, &p(&t("In cell")));
let outer = sdt(r#"<w:alias w:val="cellOuter"/><w:id w:val="20"/>"#, &inner);
let table = format!(r#"<w:tbl><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid><w:tr><w:tc>{outer}</w:tc></w:tr></w:tbl>"#);
let (_, _, xml) = round_trip(&format!("{table}{}", p(&t("After"))));
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellOuter"/>"#), 0, "{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellInner"/>"#), 0, "{xml}");
}
#[test]
fn a_run_level_control_stays_inside_its_paragraph() {
let field = sdt(r#"<w:alias w:val="inline"/><w:id w:val="30"/>"#, &t("value"));
let (_, _, xml) = round_trip(&p(&format!("{}{field}", t("Name: "))));
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inline"/>"#), 1, "{xml}");
}
/// A bound repeating section as Word writes one at the end of a form, optionally followed by
/// the body-level `_GoBack` bookmark Word leaves after the last control.
fn bound_section_body(bound: bool, bookmark: bool) -> String {
let section_binding = if bound {
r#"<w15:dataBinding w:prefixMappings="" w:xpath="/a[1]/n[1]" w:storeItemID="{00000000-0000-0000-0000-0000000000AA}"/>"#
} else {
""
};
let trailing = if bookmark { r#"<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"# } else { "" };
let bind = |x: &str| format!(r#"<w:dataBinding w:prefixMappings="" w:xpath="{x}" w:storeItemID="{{00000000-0000-0000-0000-0000000000AA}}"/>"#);
let field = format!(
r#"<w:sdt><w:sdtPr><w:alias w:val="note"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_1"/></w:placeholder>{}<w:text/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
if bound { bind("/a[1]/n[1]") } else { String::new() },
t("note 1")
);
let item = format!(
r#"<w:sdt><w:sdtPr><w:alias w:val="item"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_2"/></w:placeholder><w15:repeatingSectionItem/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
p(&format!("{field}{}", t(" ")))
);
let section = format!(
r#"<w:sdt><w:sdtPr><w:alias w:val="notes"/>{section_binding}<w15:repeatingSection/></w:sdtPr><w:sdtContent>{item}</w:sdtContent></w:sdt>"#
);
format!(r#"{}{section}{trailing}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr>"#, p(&t("Notes")))
}
fn assert_section_block_level(body: &str) {
let (d, back, xml) = round_trip(body);
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1, "{xml}");
assert_eq!(controls(&back), controls(&d));
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
}
#[test]
fn a_bound_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
assert_section_block_level(&bound_section_body(true, true));
}
#[test]
fn a_bound_repeating_section_at_the_end_of_the_body_stays_block_level() {
assert_section_block_level(&bound_section_body(true, false));
}
#[test]
fn a_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
assert_section_block_level(&bound_section_body(false, true));
}
#[test]
fn a_block_control_followed_by_a_body_bookmark_stays_around_its_paragraph() {
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="41"/>"#, &p(&t("Last paragraph")));
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="40"/>"#, &inner);
let body = format!(r#"{}{outer}<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"#, p(&t("First")));
let (d, back, xml) = round_trip(&body);
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
assert_eq!(controls(&back), controls(&d));
// The bookmark is kept.
assert!(xml.contains(r#"w:name="_GoBack""#), "{xml}");
}

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