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
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
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
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
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
275 changed files with 14457 additions and 841 deletions
+8
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 }}
Generated
+13 -1
View File
@@ -1158,10 +1158,21 @@ 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=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"wasm-bindgen",
"web-sys",
@@ -7098,6 +7109,7 @@ dependencies = [
name = "wordcraft-web"
version = "0.15.1"
dependencies = [
"craft-web-api",
"craft-web-host",
"eframe",
"egui",
+129 -2
View File
File diff suppressed because one or more lines are too long
+16 -5
View File
@@ -98,8 +98,8 @@ fn open(path: &Path, password: Option<&str>) -> Result<Session, String> {
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,
@@ -108,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,
};
@@ -145,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) {
@@ -156,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;
}
+5
View File
@@ -211,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
@@ -228,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),
] {
@@ -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");
+3 -1
View File
@@ -17,7 +17,9 @@ path = "src/main.rs"
wordcraft-engine = { workspace = true }
wordcraft-ui-egui = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
# 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 }
+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;
}
+2
View File
@@ -20,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")]
+2 -1
View File
@@ -76,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;
+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) => {
+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());
}
}
+43 -20
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;
@@ -67,6 +69,8 @@ impl eframe::App for App {
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;
@@ -295,25 +299,24 @@ fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Opt
}
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
@@ -348,6 +351,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
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()
}
}
@@ -421,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);
@@ -449,9 +454,22 @@ fn main() -> eframe::Result {
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;
@@ -566,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);
@@ -605,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);
}
}
+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)]
+7
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
@@ -197,3 +201,6 @@ display_name = "entropybeater"
[people.helenkwok]
real_name = "Helen Kwok"
[people.ashokram-si]
real_name = "Ashok Ram"
+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());
}
+9 -2
View File
@@ -772,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).
@@ -819,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.
+5 -2
View File
@@ -478,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);
@@ -490,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),
)) {
+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:?}");
+34 -9
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) {
@@ -325,12 +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 {
self.labels.insert((abs.id, lv as u8), CountedLabel { label: None, levels: Vec::new() });
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
@@ -382,7 +379,14 @@ impl Counters {
let started = *fl;
let numeric = def.format != NumFormat::Bullet;
let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() };
self.labels.insert((abs.id, lv as u8), CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) });
// 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
@@ -455,9 +459,9 @@ fn render_label(numbering: &Numbering, num: u32, def: &Level, vals: &[u32; 9], s
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").
// 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) {
NumFormat::DecimalZero => NumFormat::DecimalZero,
f @ (NumFormat::DecimalZero | NumFormat::None) => f,
_ if def.legal => NumFormat::Decimal,
f => f,
};
@@ -556,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
+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 })
+2 -1
View File
@@ -27,7 +27,8 @@ pub use char_props::{
};
pub use color::{Rgb, TextColor};
pub use para_props::{
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
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,
+65
View File
@@ -26,6 +26,67 @@ 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)]
@@ -391,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>,
@@ -462,6 +526,7 @@ impl ParaProps {
}
fn overlay_script_layout(&mut self, patch: &ParaProps) {
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 {
+31 -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 {
@@ -453,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]
+21 -4
View File
@@ -289,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(),
@@ -349,7 +350,6 @@ impl NumFormat {
| NumFormat::HindiNumbers
| NumFormat::HindiCounting
| NumFormat::ThaiLetters
| NumFormat::ThaiNumbers
| NumFormat::ThaiCounting
| NumFormat::BahtText
| NumFormat::DollarText => n.to_string(),
@@ -357,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}'];
@@ -999,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.
+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 {
+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);
}
+5 -1
View File
@@ -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;
+84 -42
View File
@@ -260,6 +260,9 @@ fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) ->
};
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;
}
@@ -502,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() {
@@ -552,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).
@@ -715,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);
}
@@ -854,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());
}
@@ -900,6 +930,18 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f
}
}
/// 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,
+159
View File
@@ -337,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(
@@ -2880,3 +2889,153 @@ fn field_results_keep_their_text_and_runs() {
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:?}");
}
+35
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
+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)
+41 -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, TextAlignment, 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.
@@ -145,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())
@@ -226,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() {
@@ -234,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
@@ -338,6 +368,7 @@ impl PropCtx {
"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)),
@@ -376,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.
@@ -402,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.
+37 -5
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 == "?") {
@@ -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) => {
+9
View File
@@ -224,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);
}
}
+39
View File
@@ -116,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") {
@@ -148,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));
+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
+3
View File
@@ -287,6 +287,9 @@ 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());
}
+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()];
+31 -14
View File
@@ -10,9 +10,19 @@ 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, fx, .. } = o else { return };
@@ -20,7 +30,7 @@ impl Writer<'_> {
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");
@@ -66,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`).
@@ -235,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")]);
@@ -288,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)]);
@@ -316,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);
}
}
@@ -502,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));
@@ -514,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 {
+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>"#;
+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}");
}
+146 -3
View File
@@ -1766,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>"#
@@ -1827,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();
@@ -2462,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]
@@ -3888,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() {
@@ -3897,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() {
@@ -4295,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""#;
+4
View File
@@ -21,8 +21,10 @@ 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;
@@ -34,9 +36,11 @@ 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}");
}
+127
View File
@@ -0,0 +1,127 @@
//! A picture's hyperlink (#5267): Word keeps it on the drawing (`wp:docPr/a:hlinkClick`,
//! ECMA-376 Part 1 §20.4.2.5) rather than in a `w:hyperlink` around the run. Original
//! synthetic packages; the 2×2 picture is generated in code.
use std::io::{Read, Write};
use std::sync::Arc;
use wordcraft_doc::{CharProps, Document, InlineObject, Pos};
fn png() -> Vec<u8> {
let mut out = Vec::new();
image::RgbaImage::from_pixel(2, 2, image::Rgba([20, 90, 200, 255]))
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}
/// An inline picture's run; `link` goes into its `wp:docPr`.
fn picture_run(id: u32, link: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="254000" cy="254000"/><wp:docPr id="{id}" name="Picture {id}">{link}</wp:docPr><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name="square.png"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rIdPic"/></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="254000" cy="254000"/></a:xfrm><a:prstGeom prst="rect"/></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
}
fn package(body: &str) -> Vec<u8> {
let xml = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><w:body>{body}<w:sectPr/></w:body></w:document>"#
);
let rels = 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>"#;
let doc_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdPic" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/square.png"/><Relationship Id="rIdWeb" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/picture" TargetMode="External"/><Relationship Id="rIdRun" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/run" TargetMode="External"/></Relationships>"#;
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="png" ContentType="image/png"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#;
let png = png();
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [
("[Content_Types].xml", types.as_bytes()),
("_rels/.rels", rels.as_bytes()),
("word/document.xml", xml.as_bytes()),
("word/_rels/document.xml.rels", doc_rels.as_bytes()),
("word/media/square.png", png.as_slice()),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap().into_inner()
}
/// The link on each picture of the first paragraph, in order.
fn picture_links(doc: &Document) -> Vec<Option<String>> {
let p = doc.body[0].as_para().unwrap();
p.object_offsets()
.into_iter()
.filter(|off| matches!(p.object_at(*off), Some(InlineObject::Image { .. })))
.map(|off| p.props_of_char(off).link.clone())
.collect()
}
fn part(bytes: &[u8], name: &str) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name(name).unwrap().read_to_string(&mut s).unwrap();
s
}
#[test]
fn a_link_on_a_pictures_drawing_is_read() {
let body = format!(
"<w:p>{}{}{}<w:hyperlink r:id=\"rIdRun\">{}</w:hyperlink></w:p>",
// Word's way: the link on the drawing.
picture_run(1, r#"<a:hlinkClick r:id="rIdWeb"/>"#),
// A click naming a relationship that is no hyperlink, and one naming none.
picture_run(2, r#"<a:hlinkClick r:id="rIdPic"/>"#),
picture_run(3, r#"<a:hlinkClick r:id="rIdMissing"/>"#),
// A link around the run wins over the drawing's.
picture_run(4, r#"<a:hlinkClick r:id="rIdWeb"/>"#),
);
let doc = wordcraft_docx::read(&package(&body)).unwrap();
assert_eq!(picture_links(&doc), [Some("https://example.com/picture".to_string()), None, None, Some("https://example.com/run".to_string())]);
}
#[test]
fn a_linked_picture_is_saved_with_its_link_on_the_drawing_too() {
let mut doc = Document::new();
doc.media.insert("square.png".into(), Arc::new(png()));
let picture = || InlineObject::Image {
media: "square.png".into(),
w: 20.0,
h: 20.0,
alt: String::new(),
float: Default::default(),
crop: [0.0; 4],
ole: None,
fx: Default::default(),
};
let linked = |l: &str| CharProps { link: Some(l.to_string()), ..Default::default() };
// A place in this document stays a `w:hyperlink w:anchor` only: `a:hlinkClick` names a relationship.
doc.insert_object(&Pos::body(0, 0), picture(), &linked("#Spot")).unwrap();
doc.insert_object(&Pos::body(0, 0), picture(), &linked("https://example.com/saved")).unwrap();
let bytes = wordcraft_docx::write(&doc).unwrap();
let xml = part(&bytes, "word/document.xml");
let clicks: Vec<&str> = xml.match_indices("<a:hlinkClick ").map(|(i, _)| &xml[i..]).collect();
assert_eq!(clicks.len(), 1, "{xml}");
let rid = clicks[0].split('"').nth(1).unwrap();
let docpr = xml.find("<wp:docPr ").unwrap();
assert!(docpr < xml.find("<a:hlinkClick ").unwrap() && xml.find("<a:hlinkClick ").unwrap() < xml.find("</wp:docPr>").unwrap(), "{xml}");
let rels = part(&bytes, "word/_rels/document.xml.rels");
assert!(rels.contains(&format!(r#"Id="{rid}""#)) && rels.contains(r#"Target="https://example.com/saved""#), "{rels}");
// The run's own hyperlink names the same relationship, so the address is stored once.
assert_eq!(rels.matches("https://example.com/saved").count(), 1, "{rels}");
let back = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(picture_links(&back), [Some("https://example.com/saved".to_string()), Some("#Spot".to_string())]);
// The drawing's link alone (as Word writes it) reads back the same.
let without_run_link = xml.replace(&format!(r#"<w:hyperlink r:id="{rid}" w:history="1">"#), "").replacen("</w:hyperlink>", "", 1);
assert_ne!(without_run_link, xml);
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.as_slice())).unwrap();
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let name = f.name().to_string();
let mut data = Vec::new();
f.read_to_end(&mut data).unwrap();
if name == "word/document.xml" {
data = without_run_link.clone().into_bytes();
}
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
out.write_all(&data).unwrap();
}
let edited = out.finish().unwrap().into_inner();
assert_eq!(picture_links(&wordcraft_docx::read(&edited).unwrap())[0], Some("https://example.com/saved".to_string()));
}
+3 -2
View File
@@ -6,8 +6,8 @@ use wordcraft_doc::fill::Fill;
use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, ParagraphTextDirection, Rgb, RowProps,
TabAlign, TabLeader, TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
use wordcraft_doc::styles::{Style, StyleKind};
@@ -195,6 +195,7 @@ fn every_para_prop_round_trips() {
suppress_hyphens: Some(true),
suppress_line_numbers: Some(true),
bidi: Some(false),
text_direction: Some(ParagraphTextDirection::TbRl),
drop_cap: None,
drop_cap_margin: None,
drop_cap_distance: None,
@@ -18,6 +18,25 @@ fn docx(body: &str) -> Vec<u8> {
zw.finish().unwrap().into_inner()
}
fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
let mut bytes = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let root = 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>"#;
let document_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
for (name, value) in [
("_rels/.rels", root.as_bytes()),
("word/_rels/document.xml.rels", document_rels.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/styles.xml", styles.as_bytes()),
] {
bytes.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
bytes.write_all(value).unwrap();
}
bytes.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();
@@ -59,3 +78,52 @@ fn explicit_horizontal_direction_is_kept_in_sections_and_cells() {
let plain = wordcraft_docx::read(&docx(r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr/>"#)).unwrap();
assert!(!document_xml(&wordcraft_docx::write(&plain).unwrap()).contains("textDirection"));
}
#[test]
fn paragraph_text_direction_round_trips_through_two_saves_and_json() {
use wordcraft_doc::props::ParaProps;
use wordcraft_doc::props::ParagraphTextDirection;
let values = ["lrTb", "lrTbV", "tb", "tbRl", "tbRlV", "tbLr", "tbLrV", "btLr", "tbV", "rl", "rlV", "lr", "lrV"];
let mut body: String =
values.iter().map(|value| format!(r#"<w:p><w:pPr><w:textDirection w:val="{value}"/></w:pPr><w:r><w:t>{value}</w:t></w:r></w:p>"#)).collect();
body.push_str(r#"<w:p><w:r><w:t>Inherited</w:t></w:r></w:p>"#);
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let paras: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
for (para, value) in paras.iter().zip(values) {
assert_eq!(para.props.text_direction, ParagraphTextDirection::from_ooxml(value), "{value}");
}
assert_eq!(paras[values.len()].props.text_direction, None);
let json = serde_json::to_value(&paras[0].props).unwrap();
assert_eq!(json["textDirection"], "lrTb");
let decoded: ParaProps = serde_json::from_value(json).unwrap();
assert_eq!(decoded.text_direction, Some(ParagraphTextDirection::LrTb));
let first = wordcraft_docx::write(&d).unwrap();
let xml = document_xml(&first);
for value in values {
assert!(xml.contains(&format!(r#"<w:textDirection w:val="{value}"/>"#)), "{xml}");
}
assert_eq!(xml.matches("w:textDirection").count(), values.len(), "inherited direction stays absent: {xml}");
let second = wordcraft_docx::write(&wordcraft_docx::read(&first).unwrap()).unwrap();
let twice = document_xml(&second);
assert_eq!(twice.matches("w:textDirection").count(), values.len(), "{twice}");
for value in values {
assert!(twice.contains(&format!(r#"<w:textDirection w:val="{value}"/>"#)), "{twice}");
}
}
#[test]
fn paragraph_style_text_direction_is_preserved_and_direct_value_overrides_it() {
let styles = r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:styleId="Vertical"><w:name w:val="Vertical"/><w:pPr><w:textDirection w:val="tbRl"/></w:pPr></w:style></w:styles>"#;
let body = r#"<w:p><w:pPr><w:pStyle w:val="Vertical"/></w:pPr><w:r><w:t>Style</w:t></w:r></w:p><w:p><w:pPr><w:pStyle w:val="Vertical"/><w:textDirection w:val="lrTb"/></w:pPr><w:r><w:t>Override</w:t></w:r></w:p>"#;
let d = wordcraft_docx::read(&docx_with_styles(body, styles)).unwrap();
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
assert_eq!(xml.matches(r#"<w:pStyle w:val="Vertical"/>"#).count(), 2, "both paragraphs keep their style: {xml}");
assert!(xml.contains(r#"<w:pStyle w:val="Vertical"/><w:textDirection w:val="lrTb"/>"#), "direct horizontal is explicit: {xml}");
assert_eq!(xml.matches("w:textDirection").count(), 1, "only the direct paragraph writes a direction: {xml}");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap();
let mut styles_xml = String::new();
z.by_name("word/styles.xml").unwrap().read_to_string(&mut styles_xml).unwrap();
assert!(styles_xml.contains(r#"<w:textDirection w:val="tbRl"/>"#), "style value survives: {styles_xml}");
}
+182
View File
@@ -0,0 +1,182 @@
//! Shapes, text boxes, groups and canvases in a Strict document (#3548). ISO/IEC 29500 Strict
//! puts them in the one WordprocessingDrawing namespace, where Transitional documents use Word
//! 2010's `wps`, `wpg` and `wpc` namespaces. Fixtures are original and hand-written from
//! ECMA-376 Part 1, 5th edition (`dml-wordprocessingDrawing.xsd`, Strict and Transitional): each
//! drawing is written once and filled in with either set of namespaces.
use std::io::{Read, Write};
use wordcraft_doc::{Document, InlineObject, PartKind};
const STRICT_NS: &str = r#"xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main" xmlns:r="http://purl.oclc.org/ooxml/officeDocument/relationships" xmlns:wp="http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing" xmlns:a="http://purl.oclc.org/ooxml/drawingml/main" xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml" w:conformance="strict""#;
const TRANSITIONAL_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas""#;
const STRICT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
const TRANSITIONAL_RELS: &str = 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>"#;
/// How a fixture names its drawing elements: the shape, group and canvas prefixes, and the
/// text box content element.
struct Names {
ns: &'static str,
rels: &'static str,
shape: &'static str,
group: &'static str,
canvas: &'static str,
content: &'static str,
}
/// Word's Strict output: everything in `wp:`, text box content in `wne:txbxContent`.
const STRICT_WORD: Names = Names { ns: STRICT_NS, rels: STRICT_RELS, shape: "wp", group: "wp", canvas: "wp", content: "wne:txbxContent" };
/// The Strict schema's own names: the text box content in `wp:` too.
const STRICT_SCHEMA: Names = Names { ns: STRICT_NS, rels: STRICT_RELS, shape: "wp", group: "wp", canvas: "wp", content: "wp:txbxContent" };
/// The same drawings in a Transitional document.
const TRANSITIONAL: Names =
Names { ns: TRANSITIONAL_NS, rels: TRANSITIONAL_RELS, shape: "wps", group: "wpg", canvas: "wpc", content: "w:txbxContent" };
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in entries {
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 solid(rgb: &str) -> String {
format!(r#"<a:solidFill><a:srgbClr val="{rgb}"/></a:solidFill>"#)
}
/// A `wsp` at (`x`, `y`) EMUs in its group, `cx` × `cy`. `text` gives it a text box.
fn shape(n: &Names, prst: &str, (x, y, cx, cy): (u32, u32, u32, u32), fill: &str, text: Option<&str>) -> String {
let s = n.shape;
let connector = prst.contains("Connector");
let nv = if connector {
format!("<{s}:cNvCnPr/>")
} else if text.is_some() {
format!(r#"<{s}:cNvSpPr txBox="1"/>"#)
} else {
format!("<{s}:cNvSpPr/>")
};
let fill = if connector { String::new() } else { solid(fill) };
let line =
if connector { r#"<a:ln w="25400"><a:solidFill><a:srgbClr val="117A65"/></a:solidFill><a:tailEnd type="triangle"/></a:ln>"# } else { "" };
let txbx = match text {
Some(t) => {
let c = n.content;
format!(r#"<{s}:txbx><{c}><w:p><w:r><w:t>{t}</w:t></w:r></w:p></{c}></{s}:txbx>"#)
}
None => String::new(),
};
format!(
r#"<{s}:wsp><{s}:cNvPr id="{cx}" name="{prst}"/>{nv}<{s}:spPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm><a:prstGeom prst="{prst}"><a:avLst/></a:prstGeom>{fill}{line}</{s}:spPr>{txbx}<{s}:bodyPr anchor="ctr"/></{s}:wsp>"#
)
}
/// Drawing `id`, anchored `x` EMUs from the page's left edge, 1 in × ½ in.
fn anchor(id: u32, x: u32, uri: &str, payload: &str) -> String {
format!(
r#"<w:r><w:drawing><wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="{id}" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="page"><wp:posOffset>{x}</wp:posOffset></wp:positionH><wp:positionV relativeFrom="page"><wp:posOffset>914400</wp:posOffset></wp:positionV><wp:extent cx="914400" cy="457200"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="{id}" name="Drawing {id}"/><wp:cNvGraphicFramePr/><a:graphic><a:graphicData uri="{uri}">{payload}</a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#
)
}
/// A rectangle, a text box, an arrow, a group holding a nested group, and a canvas holding a
/// shape and a group, each 1 in × ½ in.
fn package(n: &Names) -> Vec<u8> {
const WPS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape";
const WPG: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup";
const WPC: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas";
let full = (0, 0, 914_400, 457_200);
let half = (0, 0, 457_200, 457_200);
let g = n.group;
let group_xfrm = |(x, y, cx, cy): (u32, u32, u32, u32)| {
format!(
r#"<{g}:cNvGrpSpPr/><{g}:grpSpPr><a:xfrm><a:off x="{x}" y="{y}"/><a:ext cx="{cx}" cy="{cy}"/><a:chOff x="0" y="0"/><a:chExt cx="{cx}" cy="{cy}"/></a:xfrm>{}</{g}:grpSpPr>"#,
solid("F39C12")
)
};
let group = format!(
"<{g}:wgp>{}{}<{g}:grpSp>{}{}</{g}:grpSp></{g}:wgp>",
group_xfrm(full),
shape(n, "ellipse", half, "28B463", None),
group_xfrm((457_200, 0, 457_200, 457_200)),
shape(n, "triangle", half, "8E44AD", None),
);
let c = n.canvas;
let canvas = format!(
r#"<{c}:wpc><{c}:bg>{}</{c}:bg><{c}:whole><a:ln w="12700">{}</a:ln></{c}:whole>{}<{g}:wgp>{}{}</{g}:wgp></{c}:wpc>"#,
solid("FDF2E9"),
solid("A04000"),
shape(n, "roundRect", (45_720, 45_720, 365_760, 365_760), "16A085", None),
group_xfrm((457_200, 45_720, 365_760, 365_760)),
shape(n, "diamond", (0, 0, 365_760, 365_760), "2E86C1", None),
);
let drawings = [
anchor(1, 914_400, WPS, &shape(n, "rect", full, "C0392B", None)),
anchor(2, 2_286_000, WPS, &shape(n, "rect", full, "EAF2F8", Some("Strict text box"))),
anchor(3, 3_657_600, WPS, &shape(n, "straightConnector1", full, "", None)),
anchor(4, 5_029_200, WPG, &group),
anchor(5, 6_400_800, WPC, &canvas),
];
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {}><w:body><w:p><w:r><w:t>Shapes</w:t></w:r>{}</w:p><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:body></w:document>"#,
n.ns,
drawings.concat()
);
zip(&[("_rels/.rels", n.rels.as_bytes()), ("word/document.xml", doc.as_bytes())])
}
fn objects(d: &Document) -> Vec<InlineObject> {
d.body[0].as_para().unwrap().objects.clone()
}
/// The text of each text box story, in reading order.
fn box_texts(d: &Document) -> Vec<String> {
let mut ids = Vec::new();
d.objects_in_reading_order(&mut |o| ids.extend(o.text_boxes()));
ids.iter()
.filter_map(|id| d.parts.get(id).filter(|p| p.kind == PartKind::TextBox))
.map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.text.clone()).collect::<Vec<_>>().join("\n"))
.collect()
}
#[test]
fn strict_shapes_read_like_their_transitional_twins() {
let transitional = wordcraft_docx::read(&package(&TRANSITIONAL)).unwrap();
let want = objects(&transitional);
// The twin itself reads every drawing: a rectangle, a text box, an arrow, a group whose
// nested group is flattened into it, and a canvas with its background.
assert_eq!(want.len(), 5, "{want:#?}");
assert!(matches!(&want[0], InlineObject::Shape { story: None, freeform: None, .. }), "{:?}", want[0]);
assert!(matches!(&want[1], InlineObject::Shape { story: Some(_), .. }), "{:?}", want[1]);
assert!(matches!(&want[3], InlineObject::Group { children, canvas: None, .. } if children.len() == 2), "{:?}", want[3]);
assert!(
matches!(&want[4], InlineObject::Group { children, canvas: Some(c), .. } if children.len() == 2 && c.fill.is_some() && c.stroke.is_some()),
"{:?}",
want[4]
);
assert_eq!(box_texts(&transitional), ["Strict text box"]);
for (name, names) in [("Word's Strict names", &STRICT_WORD), ("the Strict schema's names", &STRICT_SCHEMA)] {
let strict = wordcraft_docx::read(&package(names)).unwrap();
assert_eq!(objects(&strict), want, "{name}");
assert_eq!(box_texts(&strict), ["Strict text box"], "{name}");
}
}
#[test]
fn strict_shapes_are_saved_and_reopened() {
let strict = wordcraft_docx::read(&package(&STRICT_WORD)).unwrap();
let saved = wordcraft_docx::write(&strict).unwrap();
let xml = document_xml(&saved);
for name in ["<wps:wsp>", "<wpg:wgp>", "<wpc:wpc>", "<wpc:bg>", "<w:txbxContent>", "<wps:cNvCnPr"] {
assert!(xml.contains(name), "{name} missing from the saved document");
}
let reopened = wordcraft_docx::read(&saved).unwrap();
assert_eq!(objects(&reopened), objects(&strict));
assert_eq!(box_texts(&reopened), ["Strict text box"]);
}
+12 -1
View File
@@ -132,6 +132,15 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
shape("", r#"o:insetmode="auto" inset="0,0,0,0""#),
// In a group, a member's margins go with that member's text, not an empty box's before it.
format!(r#"<w:p><w:r><w:pict><v:group style="width:100pt;height:40pt"><v:shape><v:textbox inset="9pt,9pt,9pt,9pt"/></v:shape><v:shape><v:textbox inset="0,0,0,0">{content}</v:textbox></v:shape></v:group></w:pict></w:r></w:p>"#),
// Text direction (#5193), as Word for Windows reads it: `vertical` and
// `vertical-ideographic` are `vert`; a `bottom-to-top` alt is `vert270` with any
// `layout-flow` or none; `horizontal-ideographic` stays horizontal.
shape("", r#"style="layout-flow:vertical""#),
shape("", r#"style="layout-flow:vertical;mso-layout-flow-alt:bottom-to-top;mso-next-textbox:#Text Box 4""#),
shape("", r#"style="layout-flow:vertical-ideographic""#),
shape("", r#"style="mso-layout-flow-alt:bottom-to-top""#),
shape("", r#"style="layout-flow:horizontal;mso-layout-flow-alt:bottom-to-top""#),
shape("", r#"style="layout-flow:horizontal-ideographic""#),
]
.concat();
let doc = format!(
@@ -140,13 +149,15 @@ fn vml_text_boxes_keep_their_inset_fit_and_anchor() {
let rels = br#"<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 d = wordcraft_docx::read(&zip(&[("_rels/.rels", rels), ("word/document.xml", doc.as_bytes())])).unwrap();
let bodies: Vec<TextBody> = boxes(&d).iter().map(|id| d.parts[id].body).collect();
assert_eq!(bodies.len(), 6);
assert_eq!(bodies.len(), 12);
assert_eq!(bodies[0].inset, Some([0, 0, 0, 0]));
assert_eq!((bodies[1].inset, bodies[1].auto_fit, bodies[1].anchor), (Some([36_000, 45_720, 25_400, 45_720]), true, VAlign::Center));
assert_eq!(bodies[2], TextBody::default());
assert_eq!((bodies[3].inset, bodies[3].anchor), (Some([12_700, 45_720, 0, wordcraft_doc::MAX_TEXT_BOX_INSET]), VAlign::Bottom));
assert_eq!(bodies[4].inset, None);
assert_eq!(bodies[5].inset, Some([0, 0, 0, 0]));
let verts: Vec<TextVert> = bodies[6..].iter().map(|b| b.vert).collect();
assert_eq!(verts, [TextVert::Vert, TextVert::Vert270, TextVert::Vert, TextVert::Vert270, TextVert::Vert270, TextVert::Horz]);
// Saved and read again, they stay.
let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
let again: Vec<TextBody> = boxes(&back).iter().map(|id| back.parts[id].body).collect();
+1
View File
@@ -228,6 +228,7 @@ fn hyperlink_field_targets_follow_the_field_code() {
for (para, link) in [
(complex(r#"HYPERLINK "https://example.org/a" \l "sec""#), Some("https://example.org/a#sec")),
(complex(r#"hyperlink \o "A tip" \t "_blank" "https://example.org/""#), Some("https://example.org/")),
(complex(r#"HYPERLINK "x \* Upper""#), Some("x * Upper")),
(complex(r#"HYPERLINK "C:\\Docs\\My file.docx""#), Some(r"C:\Docs\My file.docx")),
(simple(r#"HYPERLINK \l &quot;Sec&quot; \m"#), Some("#Sec")),
(simple("HYPERLINK https://example.org/b"), Some("https://example.org/b")),
+55
View File
@@ -0,0 +1,55 @@
//! Paragraph indents and spacing written as plain twips beyond 16 bits: Word keeps only the low
//! 16 bits (ECMA-376 Part 1 §17.3.1.12 `w:ind`, §17.3.1.33 `w:spacing`). Measured in Word: a
//! `w:left` of 66256 starts the text 36 pt in, a `w:right` of 66976 leaves a 72 pt right indent.
//! Fixtures are hand-written.
use wordcraft_doc::Document;
use wordcraft_doc::props::ParaProps;
use crate::fixtures::zip;
/// A package whose body is `body`.
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>"#
);
zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])
}
fn props(body: &str) -> Vec<ParaProps> {
let d: Document = wordcraft_docx::read(&docx(body)).unwrap();
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.clone()).collect()
}
#[test]
fn indents_beyond_16_bits_wrap() {
let p = props(
r#"<w:p><w:pPr><w:ind w:left="66256"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:right="66976"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:start="65556" w:end="-65516"/></w:pPr><w:r><w:t>c</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:left="98304" w:hanging="65896"/></w:pPr><w:r><w:t>d</w:t></w:r></w:p>"#,
);
assert_eq!(p[0].indent_left, Some(36.0));
assert_eq!(p[1].indent_right, Some(72.0));
// 65556 -> 20 twips; -65516 -> 20 twips.
assert_eq!((p[2].indent_left, p[2].indent_right), (Some(1.0), Some(1.0)));
// 98304 = 0x18000 -> 0x8000 = -32768 as a signed 16-bit value; hanging 65896 -> 360.
assert_eq!((p[3].indent_left, p[3].indent_first), (Some(-1638.4), Some(-18.0)));
}
#[test]
fn spacing_beyond_16_bits_wraps() {
let p = props(r#"<w:p><w:pPr><w:spacing w:before="65537" w:after="65776"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>"#);
assert_eq!((p[0].space_before, p[0].space_after), (Some(0.05), Some(12.0)));
}
#[test]
fn in_range_and_unit_values_are_unchanged() {
let p = props(
r#"<w:p><w:pPr><w:ind w:left="32767" w:right="-32768"/><w:spacing w:before="65535"/></w:pPr><w:r><w:t>a</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:left="1in" w:right="720"/><w:spacing w:before="12pt"/></w:pPr><w:r><w:t>b</w:t></w:r></w:p>"#,
);
assert_eq!((p[0].indent_left, p[0].indent_right, p[0].space_before), (Some(1638.35), Some(-1638.4), Some(3276.75)));
assert_eq!((p[1].indent_left, p[1].indent_right, p[1].space_before), (Some(72.0), Some(36.0), Some(12.0)));
}
+9
View File
@@ -270,3 +270,12 @@ fn flat_opc_limits_count_what_is_not_kept() {
// A part nothing reads, one element over what the XML tree reader takes in a part (8 000 000).
assert!(limited(&format!(r#"<pkg:part pkg:name="/word/unused.xml"><pkg:xmlData><r>{}</r></pkg:xmlData></pkg:part>"#, "<y/>".repeat(8_000_000))));
}
/// A Word 2003 XML Document saved with Windows line ends: XML reads CR LF as one line feed (XML
/// 1.0 §2.11), so a line break inside `w:t` is one space, not two.
#[test]
fn word_2003_xml_with_windows_line_ends_reads_one_space_per_line_break() {
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<w:wordDocument xmlns:w=\"http://schemas.microsoft.com/office/word/2003/wordml\">\r\n<w:body><w:p><w:r><w:t>a\r\nb</w:t></w:r></w:p></w:body></w:wordDocument>\r\n";
let d = read_xml(xml.as_bytes()).unwrap();
assert_eq!(paras(&d)[0].plain_text(), "a b");
}
+6
View File
@@ -323,6 +323,7 @@ fn select_all(s: &mut Session, v: &Value) -> CmdResult {
}
let st = s.sel.focus.story;
s.sel = Selection { anchor: s.doc.start_of(st), focus: s.doc.end_of(st) };
s.pending = None;
s.column = None;
s.column_mode = false;
sel_result(s)
@@ -332,6 +333,7 @@ fn select_range(s: &mut Session, v: &Value) -> CmdResult {
let a = v.get("anchor").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `anchor`".into()))?;
let b = v.get("focus").and_then(parse_pos).ok_or_else(|| CmdError::Params("bad `focus`".into()))?;
s.sel = Selection { anchor: s.doc.clamp(&a), focus: s.doc.clamp(&b) };
s.pending = None;
sel_result(s)
}
@@ -392,6 +394,7 @@ fn select_unit(s: &mut Session, unit: Unit) -> CmdResult {
if s.doc.para_at(&f).is_some() {
let (a, b) = unit_range(s, &f, unit);
s.sel = Selection { anchor: a, focus: b };
s.pending = None;
}
sel_result(s)
}
@@ -486,6 +489,9 @@ fn select_text(s: &mut Session, v: &Value) -> CmdResult {
if count == n {
s.sel =
Selection { anchor: Pos { story, path: path.clone(), off: i }, focus: Pos { story, path: path.clone(), off: i + needle.len() } };
// Formatting picked for the old caret doesn't carry over (as `select.word`): what
// replaces the selection takes its formatting.
s.pending = None;
return sel_result(s);
}
}
+49 -21
View File
@@ -253,27 +253,8 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
);
}
let password = p::str(v, "password");
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
// An empty file (File Explorer's New › Microsoft Word Document makes one) opens as a new
// blank document that saves back to it (#1384).
if bytes.is_empty() {
(normal_document(s), false)
} else {
crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)?
}
} else if crate::io::is_empty_file(std::path::Path::new(path)) {
(normal_document(s), false)
} else {
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
};
// Plain text and Markdown carry no language: they are in the editing language.
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
{
Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone());
}
// An empty file opens as a new blank document that saves back to it (#1384).
let (doc, encrypted) = read_document(s, path, v)?;
match s.used_because() {
None => s.set_document(doc),
Some(why) => {
@@ -294,6 +275,53 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
)
}
/// The document the file at `path` holds (or its bytes, base64 in `data`, with `path` only
/// naming it), read with `password`, and whether it was encrypted: what `file.open` and
/// `file.newFromTemplate` read. An empty file (File Explorer's New › Microsoft Word Document makes
/// one) is a new blank document (#1384). Plain text and Markdown carry no language, so they are
/// in the editing language.
fn read_document(s: &Session, path: &str, v: &Value) -> Result<(Document, bool), CmdError> {
let password = p::str(v, "password");
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
if bytes.is_empty() {
(normal_document(s), false)
} else {
crate::io::open_bytes_in(path, &bytes, password, &s.region()).map_err(CmdError::Failed)?
}
} else if crate::io::is_empty_file(std::path::Path::new(path)) {
(normal_document(s), false)
} else {
crate::io::open_path_in(std::path::Path::new(path), password, &s.region()).map_err(CmdError::Failed)?
};
let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref())
&& matches!(ext.as_str(), "txt" | "text" | "md" | "markdown")
{
Arc::make_mut(&mut doc.styles).default_chr.lang = Some(lang.clone());
}
Ok((doc, encrypted))
}
/// File › New from a template (#2852): a new, untitled document with the template's content,
/// attached to the template as in Word, read as `file.open` reads a file (`data`, `password`).
/// It opens beside the open documents, as `file.new` does. With `replaceBlank` an untouched blank
/// document gives way to it, as to a file `file.open` opens: that is how a template the platform
/// hands the app starts (#3433). The template is only read; the new document has no file, so
/// saving it asks where to write, and a protected template's password doesn't come along.
pub(crate) fn new_from_template(s: &mut Session, v: &Value) -> CmdResult {
let path = p::req_str(v, "path")?;
let (mut doc, _) = read_document(s, path, v)?;
doc.settings.attached_template = Some(path.to_string());
if p::bool(v, "replaceBlank") == Some(true) && s.used_because().is_none() {
s.set_document(doc);
} else {
s.add_document(doc);
}
s.dirty = true;
sel_result(s)
}
/// Close a document with no window to ask about it (agents, headless sessions).
fn close_document(s: &mut Session, v: &Value) -> CmdResult {
let id = match v.get("id").filter(|x| !x.is_null()) {
+3 -1
View File
@@ -198,7 +198,9 @@ fn special(query: &str, o: &Opts) -> (String, bool) {
if (o.ignore_punct && is_punct(c)) || (o.ignore_space && is_space(c)) {
continue;
}
atoms.push(lit(c));
// A keyboard apostrophe also finds text produced by smart quotes. Explicit character
// codes above, and literal/regex/wildcard searches, keep exact character matching.
atoms.push(if c == '\'' { "['‘’]".into() } else { lit(c) });
}
let filler = match (o.ignore_punct, o.ignore_space) {
(true, true) => r"[\p{P} \t\x{A0}]*",
+26
View File
@@ -212,6 +212,32 @@ pub(crate) fn record(s: &mut Session, id: &str, before: &Document) {
}
}
/// Builds the tracked paragraph-property change for a command that also changes the paragraph's
/// text. The ordinary after-command recorder deliberately compares only paragraphs with identical
/// text, so mixed text/paragraph-format transformations use this before assigning their props.
/// Made once per command, before it creates revisions: it keeps the revisions as they were then
/// (all `Recorder::own` needs), instead of copying the list for every paragraph.
pub(crate) struct ParaPropTracker {
before: Vec<Revision>,
}
impl ParaPropTracker {
pub(crate) fn new(s: &Session) -> Self {
let tracking = s.doc.settings.track_changes && s.prefs.markup.track_formatting;
ParaPropTracker { before: if tracking { s.doc.revisions.clone() } else { Vec::new() } }
}
pub(crate) fn changed(&self, s: &mut Session, before: &ParaProps, after: &ParaProps) -> Option<Box<PropChange<ParaProps>>> {
if !s.doc.settings.track_changes || !s.prefs.markup.track_formatting {
return after.fmt_change.clone();
}
let mut r = Recorder { author: s.author.clone(), date: super::now_iso(), before: &self.before, revisions: &mut s.doc.revisions, rid: None };
r.change(before, after, ParaProps::formatting, |p| &p.fmt_change)
.map(|Replacement(change)| change)
.unwrap_or_else(|| after.fmt_change.clone())
}
}
/// Record the paragraph formatting a command changed on the paragraph at `path` whose text it also
/// changed, which [`record`] doesn't compare: `before` is the paragraph's properties before the
/// command. As in [`record`], the author's own earlier change keeps the formatting before it and
+38
View File
@@ -63,6 +63,17 @@ pub(super) fn link(s: &mut Session, v: &Value) -> CmdResult {
let end = s.doc.insert_text(&at, &text, &props)?;
s.sel = Selection::caret(end);
s.pending = Some(CharProps::default());
} else if let Some(text) = retyped_text(s, v) {
// Text to display changed: the selection is retyped with it, then linked (as in Word).
crate::cmd::paste_text(s, &text)?;
let end = s.sel.focus.clone();
let start = Pos { off: end.off.saturating_sub(text.len()), ..end.clone() };
s.doc.format_range(&start, &end, &|c| {
c.link = Some(url.clone());
c.style = style.clone();
})?;
s.sel = Selection::caret(end);
s.pending = Some(CharProps::default());
} else {
let (a, b) = s.sel.ordered();
s.doc.format_range(&a, &b, &|c| {
@@ -73,6 +84,33 @@ pub(super) fn link(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// The `text` to retype a selection with before linking it: only when it differs from the selected
/// text and both fit in one line of one paragraph without pictures or fields, outside any field's
/// result. Otherwise (as Word shows `<<Selection in Document>>`) the selection keeps its text.
fn retyped_text(s: &Session, v: &Value) -> Option<String> {
use wordcraft_doc::para::{COLUMN_BREAK, LINE_BREAK, OBJ, PAGE_BREAK};
/// What can't stand in one line of text: breaks, and pictures or fields.
const NOT_ONE_LINE: [char; 7] = [LINE_BREAK, '\r', '\u{2029}', '\u{b}', PAGE_BREAK, COLUMN_BREAK, OBJ];
let text = p::str(v, "text").filter(|t| !t.is_empty() && t.len() <= 4096)?;
if text.contains(NOT_ONE_LINE) {
return None;
}
let (a, b) = s.sel.ordered();
if a.story != b.story || a.path != b.path {
return None;
}
let shown = s.doc.para(a.story, &a.path)?.text.get(a.off..b.off)?;
if shown.contains(NOT_ONE_LINE) || shown == text {
return None;
}
// A selection in a field's result (between its markers, which it doesn't hold) is retyped by
// the next update of the field, not here.
if s.doc.field_ranges(a.story).iter().any(|r| r.start < b && a <= r.end) {
return None;
}
Some(text.to_string())
}
/// Whether `name` can name a bookmark: starts with a letter or underscore, contains only alphanumeric characters
/// or underscores, and is at most 40 characters (#3491). Word allows leading underscores for hidden bookmarks (_Toc, _Ref).
pub fn bookmark_name_ok(name: &str) -> bool {
+32 -1
View File
@@ -452,6 +452,36 @@ mod tests {
assert_eq!(numbered(&mut b), want);
}
/// #5757: a list numbered in Thai digits shows them on the page, with the level's text around
/// them (`๑.`, `(๒)`), and keeps its format through a save to .docx and back.
#[test]
fn a_thai_numbered_list_shows_thai_digits_and_survives_docx() {
let mut s = Session::new(Document::new());
for (i, t) in ["หนึ่ง", "สอง", "สาม", "a", "b", "c", "d", "e", "f", "g", "h"].iter().enumerate() {
if i > 0 {
s.run("text.newParagraph", &json!({})).unwrap();
}
s.run("text.insert", &json!({"text": t})).unwrap();
}
s.run("select.all", &json!({})).unwrap();
let levels = json!([{"format": "thaiNumbers", "text": "%1.", "start": 1}]);
s.run("list.define", &json!({"levels": levels})).unwrap();
let page: Vec<Option<String>> = numbered(&mut s).into_iter().map(|x| x.0).collect();
let want: Vec<Option<String>> =
["๑.", "๒.", "๓.", "๔.", "๕.", "๖.", "๗.", "๘.", "๙.", "๑๐.", "๑๑."].iter().map(|l| Some(l.to_string())).collect();
assert_eq!(page, want);
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let mut b = Session::new(back);
let again: Vec<Option<String>> = numbered(&mut b).into_iter().map(|x| x.0).collect();
assert_eq!(again, want, "thaiNumbers survives .docx");
// In parentheses too.
let mut s = Session::new(Document::new());
s.run("text.insert", &json!({"text": "x"})).unwrap();
s.run("select.all", &json!({})).unwrap();
s.run("list.define", &json!({"levels": [{"format": "thaiNumbers", "text": "(%1)", "start": 2}]})).unwrap();
assert_eq!(numbered(&mut s)[0].0.as_deref(), Some("(๒)"));
}
/// #1793: the other heading presets of Word's List Library, level by level.
#[test]
fn every_heading_preset_numbers_three_heading_levels() {
@@ -459,7 +489,8 @@ mod tests {
("headings", [Some("1"), Some("1.1"), Some("1.1.1")]),
("article", [Some("Article I."), Some("Section 1.01"), Some("(a)")]),
("outlineHeadings", [Some("I."), Some("A."), Some("1.")]),
("chapter", [Some("Chapter 1"), None, None]),
// The lower levels are "none" levels with no text: an empty label, as in Word (#4985).
("chapter", [Some("Chapter 1"), Some(""), Some("")]),
] {
let mut s = headings("Heading3");
s.run("list.multilevelPreset", &json!({"preset": preset})).unwrap();
+14 -3
View File
@@ -76,9 +76,11 @@ pub fn cropped_original_size(s: &Session, o: &InlineObject) -> Option<(f32, f32)
pub(super) fn size(s: &mut Session, v: &Value) -> CmdResult {
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no object".into()))?;
let lock = p::bool(v, "lockAspect").unwrap_or(true);
let (mut w, mut h) = obj_size(&o);
let (w0, h0) = obj_size(&o);
let (mut w, mut h) = (w0, h0);
let scale = p::f32(v, "scale");
if p::bool(v, "original").unwrap_or(false) {
let original = p::bool(v, "original").unwrap_or(false);
if original {
let (ow, oh) =
cropped_original_size(s, &o).ok_or_else(|| CmdError::Disabled("only a picture whose image can be read has an original size".into()))?;
let k = scale.unwrap_or(100.0) / 100.0;
@@ -113,7 +115,16 @@ pub(super) fn size(s: &mut Session, v: &Value) -> CmdResult {
(None, None) => true,
_ => lock,
};
let (w, h) = fit_size(w, h, keep);
// Sides set in proportion (from the original, or from a size whose sides both have a length)
// fit the limits together. Otherwise each side the call sets fits on its own, and a side it
// leaves as it was keeps its size, a straight line's 0 too.
let (w, h) = if keep && (original || (w0 > 0.0 && h0 > 0.0 && (w, h) != (w0, h0))) {
fit_size(w, h, true)
} else {
let (fw, fh) = fit_size(w, h, false);
let left = |new: f32, old: f32| !original && new == old;
(if left(w, w0) { w } else { fw }, if left(h, h0) { h } else { fh })
};
// Any drawing: a group's members scale with its frame (its child space is kept); a chart or
// SmartArt graphic lays itself out again at the new size.
let theme = s.doc.settings.theme_colors.clone();
+18 -6
View File
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("picture.size", "Size", "Picture Format › Size", size)
.params(
r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent, "original"?: bool} (scale: of the current size, or with original of the picture's original size, its pixels at 96 ppi less what is cropped; original alone restores that size; one side alone keeps the proportions unless lockAspect is false; 1–4000 pt: when the proportions are kept, a side past 4000 pt shrinks the other with it)"#,
r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent, "original"?: bool} (scale: of the current size, or with original of the picture's original size, its pixels at 96 ppi less what is cropped; original alone restores that size; one side alone keeps the proportions unless lockAspect is false; 1–4000 pt: when the proportions are kept (from the original, or with both sides having a length), a side past 4000 pt shrinks the other with it; a side the call leaves as it was keeps its size, a straight line's 0 too)"#,
)
.when(has_sizable),
CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", crop)
@@ -132,14 +132,26 @@ pub fn specs() -> Vec<CommandSpec> {
} else {
return Err(CmdError::Params("`path` or `data` required".into()));
};
wordcraft_geom::picture::probe(&bytes).map_err(|e| match e {
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
_ => CmdError::Failed("not a supported image".into()),
})?;
let (px_w, px_h) = wordcraft_geom::picture::probe(&bytes)
.map(|p| (p.width, p.height))
.map_err(|e| match e {
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
_ => CmdError::Failed("not a supported image".into()),
})?;
let key = s.doc.add_media(bytes.clone(), crate::cmd::insert::image_ext(&bytes));
with_obj(s, |o| {
if let InlineObject::Image { media, .. } = o {
if let InlineObject::Image { media, w, h, crop, .. } = o {
*media = key.clone();
// The replacement keeps the picture's width and takes its own aspect ratio, so a
// picture of another shape isn't stretched into the old frame. The old crop
// described the old picture, so it is dropped.
if px_w > 0 && px_h > 0 && w.is_finite() && *w > 0.0 {
let nh = *w * px_h as f32 / px_w as f32;
if nh.is_finite() && nh > 0.0 {
*h = nh;
}
}
*crop = [0.0; 4];
}
})
})
+97
View File
@@ -77,6 +77,43 @@ fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
}
/// Leaf #107: wrapping can leave the anchor alone to the left and put the text on the right
/// of the same physical row. A drop after the break must still stay on the destination page.
#[test]
fn a_picture_with_wrap_spacing_dropped_after_a_leading_break_stays_there() {
for gap in [0.0, 6.0, 12.0] {
let mut s = Session::new(wordcraft_doc::Document::from_text("Source\nDestination"));
s.run("caret.set", &json!({"pos": Pos::body(1, 0)})).unwrap();
s.run("layout.break", &json!({"kind": "page"})).unwrap();
s.run("caret.set", &json!({"pos": Pos::body(0, 0)})).unwrap();
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data, "width": 120})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
super::select::with_float(&mut s, |f| {
f.dist = gap;
f.dist_top = gap;
f.dist_bottom = gap;
})
.unwrap();
let before = serde_json::to_value(&s.doc.body).unwrap();
let depth = s.undo_depth();
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 110})).unwrap();
let hit = object_area(&mut s);
assert_eq!(hit.page, 1, "gap={gap}: {hit:?}");
assert!((hit.rect.x - 100.0).abs() < 0.5 && (hit.rect.y - 110.0).abs() < 0.5, "gap={gap}: {:?}", hit.rect);
assert_eq!(s.undo_depth(), depth + 1);
let after = serde_json::to_value(&s.doc.body).unwrap();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), before);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(serde_json::to_value(&s.doc.body).unwrap(), after);
let mut copy = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap());
let saved = object_area(&mut copy);
assert_eq!(saved.page, 1, "DOCX gap={gap}: {saved:?}");
assert!((saved.rect.x - hit.rect.x).abs() < 0.5 && (saved.rect.y - hit.rect.y).abs() < 0.5, "saved at the drop position");
}
}
/// What a click and a handle or body drag do in the editor (#142, #82): hit-test the object,
/// select it, then resize and move it with `arrange.bounds`.
fn click_resize_and_drag(insert: &str, params: Value) {
@@ -1034,6 +1071,66 @@ fn picture_size_past_the_limit_keeps_the_proportions() {
}
}
/// A side the call leaves as it was keeps its size, even outside 1–4000 pt: a straight line's 0
/// stays 0 when only its length is typed. Sides set in proportion (the original, or a size whose
/// sides both have a length) fit the limits together; any other side the call sets, on its own.
#[test]
fn picture_size_keeps_the_side_it_leaves() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.shape", &json!({"kind": "straightConnector", "width": 100.0, "height": 0.0})).unwrap();
let size = |s: &Session| obj_size(&selected(s).unwrap().1);
// A side a scale sets on its own: 0 has no length to scale.
let scaled = |x: f32, k: f32| if x == 0.0 { 0.0 } else { (x * k).clamp(1.0, 4000.0) };
// Sizes with both sides follow each other: they fit 1–4000 pt by the one factor nearest 1, worked out by hand
// for the calls `width` locked, `height` locked, `scale` 50 and `scale` 200 with a width (in that order).
let states = [
((100.0, 0.0), None),
((0.0, 100.0), None),
((5000.0, 0.0), None),
((0.0, 5000.0), None),
((100.0, 0.5), Some([(200.0, 1.0), (4000.0, 20.0), (200.0, 1.0), (200.0, 1.0)])),
((5000.0, 50.0), Some([(200.0, 2.0), (4000.0, 40.0), (2500.0, 25.0), (200.0, 2.0)])),
((100.0, 50.0), Some([(200.0, 100.0), (400.0, 200.0), (50.0, 25.0), (200.0, 100.0)])),
];
for ((w0, h0), together) in states {
let follow = |i: usize, alone: (f32, f32)| together.map_or(alone, |t: [(f32, f32); 4]| t[i]);
let cases = [
(json!({"width": 200.0, "lockAspect": false}), (200.0, h0)),
(json!({"height": 200.0, "lockAspect": false}), (w0, 200.0)),
(json!({"width": 200.0}), follow(0, (200.0, h0))),
(json!({"height": 200.0}), follow(1, (w0, 200.0))),
(json!({"scale": 50}), follow(2, (scaled(w0, 0.5), scaled(h0, 0.5)))),
(json!({"scale": 200, "width": 200.0}), follow(3, (200.0, scaled(h0, 2.0)))),
(json!({"scale": 100}), (w0, h0)),
(json!({}), (w0, h0)),
// The Size dialog: both sides sent, the height as it was.
(json!({"width": 200.0, "height": h0, "lockAspect": false}), (200.0, h0)),
(json!({"width": 200.0, "height": 300.0}), (200.0, 300.0)),
];
for (v, (ew, eh)) in cases {
with_obj(&mut s, |o| o.set_size(w0, h0)).unwrap();
s.run("picture.size", &v).unwrap();
let (w, h) = size(&s);
assert!((w - ew).abs() < 1e-3 && (h - eh).abs() < 1e-3, "{w0}×{h0} {v}: {w}×{h}, not {ew}×{eh}");
}
}
// Both sides of a picture's original size fit together, whatever size it had, even when the
// height comes out as it was: 4050×1012.5 pt is 4000×1000, not 4000×1012.5.
let data = super::super::insert::base64_encode(&png(400, 100));
s.run("insert.picture", &json!({"data": data})).unwrap();
for (w0, h0) in [(3000.0, 1012.5), (4050.0, 0.0), (100.0, 100.0)] {
with_obj(&mut s, |o| o.set_size(w0, h0)).unwrap();
s.run("picture.size", &json!({"original": true, "scale": 1350})).unwrap();
let (w, h) = size(&s);
assert!((w - 4000.0).abs() < 0.01 && (h - 1000.0).abs() < 0.01, "{w0}×{h0}: {w}×{h}");
}
// A height the original sets is set, though it is the one the picture had: 300×75 pt at
// 5400 % is 16200×4050, so with a width of 200 the height fits to 4000.
with_obj(&mut s, |o| o.set_size(100.0, 4050.0)).unwrap();
s.run("picture.size", &json!({"original": true, "scale": 5400, "width": 200.0, "lockAspect": false})).unwrap();
assert_eq!(size(&s), (200.0, 4000.0));
}
/// A crop handle drop: `picture.crop` with the new size is one edit, one undo step.
#[test]
fn picture_crop_with_size_is_one_undo_step() {
+52 -1
View File
@@ -16,6 +16,7 @@ mod lists;
mod outline;
mod styles;
mod tabs_borders;
mod tools;
use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog};
pub use format::{cur as selection_para, fmt, indent, outdent};
@@ -26,6 +27,10 @@ use outline::{move_paras, outline_step, sort};
use styles::{apply_style, apply_styles, create_style, delete_style, modify_style, set_visibility, style_id, update_to_match};
pub use tabs_borders::border_sides;
use tabs_borders::{borders, tabs};
use tools::{
blank_between, convert_breaks, indent_to_spaces, remove_blank, remove_extra_spaces, remove_line_breaks, separate_paragraphs, spaces_to_indent,
trim_leading,
};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -183,9 +188,17 @@ pub fn specs() -> Vec<CommandSpec> {
.key(if cfg!(target_os = "macos") { "Ctrl+Shift+Right" } else { "Alt+Shift+Right" }),
CommandSpec::new("para.shading", "Shading", "Home › Paragraph", |s, v| {
let c = p::str(v, "color").and_then(Rgb::parse);
// Text selected within one paragraph is shaded itself, as in Word, so No Color takes
// off a shading a file gave the text (#4685); a caret or paragraphs shade paragraphs.
// `paragraph` (Borders and Shading › Apply to: Paragraph) shades the paragraphs anyway.
if p::bool(v, "paragraph") != Some(true) && shades_text(s) {
return super::format::apply(s, &|x| x.shading = c);
}
fmt(s, &|p| p.shading = c)
})
.params(r#"{"color": "RRGGBB" | null}"#),
.params(
r#"{"color": "RRGGBB" | null, "paragraph"?: bool} (text selected inside one paragraph is shaded itself, as in Word; `paragraph: true` shades the paragraphs instead, as Borders and Shading › Apply to: Paragraph)"#,
),
CommandSpec::new("para.borders", "Borders", "Home › Paragraph", borders).params(
r#"{"kind": "bottom|top|left|right|none|all|outside|inside|horizontalLine", "width"?: pt, "color"?: "RRGGBB", "style"?: "single|double|dotted|dashed|thick|triple|dotDash|wave", "sides"?: Sides} where Sides = {"top"|"left"|"bottom"|"right"|"between": {"style"?, "width"?: pt, "color"?: "RRGGBB"} | null}: exactly these sides (others off), instead of `kind`"#,
),
@@ -262,5 +275,43 @@ pub fn specs() -> Vec<CommandSpec> {
}
sel_result(s)
}),
// Advanced paragraph tools (#5043, as in WPS Office): conversions and cleanup over the
// selection, or the whole document with `scope: "all"`. One undo step per command.
CommandSpec::new("para.convertBreaks", "Convert Line Breaks to Paragraphs", "Home › Paragraph › Paragraph Tools", convert_breaks)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.blankBetween", "Insert Blank Paragraphs Between", "Home › Paragraph › Paragraph Tools", blank_between)
.params(r#"{"scope"?: "selection|all", "after"?: bool (also after the last paragraph)}"#),
CommandSpec::new("para.separateParagraphs", "Separate Paragraphs", "Home › Paragraph › Paragraph Tools", separate_paragraphs)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeBlank", "Delete Blank Paragraphs", "Home › Paragraph › Paragraph Tools", remove_blank)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.trimLeadingSpaces", "Remove Leading Spaces", "Home › Paragraph › Paragraph Tools", trim_leading)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeExtraSpaces", "Remove Extra Spaces", "Home › Paragraph › Paragraph Tools", remove_extra_spaces)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.spacesToIndent", "Convert Spaces to Indentation", "Home › Paragraph › Paragraph Tools", spaces_to_indent)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.indentToSpaces", "Convert Indents to Spaces", "Home › Paragraph › Paragraph Tools", indent_to_spaces)
.params(r#"{"scope"?: "selection|all"}"#),
CommandSpec::new("para.removeLineBreaks", "Remove Line Breaks", "Home › Paragraph › Paragraph Tools", remove_line_breaks)
.params(r#"{"scope"?: "selection|all"}"#),
]
}
/// Whether Shading applies to the selected text rather than to paragraphs: a selection inside one
/// paragraph, short of its mark. A selection reaching past the mark ends at the next paragraph's
/// start, so all of a paragraph's text is still text, except in the last paragraph of its story,
/// cell or box, where it stands for the paragraph with its mark. A selected picture, shape or text
/// box is its object character, not text (a field's object character is).
fn shades_text(s: &Session) -> bool {
let (a, b) = s.sel.ordered();
if a == b || a.story != b.story || a.path != b.path {
return false;
}
let Some(p) = s.doc.para_at(&a) else { return false };
if super::objects::object_selection(s).is_some() {
return false;
}
let last = s.doc.next_para(a.story, &a.path).is_none_or(|q| q.parent() != a.path.parent());
!(last && a.off == 0 && b.off >= p.len())
}
+332
View File
@@ -0,0 +1,332 @@
//! Advanced paragraph tools (#5043): converting and cleaning up paragraphs, like WPS Office's
//! paragraph tools. Each command works on the paragraphs the selection covers, or on the whole
//! document with `scope: "all"`, and runs as one undo step. Deletions respect Track Changes.
use serde_json::{Value, json};
use wordcraft_doc::para::LINE_BREAK;
use wordcraft_doc::{Path, Pos, RevisionKind, StoryRef};
use super::{CmdError, CmdResult, Session, p, sel_result};
use crate::cmd::{has_next_para, new_revision, split_para, track_delete};
/// The maximum number of paragraphs one call may touch: the work, and the blank paragraphs a
/// command can add, stay bounded whatever the document says.
const MAX_TOOL_PARAS: usize = 10_000;
/// Leading spaces one paragraph may gain from an indent, whatever the file's indent says.
const MAX_INDENT_SPACES: usize = 1_000;
/// The scope of a tools command: `{"scope": "selection|all"}`, `selection` by default.
/// `all` selects the whole document first, so the command, its undo step and the result all
/// follow the full selection like any other command.
fn scope_all(s: &mut Session, v: &Value) -> Result<(), CmdError> {
match p::str(v, "scope").unwrap_or("selection") {
"selection" => Ok(()),
"all" => {
s.run("select.all", &json!({"whole": true}))?;
Ok(())
}
x => Err(CmdError::Params(format!("unknown scope `{x}` (selection|all)"))),
}
}
/// Refuse a paragraph rewrite that would touch content locked against editing. Commands operate
/// on whole paragraphs, so checking each full paragraph also covers controls outside the exact
/// selection offsets in a selected paragraph.
fn check_target_edits(s: &mut Session, story: StoryRef, paths: &[Path]) -> Result<(), CmdError> {
let ranges: Vec<(Pos, Pos)> = paths
.iter()
.filter_map(|path| {
let len = s.doc.para(story, path)?.len();
Some((Pos { story, path: path.clone(), off: 0 }, Pos { story, path: path.clone(), off: len }))
})
.collect();
for (a, b) in ranges {
crate::cmd::controls::check_edit(s, &a, &b)?;
}
Ok(())
}
/// The paragraphs a command runs over, in document order, and the story they are in.
fn targets(s: &Session) -> Result<(StoryRef, Vec<Path>), CmdError> {
let (a, b) = s.sel.ordered();
let paths = s.doc.para_paths_in(&a, &b);
if paths.is_empty() {
return Err(CmdError::Failed("no paragraphs selected".into()));
}
if paths.len() > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("select at most {MAX_TOOL_PARAS} paragraphs to process them in one command")));
}
Ok((a.story, paths))
}
/// Deletes `a..b` inside one paragraph, recording a tracked deletion when Track Changes is on.
/// Offsets past the paragraph's end are clamped, so the document is never half-edited.
fn delete_in_para(s: &mut Session, story: StoryRef, path: &Path, a: usize, b: usize) -> Result<(), CmdError> {
let len = s.doc.para(story, path).ok_or_else(|| CmdError::Failed("no paragraph here".into()))?.len();
let (a, b) = (a.min(b).min(len), b.min(len));
if a >= b {
return Ok(());
}
let at = |off: usize| Pos { story, path: path.clone(), off };
crate::cmd::controls::check_delete(s, &at(a), &at(b))?;
if s.doc.settings.track_changes {
track_delete(s, &at(a), &at(b))?;
} else {
s.doc.delete_range(&at(a), &at(b))?;
}
Ok(())
}
/// The deletions to make in one paragraph, as byte ranges, merged where they touch and applied
/// last first so the earlier offsets stay valid.
fn apply_deletions(s: &mut Session, story: StoryRef, path: &Path, mut ranges: Vec<(usize, usize)>) -> Result<(), CmdError> {
ranges.sort();
let mut merged: Vec<(usize, usize)> = Vec::new();
for (a, b) in ranges {
match merged.last_mut() {
Some(last) if a <= last.1 => last.1 = last.1.max(b),
_ => merged.push((a, b)),
}
}
for (a, b) in merged.into_iter().rev() {
delete_in_para(s, story, path, a, b)?;
}
Ok(())
}
/// The characters of the paragraph that are not tracked-deleted, as (byte offset, char). Under
/// Track Changes deleted text stays in `para.text`, so every scan that decides what to delete or
/// count goes through this.
fn live_chars(para: &wordcraft_doc::Paragraph) -> impl Iterator<Item = (usize, char)> + '_ {
para.run_ranges().filter(|(_, c)| c.del.is_none()).flat_map(|(r, _)| para.text[r.clone()].char_indices().map(move |(i, c)| (r.start + i, c)))
}
/// The byte ranges of every manual line break in the paragraph that is not already deleted.
fn break_ranges(para: &wordcraft_doc::Paragraph) -> Vec<(usize, usize)> {
live_chars(para).filter(|(_, c)| *c == LINE_BREAK).map(|(i, _)| (i, i + 1)).collect()
}
/// `para.convertBreaks`: every manual line break (Shift+Enter) in the selection becomes a
/// paragraph break. Paragraphs are handled last first: a split only shifts the paths after it.
pub(super) fn convert_breaks(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let breaks: usize = paths.iter().filter_map(|path| s.doc.para(story, path)).map(|p| break_ranges(p).len()).sum();
if breaks > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("convert at most {MAX_TOOL_PARAS} line breaks at once")));
}
for path in paths.iter().rev() {
let Some(para) = s.doc.para(story, path) else { continue };
for (i, _) in break_ranges(para).into_iter().rev() {
let new = split_para(s, &Pos { story, path: path.clone(), off: i })?;
// The break stays at the head of the new paragraph; the paragraph break replaces it.
delete_in_para(s, story, &new.path, 0, 1)?;
}
}
sel_result(s)
}
/// `para.blankBetween`: insert one empty paragraph between the selected paragraphs; `after`
/// adds one blank paragraph after the last selected paragraph too.
pub(super) fn blank_between(s: &mut Session, v: &Value) -> CmdResult {
let after = p::bool(v, "after").unwrap_or(false);
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let count = paths.len().saturating_sub(1) + usize::from(after);
if count > MAX_TOOL_PARAS {
return Err(CmdError::Params(format!("insert at most {MAX_TOOL_PARAS} blank paragraphs at once")));
}
// Last first, each split at a paragraph's end: a split only shifts the paths after it.
if after {
let last = paths.last().ok_or_else(|| CmdError::Failed("no paragraph selected".into()))?;
let len = s.doc.para(story, last).map(|p| p.len()).unwrap_or(0);
split_para(s, &Pos { story, path: last.clone(), off: len })?;
}
for path in paths[..paths.len().saturating_sub(1)].iter().rev() {
let len = s.doc.para(story, path).map(|p| p.len()).unwrap_or(0);
split_para(s, &Pos { story, path: path.clone(), off: len })?;
}
sel_result(s)
}
/// `para.separateParagraphs`: every selected paragraph becomes a paragraph of its own with a
/// blank line between them.
pub(super) fn separate_paragraphs(s: &mut Session, v: &Value) -> CmdResult {
blank_between(s, &json!({"scope": p::str(v, "scope").unwrap_or("selection"), "after": true}))
}
/// `para.removeBlank`: delete every empty paragraph in the selection.
pub(super) fn remove_blank(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
// Last first: removing a block leaves the paths before it as they are.
for path in paths.iter().rev() {
let Some(para) = s.doc.para(story, path) else { continue };
// A paragraph must be empty, carry no section break and have a paragraph after it in
// its container: a story's, part's or cell's last mark can't be removed, and a paragraph
// before a table can't be joined across it.
if !para.is_empty() || para.section.is_some() || !has_next_para(s, story, path) {
continue;
}
let next = path.with_last(path.last() + 1);
let (a, b) = (Pos { story, path: path.clone(), off: 0 }, Pos { story, path: next, off: 0 });
crate::cmd::controls::check_delete(s, &a, &b)?;
if s.doc.settings.track_changes {
track_delete(s, &a, &b)?;
} else {
s.doc.remove_block(story, path)?;
}
}
sel_result(s)
}
/// The number of leading spaces in the paragraph's text, deleted ones included (the range
/// `trimLeadingSpaces` deletes).
fn leading_spaces(para: &wordcraft_doc::Paragraph) -> usize {
para.text.chars().take_while(|c| *c == ' ').count()
}
/// The byte offsets of the paragraph's leading spaces that are not tracked-deleted.
fn live_leading_spaces(para: &wordcraft_doc::Paragraph) -> Vec<usize> {
live_chars(para).take_while(|(_, c)| *c == ' ').map(|(i, _)| i).collect()
}
/// `para.trimLeadingSpaces`: delete the spaces at the start of each selected paragraph.
pub(super) fn trim_leading(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let n = leading_spaces(para);
apply_deletions(s, story, &path, vec![(0, n)])?;
}
sel_result(s)
}
/// `para.removeExtraSpaces`: every run of spaces outside the paragraph's start becomes one
/// space. Tabs and page breaks are document structure, not redundant spaces, and stay in place.
/// Leading spaces stay (that is `para.trimLeadingSpaces`).
pub(super) fn remove_extra_spaces(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
// Runs of live spaces (deleted characters are skipped, as if absent). A run at the
// paragraph's start stays; a run inside the text collapses to its last space, and one
// that reaches the paragraph's end to its first.
let mut runs = Vec::new();
let mut run: Vec<usize> = Vec::new();
let mut leading = true;
for (i, c) in live_chars(para) {
if c == ' ' {
run.push(i);
continue;
}
if !leading && run.len() > 1 {
runs.extend(run[..run.len() - 1].iter().map(|&a| (a, a + 1)));
}
run.clear();
leading = false;
}
if !leading && run.len() > 1 {
runs.extend(run[1..].iter().map(|&a| (a, a + 1)));
}
apply_deletions(s, story, &path, runs)?;
}
sel_result(s)
}
/// `para.spacesToIndent`: each paragraph's leading spaces become a first-line indent of 72 pt
/// per space (Word's point-based indent from typing spaces), and are deleted.
pub(super) fn spaces_to_indent(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let live = live_leading_spaces(para);
let spaces = live.len();
if spaces == 0 {
continue;
}
let current = para.props.indent_first.unwrap_or(0.0).clamp(-1584.0, 1584.0);
let represented = spaces.min(((1584.0 - current).max(0.0) / 72.0).floor() as usize);
if represented == 0 {
continue;
}
let before = para.props.clone();
let mut after = before.clone();
after.indent_first = Some(current + represented as f32 * 72.0);
after.cancel_char_indents(false, false, true);
after.fmt_change = tracker.changed(s, &before, &after);
{
let p = s.doc.para_mut(story, &path)?;
p.props = after;
p.touch();
}
// A space is one byte; deleted spaces in between stay as they are.
apply_deletions(s, story, &path, live[..represented].iter().map(|&a| (a, a + 1)).collect())?;
}
sel_result(s)
}
/// `para.indentToSpaces`: each paragraph's first-line and left indents become leading spaces
/// (72 pt per space, as typing spaces indents), and the indents go to zero.
pub(super) fn indent_to_spaces(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
let tracker = crate::cmd::fmt_revisions::ParaPropTracker::new(s);
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
let pts = para.props.indent_left.unwrap_or(0.0) + para.props.indent_first.unwrap_or(0.0);
let n = (pts / 72.0).round().clamp(0.0, MAX_INDENT_SPACES as f32) as usize;
if n == 0 && para.props.indent_left.unwrap_or(0.0) == 0.0 && para.props.indent_first.unwrap_or(0.0) == 0.0 {
continue;
}
let mut props = s.doc.para(story, &path).map(|p| p.props_at(0)).cloned().unwrap_or_default();
// The revision first: `new_revision` edits `s.doc`, so it runs before the paragraph borrow.
// The spaces are marked as a tracked insertion through `props.ins`, as typing is.
props.fmt_change = None;
props.del = None;
if s.doc.settings.track_changes && n > 0 {
props.ins = Some(new_revision(s, RevisionKind::Insert));
} else {
props.ins = None;
}
let before = s.doc.para(story, &path).map(|p| p.props.clone()).unwrap_or_default();
let mut after = before.clone();
after.indent_first = Some(0.0);
after.indent_left = Some(0.0);
after.cancel_char_indents(true, false, true);
after.fmt_change = tracker.changed(s, &before, &after);
let p = s.doc.para_mut(story, &path)?;
if n > 0 {
p.insert_text(0, &" ".repeat(n), &props)?;
}
p.props = after;
p.touch();
}
sel_result(s)
}
/// `para.removeLineBreaks`: delete every manual line break in the selection, so the lines
/// join into one paragraph.
pub(super) fn remove_line_breaks(s: &mut Session, v: &Value) -> CmdResult {
scope_all(s, v)?;
let (story, paths) = targets(s)?;
check_target_edits(s, story, &paths)?;
for path in paths {
let Some(para) = s.doc.para(story, &path) else { continue };
apply_deletions(s, story, &path, break_ranges(para))?;
}
sel_result(s)
}
+4 -3
View File
@@ -103,8 +103,9 @@ pub fn specs() -> Vec<CommandSpec> {
/// A field instruction split into its name, positional arguments and switches (ECMA-376
/// §17.16.1): `TOC \o "1-3" \h` → name `TOC`, switches `o` = `1-3` and `h`. Quoted text stays
/// whole (without the quotes; `\"` and `\\` inside it are a quote and a backslash). A switch
/// takes the word after it as its argument unless that word is another switch.
/// whole (without the quotes; a backslash inside it makes the next character literal, as in Word:
/// `\"` is a quote, `\\` a backslash, `\*` an asterisk). A switch takes the word after it as its
/// argument unless that word is another switch.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FieldCode {
pub name: String,
@@ -126,7 +127,7 @@ pub fn field_words(instr: &str) -> Vec<(String, bool)> {
while let Some(c) = chars.next() {
match c {
'"' => break,
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
'\\' if chars.peek().is_some() => w.extend(chars.next()),
c => w.push(c),
}
}
+13 -4
View File
@@ -102,7 +102,9 @@ struct SeqCount {
/// The fresh results of the unlocked SEQ fields in the body and in the text boxes anchored there
/// ([`update_seq`]). A text box's fields count where the box is anchored, before the fields after
/// the anchor in its paragraph and after those before it (measured in Word for Windows).
/// the anchor in its paragraph and after those before it (measured in Word for Windows). A locked
/// SEQ keeps its result but still counts, `\r` included; a SEQ in a tracked deletion keeps its
/// result and doesn't count, with markup shown or not (measured in Word for Windows, exporting).
fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
let mut count = SeqCount::default();
for path in doc.para_paths(StoryRef::Body) {
@@ -120,7 +122,11 @@ fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
/// Number the SEQ fields of paragraph `p` (at `path` in `story`) and of the text boxes anchored in it.
fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Paragraph, count: &mut SeqCount) {
for (k, o) in p.objects.iter().enumerate() {
// The runs come in order and the objects by offset, so one cursor walks the deleted ranges.
let mut deleted = p.deleted_ranges().into_iter().peekable();
for (k, (off, o)) in p.objects_with_offsets().enumerate() {
while deleted.next_if(|r| r.end <= off).is_some() {}
let in_deletion = deleted.peek().is_some_and(|r| r.contains(&off));
for id in o.text_boxes() {
if count.boxes.insert(id) {
let part = StoryRef::Part(id);
@@ -131,9 +137,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
}
}
}
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
let InlineObject::Field { instr, locked, .. } = o else { continue };
let f = FieldCode::parse(instr);
if f.name != "SEQ" {
if f.name != "SEQ" || in_deletion {
continue;
}
let Some(id) = f.args.first() else { continue };
@@ -149,6 +155,9 @@ fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Pa
} else if !f.has('c') {
n.0 = n.0.saturating_add(1);
}
if *locked {
continue;
}
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
let r = if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) };
count.out.push((story, path.clone(), k, r));
+120 -1
View File
@@ -1,3 +1,4 @@
use super::properties::{new_property_value, text_result};
use super::*;
fn fields(s: &Session) -> Vec<(String, String)> {
@@ -446,7 +447,7 @@ fn update_fields_apply_the_capitalization_switches() {
"seventh",
"Seventh",
"BUDGET",
"new \\* Upper"
"new * Upper"
]
);
}
@@ -548,6 +549,85 @@ fn caption_for_an_object_in_a_cell_stays_in_the_cell() {
assert_eq!(texts.get(1).map(String::as_str), Some("Figure 1"), "{texts:?}");
}
/// A fresh scratch directory for the file-name tests below.
#[cfg(not(target_arch = "wasm32"))]
fn filename_scratch_dir(tag: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("wordcraft-filename-field-{tag}-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// #3796: after Save As to a new name, Update Field with the caret in a header shows the new
/// name for FILENAME and the new full path for FILENAME \p.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn filename_fields_in_a_header_follow_save_as() {
let dir = filename_scratch_dir("save-as");
let (mut s, hdr) = header_fields_session(wordcraft_doc::Document::from_text("body"), &["FILENAME", r"FILENAME \p"]);
let first = dir.join("report_v1.docx");
let second = dir.join("report_v2.docx");
s.run("file.save", &json!({"path": first.to_string_lossy()})).unwrap();
s.run("references.updateFields", &json!({})).unwrap();
let got = story_fields(&s, StoryRef::Part(hdr));
assert_eq!(got[0].1, "report_v1.docx");
assert_eq!(got[1].1, first.to_string_lossy());
s.run("file.saveAs", &json!({"path": second.to_string_lossy()})).unwrap();
s.run("references.updateFields", &json!({})).unwrap();
let got = story_fields(&s, StoryRef::Part(hdr));
assert_eq!(got[0].1, "report_v2.docx");
assert_eq!(got[1].1, second.to_string_lossy());
let _ = std::fs::remove_dir_all(&dir);
}
/// #3796: a Word-style `FILENAME \* MERGEFORMAT` field whose cached text names the old file
/// shows the new name after Save As and Update Field.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn filename_mergeformat_field_replaces_its_cached_name_after_save_as() {
let dir = filename_scratch_dir("mergeformat");
let (mut s, hdr) = header_fields_session(wordcraft_doc::Document::from_text("body"), &[r"FILENAME \* MERGEFORMAT"]);
let first = dir.join("report_v1.docx");
s.run("file.save", &json!({"path": first.to_string_lossy()})).unwrap();
// The cached text a file written earlier would carry.
let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0);
if let Some(InlineObject::Field { result, .. }) = s.doc.para_mut(StoryRef::Part(hdr), &path).unwrap().objects.first_mut() {
*result = "report_v1.docx".into();
}
s.run("file.saveAs", &json!({"path": dir.join("report_v2.docx").to_string_lossy()})).unwrap();
s.run("references.updateFields", &json!({})).unwrap();
assert_eq!(story_fields(&s, StoryRef::Part(hdr))[0].1, "report_v2.docx");
let _ = std::fs::remove_dir_all(&dir);
}
/// #3796: renaming the document (File > Info) changes what FILENAME shows on the next Update
/// Field; the extension stays.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn filename_field_in_a_header_follows_rename() {
let dir = filename_scratch_dir("rename");
let (mut s, hdr) = header_fields_session(wordcraft_doc::Document::from_text("body"), &["FILENAME"]);
s.run("file.save", &json!({"path": dir.join("report_v1.docx").to_string_lossy()})).unwrap();
s.run("file.rename", &json!({"name": "report_v3"})).unwrap();
s.run("references.updateFields", &json!({})).unwrap();
assert_eq!(story_fields(&s, StoryRef::Part(hdr))[0].1, "report_v3.docx");
let _ = std::fs::remove_dir_all(&dir);
}
/// A backslash in a quoted field argument makes the next character literal, so `\*` there is an
/// asterisk and not the `\* Upper` switch (Word shows `x * Upper` for `TITLE "x \* Upper"`).
#[test]
fn quoted_field_argument_backslash_escapes() {
for (instr, shown) in [(r#"TITLE "x \* Upper""#, "x * Upper"), (r#"TITLE "x \\* Upper""#, r"x \* Upper"), (r#"TITLE "a \"b\" c""#, r#"a "b" c"#)]
{
let value = new_property_value(instr).unwrap();
assert_eq!(value, shown, "{instr}");
assert_eq!(text_result(instr, value), shown, "{instr}");
}
assert_eq!(text_result(r#"TITLE "ab" \* Upper"#, new_property_value(r#"TITLE "ab" \* Upper"#).unwrap()), "AB");
}
/// Output (PDF, print, page images) shows the body's unlocked SEQ, STYLEREF and REF fields with
/// their fresh results, as Word fills them in when it prints or exports even with "Update fields
/// before printing" off; NUMWORDS and a locked field keep their stored results there, and the
@@ -713,6 +793,45 @@ fn output_shows_references_to_bookmarks_outside_the_body() {
assert_eq!(results, expected);
}
/// Printed or exported, a locked SEQ keeps its result but still counts, its `\r` too, and a SEQ
/// in a tracked deletion keeps its result and doesn't count (measured in Word for Windows,
/// exporting with markup shown and hidden: Del 1, 9, 2; Lock 1, 7, 3; R 7, 6).
#[test]
fn output_counts_locked_seq_fields_and_not_deleted_ones() {
let mut s = Session::new(wordcraft_doc::Document::from_text(""));
let fields_in = ["SEQ Del", "SEQ Del", "SEQ Del", "SEQ Lock", "SEQ Lock", "SEQ Lock", "SEQ R \\r 5", "SEQ R"];
for instr in fields_in {
s.run("insert.field", &json!({"instr": instr, "result": "9"})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
let top = wordcraft_doc::Path::top(0);
let para = s.doc.para_mut(StoryRef::Body, &top).unwrap();
for (k, o) in para.objects.iter_mut().enumerate() {
if let InlineObject::Field { result, locked, .. } = o {
*locked = matches!(k, 4 | 6);
*result = if *locked { "7" } else { "9" }.into();
}
}
para.touch();
// The second Del field is a tracked deletion.
let off = s.doc.para(StoryRef::Body, &top).unwrap().object_offsets()[1];
let at = |off| Pos { story: StoryRef::Body, path: top.clone(), off };
s.doc.format_range(&at(off), &at(off + '\u{FFFC}'.len_utf8()), &|c| c.del = Some(1)).unwrap();
let out = output_fields(&s.doc).expect("the stale results change");
let shown: Vec<String> = out
.para(StoryRef::Body, &top)
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.clone()),
_ => None,
})
.collect();
assert_eq!(shown, ["1", "9", "2", "1", "7", "3", "7", "6"]);
assert!(fields(&s).iter().all(|f| f.1 == "9" || f.1 == "7"), "the document keeps its results");
}
/// Printed or exported, a REF to a bookmark the document doesn't have shows Word's error
/// (measured in Word for Windows: "Error! Reference source not found."); the editor and the
/// document keep the stored result, and a REF showing "above" or "below" (`\p`) keeps it too.
+14 -5
View File
@@ -54,11 +54,20 @@ fn resolve_para(
// An inserted paragraph mark that is rejected, or a deleted one that is accepted, goes away:
// the next paragraph joins this one (the split is undone). A paragraph left empty goes away
// whole, so the next one keeps its own style and numbering, as in Word. The mark at the end
// of a story or cell can't go, so it just stops being a revision.
if ((del.is_some() && accept) || (ins.is_some() && !accept))
&& (crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)?)
{
return Ok(());
// of a story or cell can't go, so it just stops being a revision. An empty paragraph whose
// mark carries a section break goes whole too: its section joins the following one, as when
// the break is deleted (#5667). Joining instead gave the next paragraph its properties.
if (del.is_some() && accept) || (ins.is_some() && !accept) {
let sectioned = story == StoryRef::Body
&& path.0.len() == 1
&& s.doc.para(story, path).is_some_and(|p| p.is_empty() && p.section.is_some())
&& crate::cmd::has_next_para(s, story, path);
if sectioned {
s.doc.remove_section_break(path.last());
}
if crate::cmd::remove_empty_para(s, story, path)? || crate::cmd::join_next_para(s, story, path)? {
return Ok(());
}
}
let para = s.doc.para_mut(story, path)?;
para.mark.ins = None;
+1 -1
View File
@@ -195,7 +195,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"text"?: string, "title"?: string} (empty clears)"#)),
t(CommandSpec::new("table.sort", "Sort", "Table Layout › Data", sort_table)
.params(r#"{"column"?: n, "descending"?: bool, "header"?: bool}"#)),
.params(r#"{"column"?: non-negative JSON integer (0-based, default 0; must name a column in the table), "descending"?: bool, "header"?: bool}"#)),
t(CommandSpec::new("table.toText", "Convert to Text", "Table Layout › Data", to_text)
.params(r#"{"separator"?: "tab|comma|paragraph|other", "char"?: string (with other)} (the selected rows, or the whole table)"#)),
t(CommandSpec::new("table.formula", "Formula", "Table Layout › Data", formula).params(r#"{"formula"?: "=SUM(ABOVE)"}"#)),
+8 -1
View File
@@ -8,11 +8,18 @@ use super::{
/// Sort the table's rows by a column. Numbers are read in each cell's language (`1,5` is one and
/// a half in Polish) and sort first, numerically; other text sorts in the column's language.
pub(super) fn sort_table(s: &mut Session, v: &Value) -> CmdResult {
let col = p::u64(v, "column").unwrap_or(0) as usize;
let column = match v.get("column") {
None => 0,
Some(value) => value.as_u64().ok_or_else(|| CmdError::Params("`column` must be a non-negative JSON integer".into()))?,
};
let desc = p::bool(v, "descending").unwrap_or(false);
let header = p::bool(v, "header");
let (tp, _, _) = cell(s)?;
let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Failed("no table".into()))?;
let col = usize::try_from(column)
.ok()
.filter(|&col| t.rows.iter().any(|row| col < row.cells.len()))
.ok_or_else(|| CmdError::Params("`column` is outside the table".into()))?;
let skip = usize::from(header.unwrap_or(t.rows.first().is_some_and(|r| r.props.header))).min(t.rows.len());
let column = || t.rows.iter().skip(skip).map(|r| r.cells.get(col));
let lang = column().flatten().find_map(|c| cell_lang(&s.doc, c)).or_else(|| crate::cmd::default_lang(s));
+2 -1
View File
@@ -14,7 +14,7 @@ mod enter_tab;
mod unicode;
use autoformat::insert;
use delete::{backspace, delete, delete_word_back, delete_word_fwd};
use delete::{backspace, delete, delete_sentence_back, delete_word_back, delete_word_fwd};
pub use enter_tab::MAX_TYPED_PARAGRAPHS;
use enter_tab::{back_tab, new_paragraph, tab};
pub use unicode::{alt_code_char, oem_code_page, system_code_pages, system_oem_code_page, windows_code_pages};
@@ -36,6 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("text.backspace", "Backspace", "Editing", backspace).key("Backspace"),
CommandSpec::new("text.delete", "Delete", "Editing", delete).key("Delete"),
CommandSpec::new("text.deleteWordBack", "Delete Previous Word", "Editing", delete_word_back).key("Mod+Backspace / Alt+Backspace"),
CommandSpec::new("text.deleteSentenceBack", "Delete Previous Sentence", "Editing", delete_sentence_back).key("Mod+Shift+Backspace"),
CommandSpec::new("text.deleteWordForward", "Delete Next Word", "Editing", delete_word_fwd).key("Mod+Delete / Alt+Delete"),
CommandSpec::new("text.nbsp", "Nonbreaking Space", "Insert › Symbols", |s, _| ins_char(s, NBSP)).key("Mod+Shift+Space"),
CommandSpec::new("text.nbHyphen", "Nonbreaking Hyphen", "Insert › Symbols", |s, _| ins_char(s, NB_HYPHEN)).key("Mod+Shift+-"),
+17
View File
@@ -237,3 +237,20 @@ pub(super) fn delete_word_fwd(s: &mut Session, v: &Value) -> CmdResult {
crate::cmd::controls::restore_placeholder(s)?;
sel_result(s)
}
/// Delete to the current sentence start, or the preceding sentence when exactly at its boundary.
/// Sentence boundaries match Select Sentence. Never remove a paragraph or cell boundary.
pub(super) fn delete_sentence_back(s: &mut Session, v: &Value) -> CmdResult {
if !s.sel.is_collapsed() {
return backspace(s, v);
}
let mut f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return sel_result(s) };
f.off = para.clamp(f.off);
let Some((previous, _)) = para.text.get(..f.off).and_then(|text| text.char_indices().next_back()) else { return sel_result(s) };
let start = para.sentence_at(previous).0;
s.sel = Selection { anchor: Pos { off: start, ..f.clone() }, focus: f };
delete_selection(s)?;
crate::cmd::controls::restore_placeholder(s)?;
sel_result(s)
}
+39
View File
@@ -341,3 +341,42 @@ pub(super) fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult {
}
Ok(Value::Object(out))
}
/// The rows of an `insert.spreadsheet` call: `values` (a rectangular array of cells) or `csv`.
pub(super) fn spreadsheet_rows(v: &Value) -> Result<Vec<Vec<String>>, CmdError> {
// The CSV fallback is for an absent `values`; an explicit null is a malformed argument.
let Some(values) = v.get("values") else {
return Ok(crate::cmd::mailings::parse_csv(p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6")));
};
let list = values.as_array().ok_or_else(|| CmdError::Params("`values` must be an array of rows".into()))?;
if list.is_empty() || list.len() > 1000 {
return Err(CmdError::Params("`values` needs 1 to 1000 rows".into()));
}
let mut rows: Vec<Vec<String>> = Vec::with_capacity(list.len());
for (r, row) in list.iter().enumerate() {
let cells = row.as_array().ok_or_else(|| CmdError::Params(format!("`values` row {} is not an array", r + 1)))?;
if cells.is_empty() || cells.len() > 63 {
return Err(CmdError::Params(format!("`values` row {} needs 1 to 63 cells", r + 1)));
}
if rows.first().is_some_and(|f| f.len() != cells.len()) {
return Err(CmdError::Params(format!(
"`values` row {} has {} cells, row 1 has {}",
r + 1,
cells.len(),
rows.first().map_or(0, Vec::len)
)));
}
let mut out = Vec::with_capacity(cells.len());
for cell in cells {
out.push(match cell {
Value::String(x) => x.clone(),
Value::Number(x) => x.to_string(),
Value::Bool(x) => x.to_string(),
Value::Null => String::new(),
_ => return Err(CmdError::Params(format!("`values` row {} holds a cell that is not text, a number or a boolean", r + 1))),
});
}
rows.push(out);
}
Ok(rows)
}
+15 -15
View File
@@ -1,5 +1,6 @@
use super::actions::{
customize_keyboard, customize_ribbon, hf_nav, language, macros, preferred_language, quick_parts, search_commands, select_similar,
spreadsheet_rows,
};
use super::autocorrect::autocorrect_options;
use super::compare::{COMPARE_PARAMS, compare};
@@ -121,17 +122,11 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("file.recover", "Recover Unsaved Documents", "File › Info", |s, v| s.recover(v))
.params(r#"{"restore"?: recoveryId, "discard"?: recoveryId, "refresh"?: true}; desktop only; returns {enabled,busy,documents,error}; restore loads asynchronously without a file path"#)
.pure(),
CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", |s, v| {
let path = p::req_str(v, "path")?;
let mut doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
// As in Word, the new document is attached to the template it came from (#2852).
doc.settings.attached_template = Some(path.to_string());
s.add_document(doc);
s.dirty = true;
sel_result(s)
})
.params(r#"{"path": string} → a new, untitled document from the template, attached to it"#)
.pure(),
CommandSpec::new("file.newFromTemplate", "New from Template", "File › New", crate::cmd::file::new_from_template)
.params(
r#"{"path": string, "data"?: base64 (the template's bytes; `path` then only names it), "password"?: string (for a password-protected template), "replaceBlank"?: bool (an untouched blank document gives way to it, as to a file file.open opens)} → a new, untitled document from the template, attached to it"#,
)
.pure(),
CommandSpec::new("file.saveTemplate", "Save as Template", "File › Save As", |s, v| {
let path = p::req_str(v, "path")?;
// A .dotm keeps its name (#2852); anything else becomes a .dotx.
@@ -219,10 +214,10 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"path": string}"#),
CommandSpec::new("insert.spreadsheet", "Spreadsheet Table", "Insert › Tables", |s, v| {
let csv = p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6");
let rows = crate::cmd::mailings::parse_csv(csv);
let rows = spreadsheet_rows(v)?;
let cols = rows.iter().map(Vec::len).max().unwrap_or(1).clamp(1, 63);
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": "GridTable1Light"}))?;
let style = p::str(v, "style").unwrap_or("GridTable1Light");
s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": style}))?;
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return sel_result(s) };
let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
for (r, row) in rows.iter().enumerate() {
@@ -232,9 +227,14 @@ pub fn specs() -> Vec<CommandSpec> {
}
}
}
if p::bool(v, "header").unwrap_or(false)
&& let Some(first) = t.rows.first_mut()
{
first.props.header = true;
}
sel_result(s)
})
.params(r#"{"csv"?: string}"#),
.params(r#"{"csv"?: string, "values"?: [[cell, …], …] (rows of equal length, at most 1000 rows × 63 columns; wins over csv), "header"?: bool (first row repeats on each page), "style"?: table style name (default GridTable1Light)}"#),
CommandSpec::new("layout.linkToPrevious", "Link to Previous", "Header & Footer › Navigation", |s, v| {
let header = p::bool(v, "footer").map(|f| !f).unwrap_or(true);
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
+53
View File
@@ -255,6 +255,59 @@ fn proofing_language_options() {
assert!(s.run("review.language", &json!({"lang": "<script>"})).is_err());
}
/// #2242: `insert.spreadsheet` fills a table from `values`, with a repeating header and a style.
#[test]
fn spreadsheet_takes_values_header_and_style() {
let mut s = Session::new(Document::from_text(""));
s.run("insert.spreadsheet", &json!({"values": [["Month", "Cases"], ["Jan", 12], ["Feb", null]], "header": true, "style": "TableGrid"})).unwrap();
let Some(Block::Table(t)) = s.doc.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!("no table") };
assert_eq!((t.rows.len(), t.rows[0].cells.len()), (3, 2));
assert!(t.rows[0].props.header && !t.rows[1].props.header);
assert_eq!(t.rows[1].cells[1].blocks[0].as_para().unwrap().text, "12");
assert_eq!(t.props.style.as_deref(), Some("TableGrid"));
}
/// An explicit `values: null` is a parameter error, not the legacy CSV sample table: the document
/// and the undo history stay as they were. Only an absent `values` falls back to `csv`.
#[test]
fn spreadsheet_rejects_an_explicit_null_values_without_changing_anything() {
let mut s = Session::new(Document::from_text("keep"));
let depth = s.undo_depth();
for args in [json!({"values": null}), json!({"values": null, "csv": "a,b\n1,2"})] {
assert!(s.run("insert.spreadsheet", &args).is_err(), "{args}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "keep");
assert!(!s.doc.body.iter().any(|b| matches!(&**b, Block::Table(_))));
assert_eq!(s.undo_depth(), depth, "{args}: no undo step");
}
s.run("insert.spreadsheet", &json!({"csv": "a,b\n1,2"})).unwrap();
let Some(Block::Table(t)) = s.doc.body.iter().map(|b| &**b).find(|b| matches!(b, Block::Table(_))) else { panic!("no table") };
assert_eq!((t.rows.len(), t.rows[0].cells.len()), (2, 2), "an absent `values` still takes the csv");
}
/// #2242: malformed `values` is an error, never a panic, and leaves the document alone.
#[test]
fn spreadsheet_rejects_hostile_values() {
let wide: Vec<Value> = (0..64).map(|i| json!(i)).collect();
let many: Vec<Value> = (0..1001).map(|_| json!(["x"])).collect();
for bad in [
json!("x"),
json!(5),
json!([]),
json!(["a"]),
json!([["a", "b"], ["c"]]),
json!([wide]),
json!(many),
json!([[{"a": 1}]]),
json!([[[1]]]),
json!([[]]),
] {
let mut s = Session::new(Document::from_text("keep"));
assert!(s.run("insert.spreadsheet", &json!({"values": bad})).is_err(), "{bad}");
assert_eq!(s.doc.plain_text(StoryRef::Body), "keep");
assert!(!s.doc.body.iter().any(|b| matches!(&**b, Block::Table(_))));
}
}
/// The inspector's removals delete straight from the document, so they aren't tracked: on a
/// document that tracks changes the text would go with no revision to show for it, and the
/// revision trail would be broken behind the user's back. The removal is refused instead,
+67
View File
@@ -337,6 +337,73 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
/// #3433: a template the platform hands the app takes an untouched blank document's place
/// (`replaceBlank`), as a file `file.open` opens does; otherwise, and without the flag, the
/// new document opens beside the others, as File › New's does. Either way the template on
/// disk is only read: the new document has no path, so a save can't write over it.
#[test]
fn new_from_template_replaces_an_untouched_blank_document_only_when_asked() {
let d = scratch("replace-blank");
let path = d.join("Letterhead.dotx");
template(&path, "Letterhead text", "");
let bytes = std::fs::read(&path).unwrap();
let from = json!({"path": path.to_string_lossy(), "replaceBlank": true});
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("file.newFromTemplate", &from).unwrap();
assert_eq!(s.documents().len(), 1, "the untouched blank document gave way");
assert_eq!(s.path, None);
assert!(s.dirty);
assert_eq!(s.doc.settings.attached_template.as_deref(), Some(path.to_string_lossy().as_ref()));
assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Letterhead text"));
let saved = s.run("file.save", &json!({})).unwrap();
assert_eq!(saved["saved"], json!(false), "no file to save to: Save As asks where");
assert_eq!(std::fs::read(&path).unwrap(), bytes, "the template is never written over");
// The new document has unsaved changes, so a second one opens beside it.
s.run("file.newFromTemplate", &from).unwrap();
assert_eq!(s.documents().len(), 2);
// Without the flag, even an untouched blank document stays (File › New).
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("file.newFromTemplate", &json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(s.documents().len(), 2);
assert_eq!(std::fs::read(&path).unwrap(), bytes);
let _ = std::fs::remove_dir_all(&d);
}
/// #3433: a protected template asks for its password like `file.open`, and the document made
/// from it isn't protected; a template's bytes can come in `data` (a file name that isn't
/// valid Unicode, #1440), its path then only naming it.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn new_from_template_reads_a_protected_template_and_bytes_like_file_open() {
let d = scratch("protected");
let path = d.join("Locked.dotx");
let mut t = Session::new(wordcraft_doc::Document::from_text("Locked template"));
t.run("file.save", &json!({"path": path.to_string_lossy(), "password": "sesame"})).unwrap();
let name = path.to_string_lossy();
let mut s = Session::new(wordcraft_doc::Document::new());
let e = s.run("file.newFromTemplate", &json!({"path": name})).unwrap_err().to_string();
assert!(crate::io::needs_password(&e), "{e}");
let e = s.run("file.newFromTemplate", &json!({"path": name, "password": "wrong"})).unwrap_err().to_string();
assert!(crate::io::wrong_password(&e), "{e}");
assert_eq!(s.documents().len(), 1, "nothing opened yet");
s.run("file.newFromTemplate", &json!({"path": name, "password": "sesame", "replaceBlank": true})).unwrap();
assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Locked template"));
assert_eq!((s.path.as_ref(), s.password.is_some()), (None, false));
let plain = d.join("Plain.dotx");
template(&plain, "From bytes", "");
let data = crate::cmd::insert::base64_encode(&std::fs::read(&plain).unwrap());
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("file.newFromTemplate", &json!({"path": "Pl\u{FFFD}in.dotx", "data": data, "replaceBlank": true})).unwrap();
assert!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("From bytes"));
assert_eq!(s.doc.settings.attached_template.as_deref(), Some("Pl\u{FFFD}in.dotx"));
assert_eq!(s.path, None);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_templates_folder_option_is_saved_and_checked() {
let d = scratch("option");
+145
View File
@@ -1,5 +1,6 @@
//! View tab and status bar: views, zoom, show/hide, panes.
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use wordcraft_doc::{Zoom, ZoomFit};
use wordcraft_layout::{NumeralMode, ViewMode};
@@ -40,6 +41,15 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("view.readPageColor", "Reading Page Color", "Reading › Layout", read_page_color)
.params(r#"{"value": "none" | "sepia" | "inverse"}"#)
.pure(),
CommandSpec::new("view.paperColor", "Screen Paper Color", "View › Display", |s, v| {
let color: PaperColor = serde_json::from_value(v.get("value").cloned().unwrap_or(Value::Null))
.map_err(|_| CmdError::Params("screen paper color must be document, green, cream, or gray".into()))?;
s.prefs.paper_color = color;
if color != PaperColor::Document {
s.view.dark_mode = false;
}
Ok(json!({"paperColor": color}))
}).params(r#"{"value": "document" | "green" | "cream" | "gray"}"#).pure(),
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
.params(r#"{"value"?: bool}"#)
@@ -141,6 +151,30 @@ pub fn specs() -> Vec<CommandSpec> {
})
.key("Mod+Alt+S")
.pure(),
// View › Window across WordCraft windows (#322): the desktop app's native windows
// perform these (see `Windows`), elsewhere they are disabled.
CommandSpec::new("view.arrangeAll", "Arrange All", "View › Window", |s, _| {
s.ui_requests.push(json!({"windows": "arrange"}));
Ok(json!({"windows": s.windows.others.len() + 1}))
})
.when(windows_available)
.pure(),
CommandSpec::new("view.sideBySide", "View Side by Side", "View › Window", side_by_side)
.params(r#"{"value"?: bool, "window"?: id of the other window (optional when one other window is open)}"#)
.when(|s| match windows_available(s) {
None if s.windows.side_by_side.is_none() => has_other_window(s),
r => r,
})
.pure(),
CommandSpec::new("view.syncScroll", "Synchronous Scrolling", "View › Window", |s, v| {
let Some(with) = s.windows.side_by_side else { return Err(CmdError::Disabled("turn on View Side by Side first".into())) };
s.windows.sync_scroll = p::bool(v, "value").unwrap_or(!s.windows.sync_scroll);
s.ui_requests.push(json!({"windows": "syncScroll", "with": with, "value": s.windows.sync_scroll}));
Ok(json!({"value": s.windows.sync_scroll}))
})
.params(r#"{"value"?: bool}"#)
.when(|s| if s.windows.side_by_side.is_some() { None } else { Some("turn on View Side by Side first") })
.pure(),
// The window fills the screen, OS-level full screen (#980). F11 as in browsers and most
// desktop apps; ⌃⌘F first on macOS, where Ctrl is a key of its own (elsewhere Mod is Ctrl,
// so "Mod+Ctrl+F" would read Ctrl+Ctrl+F). Escape also leaves it. The front end applies it.
@@ -170,6 +204,83 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// View › Window (#322): the other native windows of the app as the window being drawn sees them
/// (by window id, the ids of `ui.inspect`'s `windows`), and its Side by Side pairing. The front
/// end keeps these current as each window draws; elsewhere (web, CLI, MCP without the app) they
/// stay empty and the window commands are disabled.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Windows {
/// The host can list and move its windows (the desktop app).
pub available: bool,
/// The other open windows, by id.
pub others: Vec<OtherWindow>,
/// View Side by Side is on, with this window.
pub side_by_side: Option<u64>,
/// Synchronous Scrolling (only while Side by Side is on).
pub sync_scroll: bool,
}
/// Another open WordCraft window.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OtherWindow {
pub id: u64,
pub title: String,
}
fn windows_available(s: &Session) -> Option<&'static str> {
if s.windows.available { None } else { Some("only the desktop app has several windows") }
}
fn has_other_window(s: &Session) -> Option<&'static str> {
windows_available(s).or(if s.windows.others.is_empty() { Some("no other WordCraft window is open") } else { None })
}
/// The window a command means: `window`, or the only other one.
fn pick_window(s: &Session, v: &Value) -> Result<u64, CmdError> {
let others = &s.windows.others;
match p::u64(v, "window") {
Some(id) if others.iter().any(|w| w.id == id) => Ok(id),
Some(id) => Err(CmdError::Params(format!("no open window {id}"))),
None => match others.as_slice() {
[only] => Ok(only.id),
[] => Err(CmdError::Disabled("no other WordCraft window is open".into())),
several => {
let list: Vec<String> = several.iter().map(|w| format!("{} ({})", w.id, w.title)).collect();
Err(CmdError::Params(format!("pass `window`: one of {}", list.join(", "))))
}
},
}
}
/// View Side by Side: on with a window (left this one, right that one, scrolling together), or
/// off.
fn side_by_side(s: &mut Session, v: &Value) -> CmdResult {
let on = p::bool(v, "value").unwrap_or(s.windows.side_by_side.is_none() || v.get("window").is_some());
if !on {
if let Some(id) = s.windows.side_by_side.take() {
s.ui_requests.push(json!({"windows": "sideBySide", "off": id}));
}
s.windows.sync_scroll = false;
return Ok(json!({"value": false}));
}
let id = match (s.windows.side_by_side, v.get("window")) {
(Some(id), None) => id,
_ => pick_window(s, v)?,
};
if let Some(old) = s.windows.side_by_side.filter(|old| *old != id) {
s.ui_requests.push(json!({"windows": "sideBySide", "off": old}));
}
// The partner it already has keeps the user's Synchronous Scrolling choice and is not asked
// again; a new pairing starts with scrolling on.
if s.windows.side_by_side != Some(id) {
s.windows.side_by_side = Some(id);
s.windows.sync_scroll = true;
s.ui_requests.push(json!({"windows": "sideBySide", "with": id}));
}
Ok(json!({"value": true, "window": id, "syncScroll": s.windows.sync_scroll}))
}
/// The browser keeps full screen to itself (its own F11); the web app can't ask for it.
fn full_screen_available(_: &Session) -> Option<&'static str> {
cfg!(target_arch = "wasm32").then_some("Use the browser's full screen (F11)")
@@ -234,6 +345,40 @@ fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"spacing": spacing}))
}
/// Screen-only paper presets, saved with user preferences, never in the document.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PaperColor {
#[default]
Document,
Green,
Cream,
Gray,
}
impl PaperColor {
pub const ALL: [Self; 4] = [Self::Document, Self::Green, Self::Cream, Self::Gray];
pub fn label(self) -> &'static str {
match self {
Self::Document => "Document background",
Self::Green => "Soft green",
Self::Cream => "Warm cream",
Self::Gray => "Soft gray",
}
}
pub fn color(self) -> wordcraft_doc::props::Rgb {
use wordcraft_doc::props::Rgb;
match self {
Self::Document => Rgb::WHITE,
Self::Green => Rgb(0xCC, 0xE8, 0xCF),
Self::Cream => Rgb(0xFA, 0xF0, 0xDC),
Self::Gray => Rgb(0xE8, 0xEA, 0xED),
}
}
}
/// Read Mode › View › Page Color, as in Word. Screen only: the document and its own page color
/// are unchanged.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
+2 -2
View File
@@ -771,8 +771,8 @@ pub fn list_dir(path: &std::path::Path, max: usize) -> Result<(Vec<DirEntry>, bo
#[cfg(not(target_arch = "wasm32"))]
pub fn package_title(path: &std::path::Path, max_file: u64, max_part: u64) -> Option<String> {
crate::file_access::check_read(&path.to_string_lossy()).ok()?;
let file = std::fs::File::open(path).ok()?;
if file.metadata().ok()?.len() > max_file {
let (file, meta) = open_regular_file(path).ok()?;
if meta.len() > max_file {
return None;
}
wordcraft_docx::package_title(std::io::BufReader::new(file), max_part)
+9
View File
@@ -7,6 +7,9 @@ use serde::{Deserialize, Serialize};
pub struct Prefs {
/// Word Count includes text boxes, footnotes and endnotes (Word's default).
pub count_notes: bool,
/// On-screen paper for pages without a document background; excluded from saved documents.
#[serde(deserialize_with = "lenient_paper_color")]
pub paper_color: crate::cmd::view::PaperColor,
/// Track Changes Options: what markup shows and how revisions are drawn (per user, as in
/// Word).
pub markup: wordcraft_layout::display::MarkupOptions,
@@ -64,6 +67,7 @@ pub struct Prefs {
impl Default for Prefs {
fn default() -> Self {
Prefs {
paper_color: Default::default(),
count_notes: true,
markup: Default::default(),
print_markup: false,
@@ -105,6 +109,11 @@ fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Op
Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag))
}
fn lenient_paper_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::PaperColor, D::Error> {
let value = serde_json::Value::deserialize(d)?;
Ok(serde_json::from_value(value).unwrap_or_default())
}
/// Ignore an invalid saved Page Color without discarding other preferences.
fn lenient_read_page_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<crate::cmd::view::ReadPageColor, D::Error> {
let value = serde_json::Value::deserialize(d)?;
+2
View File
@@ -183,6 +183,8 @@ pub struct Session {
pub merge: crate::cmd::mailings::MergeState,
/// Requests from commands to the UI (open a dialog, scroll…), drained by the front end.
pub ui_requests: Vec<Value>,
/// View › Window: the other WordCraft windows and Side by Side (#322).
pub windows: crate::cmd::view::Windows,
/// Read Aloud player (Review › Speech).
pub read_aloud: crate::speech::ReadAloud,
/// Preferences the front end saves between runs.
+4 -2
View File
@@ -250,6 +250,8 @@ impl Session {
author: _,
initials: _,
painter: _,
// What the drawn window knows of the others: its front end sets it as it draws.
windows: _,
status: _,
last_command: _,
recording: _,
@@ -390,7 +392,7 @@ impl Session {
let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?;
let mut d = self.others.swap_remove(at);
self.swap_doc(&mut d);
self.view.read_page_color = self.prefs.read_page_color;
self.view.follow_prefs(&self.prefs);
self.others.push(d);
Ok(())
}
@@ -471,7 +473,7 @@ impl Session {
doc.ensure_nonempty();
let mut view = self.view.clone();
view.outline.collapsed.clear();
view.read_page_color = self.prefs.read_page_color;
view.follow_prefs(&self.prefs);
let mut d = DocState::new(doc, untitled, view);
let id = d.document_id;
self.order.push(id);
+1
View File
@@ -157,6 +157,7 @@ impl Session {
ribbon: crate::RibbonLayout::default(),
editing_lang: None,
read_aloud: Default::default(),
windows: Default::default(),
password,
#[cfg(not(target_arch = "wasm32"))]
recovery: None,
+3 -1
View File
@@ -32,7 +32,7 @@ impl Session {
self.hf_return = None;
self.go_back_step = 0;
self.view.outline.collapsed.clear();
self.view.read_page_color = self.prefs.read_page_color;
self.view.follow_prefs(&self.prefs);
// A copy the replaced document kept stays in the recovery list.
#[cfg(not(target_arch = "wasm32"))]
{
@@ -139,6 +139,8 @@ impl Session {
pending_ime_application_change: _,
ime_origin: _,
read_aloud: _,
// The other windows, like `view`: not part of the document or its history.
windows: _,
prefs: _,
system_region: _,
keymap: _,
+1 -1
View File
@@ -135,7 +135,7 @@ impl Session {
let current = document_id == self.document_id && rev == self.rev;
self.sel = sel;
self.view = view;
self.view.read_page_color = self.prefs.read_page_color;
self.view.follow_prefs(&self.prefs);
self.pending = pending;
self.goal_x = goal_x;
self.page_hint = page_hint;
+487 -8
View File
@@ -12,9 +12,11 @@ use crate::{Session, cmd};
mod bidi;
mod documents;
mod find_apostrophes;
mod font_sizes;
mod fonts;
mod ime_noop;
mod para_tools;
mod revision_checks;
mod typing;
mod wordart;
@@ -4681,10 +4683,8 @@ fn typed_paragraphs_are_capped_for_every_caller() {
assert!(!body_texts(&s).iter().any(|p| p.contains('y')));
}
#[test]
fn hostile_params_never_panic() {
let reg = cmd::registry();
let junk = [
fn hostile_junk() -> [serde_json::Value; 8] {
[
json!(null),
json!({}),
json!({"text": 5, "size": "x", "path": [], "pos": {"block": 9999, "off": 99999}}),
@@ -4693,11 +4693,23 @@ fn hostile_params_never_panic() {
json!({"page": 0, "x": 1e30, "y": -1e30, "extend": 1}),
json!({"width": 1e-9, "height": 1e30, "top": -1e9, "left": 1e9}),
json!({"section": {"pageW": -1e308, "pageH": 0, "marginTop": 1e30, "header": -5, "columns": {"count": 1000000, "space": -1e300, "widths": [[0, 0]]}}}),
];
]
}
/// `file.*`, `insert.picture` and `insert.textFromFile`: `hostile_file_params_never_panic` runs
/// them, held to a folder of its own, and `hostile_params_never_panic` every other command.
fn reaches_files(id: &str) -> bool {
id.starts_with("file.") || id == "insert.picture" || id == "insert.textFromFile"
}
#[test]
fn hostile_params_never_panic() {
let reg = cmd::registry();
let junk = hostile_junk();
for spec in reg.all() {
// These reach outside the session (files). Read Aloud (`review.readAloud`, `readAloud.*`)
// is fuzzed too: under `cfg(test)` its backend is the silent `Hold`, asserted below.
if spec.id.starts_with("file.") || spec.id == "insert.picture" || spec.id == "insert.textFromFile" {
// Read Aloud (`review.readAloud`, `readAloud.*`) is fuzzed too: under `cfg(test)` its
// backend is the silent `Hold`, asserted below.
if reaches_files(spec.id) {
continue;
}
for j in &junk {
@@ -4711,6 +4723,127 @@ fn hostile_params_never_panic() {
}
}
/// `file.*`, `insert.picture` and `insert.textFromFile` take junk params and paths to files they
/// can't use (#3719). They may read only `in` and write only `out`, so a save can't replace a file
/// another run is meant to find broken, and nothing outside those folders is touched.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn hostile_file_params_never_panic() {
/// What is directly in `dir`: each name with its bytes (`None` for a folder).
fn contents(dir: &std::path::Path) -> std::collections::BTreeMap<String, Option<Vec<u8>>> {
let entries = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().path());
entries.map(|p| (p.file_name().unwrap().to_string_lossy().into_owned(), std::fs::read(&p).ok())).collect()
}
/// The names whose bytes differ, so a failure lists them instead of printing every file.
fn changed(before: &std::collections::BTreeMap<String, Option<Vec<u8>>>, dir: &std::path::Path) -> Vec<String> {
let after = contents(dir);
let names: std::collections::BTreeSet<_> = before.keys().chain(after.keys()).collect();
names.into_iter().filter(|n| before.get(*n) != after.get(*n)).cloned().collect()
}
let reg = cmd::registry();
let base = scratch_dir("hostile-files");
let (input, output, outside) = (base.join("in"), base.join("out"), base.join("outside"));
for dir in [&outside, &input.join("folder.docx"), &output.join("folder.docx")] {
std::fs::create_dir_all(dir).unwrap();
}
let at = |dir: &std::path::Path, name: &str| dir.join(name).to_string_lossy().into_owned();
// Fixed bytes, so a failure comes back on the next run.
let noise: Vec<u8> = (0..512u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect();
let picture_exts = ["png", "jpg", "gif", "bmp", "svg", "emf"];
let mut paths = vec![];
for ext in crate::io::OPEN_EXTS.iter().chain(&picture_exts) {
let name = format!("random.{ext}");
std::fs::write(input.join(&name), &noise).unwrap();
paths.push(at(&input, &name));
}
paths.extend(crate::io::SAVE_EXTS.iter().map(|ext| at(&output, &format!("new.{ext}"))));
std::fs::write(input.join("empty"), b"").unwrap();
std::fs::write(input.join("empty.docx"), b"").unwrap();
std::fs::write(input.join("good.txt"), "Good text").unwrap();
std::fs::write(input.join("good.png"), tiny_png()).unwrap();
paths.extend(["empty", "empty.docx", "folder.docx", "missing", "missing.docx"].map(|name| at(&input, name)));
paths.extend(["folder.docx", "missing/new.docx", &format!("{}.docx", "x".repeat(5000))].map(|name| at(&output, name)));
// Outside the roots, however it is spelled, and not paths at all.
paths.extend(["new.docx", "new"].map(|name| at(&outside, name)));
paths.push(format!("{}/../outside/new.docx", input.to_string_lossy()));
paths.extend(["hostile-relative.docx", "hostile-relative", "", "\0", "a\0b.docx"].map(String::from));
// Every path alone; each of these also with every other kind of junk param. The numbers that
// cost time (`copies`, `scale`) stay small: huge ones are #3720.
let mut params: Vec<serde_json::Value> = hostile_junk().into();
params.extend(paths.iter().map(|p| json!({"path": p})));
for name in [
"in/random.docx",
"in/empty.docx",
"in/folder.docx",
"in/missing.docx",
"in/good.txt",
"in/good.png",
"out/new.docx",
"out/new.pdf",
"out/new.png",
] {
let path = at(&base, name);
params.extend([
json!({"path": path, "password": "x", "format": "wordml2003", "markup": "x", "confirmDamaged": 1}),
json!({"path": path, "password": 5, "format": 5, "markup": true}),
json!({"path": path, "page": 0, "scale": 0, "width": -1e308, "alt": 5}),
json!({"path": path, "data": "not base64!"}),
json!({"path": path, "data": "AAAA"}),
json!({"path": path, "pagesPerSheet": 7, "range": "x", "pages": "9-1,,", "parity": "x", "copies": 3, "paper": "x"}),
json!({"dir": path, "name": path, "template": path, "userTemplatesFolder": path, "id": 1e30, "restore": 1e30, "save": path}),
]);
}
let before = contents(&input);
let working_dir = std::path::Path::new(".");
let working_dir_before = contents(working_dir);
let roots = crate::file_access::AutomationRoots::new(Some(&input), Some(&output)).unwrap();
let confined = |s: &mut Session, id: &str, v: &serde_json::Value| crate::file_access::confine(roots.as_ref(), || s.run(id, v));
for spec in reg.all() {
if !reaches_files(spec.id) {
continue;
}
for v in &params {
let mut s = Session::new(crate::sample::sample_document());
let r = confined(&mut s, spec.id, v);
assert!(!caught_panic(&reg, &r), "{} panicked on {v}: {r:?}", spec.id);
s.clamp_selection();
let _ = s.layout();
}
}
let nothing = Vec::<String>::new();
assert_eq!(changed(&before, &input), nothing, "the read root is only read");
assert_eq!(changed(&Default::default(), &outside), nothing, "nothing outside the write root");
// Every name counts: an empty path comes out as `.pdf` or `.dotx` here.
assert_eq!(changed(&working_dir_before, working_dir), nothing, "nothing in the working directory");
// The junk above met the files, not a closed door: usable files work through the same roots...
let mut s = s();
confined(&mut s, "file.open", &json!({"path": at(&input, "good.txt")})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "Good text");
confined(&mut s, "insert.textFromFile", &json!({"path": at(&input, "good.txt")})).unwrap();
confined(&mut s, "insert.picture", &json!({"path": at(&input, "good.png")})).unwrap();
let saved: [(&str, &str, &[u8]); 3] =
[("file.saveAs", "control.docx", b"PK\x03\x04"), ("file.exportPdf", "control.pdf", b"%PDF-"), ("file.exportPng", "control.png", b"\x89PNG")];
for (id, name, signature) in saved {
confined(&mut s, id, &json!({"path": at(&output, name)})).unwrap();
assert_eq!(std::fs::read(output.join(name)).unwrap().get(..signature.len()), Some(signature), "{name}");
}
// ...the noise is refused by the reader, and only a path outside by the roots.
let refused = |s: &mut Session, path: String| match confined(s, "file.open", &json!({"path": path})) {
Err(crate::CmdError::Failed(m)) => m,
r => panic!("not refused: {r:?}"),
};
let unreadable = refused(&mut s, at(&input, "random.docx"));
assert!(!unreadable.contains("automation path rejected"), "{unreadable}");
let rejected = refused(&mut s, at(&outside, "new.docx"));
assert!(rejected.contains("automation path rejected"), "{rejected}");
let _ = std::fs::remove_dir_all(&base);
}
/// A panic that `Session::run`'s guard turned into an error, in a command or one it ran: still a
/// crash path (#3718). Only the guard's own `"{id} panicked: "` counts, not an error that echoes
/// those words.
@@ -5615,6 +5748,39 @@ fn opening_a_named_pipe_or_device_is_refused_without_blocking() {
assert!(device.unwrap_err().contains("not a file"));
}
#[cfg(unix)]
#[test]
fn reading_a_template_title_from_a_named_pipe_is_refused_without_blocking() {
let dir = scratch_dir("template-title-fifo");
let fifo = dir.join("pipe.dotx");
let made = std::process::Command::new("mkfifo").arg(&fifo).status();
if !made.is_ok_and(|s| s.success()) {
let _ = std::fs::remove_dir_all(&dir);
eprintln!("mkfifo unavailable; skipping");
return;
}
let (tx, rx) = std::sync::mpsc::channel();
let path = fifo.clone();
std::thread::spawn(move || {
let title = crate::io::package_title(&path, 64 << 20, 64 << 10);
let _ = tx.send(title);
});
let result = rx.recv_timeout(std::time::Duration::from_secs(10));
if result.is_err() {
// Unblock a stuck reader so the test thread can finish.
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
}
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(result.expect("reading a template title blocked on a named pipe"), None);
}
#[cfg(unix)]
#[test]
fn html_picture_that_is_a_named_pipe_is_skipped_without_blocking() {
@@ -7812,6 +7978,97 @@ fn colour_commands_take_any_custom_hex() {
assert_eq!(s.doc.settings.page_color, wordcraft_doc::Rgb::parse("123ABC"));
}
/// #4685: Home › Paragraph › Shading on text selected inside a paragraph shades that text, as in
/// Word, so No Color takes off a text shading a file gave it (an orange "highlight" from a .docx is
/// `w:shd`, which Text Highlight Color › No Color leaves, in Word too). A caret or a whole
/// paragraph still shades the paragraph.
#[test]
fn shading_selected_text_shades_the_text_and_no_color_clears_it() {
let orange = wordcraft_doc::Rgb::parse("FFA500");
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Orange text here"}));
run(&mut s, "select.text", json!({"text": "Orange"}));
run(&mut s, "para.shading", json!({"color": "FFA500"}));
let p = s.doc.para_at(&s.sel.focus).unwrap();
assert_eq!((p.props.shading, p.props_of_char(0).shading, p.props_of_char(7).shading), (None, orange, None));
// Highlight › No Color doesn't take it off; Shading › No Color does.
run(&mut s, "format.highlight", json!({"color": "none"}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().props_of_char(0).shading, orange);
run(&mut s, "para.shading", json!({"color": null}));
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().props_of_char(0).shading, None);
// A caret, or the whole paragraph, shades the paragraph and leaves the text alone.
for whole in [false, true] {
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "Orange text here"}));
run(&mut s, if whole { "select.all" } else { "caret.left" }, json!({}));
run(&mut s, "para.shading", json!({"color": "FFA500"}));
let p = s.doc.para_at(&s.sel.focus).unwrap();
assert_eq!((p.props.shading, p.props_of_char(0).shading), (orange, None), "whole: {whole}");
}
// All the text of a paragraph that isn't the last is still text: a selection with its mark
// would end at the next paragraph's start.
let mut s = self::s();
run(
&mut s,
"document.setText",
json!({"text": "Word
More"}),
);
run(&mut s, "select.text", json!({"text": "Word"}));
run(&mut s, "para.shading", json!({"color": "FFA500"}));
let p = s.doc.para_at(&s.sel.focus).unwrap();
assert_eq!((p.props.shading, p.props_of_char(0).shading), (None, orange));
// A selected text box shades its anchor's paragraph, not every line of the box.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "Anchor"}));
run(&mut s, "insert.textBox", json!({"text": "Inside"}));
let id = s.doc.parts.iter().find(|(_, p)| p.kind == wordcraft_doc::PartKind::TextBox).map(|(id, _)| *id).unwrap();
let end = s.doc.text_box_anchor(id).unwrap();
let start = wordcraft_doc::Pos { off: end.off - wordcraft_doc::para::OBJ.len_utf8(), ..end.clone() };
run(&mut s, "select.range", json!({"anchor": start, "focus": end}));
run(&mut s, "para.shading", json!({"color": "FFA500"}));
let boxed = s.doc.parts.get(&id).and_then(|p| p.blocks.first()).and_then(|b| b.as_para()).unwrap();
assert_eq!(boxed.props_of_char(0).shading, None, "the box's text isn't shaded");
assert_eq!(s.doc.para_at(&end).unwrap().props.shading, orange);
// A selected field is text (its object character isn't a drawing): No Color clears its shading.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "Page "}));
run(&mut s, "insert.pageNumber", json!({"position": "current"}));
let end = s.sel.focus.clone();
let start = wordcraft_doc::Pos { off: end.off - wordcraft_doc::para::OBJ.len_utf8(), ..end.clone() };
run(&mut s, "select.range", json!({"anchor": start, "focus": end}));
run(&mut s, "para.shading", json!({"color": "FFA500"}));
let p = s.doc.para_at(&end).unwrap();
assert_eq!((p.props.shading, p.props_of_char(start.off).shading), (None, orange), "the field is shaded");
run(&mut s, "para.shading", json!({"color": null}));
assert_eq!(s.doc.para_at(&end).unwrap().props_of_char(start.off).shading, None);
// Borders and Shading › Apply to: Paragraph shades the paragraph, whatever is selected.
run(&mut s, "select.text", json!({"text": "Page"}));
run(&mut s, "para.shading", json!({"color": "FFA500", "paragraph": true}));
let p = s.doc.para_at(&s.sel.focus).unwrap();
assert_eq!((p.props.shading, p.props_of_char(0).shading), (orange, None));
}
/// #4685 review: in a cell's last paragraph all of its text stands for the paragraph with its
/// mark (a selection can't reach the next paragraph's start there), so Shading shades the
/// paragraph; part of the text is text.
#[test]
fn shading_all_of_a_cells_last_paragraph_shades_the_paragraph() {
let orange = wordcraft_doc::Rgb::parse("FFA500");
for (from, to, paragraph) in [(0, 9, true), (0, 4, false)] {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
run(&mut s, "text.insert", json!({"text": "Cell text"}));
let cell = s.sel.focus.clone();
assert!(cell.path.cell().is_some());
run(&mut s, "select.range", json!({"anchor": Pos { off: from, ..cell.clone() }, "focus": Pos { off: to, ..cell.clone() }}));
run(&mut s, "para.shading", json!({"color": "FFA500"}));
let p = s.doc.para_at(&cell).unwrap();
let want = if paragraph { (orange, None) } else { (None, orange) };
assert_eq!((p.props.shading, p.props_of_char(0).shading), want, "{from}..{to}");
}
}
/// #2738: Home › Underline › Underline Color sets the underline's own colour and leaves its style
/// alone; `auto` drops it again. One undo step each, kept through a DOCX round trip, and junk is
/// a params error that changes nothing.
@@ -10167,6 +10424,73 @@ fn column_texts(s: &Session) -> Vec<String> {
t.rows.iter().map(|r| r.cells[0].blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<String>()).collect()
}
#[test]
fn table_sort_rejects_invalid_columns_without_changing_rows_or_history() {
for column in [
json!(-1),
json!(0.5),
json!(0.0),
json!("0"),
json!(null),
json!(true),
json!([]),
json!({}),
json!(1),
json!(u32::MAX as u64 + 1),
json!(u64::MAX),
] {
let mut s = column_table("en-US", &["Charlie", "Alpha", "Bravo"]);
s.close_typing();
run(&mut s, "text.insert", json!({"text": "!"}));
let before = s.doc.clone();
let selection = s.sel.clone();
let dirty = s.dirty;
assert!(s.run("table.sort", &json!({"column": column, "header": false})).is_err(), "column {column}");
assert_eq!(s.doc, before, "column {column}");
assert_eq!(s.sel, selection, "column {column}");
assert_eq!(s.dirty, dirty, "column {column}");
run(&mut s, "edit.undo", json!({}));
assert_eq!(column_texts(&s), ["Charlie", "Alpha", "Bravo"], "column {column}: refusal must not consume Undo");
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc, before, "column {column}: Redo must restore the preceding edit");
}
}
#[test]
fn table_sort_keeps_default_and_valid_columns() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 3, "cols": 2}));
for (k, value) in ["Charlie", "1", "Alpha", "3", "Bravo", "2"].into_iter().enumerate() {
if k > 0 {
run(&mut s, "text.tab", json!({}));
}
run(&mut s, "text.insert", json!({"text": value}));
}
let original = s.doc.clone();
for params in [json!({"header": false}), json!({"column": 0, "header": false})] {
run(&mut s, "table.sort", params);
assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc, original);
}
run(&mut s, "table.sort", json!({"column": 1, "header": false}));
assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]);
run(&mut s, "table.sort", json!({"column": 1, "header": false, "descending": true}));
assert_eq!(column_texts(&s), ["Alpha", "Bravo", "Charlie"]);
// A column may be missing in some rows. Its present cells still sort normally.
let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
let table = s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap();
table.rows[0].cells.remove(1);
run(&mut s, "table.sort", json!({"column": 1, "header": false}));
assert_eq!(column_texts(&s), ["Charlie", "Bravo", "Alpha"]);
let mut s = column_table("en-US", &["Title", "3", "1", "2"]);
let at = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32;
s.doc.table_mut(StoryRef::Body, &wordcraft_doc::Path(vec![at])).unwrap().rows[0].props.header = true;
run(&mut s, "table.sort", json!({"column": 0}));
assert_eq!(column_texts(&s), ["Title", "1", "2", "3"]);
}
/// #836: table sort reads numbers in the text's language: `1,5` is one and a half in Polish.
#[test]
fn table_sort_reads_decimal_comma_in_polish() {
@@ -10465,6 +10789,44 @@ fn insert_picture_takes_a_windows_metafile() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn window_commands_need_the_desktop_and_name_a_window() {
use crate::cmd::view::OtherWindow;
let mut s = s();
// Web, CLI, MCP: one window, so View › Window is disabled.
for id in ["view.arrangeAll", "view.sideBySide", "view.syncScroll"] {
assert!(s.run(id, &json!({})).is_err(), "{id} without a window host");
}
s.windows.available = true;
run(&mut s, "view.arrangeAll", json!({}));
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "arrange"})));
assert!(s.run("view.sideBySide", &json!({})).is_err(), "no other window");
s.windows.others = vec![OtherWindow { id: 7, title: "Notes".into() }, OtherWindow { id: 9, title: "Draft".into() }];
let e = s.run("view.sideBySide", &json!({})).unwrap_err().to_string();
assert!(e.contains("7 (Notes)") && e.contains("9 (Draft)"), "several windows: say which: {e}");
assert!(s.run("view.sideBySide", &json!({"window": 8})).is_err(), "an unknown window");
assert!(s.run("view.sideBySide", &json!({"window": "9"})).is_err());
// Side by Side pairs with one window and scrolls together until turned off.
assert!(s.run("view.syncScroll", &json!({})).is_err(), "only while Side by Side is on");
let r = run(&mut s, "view.sideBySide", json!({"window": 7}));
assert_eq!((r["value"].as_bool(), s.windows.side_by_side, s.windows.sync_scroll), (Some(true), Some(7), true));
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "with": 7})));
run(&mut s, "view.syncScroll", json!({}));
assert!(!s.windows.sync_scroll);
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "syncScroll", "with": 7, "value": false})));
// Asking again for the partner it already has keeps scrolling off and pairs nothing anew.
let r = run(&mut s, "view.sideBySide", json!({"window": 7, "value": true}));
assert_eq!((r["syncScroll"].as_bool(), s.windows.sync_scroll), (Some(false), false));
assert!(s.ui_requests.is_empty());
// Another partner replaces the first; the bare button turns it off.
run(&mut s, "view.sideBySide", json!({"window": 9}));
assert_eq!(std::mem::take(&mut s.ui_requests), [json!({"windows": "sideBySide", "off": 7}), json!({"windows": "sideBySide", "with": 9})]);
run(&mut s, "view.sideBySide", json!({}));
assert_eq!((s.windows.side_by_side, s.windows.sync_scroll), (None, false));
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "off": 9})));
assert!(!s.dirty, "window commands never touch the document");
}
/// Change Picture must keep the replacement's own format: the part's name and content type
/// come from the extension, so a JPEG stored as `imageN.png` saves declared `image/png` (Word
/// and LibreOffice then show a broken picture).
@@ -10494,6 +10856,44 @@ fn picture_change_keeps_the_replacement_format() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Change Picture keeps the width and takes the replacement's aspect ratio: swapping a figure for
/// a revised one of a different shape must not stretch it into the old frame.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn picture_change_keeps_width_and_takes_new_aspect() {
let dir = scratch_dir("picture-change-aspect");
let mut wide = Vec::new();
image::RgbImage::from_pixel(40, 20, image::Rgb([200, 30, 30])).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap();
let mut tall = Vec::new();
image::RgbImage::from_pixel(30, 60, image::Rgb([30, 30, 200])).write_to(&mut std::io::Cursor::new(&mut tall), image::ImageFormat::Png).unwrap();
let (first, second) = (dir.join("wide.png"), dir.join("tall.png"));
std::fs::write(&first, &wide).unwrap();
std::fs::write(&second, &tall).unwrap();
let mut s = Session::new(wordcraft_doc::Document::new());
run(&mut s, "insert.picture", json!({"path": first.to_string_lossy()}));
let size = |s: &Session| {
let mut out = (0.0f32, 0.0f32, [1.0f32; 4]);
s.doc.objects_in_reading_order(&mut |o| {
if let wordcraft_doc::para::InlineObject::Image { w, h, crop, .. } = o {
out = (*w, *h, *crop);
}
});
out
};
let (w0, h0, _) = size(&s);
assert!((w0 / h0 - 2.0).abs() < 1e-3, "inserted at its own 2:1 shape: {w0} x {h0}");
run(&mut s, "picture.crop", json!({"left": 0.1}));
let (w_cropped, _, crop) = size(&s);
assert!(crop[0] > 0.0, "cropped before the change");
run(&mut s, "picture.change", json!({"path": second.to_string_lossy()}));
let (w1, h1, crop) = size(&s);
assert!((w1 - w_cropped).abs() < 1e-3, "the width is kept: {w_cropped} -> {w1}");
assert!((h1 / w1 - 2.0).abs() < 1e-3, "the height follows the new 1:2 picture: {w1} x {h1}");
assert_eq!(crop, [0.0; 4], "the old picture's crop is dropped");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn json_open_repairs_hostile_run_lengths() {
use wordcraft_doc::{Block, Document};
@@ -12207,6 +12607,40 @@ fn export_png_rejects_a_page_number_that_does_not_fit() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn screen_paper_color_is_a_persistent_preference_not_document_formatting() {
use crate::cmd::view::PaperColor;
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Screen paper test", "raw": true}));
let before = serde_json::to_value(&s.doc).unwrap();
let docx_before = wordcraft_docx::write(&s.doc).unwrap();
s.dirty = false;
let dirty = s.dirty;
s.view.dark_mode = true;
for (value, expected) in
[("green", PaperColor::Green), ("cream", PaperColor::Cream), ("gray", PaperColor::Gray), ("document", PaperColor::Document)]
{
run(&mut s, "view.paperColor", json!({"value": value}));
assert_eq!(s.prefs.paper_color, expected);
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
assert_eq!(s.dirty, dirty);
assert_eq!(wordcraft_docx::write(&s.doc).unwrap(), docx_before);
assert!(!s.view.dark_mode, "choosing a tint turns off dark paper");
let saved = serde_json::to_value(&s.prefs).unwrap();
let restored: crate::Prefs = serde_json::from_value(saved).unwrap();
assert_eq!(restored.paper_color, expected);
}
for v in [json!({}), json!({"value": "red"}), json!({"value": 42})] {
assert!(s.run("view.paperColor", &v).is_err());
assert_eq!(s.prefs.paper_color, PaperColor::Document);
}
let legacy: crate::Prefs = serde_json::from_value(json!({})).unwrap();
assert_eq!(legacy.paper_color, PaperColor::Document);
let future: crate::Prefs = serde_json::from_value(json!({"paperColor":"future", "countNotes":false})).unwrap();
assert_eq!(future.paper_color, PaperColor::Document);
assert!(!future.count_notes);
}
/// Font › Emphasis mark puts a mark over (or under) each selected character, and the layout
/// draws one per character; Format state reports it, and none takes it off.
#[test]
@@ -12497,3 +12931,48 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
assert!(s.sel.focus.path.0[0] > 6, "Page Down extends the selection a page on: {:?}", s.sel.focus);
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
}
/// Issue #5667's document: `Intro`, then an inserted `Appendix` paragraph whose mark carries a
/// section break (Normal, indented), then `Body` in Heading 1. The whole of `Appendix` is
/// tracked as an insertion by `rev`, or as a deletion when `delete`.
fn sectioned_revision(delete: bool) -> Session {
use wordcraft_doc::{Revision, RevisionKind};
let mut s = Session::new(wordcraft_doc::Document::from_text("Intro\nAppendix\nBody"));
run(&mut s, "caret.set", json!({"pos": {"block": 2, "off": 0}}));
run(&mut s, "para.style", json!({"style": "Heading1"}));
let kind = if delete { RevisionKind::Delete } else { RevisionKind::Insert };
s.doc.revisions.push(Revision { kind, author: "Ada".into(), date: String::new(), move_name: None });
s.doc.last_section.margin_left = 72.0;
let mut sect = s.doc.last_section.clone();
sect.margin_left = 144.0;
let p = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap();
let len = p.len();
p.format(0, len, &|c| if delete { c.del = Some(0) } else { c.ins = Some(0) }).unwrap();
if delete {
p.mark.del = Some(0)
} else {
p.mark.ins = Some(0)
}
p.props.indent_left = Some(36.0);
p.section = Some(Box::new(sect));
s
}
#[test]
fn resolving_away_an_empty_paragraph_with_a_section_break_keeps_the_next_paragraphs_properties() {
// #5667: the resolved paragraph was left empty but kept for its section break, so the next
// paragraph joined it and took its properties (Normal, its indent). It goes whole instead,
// as an empty paragraph without a break does, and its section joins the following one.
for (delete, cmd) in [(false, "review.rejectAll"), (true, "review.acceptAll")] {
let mut s = sectioned_revision(delete);
assert_eq!(s.doc.sections().len(), 2);
run(&mut s, cmd, json!({}));
assert_eq!(body_texts(&s), ["Intro", "Body"], "{cmd}");
let body = s.doc.para(StoryRef::Body, &wordcraft_doc::Path(vec![1])).unwrap();
assert_eq!(body.props.style.as_deref(), Some("Heading1"), "{cmd}: Body keeps its style");
assert_eq!(body.props.indent_left, None, "{cmd}: and not the removed paragraph's indent");
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.section.is_none()), "{cmd}: the break went with it");
assert_eq!(s.doc.sections().len(), 1, "{cmd}");
assert_eq!(s.doc.last_section.margin_left, 72.0, "{cmd}: the following section's setup stays");
}
}
+48
View File
@@ -346,3 +346,51 @@ fn input_method_joins_stay_with_their_window() {
assert_eq!(text(&s).trim(), "Hello", "B's typing is its own undo step");
drop(a);
}
/// Check spelling as you type is one setting for the app, not one per open document (#5740):
/// turned off (File › Options › Proofing, or Review's command) with one document active, it is off
/// in the others too, and switching to another document doesn't turn it back on, so what is saved
/// for the next run is what was chosen, whichever document is active then.
#[test]
fn check_spelling_is_one_setting_for_every_open_document() {
let mut s = s();
let a = s.document_id();
run(&mut s, "file.new", json!({}));
let b = s.document_id();
run(&mut s, "file.new", json!({}));
let c = s.document_id();
assert!(s.view.proofing);
s.activate(a).unwrap();
run(&mut s, "file.options", json!({"checkSpelling": false}));
for id in [b, c, a] {
s.activate(id).unwrap();
assert!(!s.view.proofing, "off in document {id}");
assert!(!s.prefs.check_spelling);
}
run(&mut s, "review.proofing", json!({"value": true}));
for id in [b, c] {
s.activate(id).unwrap();
assert!(s.view.proofing, "back on in document {id}");
}
// A document opened now starts with the setting as it is.
run(&mut s, "review.proofing", json!({"value": false}));
run(&mut s, "file.new", json!({}));
assert!(!s.view.proofing);
}
/// A window's view put back after Check spelling as you type changed in another window takes the
/// setting as it is now, not as it was when the window's view was taken (#5740): every view made
/// active follows the saved preference.
#[test]
fn a_window_view_put_back_follows_the_spelling_setting() {
let mut s = s();
assert!(s.view.proofing);
let held = s.take_view();
run(&mut s, "file.options", json!({"checkSpelling": false}));
s.put_view(held);
assert!(!s.view.proofing, "the view taken with spelling on comes back with it off");
let held = s.take_view();
run(&mut s, "review.proofing", json!({"value": true}));
s.put_view(held);
assert!(s.view.proofing);
}
@@ -0,0 +1,48 @@
use super::*;
/// Keyboard smart quotes stay searchable, and replacement keeps their original spelling.
#[test]
fn keyboard_apostrophe_finds_smart_quoted_typing() {
let mut s = s();
for ch in "Tau'va".chars() {
run(&mut s, "text.insert", json!({"text": ch.to_string()}));
}
assert_eq!(text(&s), "Tau’va");
for query in ["Tau'", "Tau'v", "Tau'va", "tau'VA"] {
assert_eq!(run(&mut s, "edit.find", json!({"text": query}))["count"], 1, "{query}");
}
assert_eq!(run(&mut s, "edit.find", json!({"text": "tau'va", "matchCase": true}))["count"], 0);
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "Tau'va", "with": "[^&]", "matchCase": false}))["replaced"], 1);
assert_eq!(text(&s), "[Tau’va]");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "Tau’va");
run(&mut s, "edit.redo", json!({}));
assert_eq!(text(&s), "[Tau’va]");
}
/// Normal search uses raw UTF-8 ranges while explicit syntax and modifier letters stay exact.
#[test]
fn normal_apostrophe_search_keeps_exact_modes_and_original_byte_ranges() {
let mut s = s();
let original = "🙂 Tau'va Tau’va Tau‘va Tauʼva";
run(&mut s, "document.setText", json!({"text": original}));
let found = run(&mut s, "edit.find", json!({"text": "Tau'va", "wholeWord": true}));
assert_eq!(found["count"], 3);
let ranges: Vec<_> =
found["matches"].as_array().unwrap().iter().map(|m| (m["start"]["off"].as_u64().unwrap(), m["end"]["off"].as_u64().unwrap())).collect();
assert_eq!(ranges, [(5, 11), (12, 20), (21, 29)]);
for options in [json!({"special": false}), json!({"wildcards": true}), json!({"regex": true})] {
let mut exact = super::s();
run(&mut exact, "document.setText", json!({"text": original}));
let mut params = options;
params["text"] = json!("Tau'va");
assert_eq!(run(&mut exact, "edit.find", params)["count"], 1);
}
for query in ["Tau^u39va", "Tau^u8217va", "Tau’va", "Tau‘va", "Tauʼva"] {
assert_eq!(run(&mut s, "edit.find", json!({"text": query}))["count"], 1, "{query}");
}
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": "Tau'va", "with": "<^&>"}))["replaced"], 3);
assert_eq!(text(&s), "🙂 <Tau'va> <Tau’va> <Tau‘va> Tauʼva");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), original);
}

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