Compare commits

..
Author SHA1 Message Date
Martin Ball 49b3adb858 Merge remote-tracking branch 'origin/main' into HEAD
; Conflicts:
;	ROADMAP.md
;	docs/gaps.md
2026-11-08 02:27:52 +00: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
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
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
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
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
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
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
Martin BallandOPUZZLES 729cd0a5fc Typing: avoid repeated scans at protected typing boundaries (#3614) (#4754)
Lands #4754.

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

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

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

Co-Authored-By: D13GOOOO <106814760+D13GOOOO@users.noreply.github.com>
2026-11-06 12:44:48 +01:00
Martin BallandRatkoR beca3f3485 Layout: a float anchored on a later page of a long paragraph is drawn there (#4250) (#4267)
Lands #4267.

Co-Authored-By: RatkoR <6648783+RatkoR@users.noreply.github.com>
2026-11-06 12:13:11 +01:00
Dylan M. Taylor 6ba25ee491 xtask: no symlink walks, exclusive test dir; Paste Special picture files must be regular files 2026-11-03 19:11:23 +00:00
127 changed files with 5653 additions and 374 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 }}
+50 -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");
+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) => {
+25 -4
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;
@@ -421,14 +423,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 +452,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 +582,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 +625,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)]
+1 -1
View File
@@ -1 +1 @@
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
1e515422afdda2f317af430c1f9d68a5f83935b4
+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());
}
+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),
)) {
+1
View File
@@ -125,6 +125,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")),
+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
+19 -5
View File
@@ -316,20 +316,34 @@ impl InlineObject {
/// A group's members placed in a `w` × `h` box at (`x`, `y`): each member's rectangle
/// (x, y, w, h) and the member. Empty for anything else.
pub fn group_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<([f32; 4], &InlineObject)> {
let InlineObject::Group { ch_w, ch_h, children, .. } = self else { return Vec::new() };
let InlineObject::Group { children, .. } = self else { return Vec::new() };
self.member_rects(x, y, w, h).into_iter().filter_map(|(i, r)| Some((r, &children.get(i)?.obj))).collect()
}
/// [`InlineObject::group_rects`] with each member's index in `children` instead of the member.
pub fn member_rects(&self, x: f32, y: f32, w: f32, h: f32) -> Vec<(usize, [f32; 4])> {
let InlineObject::Group { children, .. } = self else { return Vec::new() };
let (sx, sy) = self.group_scale(w, h);
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
children
.iter()
.take(MAX_GROUP_CHILDREN)
.filter_map(|c| {
.enumerate()
.filter_map(|(i, c)| {
let (cw, chh, _) = c.obj.frame()?;
let r = [x + fin(c.x) * sx, y + fin(c.y) * sy, (fin(cw) * sx).clamp(0.0, 4000.0), (fin(chh) * sy).clamp(0.0, 4000.0)];
(!matches!(c.obj, InlineObject::Group { .. })).then_some((r, &c.obj))
(!matches!(c.obj, InlineObject::Group { .. })).then_some((i, r))
})
.collect()
}
/// How much a group drawn `w` × `h` scales its members' space (x, y): 1 for a canvas, whose
/// members keep their size.
pub fn group_scale(&self, w: f32, h: f32) -> (f32, f32) {
let InlineObject::Group { ch_w, ch_h, .. } = self else { return (1.0, 1.0) };
let fin = |v: f32| if v.is_finite() { v } else { 0.0 };
let sx = if fin(*ch_w) > 0.0 { fin(w) / ch_w } else { 1.0 };
let sy = if fin(*ch_h) > 0.0 { fin(h) / ch_h } else { 1.0 };
(sx, sy)
}
/// A legacy form field's definition (`FORMCHECKBOX`, `FORMTEXT`, `FORMDROPDOWN`), read from
/// the `w:ffData` it keeps; `None` for anything else.
pub fn legacy_form(&self) -> Option<crate::legacy_form::LegacyForm> {
+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.
+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;
+81 -42
View File
@@ -502,29 +502,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 +560,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 +723,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 +862,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 +927,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,
+150
View File
@@ -2880,3 +2880,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:?}");
}
+25
View File
@@ -2462,6 +2462,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]
+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")),
+6 -1
View File
@@ -169,6 +169,9 @@ pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete
if a == b {
return Ok(SelectionDelete { at: a, content_changed: false, state_changed: false });
}
if canvas::selected_member(s).is_some() {
return Err(CmdError::Disabled("a shape inside a drawing canvas is selected".into()));
}
controls::check_delete(s, &a, &b)?;
let effect = if s.doc.settings.track_changes {
if !track_delete_would_change(s, &a, &b) {
@@ -186,7 +189,9 @@ pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete
Ok(effect)
}
/// Delete the selection (respecting track changes); returns the collapsed position.
/// Delete the selection (respecting track changes); returns the collapsed position. With a
/// Drawing Canvas member picked (#2637) the selection is the whole canvas, which a command that
/// replaces the selection (Insert › Table, Equation, Symbol, Field…) would take away: refused.
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
delete_selection_effect(s).map(|e| e.at)
}
+320
View File
@@ -5,6 +5,10 @@
//! top-left corner, unscaled). While a canvas is selected, Insert › Pictures, Shapes and Text Box
//! put the new object inside it ([`add_member`]). Fit, Expand and Scale Drawing change its frame;
//! Shape Fill and Shape Outline colour its background and border.
//!
//! A member can be picked on its own (#2637, `select.member`; a click on it does this): Shape
//! Format, Size, Rotate, Alt Text, Delete, Cut and Copy then act on that member, and Move or
//! Resize and Nudge move it inside the canvas. The canvas stays the selection in the text.
use serde_json::{Value, json};
use wordcraft_doc::para::{CanvasStyle, Float, GroupChild, InlineObject, MAX_GROUP_CHILDREN};
@@ -14,6 +18,29 @@ use super::objects::object_selection;
use super::{delete_selection, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
/// A member of the selected canvas picked on its own: its index, and the selection it was picked
/// in (it counts only while that selection stands).
#[derive(Clone, Debug, PartialEq)]
pub struct CanvasMember {
sel: Selection,
pub index: usize,
}
/// The canvas member picked on its own: the canvas's position, the member's index and the member.
pub fn selected_member(s: &Session) -> Option<(Pos, usize, &InlineObject)> {
let m = s.member.as_ref().filter(|m| m.sel == s.sel)?;
let (pos, canvas) = selected_canvas(s)?;
let InlineObject::Group { children, .. } = canvas else { return None };
let c = children.get(m.index).filter(|c| pickable(c))?;
Some((pos, m.index, &c.obj))
}
/// Whether a member can be picked on its own: a picture, shape or text box that is drawn (a
/// group inside a canvas isn't, nor a hidden member).
pub fn pickable(c: &GroupChild) -> bool {
c.obj.is_drawing() && !matches!(c.obj, InlineObject::Group { .. }) && !c.obj.frame().is_some_and(|(_, _, f)| f.hidden)
}
/// Largest canvas side and member offset, points (well past any page).
const MAX_SIDE: f32 = 4000.0;
/// Smallest canvas side, points.
@@ -47,6 +74,10 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("canvas.scale", "Scale Drawing", "Shape Format › Drawing Canvas", scale)
.params(r#"{"factor": number (0.1–10)} (scales the frame and every member together)"#)
.when(has_canvas),
CommandSpec::new("select.member", "Select Canvas Member", "Shape Format › Drawing Canvas", select_member)
.params(r#"{"index": n | null} (picks member `index` of the selected drawing canvas on its own: Shape Format, Size, Rotate, Alt Text, Delete, Cut and Copy then act on it, and Move or Resize and Nudge move it inside the canvas; null: the canvas itself again)"#)
.when(has_canvas)
.pure(),
CommandSpec::new("canvas.member", "Canvas Member", "Shape Format › Drawing Canvas", member)
.params(r#"{"index": n, "x"?: pt, "y"?: pt, "width"?: pt, "height"?: pt, "delete"?: bool} (moves, resizes or removes member `index` of the selected canvas; x/y from the canvas's top-left; with no change, returns the members)"#)
.when(has_canvas),
@@ -224,9 +255,171 @@ fn member(s: &mut Session, v: &Value) -> CmdResult {
c.obj.set_size(nw.unwrap_or(w0).clamp(1.0, MAX_SIDE), nh.unwrap_or(h0).clamp(1.0, MAX_SIDE));
}
})?;
// The indices after a removed member shift down, so a picked member would name another one.
if delete {
s.member = None;
}
result(&o)
}
/// `select.member`: pick a member of the selected canvas on its own, or (`null`) the canvas again.
fn select_member(s: &mut Session, v: &Value) -> CmdResult {
let Some((_, InlineObject::Group { children, .. })) = selected_canvas(s) else {
return Err(CmdError::Disabled("select a drawing canvas first".into()));
};
let index = match v.get("index") {
None | Some(Value::Null) => None,
Some(_) => {
Some(p::u64(v, "index").and_then(|i| usize::try_from(i).ok()).ok_or_else(|| CmdError::Params("`index` is a member number".into()))?)
}
};
let Some(i) = index else {
s.member = None;
return sel_result(s);
};
let count = children.len();
let c = children.get(i).ok_or_else(|| CmdError::Params(format!("no member {i} (the canvas has {count})")))?;
if !pickable(c) {
return Err(CmdError::Params(format!("member {i} can't be selected on its own")));
}
let (w, h) = c.obj.frame().map(|(w, h, _)| (w, h)).unwrap_or((0.0, 0.0));
let out = json!({"index": i, "x": c.x, "y": c.y, "width": w, "height": h, "object": serde_json::to_value(&c.obj).unwrap_or(Value::Null)});
s.member = Some(CanvasMember { sel: s.sel.clone(), index: i });
Ok(out)
}
/// Delete, Backspace (and their word forms), Cut and Copy with a member picked act on the member
/// alone; the canvas stays selected. Typing and pasting, which would replace the whole canvas,
/// are disabled. `None`: another command, or no member picked.
pub(crate) fn member_command(s: &mut Session, id: &str, _: &Value) -> Option<CmdResult> {
let replaces = (id.starts_with("text.") && id != "text.overtype")
|| matches!(
id,
"edit.paste" | "edit.pasteText" | "edit.pasteMerge" | "edit.pasteSpecial" | "edit.pasteClipboardItem" | "edit.pasteAllClipboard"
);
if !replaces && !matches!(id, "edit.cut" | "edit.copy") {
return None;
}
let (pos, index, _) = selected_member(s)?;
Some(match id {
"edit.copy" => copy_member(s, &pos, index),
"edit.cut" => copy_member(s, &pos, index).and_then(|r| remove_member(s, &pos, index).map(|_| r)),
"text.delete" | "text.backspace" | "text.deleteWordBack" | "text.deleteWordForward" => {
remove_member(s, &pos, index).and_then(|_| sel_result(s))
}
_ => Err(CmdError::Disabled("a shape inside a drawing canvas is selected".into())),
})
}
/// Copy member `index` of the canvas at `pos` alone: pasted, it is a picture or shape of its own.
fn copy_member(s: &mut Session, pos: &Pos, index: usize) -> CmdResult {
let end = Pos { off: pos.off + wordcraft_doc::para::OBJ.len_utf8(), ..pos.clone() };
let mut f = s.doc.copy_range(pos, &end);
let mut copied = false;
for b in &mut f.blocks {
let wordcraft_doc::Block::Para(p) = b else { continue };
for o in &mut p.objects {
if let InlineObject::Group { children, canvas: Some(_), .. } = o
&& let Some(c) = children.get(index)
{
*o = c.obj.clone();
copied = true;
}
}
}
if !copied {
return Err(CmdError::Failed("the member vanished".into()));
}
s.clipboard_text = f.clipboard_text();
s.clip_history.push(&f, &s.clipboard_text);
s.clipboard = Some(f);
Ok(super::edit::copy_result(s))
}
/// Take member `index` out of the canvas at `pos`; the canvas stays selected.
fn remove_member(s: &mut Session, pos: &Pos, index: usize) -> Result<(), CmdError> {
edit_canvas(s, pos, |_, _, children| {
if index < children.len() {
children.remove(index);
}
})?;
s.member = None;
Ok(())
}
/// Move or Resize with a member picked: `x`/`y` (the page point of its top-left, as dragging its
/// frame gives it) and the size become its place and size in the canvas. `None`: no member.
pub(crate) fn member_bounds(s: &mut Session, v: &Value) -> Option<CmdResult> {
let (pos, index, obj) = selected_member(s)?;
let (w0, h0, _) = obj.frame()?;
let min = super::objects::min_size(obj);
let to = match (p::f32(v, "x"), p::f32(v, "y")) {
(Some(x), Some(y)) => Some((x, y)),
(None, None) => None,
_ => return Some(Err(CmdError::Params("`x` and `y` go together".into()))),
};
// `x`/`y` are on the canvas's page: a drop on another page would put the member outside it.
if to.is_some()
&& let Some(page) = p::u64(v, "page").and_then(|n| usize::try_from(n).ok())
&& s.layout().object(&pos, s.page_hint).is_some_and(|o| o.page != page)
{
return Some(Err(CmdError::Disabled("a shape can't leave its drawing canvas".into())));
}
let (nw, nh) = (p::f32(v, "width"), p::f32(v, "height"));
Some(edit_member(s, &pos, index, |frame, (sx, sy), c| {
if let Some((x, y)) = to {
c.x = ((x - frame.x) / sx).clamp(-MAX_SIDE, MAX_SIDE);
c.y = ((y - frame.y) / sy).clamp(-MAX_SIDE, MAX_SIDE);
}
let w = nw.map_or(w0, |w| w / sx).clamp(min, MAX_SIDE);
let h = nh.map_or(h0, |h| h / sy).clamp(min, MAX_SIDE);
if (w, h) != (w0, h0) {
c.obj.set_size(w, h);
}
}))
}
/// Nudge with a member picked: it moves inside the canvas. `None`: no member.
pub(crate) fn member_nudge(s: &mut Session, dx: f32, dy: f32) -> Option<CmdResult> {
let (pos, index, _) = selected_member(s)?;
Some(edit_member(s, &pos, index, |_, (sx, sy), c| {
c.x = (c.x + dx / sx).clamp(-MAX_SIDE, MAX_SIDE);
c.y = (c.y + dy / sy).clamp(-MAX_SIDE, MAX_SIDE);
}))
}
/// Change member `index` of the canvas at `pos`: `f` gets the canvas's frame on the page, the
/// scale of its members' space and the member.
fn edit_member(s: &mut Session, pos: &Pos, index: usize, f: impl FnOnce(wordcraft_geom::Rect, (f32, f32), &mut GroupChild)) -> CmdResult {
let frame = s.layout().object(pos, s.page_hint).ok_or_else(|| CmdError::Failed("the drawing canvas isn't laid out".into()))?.rect;
let canvas =
s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("the drawing canvas vanished".into()))?;
let scale = canvas.group_scale(frame.w, frame.h);
let mut f = Some(f);
let canvas = edit_canvas(s, pos, |_, _, children| {
if let (Some(c), Some(f)) = (children.get_mut(index), f.take()) {
f(frame, scale, c);
}
})?;
let InlineObject::Group { children, .. } = &canvas else { return result(&canvas) };
let member = children.get(index).map(|c| serde_json::to_value(&c.obj).unwrap_or(Value::Null)).unwrap_or(Value::Null);
Ok(json!({"object": member, "pos": super::pos_json(pos), "member": index}))
}
/// Change the picked member's own object (Shape Format, Size, Rotate, Alt Text) when `pos` is its
/// canvas. `None`: no member picked there.
pub(crate) fn edit_member_object(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Option<Result<InlineObject, CmdError>> {
let (at, index, _) = selected_member(s).filter(|(at, ..)| at == pos)?;
let mut out = None;
let r = edit_canvas(s, &at, |_, _, children| {
if let Some(c) = children.get_mut(index) {
f(&mut c.obj);
out = Some(c.obj.clone());
}
});
Some(r.and_then(|_| out.ok_or_else(|| CmdError::Failed("the member vanished".into()))))
}
/// Shape Fill and Shape Outline on a selected canvas colour its background and border.
pub fn style_mut(o: &mut InlineObject) -> Option<&mut CanvasStyle> {
match o {
@@ -327,4 +520,131 @@ mod tests {
fn canvas_in_text(s: &Session) -> InlineObject {
s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.objects.first()).cloned().unwrap()
}
fn member_fill(o: &InlineObject, i: usize) -> Option<wordcraft_doc::fill::Fill> {
let InlineObject::Group { children, .. } = o else { panic!("{o:?}") };
match &children[i].obj {
InlineObject::Shape { fill, .. } => fill.clone(),
o => panic!("{o:?}"),
}
}
/// #2637: a shape inside a drawing canvas can be picked on its own, as in Word. Shape Format,
/// Move or Resize, Nudge, Delete and Copy then act on it rather than on the whole canvas,
/// which stays the selection in the text; the commands that place an object in the text
/// (wrapping, position) don't apply to it.
#[test]
fn a_canvas_member_is_selected_and_changed_on_its_own() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.canvas", &json!({"width": 400, "height": 300})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
s.run("canvas.member", &json!({"index": 0, "x": 40, "y": 30})).unwrap();
s.run("canvas.member", &json!({"index": 1, "x": 200, "y": 100})).unwrap();
let canvas_sel = s.sel.clone();
assert!(s.run("select.member", &json!({"index": 5})).is_err(), "no such member");
s.run("select.member", &json!({"index": 1})).unwrap();
assert_eq!(selected_member(&s).map(|(_, i, _)| i), Some(1));
assert_eq!(s.sel, canvas_sel, "the canvas stays the selection in the text");
// Shape Fill colours the member, not the canvas or the other member.
let other = member_fill(&canvas(&s), 0);
s.run("shape.fill", &json!({"color": "FF0000"})).unwrap();
let c = canvas(&s);
assert_eq!(member_fill(&c, 1), wordcraft_doc::props::Rgb::parse("FF0000").map(wordcraft_doc::fill::Fill::solid));
assert_eq!(member_fill(&c, 0), other);
assert_eq!(c.canvas_style().and_then(|st| st.fill.clone()), None);
assert!(selected_member(&s).is_some(), "still picked after a change");
// Dragging its frame: the page point becomes its place in the canvas.
let frame = s.layout().object(&selected_canvas(&s).unwrap().0, 0).unwrap().rect;
s.run("arrange.bounds", &json!({"page": 0, "x": frame.x + 10.0, "y": frame.y + 20.0, "width": 60, "height": 30})).unwrap();
assert_eq!(members(&canvas(&s))[1], (10.0, 20.0, 60.0, 30.0));
assert_eq!(canvas(&s).frame().map(|(w, h, _)| (w, h)), Some((400.0, 300.0)), "the canvas keeps its frame");
s.run("arrange.nudge", &json!({"dx": 6, "dy": -6})).unwrap();
assert_eq!(members(&canvas(&s))[1], (16.0, 14.0, 60.0, 30.0));
assert!(s.run("arrange.wrap", &json!({"wrap": "square"})).is_err(), "the canvas places its members");
// Copy puts the member alone on the clipboard; Delete takes it out of the canvas.
s.run("edit.copy", &json!({})).unwrap();
let clip = s.clipboard.clone().unwrap();
let copied: Vec<&InlineObject> = clip.blocks.iter().filter_map(|b| b.as_para()).flat_map(|p| p.objects.iter()).collect();
assert!(matches!(copied.as_slice(), [InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, .. }]), "{copied:?}");
s.run("text.delete", &json!({})).unwrap();
assert_eq!(members(&canvas(&s)), [(40.0, 30.0, 100.0, 40.0)]);
assert!(selected_member(&s).is_none());
assert_eq!(s.sel, canvas_sel, "the canvas is still selected");
// Undo puts the member back.
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(members(&canvas_in_text(&s)).len(), 2);
// Picking the canvas again (or anything else) ends the member's selection.
s.run("select.objects", &json!({"index": 0})).unwrap();
s.run("select.member", &json!({"index": 0})).unwrap();
s.run("select.member", &json!({"index": null})).unwrap();
assert!(selected_member(&s).is_none());
s.run("select.member", &json!({"index": 0})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
assert!(selected_member(&s).is_none());
// Pasted outside the canvas, the copied member is a shape of its own in the text.
s.run("edit.paste", &json!({})).unwrap();
let para = s.doc.body.first().and_then(|b| b.as_para()).unwrap();
assert!(matches!(para.objects.last(), Some(InlineObject::Shape { kind: wordcraft_doc::para::ShapeKind::Ellipse, .. })), "{:?}", para.objects);
}
#[test]
fn typing_or_removing_a_member_never_hits_another_shape_or_the_canvas() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.canvas", &json!({"width": 400, "height": 300})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle", "width": 100, "height": 40})).unwrap();
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
s.run("select.member", &json!({"index": 1})).unwrap();
// Typing or pasting would replace the whole canvas: refused, and the member stays picked.
for (id, v) in [
("text.insert", json!({"text": "x"})),
("text.newParagraph", json!({})),
("edit.paste", json!({"text": "x"})),
("edit.pasteText", json!({"text": "x"})),
] {
assert!(matches!(s.run(id, &v), Err(CmdError::Disabled(_))), "{id}");
assert_eq!(members(&canvas(&s)).len(), 2, "{id}");
}
assert_eq!(selected_member(&s).map(|(_, i, _)| i), Some(1));
// Ctrl+Backspace removes the member alone, like Backspace.
s.run("text.deleteWordBack", &json!({})).unwrap();
let sizes: Vec<(f32, f32)> = members(&canvas(&s)).iter().map(|m| (m.2, m.3)).collect();
assert_eq!(sizes, [(100.0, 40.0)], "the ellipse went, the rectangle stays");
assert!(selected_member(&s).is_none());
// Removing an earlier member through `canvas.member` drops the pick: its index would now
// name the next member.
s.run("insert.shape", &json!({"kind": "ellipse", "width": 50, "height": 50})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle", "width": 20, "height": 20})).unwrap();
s.run("select.member", &json!({"index": 1})).unwrap();
s.run("canvas.member", &json!({"index": 0, "delete": true})).unwrap();
assert!(selected_member(&s).is_none(), "the pick doesn't slide onto the member that moved up");
// Commands that replace the selection (the whole canvas) are refused too.
s.run("select.member", &json!({"index": 0})).unwrap();
for (id, v) in [
("insert.table", json!({"rows": 1, "cols": 1})),
("table.quick", json!({})),
("insert.equation", json!({})),
("insert.symbol", json!({"char": "§"})),
("insert.field", json!({"instr": "AUTHOR"})),
("insert.pageBreak", json!({})),
] {
assert!(s.run(id, &v).is_err(), "{id}");
assert_eq!(members(&canvas(&s)).len(), 2, "{id}: the canvas and its members stay");
}
// Moving the member: on the canvas's page it moves; a drop on another page is refused.
let frame = s.layout().object(&selected_canvas(&s).unwrap().0, 0).unwrap();
let before = members(&canvas(&s));
let other = frame.page + 1;
assert!(matches!(s.run("arrange.bounds", &json!({"page": other, "x": 100, "y": 100})), Err(CmdError::Disabled(_))));
assert_eq!(members(&canvas(&s)), before);
s.run("arrange.bounds", &json!({"page": frame.page, "x": frame.rect.x + 5.0, "y": frame.rect.y + 5.0})).unwrap();
assert_ne!(members(&canvas(&s)), before);
}
}
+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);
}
}
+1 -1
View File
@@ -361,7 +361,7 @@ impl ClipHistory {
self.items.is_empty()
}
fn push(&mut self, f: &Fragment, text: &str) {
pub(crate) fn push(&mut self, f: &Fragment, text: &str) {
// Text plus per-paragraph overhead; pictures are references into the document.
let paras = text.matches('\n').count().saturating_add(f.blocks.len()).saturating_add(f.parts.len()).saturating_add(1);
let bytes = text.len().saturating_mul(2).saturating_add(paras.saturating_mul(256));
+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()) {
+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();
+7 -3
View File
@@ -6,6 +6,9 @@ pub(super) const MAX_OFFSET: f32 = 4000.0;
/// Resize and/or move the selected object (dragging its frame or handles).
pub(super) fn bounds(s: &mut Session, v: &Value) -> CmdResult {
if let Some(r) = crate::cmd::canvas::member_bounds(s, v) {
return r;
}
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?;
let to = match (p::f32(v, "x"), p::f32(v, "y")) {
(Some(x), Some(y)) => Some((x, y)),
@@ -58,7 +61,7 @@ fn landing(s: &Session, pos: &Pos, to: &Pos) -> Result<Pos, CmdError> {
/// Move the In Line with Text object at `pos` to the text position `to`, where it stays In Line
/// with Text, keeping its character formatting: Word's drag of an inline picture, which lands
/// where its dashed insertion cursor was (#2233, #2234). Dropped on itself, nothing changes.
/// where its insertion cursor was (#2233, #2234). Dropped on itself, nothing changes.
/// Returns the object's position afterwards.
fn move_in_text(s: &mut Session, pos: Pos, to: &Pos) -> Result<Pos, CmdError> {
let to = landing(s, &pos, to)?;
@@ -161,8 +164,9 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w,
Some(path) => (Pos { story, path, off: 0 }, false),
None => (nearest_line(&layout, page, y, story).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true),
};
// Move its character to the anchor (unless it's already in that paragraph).
let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page {
// Move its character to the anchor, unless it's already in that paragraph and drawn on this
// page (one anchored on a later page of it is drawn there, #4250).
let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page && layout.object(pos, page).is_some_and(|o| o.page == page) {
pos.clone()
} else {
let obj = s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).cloned().ok_or_else(|| CmdError::Failed("object vanished".into()))?;
+24 -2
View File
@@ -12,6 +12,10 @@ pub(super) fn has_object(s: &Session) -> Option<&'static str> {
}
pub(super) fn has_floating(s: &Session) -> Option<&'static str> {
// A canvas member moves inside its canvas.
if crate::cmd::canvas::selected_member(s).is_some() {
return None;
}
match selected(s) {
Some((_, InlineObject::Graphic { .. })) => Some(FROZEN),
Some((_, o)) if o.is_floating() => None,
@@ -51,6 +55,15 @@ pub(super) fn has_movable(s: &Session) -> Option<&'static str> {
}
}
/// `has_movable` for what places an object in the text (wrapping, position, alignment, Move with
/// Text): not for a member picked inside a drawing canvas, which the canvas places.
pub(super) fn has_placeable(s: &Session) -> Option<&'static str> {
if crate::cmd::canvas::selected_member(s).is_some() {
return Some("a shape inside a drawing canvas is placed by the canvas");
}
has_movable(s)
}
/// Word's own ink in a group is kept in the form a group member takes; it can't stand on its own
/// yet, so such a group stays whole.
pub(super) const INK_GROUP: &str = "a group holding Word ink can't be ungrouped yet";
@@ -99,7 +112,9 @@ pub fn object_selection(s: &Session) -> Option<(Pos, &InlineObject)> {
o.is_drawing().then_some((a, o))
}
/// The first picture/shape in the selection, or just before a collapsed caret.
/// The first picture/shape in the selection, or just before a collapsed caret. With a canvas
/// member picked ([`crate::cmd::canvas::selected_member`]), that member, at its canvas's position:
/// the commands that change it go through [`edit_obj`], which changes the member.
pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
selected_all(s, 1).into_iter().next()
}
@@ -107,6 +122,9 @@ pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> {
/// The pictures and shapes in the selection (at most `max`), or the one just before a collapsed
/// caret.
pub fn selected_all(s: &Session, max: usize) -> Vec<(Pos, InlineObject)> {
if let Some((pos, _, member)) = crate::cmd::canvas::selected_member(s) {
return vec![(pos, member.clone())];
}
let mut out = Vec::new();
let (a, b) = s.sel.ordered();
let max = if a == b { 1 } else { max };
@@ -175,8 +193,12 @@ pub(super) fn with_obj(s: &mut Session, f: impl Fn(&mut InlineObject)) -> CmdRes
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
}
/// Change the object at `pos`; returns it afterwards.
/// Change the object at `pos`; returns it afterwards. With a member of the canvas at `pos`
/// picked, the member changes.
pub(crate) fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result<InlineObject, CmdError> {
if let Some(r) = crate::cmd::canvas::edit_member_object(s, pos, &f) {
return r;
}
let para = s.doc.para_mut(pos.story, &pos.path)?;
let o = para.object_at_mut(pos.off).ok_or_else(|| CmdError::Failed("object vanished".into()))?;
f(o);
+13 -10
View File
@@ -2,8 +2,8 @@ use super::arrange::{MAX_OFFSET, bounds, copy_to, move_with_text, unalign};
use super::group::{add_object, group, ungroup};
use super::picture::{adjust, adjust_media, clear_lum, crop, original_media, recolor, relook, remove_background, reset, size};
use super::select::{
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_shape, has_shape_or_canvas,
has_sizable, obj_size, objects_list, selected, with_float, with_obj,
all_objects, alt_text, can_group, edit_obj, has_floating, has_group, has_movable, has_object, has_picture, has_placeable, has_shape,
has_shape_or_canvas, has_sizable, obj_size, objects_list, selected, with_float, with_obj,
};
use super::shape::{NO_TEXT_BOX, can_link, has_text_box, link_text_box, set_text_body, shape_effects, text_box_target};
use super::{CmdError, CommandSpec, InlineObject, Pos, Rgb, Selection, Spin, TextVert, VAlign, Wrap, WrapText, json, p, sel_result};
@@ -193,17 +193,17 @@ pub fn specs() -> Vec<CommandSpec> {
with_float(s, |f| f.wrap = wrap)
})
.params(r#"{"wrap": "inline|square|tight|through|topAndBottom|behindText|inFrontOfText"}"#)
.when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.wrapText", "Wrap Text Sides", "Layout › Arrange", |s, v| {
let side = p::str(v, "side").unwrap_or("bothSides");
let side = WrapText::from_ooxml(side).ok_or_else(|| CmdError::Params(format!("unknown side {side:?}")))?;
with_float(s, |f| f.wrap_text = side)
})
.params(r#"{"side": "bothSides|left|right|largest"} (the sides of the selected Square, Tight or Through object text flows on; largest: the wider side, line by line)"#)
.when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.moveWithText", "Move with Text", "Layout › Arrange › Wrap Text", |s, v| move_with_text(s, p::bool(v, "value").unwrap_or(true)))
.params(r#"{"value": bool} (true: Move with Text, the floating object's vertical position follows its paragraph; false: Fix Position on Page. It stays where it is)"#)
.when(has_floating),
.when(|s| has_placeable(s).or_else(|| has_floating(s))),
CommandSpec::new("arrange.position", "Position", "Layout › Arrange", |s, v| {
let preset = p::str(v, "preset").unwrap_or("middleCenter").to_string();
let (x, y) = (p::f32(v, "x"), p::f32(v, "y"));
@@ -224,9 +224,9 @@ pub fn specs() -> Vec<CommandSpec> {
})
})
.params(r#"{"preset"?: "topLeft|topCenter|topRight|middleLeft|middleCenter|middleRight|bottomLeft|bottomCenter|bottomRight", "x"?: pt, "y"?: pt}"#)
.when(has_movable),
CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_movable),
CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_placeable),
CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_placeable),
CommandSpec::new("arrange.align", "Align", "Layout › Arrange", |s, v| {
let h = p::str(v, "value").unwrap_or("center").to_string();
let tw = crate::cmd::page::sect(s).text_width();
@@ -245,17 +245,20 @@ pub fn specs() -> Vec<CommandSpec> {
})
})
.params(r#"{"value": "left|center|right"}"#)
.when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.selectionPane", "Selection Pane", "Layout › Arrange", objects_list).pure(),
CommandSpec::new("arrange.bounds", "Move or Resize", "Layout › Arrange", bounds)
.params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n, "to"?: Pos} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object by x/y floats it. to: an In Line with Text object moves to that text position and stays inline, as a drag does)"#)
.when(has_movable),
CommandSpec::new("arrange.copyTo", "Copy to", "Layout › Arrange", copy_to)
.params(r#"{"x": pt, "y": pt, "page"?: n} | {"to": Pos} (Ctrl+drag: a copy of the selected object lands with its top-left there, or an In Line with Text one at the text position `to`, and is selected; the original and the clipboard stay as they were)"#)
.when(has_movable),
.when(has_placeable),
CommandSpec::new("arrange.nudge", "Nudge", "Layout › Arrange", |s, v| {
let d = |k| p::f32(v, k).unwrap_or(0.0).clamp(-MAX_OFFSET, MAX_OFFSET);
let (dx, dy) = (d("dx"), d("dy"));
if let Some(r) = crate::cmd::canvas::member_nudge(s, dx, dy) {
return r;
}
unalign(s)?;
with_float(s, |f| {
f.x = (f.x + dx).clamp(-MAX_OFFSET, MAX_OFFSET);
+63
View File
@@ -14,6 +14,69 @@ fn object_area(s: &mut Session) -> wordcraft_layout::hit::ObjectHit {
s.layout().find_object(0, |o| o.text_box.is_none()).unwrap()
}
/// #4250: a shape dragged into the middle of a page that only continues a long paragraph
/// stays there, as in Word: anchored at the line beside it, it's drawn on that page, not
/// thrown back to the page the paragraph starts on. One undo step puts it back.
#[test]
fn a_shape_dropped_on_a_page_a_paragraph_continues_onto_stays_there() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "The quick brown fox jumps over the lazy dog. ".repeat(400)})).unwrap();
s.run("caret.docStart", &json!({})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 250, "width": 100, "height": 50})).unwrap();
assert!(s.layout().pages.len() > 2);
let before = (object_area(&mut s), s.undo_depth());
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 275})).unwrap();
let o = object_area(&mut s);
assert_eq!(o.page, 1, "on the page it was dropped on: {o:?}");
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 275.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
assert_eq!(s.undo_depth(), before.1 + 1);
s.run("edit.undo", &json!({})).unwrap();
let back = object_area(&mut s);
assert_eq!((back.page, back.rect), (before.0.page, before.0.rect));
}
/// #4250: and dragged back onto the page the paragraph starts on, it stays there: its anchor
/// goes back to the paragraph's start rather than staying on the later page's line.
#[test]
fn a_shape_dragged_back_to_the_page_its_paragraph_starts_on_stays_there() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("text.insert", &json!({"text": "The quick brown fox jumps over the lazy dog. ".repeat(400)})).unwrap();
s.run("caret.docStart", &json!({})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 275, "width": 100, "height": 50})).unwrap();
let before = (object_area(&mut s), s.undo_depth());
assert!(before.0.page == 1 && before.0.off > 0, "anchored on page 2's line: {:?}", before.0);
s.run("arrange.bounds", &json!({"page": 0, "x": 120, "y": 300})).unwrap();
let o = object_area(&mut s);
assert_eq!((o.page, o.off), (0, 0), "on the page it was dropped on, anchored at the paragraph's start: {o:?}");
assert!((o.rect.x - 120.0).abs() < 0.5 && (o.rect.y - 300.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
assert_eq!(s.undo_depth(), before.1 + 1);
}
/// #4262: dropped beside the first line after a page break inside a paragraph, a shape stays
/// on the page it was dropped on, anchored in that line.
#[test]
fn a_shape_dropped_just_after_a_page_break_in_a_paragraph_stays_there() {
let mut s = Session::new(wordcraft_doc::Document::new());
let text = "The quick brown fox jumps over the lazy dog. ";
s.run("text.insert", &json!({"text": text.repeat(20)})).unwrap();
s.run("insert.pageBreak", &json!({})).unwrap();
s.run("text.insert", &json!({"text": text.repeat(40)})).unwrap();
assert_eq!(s.doc.body.len(), 1, "one paragraph");
s.run("caret.docStart", &json!({})).unwrap();
s.run("insert.shape", &json!({"kind": "rectangle"})).unwrap();
s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap();
s.run("arrange.bounds", &json!({"page": 0, "x": 100, "y": 250, "width": 100, "height": 50})).unwrap();
s.run("arrange.bounds", &json!({"page": 1, "x": 100, "y": 80})).unwrap();
let o = object_area(&mut s);
let after_break = text.len() * 20 + wordcraft_doc::para::PAGE_BREAK.len_utf8();
assert_eq!((o.page, o.off), (1, after_break), "on the page it was dropped on, anchored right after the break: {o:?}");
assert!((o.rect.x - 100.0).abs() < 0.5 && (o.rect.y - 80.0).abs() < 0.5, "where it was dropped: {:?}", o.rect);
}
/// 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) {
+27 -1
View File
@@ -183,9 +183,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`"#,
),
@@ -264,3 +272,21 @@ pub fn specs() -> Vec<CommandSpec> {
}),
]
}
/// 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())
}
+22 -8
View File
@@ -652,19 +652,33 @@ pub(crate) fn typing_pos(s: &Session, p: &Pos) -> Pos {
if para.objects.is_empty() {
return p.clone();
}
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
let is_start = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
let is_end = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
let Some(before) = para.text.get(..p.off) else { return p.clone() };
let Some(after) = para.text.get(p.off..) else { return p.clone() };
if !before.ends_with(OBJ) && !after.starts_with(OBJ) {
return p.clone();
}
let editable: HashSet<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
// Count the preceding objects once. Each adjacent marker can then use the matching
// object directly, forwards for starts and backwards for ends, even with short storage.
let index = before.chars().filter(|c| *c == OBJ).count();
let w = OBJ.len_utf8();
let mut off = p.off;
if is_start(off) {
while is_start(off) {
off += w;
for (ch, object) in after.chars().zip(para.objects.iter().skip(index)) {
if ch != OBJ || !matches!(object, InlineObject::PermStart { id, .. } if editable.contains(id)) {
break;
}
off += w;
}
if off > p.off {
return Pos { off, ..p.clone() };
}
while off >= w && is_end(off - w) {
off -= w;
if let Some(objects) = para.objects.get(..index) {
for (ch, object) in before.chars().rev().zip(objects.iter().rev()) {
if ch != OBJ || !matches!(object, InlineObject::PermEnd { id } if editable.contains(id)) {
break;
}
off -= w;
}
}
Pos { off, ..p.clone() }
}
+156
View File
@@ -922,3 +922,159 @@ fn legacy_password_is_verified_and_unknown_forms_refused() {
assert!(e.to_string().contains(super::UNSUPPORTED_PASSWORD));
assert!(super::enforced(&s.doc));
}
/// Consecutive permission boundaries must not recount the paragraph prefix or search all
/// editable IDs once per marker. Both ends are used by typing and Enter.
#[test]
fn typing_boundary_many_permission_markers_is_not_quadratic() {
use wordcraft_doc::para::Run;
let cpu = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
let measure = |n: usize, at_end: bool| {
let mut p = wordcraft_doc::Paragraph::new();
p.text.push_str("λ prefix ");
let start = p.text.len();
for i in 0..n {
p.text.push(OBJ);
p.objects.push(InlineObject::PermStart { id: i as u32, group: "everyone".into(), editor: String::new() });
}
p.text.push_str("body");
let end = p.text.len();
for i in 0..n {
p.text.push(OBJ);
p.objects.push(InlineObject::PermEnd { id: i as u32 });
}
let after = p.text.len();
p.runs.push(Run { len: p.text.len(), props: CharProps::default() });
let mut doc = Document::new();
doc.body = vec![Block::Para(p).into()];
doc.settings.protection = Some("readOnly".into());
let s = Session::new(doc);
let at = Pos::body(0, if at_end { after } else { start });
let expected = Pos::body(0, if at_end { end } else { start + n * OBJ.len_utf8() });
let before = cpu();
for _ in 0..3 {
assert_eq!(typing_pos(std::hint::black_box(&s), &at), expected);
}
match (before, cpu()) {
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
_ => None,
}
};
for at_end in [false, true] {
let (small, large) = (measure(2_000, at_end), measure(16_000, at_end));
let (Some(a), Some(b)) = (small, large) else {
eprintln!("Typing boundary: CPU counter unavailable; semantic checks passed, timing assertion skipped");
continue;
};
eprintln!("Typing boundary end={at_end}: small={a:.9}s large={b:.9}s ratio={:.2}", b / a);
assert!(b < a * 24.0 + 0.005, "8x permission markers must not take quadratic boundary work: {a:.9}s -> {b:.9}s");
}
}
#[test]
fn typing_boundary_keeps_author_precedence_and_malformed_pairing() {
use wordcraft_doc::para::Run;
// Frozen boundary semantics from before the one-pass lookup. This oracle deliberately
// uses object_at independently of the new traversal, including malformed storage.
let original = |s: &Session, p: &Pos| {
if !matches!(s.doc.settings.protection.as_deref(), Some("readOnly" | "comments")) {
return p.clone();
}
let Some(para) = s.doc.para_at(p) else { return p.clone() };
if para.objects.is_empty() {
return p.clone();
}
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
let start = |off| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
let end = |off| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
let mut off = p.off;
if start(off) {
while start(off) {
off += OBJ.len_utf8();
}
} else {
while off >= OBJ.len_utf8() && end(off - OBJ.len_utf8()) {
off -= OBJ.len_utf8();
}
}
Pos { off, ..p.clone() }
};
let objects = [
InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() },
InlineObject::PermStart { id: 2, group: String::new(), editor: " ALICE ".into() },
InlineObject::PermStart { id: 3, group: String::new(), editor: "alice".into() },
InlineObject::PermEnd { id: 1 },
InlineObject::PermEnd { id: 3 },
InlineObject::BookmarkStart { name: "Original".into() },
InlineObject::PermEnd { id: 7 },
InlineObject::PermStart { id: 7, group: String::new(), editor: "Bob".into() },
];
for text in
[format!("λ{}x{}終", OBJ.to_string().repeat(4), OBJ.to_string().repeat(4)), OBJ.to_string().repeat(9), format!("λ{OBJ}{OBJ}x{OBJ}{OBJ}{OBJ}")]
{
for count in 0..=objects.len() {
for mode in [None, Some("readOnly"), Some("comments"), Some("forms"), Some("trackedChanges")] {
let mut p = wordcraft_doc::Paragraph::new();
p.text = text.clone();
p.objects = objects[..count].to_vec();
p.runs.push(Run { len: p.text.len(), props: CharProps::default() });
let mut doc = Document::new();
doc.body = vec![Block::Para(p).into()];
doc.settings.protection = mode.map(str::to_string);
let mut s = Session::new(doc);
s.author = " Alice ".into();
for off in 0..=text.len() + 1 {
let at = Pos::body(0, off);
assert_eq!(typing_pos(&s, &at), original(&s, &at), "mode={mode:?} count={count} off={off} text={text:?}");
}
}
}
}
let mut s = Session::new(Document::from_text("outside"));
s.doc.settings.protection = Some("readOnly".into());
assert_eq!(typing_pos(&s, &Pos::body(99, 0)), Pos::body(99, 0));
}
#[test]
fn typing_boundary_keeps_duplicate_ids_across_stories_and_start_precedence() {
use wordcraft_doc::para::Run;
let paragraph = |text: String, objects| wordcraft_doc::Paragraph {
runs: vec![Run { len: text.len(), props: CharProps::default() }],
text,
objects,
..Default::default()
};
let mut doc = Document::new();
doc.body = vec![
Block::Para(paragraph(
format!("{OBJ}x{OBJ}"),
vec![InlineObject::PermStart { id: 1, group: String::new(), editor: "Bob".into() }, InlineObject::PermEnd { id: 1 }],
))
.into(),
];
doc.add_part(
wordcraft_doc::PartKind::Header,
vec![
Block::Para(paragraph(
format!("{OBJ}y{OBJ}"),
vec![InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() }, InlineObject::PermEnd { id: 1 }],
))
.into(),
],
);
doc.settings.protection = Some("comments".into());
let mut s = Session::new(doc);
s.author = "Alice".into();
// Preserve the existing document-wide ID membership, even for a duplicate in another story.
assert_eq!(typing_pos(&s, &Pos::body(0, 0)), Pos::body(0, OBJ.len_utf8()));
assert_eq!(typing_pos(&s, &Pos::body(0, 7)), Pos::body(0, 4));
s.doc.body = vec![
Block::Para(paragraph(
format!("{OBJ}{OBJ}x"),
vec![InlineObject::PermEnd { id: 1 }, InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() }],
))
.into(),
];
// A start to the right wins over an end to the left.
assert_eq!(typing_pos(&s, &Pos::body(0, 3)), Pos::body(0, 6));
}
+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),
}
}
+14 -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,18 @@ 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 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
+5 -11
View File
@@ -121,17 +121,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.
+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");
+4
View File
@@ -219,6 +219,10 @@ pub struct Session {
/// More pictures and shapes selected along with the one the selection holds (Shift+click),
/// for Group. Cleared by any edit or selection change other than adding to it.
pub also_selected: Vec<Pos>,
/// The member of the selected Drawing Canvas picked on its own (#2637), which Shape Format,
/// Delete, Copy and the frame act on; see [`crate::cmd::canvas::selected_member`]. Cleared by
/// any selection change, caret movement or undo.
pub member: Option<crate::cmd::canvas::CanvasMember>,
/// Column (block) selection, if one was made (see [`Session::column_segments`]).
pub column: Option<ColumnBlock>,
/// Column selection mode (Ctrl+Shift+F8): caret movement extends the block.
+4
View File
@@ -276,6 +276,8 @@ impl Session {
ime_state_exhausted: _,
pending_ime_application_change: _,
ime_origin: _,
// A picked Drawing Canvas member is cleared by a switch, not kept per document.
member: _,
others: _,
order: _,
untitled_count: _,
@@ -376,6 +378,8 @@ impl Session {
swap(&mut self.widened, widened);
swap(&mut self.edit_marks, edit_marks);
swap(&mut self.go_back_step, go_back_step);
// A picked Drawing Canvas member (#2637) belongs to the document left behind.
self.member = None;
}
/// Make document `id` active without anything else a switch by the user does.
+1
View File
@@ -150,6 +150,7 @@ impl Session {
math_latex: false,
math_normal_text: false,
also_selected,
member: None,
prefs: Prefs::default(),
system_region: Default::default(),
keymap: crate::KeyMap::default(),
+13 -1
View File
@@ -185,7 +185,10 @@ impl Session {
let mutates = spec.mutates;
let ime_caret_before = self.current_ime_caret_state();
let rev_before = self.rev;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match crate::cmd::canvas::member_command(self, id, params) {
Some(r) => r,
None => crate::cmd::column::dispatch(self, id, mutates, run, params),
}));
let mut result = match result {
Ok(r) => r,
Err(p) => {
@@ -391,6 +394,15 @@ impl Session {
if !matches!(id, "select.addObject" | "arrange.ungroup" | "shape.fill" | "shape.effects") && (spec.mutates || self.sel != *sel_before) {
self.also_selected.clear();
}
// A canvas member stays picked while its canvas does and nothing else is selected.
if id != "select.member"
&& (self.sel != *sel_before
|| id.starts_with("select.")
|| id.starts_with("caret.")
|| matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo"))
{
self.member = None;
}
self.note_typing(id, before_doc.as_ref().map(|b| &b.0), sel_before);
}
+3
View File
@@ -23,6 +23,7 @@ impl Session {
self.pending = None;
self.typing = None;
self.also_selected.clear();
self.member = None;
self.column = None;
self.column_mode = false;
self.widened = None;
@@ -155,6 +156,7 @@ impl Session {
#[cfg(not(target_arch = "wasm32"))]
recovery_slot: _,
also_selected: _,
member: _,
// Column selection and a widened selection's ends, like `sel`'s shape: valid only while
// `sel` matches them.
column: _,
@@ -211,6 +213,7 @@ impl Session {
self.doc = doc;
self.sel = sel;
self.also_selected.clear();
self.member = None;
self.history.truncate(history_len);
self.redo = redo;
self.typing_open = typing_open;
+2
View File
@@ -141,6 +141,8 @@ impl Session {
self.page_hint = page_hint;
self.column_mode = column_mode;
self.math = math;
// A picked Drawing Canvas member (#2637) is not part of a view's saved selection.
self.member = None;
if current {
self.hf_return = hf_return;
self.typing_open = typing_open;
+230 -8
View File
@@ -4681,10 +4681,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 +4691,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 +4721,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.
@@ -7812,6 +7943,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.
+104
View File
@@ -1539,6 +1539,110 @@ fn typing_at_either_end_of_a_link_is_not_linked() {
assert_eq!(links(&s), [("sixte".into(), false), (String::new(), false)]);
}
#[test]
fn a_link_on_a_selection_shows_the_text_to_display() {
// Ctrl+K on selected text with a new Text to display: the selection is retyped with it and
// linked, keeping its formatting; typing after it stays outside the link (as in Word).
let mut s = typed("go see kiln now");
run(&mut s, "select.text", json!({"text": "kiln"}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"}));
typ(&mut s, "!");
assert_eq!(texts(&s), ["go see oven! now"]);
assert_eq!(links(&s), [("Oven".into(), false)]);
let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap();
assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Oven" && c.bold == Some(true)), "the new text keeps the selection's formatting");
// The same text, or text that can't stand in one line, leaves the selection as it is.
run(&mut s, "select.text", json!({"text": "see"}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "see"}));
run(&mut s, "select.text", json!({"text": "now"}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "two\nlines"}));
assert_eq!(texts(&s), ["go see oven! now"]);
}
#[test]
fn a_new_selection_drops_the_formatting_left_after_a_link() {
// A link leaves plain formatting for what is typed after it. A selection made next doesn't
// take it: retyping that selection keeps its own formatting.
let bold_run = |s: &Session, text: &str| {
let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap();
p.run_ranges().any(|(r, c)| &p.text[r] == text && c.bold == Some(true))
};
let mut s = typed("kiln");
run(&mut s, "select.text", json!({"text": "kiln"}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"}));
run(&mut s, "select.all", json!({}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Stove"}));
assert!(bold_run(&s, "Stove"), "select.all");
run(&mut s, "select.word", json!({}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Range"}));
assert!(bold_run(&s, "Range"), "select.word");
let anchor = serde_json::to_value(s.doc.start_of(s.sel.focus.story)).unwrap();
let focus = serde_json::to_value(s.sel.focus.clone()).unwrap();
run(&mut s, "select.range", json!({"anchor": anchor, "focus": focus}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Hob"}));
assert!(bold_run(&s, "Hob"), "select.range");
}
#[test]
fn a_link_over_two_paragraphs_keeps_their_text() {
// Word shows <<Selection in Document>> for such a selection: the text to display is ignored.
let mut s = typed("one\ntwo");
run(&mut s, "select.all", json!({}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "both"}));
assert_eq!(texts(&s), ["one", "two"]);
assert_eq!(links(&s), [("One".into(), false), ("two".into(), false)]);
}
#[test]
fn a_retyped_link_text_is_tracked() {
let mut s = typed("go see kiln now");
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "select.text", json!({"text": "kiln"}));
run(&mut s, "insert.link", json!({"url": "https://example.org/", "text": "Oven"}));
let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap();
assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "kiln" && c.del.is_some()), "the old text stays as a deletion");
assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Oven" && c.ins.is_some() && c.link.is_some()));
}
#[test]
fn a_link_keeps_a_selection_it_cannot_retype() {
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{InlineObject, Path};
let url = "https://example.org/";
// A second retyped link keeps its own selection's formatting: the first one's left no
// formatting pending for it.
let mut s = typed("go see kiln and vat now");
run(&mut s, "select.text", json!({"text": "kiln"}));
run(&mut s, "insert.link", json!({"url": url, "text": "Oven"}));
run(&mut s, "select.text", json!({"text": "vat"}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "insert.link", json!({"url": url, "text": "Tub"}));
let p = s.doc.body.iter().find_map(|b| b.as_para()).unwrap();
assert!(p.run_ranges().any(|(r, c)| &p.text[r] == "Tub" && c.bold == Some(true) && c.link.is_some()), "{:?}", p.text);
// A selection holding a manual line break keeps both lines.
let mut s = typed("one");
run(&mut s, "text.lineBreak", json!({}));
typ(&mut s, "two");
run(&mut s, "select.all", json!({}));
run(&mut s, "insert.link", json!({"url": url, "text": "both"}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "one\ntwo");
// A selection in a field's result is only linked; the field's next update owns that text.
let mut doc = wordcraft_doc::Document::from_text("See ");
let c = CharProps::default();
let p = doc.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
let start = p.len();
p.insert_object(start, InlineObject::FieldStart { instr: "ADDIN ZOTERO_ITEM CSL_CITATION {}".into(), locked: false, dirty: false }, &c).unwrap();
let n = p.insert_text(p.len(), "(Smith 2020)", &c).unwrap();
p.insert_object(start + 3 + n, InlineObject::FieldEnd, &c).unwrap();
let mut s = Session::new(doc);
run(&mut s, "select.text", json!({"text": "Smith"}));
run(&mut s, "insert.link", json!({"url": url, "text": "Jones"}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "See (Smith 2020)");
assert_eq!(s.doc.field_ranges(StoryRef::Body).len(), 1);
}
#[test]
fn typing_then_selecting_then_typing_are_two_undo_steps() {
// #419: a selection that moves the insertion point ends the typing group, like a caret move.
+65 -7
View File
@@ -3,6 +3,10 @@
/// Decode an entity body (`amp`, `#38`, `#x26`) to its text.
pub fn decode_entity(name: &str) -> Option<String> {
decode_char(name).map(String::from)
}
fn decode_char(name: &str) -> Option<char> {
if let Some(num) = name.strip_prefix('#') {
let (digits, radix) = match num.strip_prefix(['x', 'X']) {
Some(h) => (h, 16),
@@ -17,7 +21,7 @@ pub fn decode_entity(name: &str) -> Option<String> {
0x80..=0x9F => crate::txt::cp1252(v as u8),
_ => char::from_u32(v).unwrap_or('\u{FFFD}'),
};
return Some(c.to_string());
return Some(c);
}
let c = match name {
"amp" | "AMP" => '&',
@@ -229,11 +233,54 @@ pub fn decode_entity(name: &str) -> Option<String> {
"zwnj" => '\u{200C}',
_ => return None,
};
Some(c.to_string())
Some(c)
}
/// Replace entity references in `s`.
/// Legacy names HTML5 also decodes without a `;` (only those [`decode_char`] knows), sorted by bytes.
const BARE_NAMES: &[&str] = &[
"AElig", "AMP", "Aacute", "Acirc", "Agrave", "Aring", "Atilde", "Auml", "COPY", "Ccedil", "Eacute", "Ecirc", "Egrave", "Euml", "GT", "Iacute",
"Icirc", "Igrave", "Iuml", "LT", "Ntilde", "Oacute", "Ocirc", "Ograve", "Oslash", "Otilde", "Ouml", "QUOT", "REG", "Uacute", "Ucirc", "Ugrave",
"Uuml", "Yacute", "aacute", "acirc", "aelig", "agrave", "amp", "aring", "atilde", "auml", "ccedil", "cent", "copy", "curren", "deg", "divide",
"eacute", "ecirc", "egrave", "euml", "frac12", "frac14", "frac34", "gt", "iacute", "icirc", "iexcl", "igrave", "iquest", "iuml", "laquo", "lt",
"micro", "middot", "nbsp", "ntilde", "oacute", "ocirc", "ograve", "oslash", "otilde", "ouml", "para", "plusmn", "pound", "quot", "raquo", "reg",
"sect", "shy", "sup1", "sup2", "sup3", "szlig", "times", "uacute", "ucirc", "ugrave", "uuml", "yacute", "yen", "yuml",
];
/// A reference written without its `;` at the start of `after` (the text after `&`): a numeric one,
/// or the longest legacy name. In attribute values a name followed by `=` or an alphanumeric stays text.
fn bare_reference(after: &str, attr: bool) -> Option<(char, usize)> {
if let Some(num) = after.strip_prefix('#') {
let hex = num.starts_with(['x', 'X']);
let radix = if hex { 16 } else { 10 };
let skip = usize::from(hex);
let n = num.get(skip..)?.bytes().take_while(|b| char::from(*b).is_digit(radix)).count();
if n == 0 {
return None;
}
let len = 1 + skip + n;
return decode_char(after.get(..len)?).map(|c| (c, len));
}
// Legacy names are 2 to 6 ASCII alphanumerics: try the longest prefix first.
let run = after.bytes().take(6).take_while(u8::is_ascii_alphanumeric).count();
let len = (2..=run).rev().find(|&l| after.get(..l).is_some_and(|p| BARE_NAMES.binary_search(&p).is_ok()))?;
let next = after.as_bytes().get(len);
if attr && next.is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'=') {
return None;
}
decode_char(after.get(..len)?).map(|c| (c, len))
}
/// Replace entity references in text `s`.
pub fn unescape(s: &str) -> String {
unescape_in(s, false)
}
/// Replace entity references in an attribute value `s`.
pub fn unescape_attr(s: &str) -> String {
unescape_in(s, true)
}
fn unescape_in(s: &str, attr: bool) -> String {
if !s.contains('&') {
return s.to_string();
}
@@ -248,10 +295,10 @@ pub fn unescape(s: &str) -> String {
cut -= 1;
}
let end = after.get(..cut).and_then(|w| w.find(';'));
match end.and_then(|e| decode_entity(&after[..e]).map(|t| (t, e))) {
Some((t, e)) => {
out.push_str(&t);
rest = &after[e + 1..];
match end.and_then(|e| decode_char(&after[..e]).map(|c| (c, e + 1))).or_else(|| bare_reference(after, attr)) {
Some((c, e)) => {
out.push(c);
rest = &after[e..];
}
None => {
out.push('&');
@@ -310,3 +357,14 @@ pub fn decode_as(b: &[u8], charset: Option<&str>) -> String {
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_names_are_sorted_and_known() {
assert!(BARE_NAMES.windows(2).all(|w| w[0] < w[1]));
assert!(BARE_NAMES.iter().all(|n| (2..=6).contains(&n.len()) && decode_char(n).is_some()));
}
}
+3 -3
View File
@@ -1,4 +1,4 @@
use super::entity::unescape;
use super::entity::{unescape, unescape_attr};
// ---------------------------------------------------------------------------------------------
// Tokenizer
@@ -154,7 +154,7 @@ impl Lexer<'_> {
Some(&q) if q == b'"' || q == b'\'' => {
let v0 = i + 1;
let e = rest.get(v0..).and_then(|r| r.find(q as char)).map(|k| v0 + k).unwrap_or(rest.len());
val = unescape(rest.get(v0..e).unwrap_or(""));
val = unescape_attr(rest.get(v0..e).unwrap_or(""));
i = e + 1;
}
_ => {
@@ -162,7 +162,7 @@ impl Lexer<'_> {
while b.get(i).is_some_and(|c| !c.is_ascii_whitespace() && *c != b'>') {
i += 1;
}
val = unescape(rest.get(v0..i).unwrap_or(""));
val = unescape_attr(rest.get(v0..i).unwrap_or(""));
}
}
}
+15
View File
@@ -232,6 +232,21 @@ fn entities() {
assert_eq!(unescape("&theta; &lambda; &Delta; &nabla; &sum; &radic; &ne; &le; &ge; &hearts;"), "θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥");
}
/// Browsers decode numeric and legacy named references without their `;` in text,
/// but keep `&name=` and `&nameX` intact inside attribute values (query strings).
#[test]
fn references_without_semicolon() {
assert_eq!(unescape("&copy 2024 Acme"), "© 2024 Acme");
assert_eq!(unescape("&#169 2024 Acme"), "© 2024 Acme");
assert_eq!(unescape("&#xA9 2024 &amp &lt"), "© 2024 & <");
assert_eq!(unescape("&bogus &# &#x &"), "&bogus &# &#x &");
assert_eq!(unescape("&nbsp;x"), "\u{00A0}x");
let f = parse("<p><a href=\"/q?a=1&copy=2&reg2=3&amp;b=&lt\">l</a> &copy 2024</p>");
let FBlock::Para(p) = &f.blocks[0] else { panic!() };
assert!(p.inlines.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "l" && f.link.as_deref() == Some("/q?a=1&copy=2&reg2=3&b=<"))));
assert!(p.text().ends_with("© 2024"));
}
#[test]
fn wbr_word_break_opportunity() {
let f = parse("<p>Super<wbr>cali<wbr>fragilistic</p>");
+14 -2
View File
@@ -330,6 +330,18 @@ pub fn export(doc: &Document) -> String {
s
}
/// A heading's text on one line, with a trailing run of '#' escaped so a reader does not take it
/// for the optional closing sequence (`## Item #` reads back as "Item").
fn heading_text(body: &str) -> String {
let mut s = body.replace("\\\n", " ");
let t = s.trim_end();
let core = t.trim_end_matches('#');
if core.len() != t.len() && (core.is_empty() || core.ends_with([' ', '\t'])) {
s.insert(core.len(), '\\');
}
s
}
pub(super) fn write_blocks(blocks: &[FBlock], out: &mut Vec<String>) {
let mut i = 0;
let mut counters = [0u32; 9];
@@ -410,8 +422,8 @@ pub(super) fn write_blocks(blocks: &[FBlock], out: &mut Vec<String>) {
None => {
prev_list = false;
let line = match p.kind {
Kind::Heading(n) => format!("{} {}", "#".repeat(n.clamp(1, 6) as usize), body.replace("\\\n", " ")),
Kind::Title => format!("# {}", body.replace("\\\n", " ")),
Kind::Heading(n) => format!("{} {}", "#".repeat(n.clamp(1, 6) as usize), heading_text(&body)),
Kind::Title => format!("# {}", heading_text(&body)),
Kind::Rule => "---".to_string(),
Kind::Quote => {
let b = escape_lines(body);
+22
View File
@@ -775,3 +775,25 @@ fn exported_link_destinations_read_back_unchanged() {
assert_eq!(md("https://example.com/a b"), "[x](<https://example.com/a b>)");
assert_eq!(md("https://e.com/x\ny"), "[x](https://e.com/xy)");
}
#[test]
fn heading_trailing_hash_survives_round_trip() {
let heading = |md: &str| {
let f = parse(md);
let Some(FBlock::Para(p)) = f.blocks.first() else { panic!("no paragraph in {md:?}") };
assert_eq!(p.kind, Kind::Heading(2), "{md:?}");
p.text()
};
for (src, text, want) in [
("## Item \\#\n", "Item #", "## Item \\#\n"),
("## Issue \\##\n", "Issue ##", "## Issue \\##\n"),
("## \\#\n", "#", "## \\#\n"),
("## C#\n", "C#", "## C#\n"),
] {
assert_eq!(heading(src), text, "{src:?}");
let md = export(&import(src));
assert_eq!(md, want, "first export of {src:?}");
assert_eq!(heading(&md), text, "md was {md:?}");
assert_eq!(export(&import(&md)), want, "second export of {src:?}");
}
}
+1
View File
@@ -1122,6 +1122,7 @@ fn flow_table(doc: &Document, t: &Table, depth: usize, tracked: FlowOpts) -> FTa
width: t.props.width,
width_pct: t.props.width_pct,
heights,
keep_rows: t.rows.iter().map(|r| r.props.cant_split).collect(),
fixed: t.props.fixed,
space: [t.props.space_before, t.props.space_after],
header_rows: t.rows.iter().take_while(|r| r.props.header).count(),
+1
View File
@@ -844,6 +844,7 @@ impl<'a> Builder<'a> {
row.props.height = Some(h.min(1584.0));
row.props.height_rule = if exact { wordcraft_doc::props::HeightRule::Exact } else { wordcraft_doc::props::HeightRule::AtLeast };
}
row.props.cant_split = t.extra.as_ref().is_some_and(|x| x.keep_rows.get(ri) == Some(&true));
let mut g = 0usize;
for c in r.iter().take(wordcraft_doc::table::MAX_COLS) {
let span = (c.colspan.clamp(1, 63) as usize).min(cols.saturating_sub(g).max(1));
+2
View File
@@ -943,6 +943,8 @@ pub struct TableExtra {
pub width_pct: Option<f32>,
/// Each row's height (points) and whether it is exact (else at least), by row.
pub heights: Vec<Option<(f32, bool)>>,
/// Whether each row keeps from breaking across pages, by row.
pub keep_rows: Vec<bool>,
/// Its columns keep their widths, never fitted to their content (Word's fixed layout,
/// `w:tblLayout`): an ODT table's always do.
pub fixed: bool,
+58 -9
View File
@@ -79,6 +79,10 @@ pub(super) struct Body<'a> {
/// drawings were waiting before it.
loose_open: Option<(usize, usize, usize)>,
fmt: Vec<Fmt>,
/// For each paragraph being read: the size its heading, title or code style gives its text.
para_sizes: Vec<(Option<f32>, bool)>,
/// That size for the paragraph being finished.
para_size: Option<f32>,
para: Option<Para>,
/// The text read last ended in a space collapsed from XML whitespace (not a `text:s`).
xml_space_end: bool,
@@ -128,6 +132,9 @@ pub(super) struct Body<'a> {
paras_done: usize,
/// `(width, height, picture bytes, alt text, equation read from an embedded `draw:object`)`.
frame: Option<Frame>,
/// The first picture the open frame offers that WordCraft can't show: an object coming after it
/// keeps it as its replacement.
frame_unread: Option<Vec<u8>>,
in_desc: bool,
/// The master pages' page setups.
pages: &'a Pages,
@@ -237,6 +244,8 @@ impl<'a> Body<'a> {
xml_space_end: false,
spaces_left: MAX_EXTRA_SPACES,
para_cont: false,
para_sizes: Vec::new(),
para_size: None,
lists: Vec::new(),
lists_saved: Vec::new(),
item_used: Vec::new(),
@@ -259,6 +268,7 @@ impl<'a> Body<'a> {
pending_master: None,
paras_done: 0,
frame: None,
frame_unread: None,
in_desc: false,
pages,
sec: page.clone(),
@@ -952,6 +962,8 @@ impl Body<'_> {
} else {
self.para_cont = false;
}
let kind_size = matches!(kind, Kind::Code | Kind::Heading(_) | Kind::Title).then(|| style.and_then(|s| self.styles.named_size(s))).flatten();
self.para_sizes.push((kind_size, style.is_some_and(|s| self.styles.states_size(s))));
self.fmt.push(base);
// Drawings waiting outside a paragraph come first in it, when it is the text's own (not
// a text box's, a note's or a cell's).
@@ -970,6 +982,7 @@ impl Body<'_> {
self.index_marks.clear();
let Some(mut p) = self.para.take() else { return };
self.glue_connectors(&mut p);
self.para_size = self.para_sizes.pop().and_then(|(size, stated)| size.filter(|_| stated));
// A paragraph without text has its own text formatting as its mark's: its size is its
// height, and its drawings' runs have it.
if let Some(base) = self.fmt.pop()
@@ -1020,13 +1033,18 @@ impl Body<'_> {
p.kind = Kind::Normal;
}
if matches!(p.kind, Kind::Code | Kind::Heading(_) | Kind::Title) {
// The style says the size of its text, stated again or not: text of another size keeps
// its own.
let style_size = self.para_size;
for i in &mut p.inlines {
if let Inline::Text(_, f) = i {
if p.kind == Kind::Code {
f.code = false;
f.font = None;
}
f.size = None;
if f.size == style_size {
f.size = None;
}
}
}
}
@@ -1323,6 +1341,7 @@ impl Body<'_> {
let w = get(a, "svg:width").and_then(length);
let h = get(a, "svg:height").and_then(length);
self.frame = Some((w, h, None, String::new(), None, None, None));
self.frame_unread = None;
let g = get(a, "draw:style-name").map(|s| self.styles.graphic(s)).unwrap_or_default();
self.frame_float = Some(super::frames::place(a, w.unwrap_or(0.0), h.unwrap_or(0.0), &g, self.text_size()));
self.member_start(a, w.unwrap_or(0.0), h.unwrap_or(0.0), None);
@@ -1372,19 +1391,23 @@ impl Body<'_> {
// LibreOffice's tables keep their columns' widths, whatever their content (and however
// far past the margins): Word's fixed layout.
let space = get(a, "table:style-name").map(|s| self.styles.table_space(s)).unwrap_or_default();
let extra = model::TableExtra { align, width: None, width_pct, heights: Vec::new(), fixed: true, space, header_rows: 0 };
let extra =
model::TableExtra { align, width: None, width_pct, heights: Vec::new(), keep_rows: Vec::new(), fixed: true, space, header_rows: 0 };
self.tables.push(TableB { rows: Vec::new(), row: Vec::new(), hcover: 0, widths: Vec::new(), extra });
}
/// A table row (`table:table-row`) begins: its height, kept as rows are (see the row's end).
fn start_row(&mut self, a: &Attrs) {
let height = get(a, "table:style-name").and_then(|s| self.styles.row_height(s));
let keep = self.styles.row_keep(get(a, "table:style-name"));
if let Some(t) = self.tables.last_mut() {
t.row.clear();
t.hcover = 0;
if t.extra.heights.len() < wordcraft_doc::table::MAX_ROWS {
t.extra.heights.truncate(t.rows.len());
t.extra.heights.push(height);
t.extra.keep_rows.truncate(t.rows.len());
t.extra.keep_rows.push(keep);
}
}
}
@@ -1429,12 +1452,25 @@ impl Body<'_> {
&& fr.2.is_none()
&& fr.4.is_none()
&& fr.5.is_none()
&& fr.6.as_ref().is_none_or(|o| o.replacement.is_none())
{
let path = href.trim_start_matches("./");
fr.2 = (self.zip)(path);
if let Some(o) = &mut fr.6 {
o.replacement = fr.2.clone().map(std::sync::Arc::new);
// A frame may offer one picture in several formats, in order of preference
// (LibreOffice writes its own `.svm` metafile before a PNG): the first
// WordCraft can read. An object's replacement is the first, kept whatever it
// is, also when the object is still to come.
if let Some(d) = (self.zip)(path) {
let readable = drawing::readable_picture(&d);
match &mut fr.6 {
Some(o) if o.replacement.is_none() => o.replacement = Some(std::sync::Arc::new(d.clone())),
Some(_) => {}
None if !readable => {
self.frame_unread.get_or_insert_with(|| d.clone());
}
None => {}
}
if readable {
fr.2 = Some(d);
}
}
}
}
@@ -1478,9 +1514,9 @@ impl Body<'_> {
// A chart is kept whole too, to be written back as it was while it is unchanged.
if fr.4.is_none() {
fr.6 = super::objects::gather(href, ole, self.entries, self.manifest, &mut *self.zip);
// A replacement picture read before the object.
// A replacement picture read before the object: the first the frame offered.
if let Some(o) = &mut fr.6 {
o.replacement = fr.2.clone().map(std::sync::Arc::new);
o.replacement = self.frame_unread.take().or_else(|| fr.2.clone()).map(std::sync::Arc::new);
}
}
}
@@ -1526,7 +1562,20 @@ impl Body<'_> {
match local(q) {
"span" => {
let base = self.cur_fmt();
let fmt = get(a, "text:style-name").map_or_else(|| base.clone(), |s| self.styles.span_fmt(s, &base));
// A size in percent is of the size it is in: its heading's, where nothing nearer
// states one.
let sized = Fmt { size: base.size.or(self.para_sizes.last().and_then(|(size, _)| *size)), ..base.clone() };
let fmt = get(a, "text:style-name").map_or_else(
|| base.clone(),
|s| {
let mut fmt = self.styles.span_fmt(s, &sized);
// That size is only for working out a percent: saying nothing of size, it has none.
if base.size.is_none() && self.styles.span_fmt(s, &base).size.is_none() {
fmt.size = None;
}
fmt
},
);
self.fmt.push(fmt);
}
"a" => {
+5
View File
@@ -385,6 +385,11 @@ impl Body<'_> {
}
}
/// Whether WordCraft can show these bytes as a picture (see the function below).
pub(in crate::odt) fn readable_picture(data: &[u8]) -> bool {
wordcraft_metafile::is_metafile(data) || model::sniff_image(data).is_some()
}
/// A picture from a package's bytes: a raster image, or a Windows metafile (WMF, EMF: LibreOffice
/// keeps clip art and logos so), sized `w` × `h` (points) when the frame says.
pub(in crate::odt) fn picture(data: Vec<u8>, w: Option<f32>, h: Option<f32>, alt: &str) -> Option<model::Img> {
+5 -1
View File
@@ -87,7 +87,11 @@ impl TableB {
let stated = rows.iter().flatten().filter(|c| !c.covered).all(|c| c.borders.is_some());
let borderless = stated && rows.iter().flatten().all(|c| !c.borders.is_some_and(|b| b.any_visible()));
let extra = self.extra;
let kept = extra.fixed || extra.align.is_some() || extra.width_pct.is_some() || extra.heights.iter().any(Option::is_some);
let kept = extra.fixed
|| extra.align.is_some()
|| extra.width_pct.is_some()
|| extra.heights.iter().any(Option::is_some)
|| extra.keep_rows.contains(&true);
FTable { rows, widths, borderless, extra: kept.then(|| Box::new(extra)), kept: None, wrap: None }
}
}
+42 -8
View File
@@ -27,6 +27,7 @@ pub(super) fn defs(xml: &[u8], content: bool) -> Vec<(String, Arc<str>)> {
let mut path: Vec<String> = Vec::new();
let mut open: Option<(String, usize, usize)> = None;
let mut before_1_3 = false;
let mut prefixes = std::collections::BTreeSet::new();
events_at(xml, |ev, at| match ev {
Ev::Start(q, a) => {
path.push(q.clone());
@@ -39,11 +40,23 @@ pub(super) fn defs(xml: &[u8], content: bool) -> Vec<(String, Arc<str>)> {
if let Some(p) = k.strip_prefix("xmlns:").filter(|p| !p.is_empty() && p.len() <= 64)
&& out.len() < MAX_DEFS
{
prefixes.insert(p.to_string());
out.push((format!("ns:{p}"), Arc::from(v.as_str())));
}
}
return;
}
// Those a part's container declares too: what is kept of it leaves the container behind.
if depth == 2 {
for (k, v) in &a {
if let Some(p) = k.strip_prefix("xmlns:").filter(|p| !p.is_empty() && p.len() <= 64)
&& out.len() < MAX_DEFS
&& prefixes.insert(p.to_string())
{
out.push((format!("ns:{p}"), Arc::from(v.as_str())));
}
}
}
// A reference mark (§6.6.2), which `text:reference-ref` names: WordCraft reads it as a
// bookmark, and writes it as both.
if content
@@ -183,20 +196,41 @@ fn space(q: &str, family: &str) -> String {
pub(super) fn defined(xml: &str, def: &str) -> bool {
let head = def.split('>').next().unwrap_or("");
let el = head.trim_start_matches('<').split([' ', '/', '\t', '\n', '\r']).next().unwrap_or("");
let name = ["style:name=\"", "draw:name=\""].into_iter().find_map(|a| {
let at = head.find(a)? + a.len();
let len = head.get(at..)?.find('"')?;
head.get(at - a.len()..at + len + 1)
});
let (Some(name), false) = (name, el.is_empty()) else { return false };
let name = ["style:name", "draw:name"].into_iter().find_map(|a| Some((a, raw_attr(head, a)?)));
let (Some((attr, name)), false) = (name, el.is_empty()) else { return false };
let open = format!("<{el} ");
xml.match_indices(&open).any(|(i, _)| xml.get(i..).and_then(|t| t.split('>').next()).is_some_and(|tag| tag.contains(name)))
xml.match_indices(&open).any(|(i, _)| {
xml.get(i..).and_then(|t| t.split('>').next()).is_some_and(|tag| raw_attr(tag, attr).is_some_and(|n| attr_text(n) == attr_text(name)))
})
}
/// An attribute's value as it reads, however XML spells it (`O'Brien`, `O&apos;Brien`).
pub(super) fn attr_text(raw: &str) -> std::borrow::Cow<'_, str> {
quick_xml::escape::unescape(raw).unwrap_or(std::borrow::Cow::Borrowed(raw))
}
/// Attribute `name`'s value as `tag` (a start tag) writes it, spaced and quoted however XML allows.
pub(super) fn raw_attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> {
let past_space = |at: usize| Some(at + tag.get(at..)?.find(|c: char| !c.is_ascii_whitespace())?);
let mut at = tag.find(|c: char| c.is_ascii_whitespace())?;
loop {
let name_at = past_space(at)?;
let eq = name_at + tag.get(name_at..)?.find('=')?;
let value_at = past_space(eq + 1)?;
let rest = tag.get(value_at..)?;
let quote = rest.chars().next().filter(|c| matches!(c, '"' | '\''))?;
let len = rest.get(1..)?.find(quote)?;
if tag.get(name_at..eq)?.trim_end() == name {
return rest.get(1..1 + len);
}
at = value_at + 1 + len + 1;
}
}
/// The children of `container` (a child of the root) in `xml` (a part WordCraft wrote): each
/// one's key in `group` (as [`defs`] gives it) and its markup's byte range; and the range of
/// the container's content (`None` when there is no such container).
fn children(xml: &str, container: &str, group: &str) -> (Vec<(String, (usize, usize))>, Option<(usize, usize)>) {
pub(super) fn children(xml: &str, container: &str, group: &str) -> (Vec<(String, (usize, usize))>, Option<(usize, usize)>) {
let mut out = Vec::new();
let (mut depth, mut inside) = (0usize, false);
let mut open: Option<(String, usize)> = None;
+53
View File
@@ -900,6 +900,11 @@ fn with_kept_defs(
};
add(&mut content, "</office:automatic-styles>", auto);
add(&mut styles, "</office:styles>", common);
// A font the file declares is declared as the file does: a reader without the font picks its
// substitute by this, and a different one would reflow every page.
let faces: Vec<&str> = defs.iter().filter(|(k, _)| k.starts_with("font:")).map(|(_, d)| &**d).collect();
with_file_faces(&mut content, &faces);
with_file_faces(&mut styles, &faces);
for (xml, before) in [(&mut content, "<office:automatic-styles"), (&mut styles, "<office:styles")] {
// No declarations yet: the element they go in, where it belongs (before the styles).
if !fonts.is_empty()
@@ -913,6 +918,54 @@ fn with_kept_defs(
(content, styles)
}
/// `xml` (a part WordCraft wrote) with each font it declares that `faces` declare too (the file's
/// own `style:font-face` elements) as `faces` has it: under that name, or else of that family (a
/// face named `F1` of the family `Mystery`, which WordCraft names by its family), then keeping
/// WordCraft's name for it.
fn with_file_faces(xml: &mut String, faces: &[&str]) {
use super::kept::{attr_text, raw_attr};
let mut named = std::collections::HashMap::new();
let mut of_family = std::collections::HashMap::new();
for face in faces {
let Some(head) = face.split('>').next() else { continue };
if let Some(name) = raw_attr(head, "style:name") {
named.entry(attr_text(name).into_owned()).or_insert(*face);
}
if let Some(family) = raw_attr(head, "svg:font-family") {
let family = attr_text(family);
let first = family.split(',').next().unwrap_or("").trim().trim_matches(['\'', '"']);
if let Some(font) = model::kept_font(first) {
of_family.entry(font).or_insert(*face);
}
}
}
// WordCraft's own declarations are empty elements.
let (kids, _) = super::kept::children(xml, "office:font-face-decls", "font");
let mut out = String::with_capacity(xml.len());
let mut done = 0;
for (_, (from, to)) in kids {
let (Some(before), Some(tag)) = (xml.get(done..from), xml.get(from..to)) else { continue };
out.push_str(before);
let name = raw_attr(tag, "style:name").map(attr_text);
let same = name.as_deref().and_then(|n| named.get(n)).map(|face| face.to_string());
let family = || name.as_deref().and_then(|n| of_family.get(n).and_then(|face| face_named(face, n)));
let empty = tag.find('>').is_some_and(|end| end + 1 == tag.len());
out.push_str(same.or_else(family).filter(|_| empty).as_deref().unwrap_or(tag));
done = to;
}
out.push_str(xml.get(done..).unwrap_or(""));
*xml = out;
}
/// `face` (a `style:font-face` element) with the name `name`.
fn face_named(face: &str, name: &str) -> Option<String> {
let head = face.split('>').next()?;
let was = super::kept::raw_attr(head, "style:name")?;
// `was` is a piece of `head`, which starts `face`.
let at = (was.as_ptr() as usize).checked_sub(head.as_ptr() as usize)?;
Some(format!("{}{}{}", face.get(..at)?, x(name), face.get(at + was.len()..)?))
}
/// An OpenDocument text document's media type, and a template's (ODF 1.3 Part 2 §3.3, Appendix C).
pub const TEXT_MIME: &str = "application/vnd.oasis.opendocument.text";
pub const TEMPLATE_MIME: &str = "application/vnd.oasis.opendocument.text-template";
+41 -1
View File
@@ -252,6 +252,8 @@ struct StyleInfo {
/// A table-row style's `style:table-row-properties`: its height (`style:row-height`, exact)
/// or least height (`style:min-row-height`), points.
row_height: Option<(f32, bool)>,
/// `fo:keep-together` of a table-row style: the row doesn't break across pages.
row_keep: Option<bool>,
/// `style:master-page-name` of a paragraph or table style: its paragraphs (tables) start a
/// page of that master page. Empty: explicitly none, which stops the lookup up the chain.
master_page: Option<String>,
@@ -623,6 +625,9 @@ fn style_child(name: &str, q: &str, a: &Attrs, s: &mut StyleInfo) -> bool {
} else if let Some(h) = get(a, "style:min-row-height").and_then(length).filter(|h| h.is_finite() && *h > 0.0) {
s.row_height = Some((h.min(MAX_LEN), false));
}
if let Some(k) = get(a, "fo:keep-together") {
s.row_keep = Some(k.trim() == "always");
}
}
"table-column-properties" => {
if let Some(w) = get(a, "style:column-width").and_then(length) {
@@ -1043,6 +1048,8 @@ pub(super) struct Styles {
default_color: Option<wordcraft_doc::props::Rgb>,
/// `style:default-style` for graphics: what every drawing shape's style falls back on.
default_graphic: super::frames::Graphic,
/// `style:default-style` for table rows: `fo:keep-together`, for a row whose style doesn't say.
default_row_keep: Option<bool>,
/// The named dashes and gradients graphic styles use.
pub(super) defs: super::strokes::Defs,
/// Date and time styles (`number:date-style`, `number:time-style`) as Word pictures.
@@ -1055,6 +1062,7 @@ impl Styles {
let mut cur: Option<(String, StyleInfo)> = None;
let mut in_default = false;
let mut in_graphic_default = false;
let mut in_row_default = false;
let mut dates = DateStyles::default();
let mut list: Option<(String, Vec<ListLevel>)> = None;
// The list or outline level being read.
@@ -1083,8 +1091,12 @@ impl Styles {
"default-style" if q.starts_with("style:") => {
in_default = get(&a, "style:family") == Some("paragraph");
in_graphic_default = get(&a, "style:family") == Some("graphic");
in_row_default = get(&a, "style:family") == Some("table-row");
}
"graphic-properties" if in_graphic_default && cur.is_none() => self.default_graphic = super::frames::Graphic::read(&a),
"table-row-properties" if in_row_default && cur.is_none() => {
self.default_row_keep = get(&a, "fo:keep-together").map(|k| k.trim() == "always")
}
"text-properties" if in_default && cur.is_none() => {
self.default_font = default_font(&a, &self.faces);
if let Some(sz) = get(&a, "fo:font-size").and_then(length).filter(|v| v.is_finite() && *v >= 1.0) {
@@ -1151,7 +1163,7 @@ impl Styles {
},
Ev::End(q) => match local(&q) {
"automatic-styles" => in_auto = false,
"default-style" if q.starts_with("style:") => (in_default, in_graphic_default) = (false, false),
"default-style" if q.starts_with("style:") => (in_default, in_graphic_default, in_row_default) = (false, false, false),
"style" if q.starts_with("style:") => {
if let Some((n, s)) = cur.take()
&& self.map.len() < 100_000
@@ -1263,6 +1275,24 @@ impl Styles {
/// The formatting of a span in text style `name` over `base`: the automatic styles' over the
/// nearest custom character style, which the span then names ([`Fmt::extra`]'s style, by ODF
/// name until import makes it a document style id); without one, the whole chain's.
/// The size of text in paragraph style `name` by its named styles, not its automatic ones,
/// which are the paragraph's own: what they say on what the document says.
pub(super) fn named_size(&self, name: &str) -> Option<f32> {
// The default style's: Standard's counts only for a style based on it, in the chain.
let size = self.default_size.filter(|v| v.is_finite() && *v >= 1.0).map(|v| v.min(1638.0));
let mut f = Fmt { size, ..Fmt::default() };
for (_, s) in self.chain(name).iter().rev().filter(|(_, s)| !s.auto) {
s.fmt.apply(&mut f);
}
f.size
}
/// Whether paragraph style `name`'s named styles state a size. Those that don't leave the size
/// to WordCraft's style for its kind, so text of any size isn't known to be the style's.
pub(super) fn states_size(&self, name: &str) -> bool {
self.chain(name).iter().any(|(_, s)| !s.auto && (s.fmt.size.is_some() || s.fmt.size_pct.is_some()))
}
pub(super) fn span_fmt(&self, name: &str, base: &Fmt) -> Fmt {
let chain = self.chain(name);
let Some(at) = chain.iter().position(|(n, _)| self.is_named_text(n)) else { return self.text_fmt(name, base) };
@@ -1337,6 +1367,12 @@ impl Styles {
self.chain(name).iter().find_map(|(_, s)| s.row_height)
}
/// Whether a table row doesn't break across pages: its style says (whole chain), else the
/// default style.
pub(super) fn row_keep(&self, name: Option<&str>) -> bool {
name.and_then(|n| self.chain(n).iter().find_map(|(_, s)| s.row_keep)).or(self.default_row_keep).unwrap_or(false)
}
/// Background colour of a table-cell style (whole chain).
/// A table-cell style's background picture: its package path, repeat and position.
pub(super) fn cell_picture(&self, name: &str) -> Option<(String, String, String)> {
@@ -1462,6 +1498,10 @@ impl Styles {
};
let mut fmt = Fmt::default();
s.fmt.apply(&mut fmt);
// A percent is of what's under it: the parent's size, or with none the default style's.
if s.fmt.size_pct.is_some() {
fmt.size = self.named_size(odt);
}
// A style with no parent inherits the default style, not Standard (the document's
// defaults here): its size, when Standard's differs.
if s.parent.is_none() && fmt.size.is_none() && self.doc_size() != self.default_size {
+123
View File
@@ -284,3 +284,126 @@ fn objects_in_hidden_text_are_kept() {
assert!(p.text.contains("Hidden"), "{:?}", p.text);
assert!(p.objects.iter().any(|o| matches!(o, wordcraft_doc::para::InlineObject::BookmarkStart { name } if name == "Secret")), "{:?}", p.objects);
}
#[test]
fn a_headings_text_of_another_size_keeps_it() {
// Only the size its style gives goes with the style: a smaller word in a heading came back
// from a save at the heading's size.
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:default-outline-level="1"><style:text-properties fo:font-size="16pt"/></style:style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="10pt"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="Heading_20_1" text:outline-level="1">big<text:span text:style-name="T1">small</text:span></text:h></office:text></office:body></office:document-content>"#
);
let check = |d: &Document, what: &str| {
let p = d.body[0].as_para().unwrap();
let size = |run: usize| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[run].props).size;
assert_eq!((size(0), size(1)), (16.0, 10.0), "{what}");
};
let d = import(&package(&content, Some(&styles))).unwrap();
check(&d, "read");
assert_eq!(d.body[0].as_para().unwrap().runs[0].props.size, None, "the style's stays the style's");
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "written from the model");
}
/// In a heading style with no parent, where Standard's size and the default's differ, text stating
/// a size keeps that size.
#[test]
fn a_parentless_headings_text_keeps_its_size() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="10pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"><style:text-properties fo:font-size="14pt"/></style:style><style:style style:name="H" style:family="paragraph" style:default-outline-level="1"/></office:styles></office:document-styles>"#
);
for size in ["10pt", "14pt"] {
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="{size}"/></style:style><style:style style:name="T2" style:family="text"><style:text-properties fo:font-weight="bold"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="H" text:outline-level="1"><text:span text:style-name="T1">One</text:span><text:span text:style-name="T2">Two</text:span></text:h></office:text></office:body></office:document-content>"#
);
let want: f32 = size.trim_end_matches("pt").parse().unwrap();
let check = |d: &Document, what: &str| {
let p = d.body[0].as_para().unwrap();
assert_eq!(p.runs[1].props.size, None, "{size} {what}: a span stating no size has none");
assert_eq!(
d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).size,
want,
"{size} {what}: {:?} {:?}",
p.props.style,
p.runs[0].props.size
);
};
let d = import(&package(&content, Some(&styles))).unwrap();
check(&d, "read");
check(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap(), "written from the model");
}
}
/// A percent in a heading style with no parent is of the default style's size, not Standard's.
#[test]
fn a_percent_in_a_parentless_heading_is_of_the_default_size() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="10pt"/></style:default-style><style:style style:name="Standard" style:family="paragraph" style:class="text"><style:text-properties fo:font-size="14pt"/></style:style><style:style style:name="H" style:family="paragraph" style:default-outline-level="1"/></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="50%"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="H" text:outline-level="1"><text:span text:style-name="T1">One</text:span></text:h></office:text></office:body></office:document-content>"#
);
let d = import(&package(&content, Some(&styles))).unwrap();
assert_eq!(d.body[0].as_para().unwrap().runs[0].props.size, Some(5.0));
}
/// A heading style with no parent and a percent size is that percent of the default's size, so text
/// stating that size is at the style's size.
#[test]
fn a_parentless_heading_style_in_percent_is_of_the_default_size() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="10pt"/></style:default-style><style:style style:name="H" style:family="paragraph"><style:text-properties fo:font-size="50%"/></style:style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties fo:font-size="5pt"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="H" text:outline-level="1"><text:span text:style-name="T1">One</text:span>Two</text:h></office:text></office:body></office:document-content>"#
);
let d = import(&package(&content, Some(&styles))).unwrap();
let p = d.body[0].as_para().unwrap();
for r in &p.runs {
assert_eq!(d.styles.resolve_char(p.props.style.as_deref(), &r.props).size, 5.0, "{:?} {:?}", p.props.style, r.props.size);
}
}
/// Text at its heading's size goes with the heading style, stated or not, and a size in percent
/// is of the heading's size; text of another size keeps it, and so does a paragraph's own.
#[test]
fn a_headings_text_sizes_are_measured_against_the_heading() {
let styles = format!(
r#"<office:document-styles {NS_T}><office:styles><style:style style:name="Heading" style:family="paragraph"><style:text-properties fo:font-size="20pt"/></style:style><style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph" style:parent-style-name="Heading" style:default-outline-level="1"><style:text-properties fo:font-size="80%"/></style:style></office:styles></office:document-styles>"#
);
let span = |name: &str, size: &str| {
format!(r#"<style:style style:name="{name}" style:family="text"><style:text-properties fo:font-size="{size}"/></style:style>"#)
};
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles>{}{}{}<style:style style:name="P1" style:family="paragraph" style:parent-style-name="Heading_20_1"><style:text-properties fo:font-size="30pt"/></style:style></office:automatic-styles><office:body><office:text><text:h text:style-name="Heading_20_1" text:outline-level="1">plain <text:span text:style-name="Same">same</text:span> <text:span text:style-name="Pct">pct</text:span> <text:span text:style-name="Other">other</text:span></text:h><text:h text:style-name="P1" text:outline-level="1">own</text:h></office:text></office:body></office:document-content>"#,
span("Same", "16pt"),
span("Pct", "50%"),
span("Other", "10pt"),
);
let mut d = import(&package(&content, Some(&styles))).unwrap();
let sizes = |d: &Document| {
let p = d.body[0].as_para().unwrap();
let of = |word: &str| {
let at = p.text.find(word).unwrap();
let mut from = 0;
let run = p.runs.iter().find(|r| {
from += r.len;
at < from
});
d.styles.resolve_char(p.props.style.as_deref(), &run.unwrap().props).size
};
[of("plain"), of("same"), of("pct"), of("other")]
};
let want = |t: f32| [t, t, 8.0, 10.0];
let plain = |d: &Document| d.body[0].as_para().unwrap().runs.iter().filter(|r| r.props.size.is_some()).count();
assert_eq!(sizes(&d), want(16.0));
assert_eq!(plain(&d), 2, "only the two of another size state one");
// The paragraph's own size stays with its text.
let own = d.body[1].as_para().unwrap();
assert_eq!(d.styles.resolve_char(own.props.style.as_deref(), &own.runs[0].props).size, 30.0);
// A change to the style reaches what goes with it.
let id = d.body[0].as_para().unwrap().props.style.clone().unwrap();
std::sync::Arc::make_mut(&mut d.styles).get_mut(&id).unwrap().chr.size = Some(24.0);
assert_eq!(sizes(&d), want(24.0));
}
@@ -367,3 +367,33 @@ fn a_frame_keeps_its_place_in_the_stacking_order() {
assert_eq!(float(&d).z, 0);
assert!(crate::odt::tests::entry(&crate::odt::tests::export_model(&d).unwrap(), "content.xml").contains(r#"draw:z-index="0""#));
}
#[test]
fn a_frame_offering_several_formats_shows_the_first_it_can_read() {
// LibreOffice's letter templates: its own metafile, then a PNG. Taking the first left nothing
// to show, and the picture was gone once the paragraph was written from the model.
let png = [b"\x89PNG\r\n\x1a\n".as_slice(), &[7u8; 64]].concat();
let other = [b"\x89PNG\r\n\x1a\n".as_slice(), &[9u8; 64]].concat();
let svm = b"VCLMTF a metafile WordCraft can't draw".to_vec();
let frame = |images: &[&str]| {
let images: String = images.iter().map(|h| format!(r#"<draw:image xlink:href="{h}"/>"#)).collect();
format!(
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><text:p><draw:frame svg:width="2in" svg:height="1in" text:anchor-type="as-char">{images}</draw:frame></text:p></office:text></office:body></office:document-content>"#
)
};
let pictures = |d: &Document| d.body[0].as_para().unwrap().objects.iter().filter(|o| matches!(o, InlineObject::Image { .. })).count();
let both = frame(&["Pictures/a.svm", "Pictures/a.png"]);
let d = import(&zip_of(&[("content.xml", both.as_bytes()), ("Pictures/a.svm", &svm), ("Pictures/a.png", &png)])).unwrap();
assert_eq!(pictures(&d), 1, "the PNG");
let back = import(&crate::odt::tests::export_model(&d).unwrap()).unwrap();
assert_eq!(pictures(&back), 1, "written from the model");
// The first it can read, not the last.
let order = frame(&["Pictures/a.png", "Pictures/b.png"]);
let d = import(&zip_of(&[("content.xml", order.as_bytes()), ("Pictures/a.png", &png), ("Pictures/b.png", &other)])).unwrap();
assert_eq!(pictures(&d), 1);
assert_eq!(d.media.values().next().and_then(|m| m.get(8).copied()), Some(7), "the first");
// Nothing it can read: no picture, and no failure.
let only = frame(&["Pictures/a.svm"]);
let d = import(&zip_of(&[("content.xml", only.as_bytes()), ("Pictures/a.svm", &svm)])).unwrap();
assert_eq!(pictures(&d), 0);
}
@@ -159,3 +159,75 @@ fn a_table_starting_a_page_starts_it() {
let c = content_of(&crate::odt::tests::export_model(&d).unwrap());
assert_eq!(c.matches(r#"fo:break-before="page""#).count(), 2, "{c}");
}
#[test]
fn a_row_that_keeps_together_says_so() {
// Table › Text Flow: a row not to break across pages. It came back from a save free to break.
let styles = r#"<style:style style:name="R" style:family="table-row"><style:table-row-properties fo:keep-together="always"/></style:style><style:style style:name="H" style:family="table-row"><style:table-row-properties style:min-row-height="1in" fo:keep-together="always"/></style:style>"#;
let row = |s: &str, t: &str| format!(r#"<table:table-row{s}><table:table-cell><text:p>{t}</text:p></table:table-cell></table:table-row>"#);
let table = format!(
"<table:table>{}{}{}</table:table>",
row(r#" table:style-name="R""#, "kept"),
row("", "free"),
row(r#" table:style-name="H""#, "kept and tall")
);
let shape = |d: &wordcraft_doc::Document| {
d.body
.iter()
.find_map(|b| b.as_table())
.map(|t| t.rows.iter().map(|r| (r.props.cant_split, r.props.height)).collect::<Vec<_>>())
.unwrap_or_default()
};
let d = doc(styles, &table);
assert_eq!(shape(&d), [(true, None), (false, None), (true, Some(72.0))]);
let saved = crate::odt::tests::export_model(&d).unwrap();
assert!(content_of(&saved).contains(r#"fo:keep-together="always""#));
assert_eq!(shape(&import(&saved).unwrap()), [(true, None), (false, None), (true, Some(72.0))]);
}
/// A row whose style says nothing of it, or a row with no style, follows the default style; one
/// that says otherwise doesn't.
#[test]
fn a_rows_keep_together_defaults_to_the_default_style() {
let styles = r#"<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"><office:styles><style:default-style style:family="table-row"><style:table-row-properties fo:keep-together="always"/></style:default-style></office:styles></office:document-styles>"#;
let row = |s: &str| format!(r#"<table:table-row{s}><table:table-cell><text:p>x</text:p></table:table-cell></table:table-row>"#);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="H" style:family="table-row"><style:table-row-properties style:min-row-height="1in"/></style:style><style:style style:name="F" style:family="table-row"><style:table-row-properties fo:keep-together="auto"/></style:style></office:automatic-styles><office:body><office:text><table:table>{}{}{}</table:table></office:text></office:body></office:document-content>"#,
row(""),
row(r#" table:style-name="H""#),
row(r#" table:style-name="F""#)
);
let keeps = |d: &wordcraft_doc::Document| {
d.body.iter().find_map(|b| b.as_table()).map(|t| t.rows.iter().map(|r| r.props.cant_split).collect::<Vec<_>>()).unwrap_or_default()
};
let d = import(&package(&content, Some(styles))).unwrap();
assert_eq!(keeps(&d), [true, true, false]);
assert_eq!(keeps(&import(&crate::odt::tests::export_model(&d).unwrap()).unwrap()), [true, true, false]);
}
/// A row free to break under a default row style that keeps rows together says so when written from
/// the model, or a save takes that away: the file's default style stays.
#[test]
fn a_row_free_to_break_stays_so_over_the_default_style() {
let styles = r#"<office:document-styles xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"><office:styles><style:default-style style:family="table-row"><style:table-row-properties fo:keep-together="always"/></style:default-style></office:styles></office:document-styles>"#;
let row = |s: &str| format!(r#"<table:table-row{s}><table:table-cell><text:p>x</text:p></table:table-cell></table:table-row>"#);
let content = format!(
r#"<office:document-content {NS_T}><office:automatic-styles><style:style style:name="F" style:family="table-row"><style:table-row-properties fo:keep-together="auto"/></style:style></office:automatic-styles><office:body><office:text><table:table><table:table-column/>{}{}</table:table></office:text></office:body></office:document-content>"#,
row(r#" table:style-name="F""#),
row("")
);
let mut d = import(&package(&content, Some(styles))).unwrap();
let keeps = |d: &wordcraft_doc::Document| {
d.body.iter().find_map(|b| b.as_table()).map(|t| t.rows.iter().map(|r| r.props.cant_split).collect::<Vec<_>>()).unwrap_or_default()
};
assert_eq!(keeps(&d), [false, true]);
if let Some(wordcraft_doc::Block::Table(t)) = d.body.get_mut(0).map(std::sync::Arc::make_mut)
&& let Some(wordcraft_doc::Block::Para(p)) = t.rows[0].cells[0].blocks.get_mut(0).map(std::sync::Arc::make_mut)
{
p.text.push('!');
if let Some(r) = p.runs.last_mut() {
r.len += 1;
}
}
assert_eq!(keeps(&import(&export(&d).unwrap()).unwrap()), [false, true], "after an edit and a save");
}
@@ -287,6 +287,97 @@ fn a_watermark_is_a_shape_in_the_header_and_reads_back_as_one() {
);
}
/// A watermark added, changed or removed in WordCraft reaches a file's page definitions.
#[test]
fn 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.
let ns = crate::odt::tests::NS_T;
let styles = format!(
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm1" style:page-usage="mirrored"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-top="2cm" fo:margin-bottom="2cm" fo:margin-left="2cm" fo:margin-right="2cm"/></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"/></office:master-styles></office:document-styles>"#
);
let content =
format!("<office:document-content {ns}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>");
let plain = crate::odt::tests::package(&content, Some(&styles));
let styles_of = |d: &Document| crate::odt::tests::entry(&export(d).unwrap(), "styles.xml");
let shape = "PowerPlusWaterMarkObject";
// Added.
let mut d = import(&plain).unwrap();
d.settings.watermark = Some(wordcraft_doc::Watermark { text: "DRAFT".into(), ..Default::default() });
let saved = export(&d).unwrap();
let s = crate::odt::tests::entry(&saved, "styles.xml");
assert!(s.contains(shape) && s.contains("DRAFT"), "added: {s}");
assert_eq!(import(&saved).unwrap().settings.watermark.map(|w| w.text).as_deref(), Some("DRAFT"));
// Saved again with nothing changed, it stays as the file has it.
let read = import(&saved).unwrap();
let again = styles_of(&read);
assert_eq!(again.matches(shape).count(), s.matches(shape).count(), "{again}");
// Changed.
let mut d = import(&saved).unwrap();
if let Some(w) = d.settings.watermark.as_mut() {
w.text = "COPY".into();
}
let s = styles_of(&d);
assert!(s.contains("COPY") && !s.contains("DRAFT"), "changed: {s}");
// Removed.
let mut d = import(&saved).unwrap();
d.settings.watermark = None;
let s = styles_of(&d);
assert!(!s.contains(shape) && !s.contains("DRAFT"), "removed: {s}");
// One with no text isn't written, so it's no change to the file's page definitions.
let mut d = import(&plain).unwrap();
d.settings.watermark = Some(wordcraft_doc::Watermark { text: " ".into(), ..Default::default() });
let s = styles_of(&d);
assert!(s.contains(r#"style:page-usage="mirrored""#) && !s.contains(shape), "no text: {s}");
}
/// Different Odd & Even Pages turned on or off in WordCraft reaches a file's page definitions.
#[test]
fn different_odd_and_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.
let ns = crate::odt::tests::NS_T;
let file = |left: &str| {
let styles = format!(
r#"<office:document-styles {ns}><office:automatic-styles><style:page-layout style:name="pm1" style:page-usage="mirrored"><style:page-layout-properties fo:page-width="21cm" fo:page-height="29.7cm" fo:margin-top="2cm" fo:margin-bottom="2cm" fo:margin-left="2cm" fo:margin-right="2cm"/><style:header-style><style:header-footer-properties fo:min-height="1cm"/></style:header-style></style:page-layout></office:automatic-styles><office:master-styles><style:master-page style:name="Standard" style:page-layout-name="pm1"><style:header><text:p>Right head</text:p></style:header>{left}</style:master-page></office:master-styles></office:document-styles>"#
);
let content = format!(
"<office:document-content {ns}><office:body><office:text><text:p>x</text:p></office:text></office:body></office:document-content>"
);
crate::odt::tests::package(&content, Some(&styles))
};
let styles_of = |d: &Document| crate::odt::tests::entry(&export(d).unwrap(), "styles.xml");
// On in the file, and nothing changed: the file's own.
let both = file("<style:header-left><text:p>Left head</text:p></style:header-left>");
let d = import(&both).unwrap();
assert!(d.settings.even_odd_headers);
let s = styles_of(&d);
assert!(s.contains(r#"style:page-usage="mirrored""#) && s.contains("Left head"), "as read: {s}");
// Turned off.
let mut d = import(&both).unwrap();
d.settings.even_odd_headers = false;
let saved = export(&d).unwrap();
let s = crate::odt::tests::entry(&saved, "styles.xml");
assert!(!s.contains("<style:header-left") && s.contains("Right head"), "off: {s}");
assert!(!import(&saved).unwrap().settings.even_odd_headers);
// Off in the file, and turned on: left pages have their own header, an empty one so far.
let mut d = import(&file("")).unwrap();
assert!(!d.settings.even_odd_headers);
d.settings.even_odd_headers = true;
let saved = export(&d).unwrap();
let s = crate::odt::tests::entry(&saved, "styles.xml");
assert!(s.contains("<style:header-left") && s.contains("Right head"), "on: {s}");
assert!(import(&saved).unwrap().settings.even_odd_headers);
}
#[test]
fn libreoffices_watermark_reads_as_the_watermark() {
// As LibreOffice's Format › Watermark writes it (observed): turned, half see-through.
+158
View File
@@ -159,3 +159,161 @@ fn a_file_a_kept_definition_names_is_written_with_it() {
let m = crate::odt::tests::entry(&saved, "META-INF/manifest.xml");
assert!(m.contains(r#"manifest:full-path="Pictures/paper.png""#), "{m}");
}
#[test]
fn the_files_font_declarations_are_kept() {
// A reader without a font picks its substitute by how the file declares it. WordCraft's own
// guess at a font's kind replaced the file's, and a document came back from a save with no
// edits 13 pages shorter in LibreOffice on a computer without its font.
let ns = NS_T;
let face = r#"<style:font-face style:name="Odd Roman" svg:font-family="&apos;Odd Roman&apos;" style:font-family-generic="roman"/>"#;
let styles = format!(
r#"<office:document-styles {ns}><office:font-face-decls>{face}</office:font-face-decls><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="Odd Roman" fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {ns}><office:font-face-decls>{face}</office:font-face-decls><office:automatic-styles><style:style style:name="P1" style:family="paragraph"><style:text-properties style:font-name="Odd Roman"/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="P1">Text</text:p></office:text></office:body></office:document-content>"#
);
let check = |saved: &[u8], what: &str| {
for part in [styles_of(saved), content_of(saved)] {
assert!(part.contains(face), "{what}: {part}");
assert_eq!(part.matches(r#"<style:font-face style:name="Odd Roman""#).count(), 1, "{what}: declared once: {part}");
}
};
let mut d = import(&package(&content, Some(&styles))).unwrap();
check(&export(&d).unwrap(), "no edits");
// Text edited, written from the model: still the file's declaration.
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
p.text.push('!');
if let Some(r) = p.runs.last_mut() {
r.len += 1;
}
}
check(&export(&d).unwrap(), "an edit");
}
/// A font's name spelled another way in XML (`O'Brien`, `O&#39;Brien`) is the same font: its
/// declaration is the file's, once.
#[test]
fn a_fonts_name_spelled_another_way_is_declared_once_as_the_file_has_it() {
for spelled in ["O'Brien", "O&#39;Brien"] {
let face = format!(r#"<style:font-face style:name="{spelled}" svg:font-family="O&apos;Brien" style:font-family-generic="roman"/>"#);
let styles = format!(
r#"<office:document-styles {NS_T}><office:font-face-decls>{face}</office:font-face-decls><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="{spelled}" fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:font-face-decls>{face}</office:font-face-decls><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties style:font-name="{spelled}"/></style:style></office:automatic-styles><office:body><office:text><text:p><text:span text:style-name="T1">One</text:span></text:p></office:text></office:body></office:document-content>"#
);
let mut d = import(&package(&content, Some(&styles))).unwrap();
// Text edited, written from the model.
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
p.text.push('!');
if let Some(r) = p.runs.last_mut() {
r.len += 1;
}
}
let saved = export(&d).unwrap();
for part in [styles_of(&saved), content_of(&saved)] {
assert!(part.contains(&face), "{spelled}: the file's: {part}");
let is_it = |t: &&str| {
let name = t.split('>').next().and_then(|tag| tag.split("style:name=\"").nth(1)).and_then(|v| v.split('"').next());
name.is_some_and(|n| n == "O'Brien" || n == "O&#39;Brien" || n == "O&apos;Brien")
};
assert_eq!(part.split("<style:font-face ").skip(1).filter(is_it).count(), 1, "{spelled}: declared once: {part}");
}
}
}
/// A face named other than its family (`F1`, `Mystery`): text written from the model names the
/// family, and that declaration says what the file's does, not WordCraft's guess at the font's kind.
#[test]
fn a_face_named_other_than_its_family_gives_the_family_the_files_declaration() {
let face =
r#"<style:font-face style:name="F1" svg:font-family="&apos;Mystery&apos;" style:font-family-generic="roman" style:font-pitch="variable"/>"#;
let styles = format!(
r#"<office:document-styles {NS_T}><office:font-face-decls>{face}</office:font-face-decls><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}><office:font-face-decls>{face}</office:font-face-decls><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties style:font-name="F1"/></style:style></office:automatic-styles><office:body><office:text><text:p><text:span text:style-name="T1">One</text:span></text:p></office:text></office:body></office:document-content>"#
);
let mut d = import(&package(&content, Some(&styles))).unwrap();
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
p.text.push('!');
if let Some(r) = p.runs.last_mut() {
r.len += 1;
}
}
let saved = export(&d).unwrap();
let part = content_of(&saved);
assert!(part.contains(r#"style:font-name="Mystery""#), "{part}");
let mystery = part.split("<style:font-face ").find(|t| t.starts_with(r#"style:name="Mystery""#)).and_then(|t| t.split("/>").next());
let mystery = mystery.unwrap_or_else(|| panic!("declared: {part}"));
assert!(mystery.contains(r#"style:font-family-generic="roman""#), "{mystery}");
}
/// Only declarations are declarations: not a script's text that reads like one. And a face's name
/// is its own attribute, not another with `style:name` in its name.
#[test]
fn a_face_is_found_and_named_by_its_own_markup() {
let script =
r#"<office:scripts><office:script script:language="x"><![CDATA[<style:font-face style:name="Mystery" />]]></office:script></office:scripts>"#;
let face = r#"<style:font-face xstyle:name="F1" style:name="F1" svg:font-family="&apos;Mystery&apos;" style:font-family-generic="roman"/>"#;
let styles = format!(
r#"<office:document-styles {NS_T}><office:font-face-decls>{face}</office:font-face-decls><office:styles><style:default-style style:family="paragraph"><style:text-properties fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {NS_T}>{script}<office:font-face-decls>{face}</office:font-face-decls><office:automatic-styles><style:style style:name="T1" style:family="text"><style:text-properties style:font-name="F1"/></style:style></office:automatic-styles><office:body><office:text><text:p><text:span text:style-name="T1">One</text:span></text:p></office:text></office:body></office:document-content>"#
);
let mut d = import(&package(&content, Some(&styles))).unwrap();
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(0).map(std::sync::Arc::make_mut) {
p.text.push('!');
if let Some(r) = p.runs.last_mut() {
r.len += 1;
}
}
let saved = export(&d).unwrap();
let part = content_of(&saved);
assert!(part.contains(script), "the script as it was: {part}");
let decls = part.split("<office:font-face-decls>").last().and_then(|t| t.split("</office:font-face-decls>").next()).unwrap();
assert!(decls.contains(r#"xstyle:name="F1" style:name="Mystery""#), "{decls}");
assert!(import(&saved).is_ok());
}
/// A font declaration in single quotes is the file's too: it replaces WordCraft's, and isn't
/// declared a second time.
#[test]
fn a_font_declaration_in_single_quotes_is_kept() {
let ns = format!(r#"{NS_T} xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0""#);
let face = r#"<style:font-face style:name='Odd Roman' svg:font-family='Odd Roman' style:font-family-generic='roman'/>"#;
let styles = format!(
r#"<office:document-styles {ns}><office:font-face-decls>{face}</office:font-face-decls><office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="Odd Roman" fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {ns}><office:font-face-decls>{face}</office:font-face-decls><office:automatic-styles><style:style style:name="P1" style:family="paragraph"><style:text-properties style:font-name="Odd Roman"/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="P1">x</text:p></office:text></office:body></office:document-content>"#
);
let saved = export(&import(&package(&content, Some(&styles))).unwrap()).unwrap();
for part in [styles_of(&saved), content_of(&saved)] {
assert!(part.contains(face), "{part}");
assert_eq!(part.matches("<style:font-face").count(), part.matches("<style:font-face style:name=").count());
assert_eq!(part.matches(r#"style:name="Odd Roman""#).count(), 0, "declared once: {part}");
assert_eq!(part.matches("style:name='Odd Roman'").count(), 1, "declared once: {part}");
}
}
/// A name space only the declarations' container states goes with the declaration: without it the
/// saved part isn't well-formed in its name spaces.
#[test]
fn a_name_space_on_the_font_declarations_goes_with_them() {
let ns = format!(r#"{NS_T} xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0""#);
let decls = r#"<office:font-face-decls xmlns:x="urn:example:x"><style:font-face style:name="Odd Roman" svg:font-family="Odd Roman" x:odd="1"/></office:font-face-decls>"#;
let styles = format!(
r#"<office:document-styles {ns}>{decls}<office:styles><style:default-style style:family="paragraph"><style:text-properties style:font-name="Odd Roman" fo:font-size="12pt"/></style:default-style></office:styles></office:document-styles>"#
);
let content = format!(
r#"<office:document-content {ns}>{decls}<office:automatic-styles><style:style style:name="P1" style:family="paragraph"><style:text-properties style:font-name="Odd Roman"/></style:style></office:automatic-styles><office:body><office:text><text:p text:style-name="P1">x</text:p></office:text></office:body></office:document-content>"#
);
let saved = export(&import(&package(&content, Some(&styles))).unwrap()).unwrap();
for part in [styles_of(&saved), content_of(&saved)] {
assert!(part.contains(r#"x:odd="1""#), "{part}");
assert!(part.contains(r#"xmlns:x="urn:example:x""#), "{part}");
}
}
@@ -167,3 +167,84 @@ fn an_object_in_a_header_is_kept_too() {
assert!(z.file_names().any(|n| n.ends_with("/content.xml") && n.starts_with("Object M")), "{:?}", z.file_names().collect::<Vec<_>>());
assert!(entry(&saved, "styles.xml").contains("<draw:object xlink:href=\"./Object M"));
}
/// A replacement picture may come before its object. It is kept whatever it is, though WordCraft
/// can't show it, and a save from the model writes it back.
#[test]
fn a_replacement_picture_before_its_object_is_kept() {
let content = format!(
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><text:p>Sheet <draw:frame draw:name="Object1" text:anchor-type="as-char" svg:width="3in" svg:height="1in"><draw:image xlink:href="./ObjectReplacements/Object 1" xlink:type="simple"/><draw:object xlink:href="./Object 1" xlink:type="simple"/></draw:frame></text:p></office:text></office:body></office:document-content>"#
);
let bytes = zip_of(&[
("META-INF/manifest.xml", MANIFEST.as_bytes()),
("content.xml", content.as_bytes()),
("Object 1/content.xml", SHEET.as_bytes()),
("Object 1/styles.xml", b"<office:document-styles/>"),
("ObjectReplacements/Object 1", METAFILE),
]);
let replacement = |bytes: &[u8]| {
let flow = parse(bytes).unwrap();
paras(&flow)
.into_iter()
.flat_map(|p| &p.inlines)
.find_map(|i| if let Inline::Image(img) = i { img.object.clone() } else { None })
.and_then(|o| o.replacement.clone())
};
assert_eq!(replacement(&bytes).as_deref().map(Vec::as_slice), Some(METAFILE));
let saved = crate::odt::tests::export_model(&import(&bytes).unwrap()).unwrap();
assert_eq!(replacement(&saved).as_deref().map(Vec::as_slice), Some(METAFILE), "written from the model");
}
/// A readable picture after an unreadable replacement is what WordCraft shows, wherever the object
/// comes between them, and a save from the model keeps both.
#[test]
fn a_readable_picture_after_an_unreadable_replacement_is_shown() {
let mut png = std::io::Cursor::new(Vec::new());
image::RgbImage::new(4, 4).write_to(&mut png, image::ImageFormat::Png).unwrap();
let png = png.into_inner();
let object = r#"<draw:object xlink:href="./Object 1"/>"#;
let unread = r#"<draw:image xlink:href="./ObjectReplacements/Object 1"/>"#;
let readable = r#"<draw:image xlink:href="./Pictures/a.png"/>"#;
for frame in [format!("{unread}{object}{readable}"), format!("{object}{unread}{readable}")] {
let content = format!(
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><text:p>Sheet <draw:frame draw:name="Object1" text:anchor-type="as-char" svg:width="3in" svg:height="1in">{frame}</draw:frame></text:p></office:text></office:body></office:document-content>"#
);
let bytes = zip_of(&[
("META-INF/manifest.xml", MANIFEST.as_bytes()),
("content.xml", content.as_bytes()),
("Object 1/content.xml", SHEET.as_bytes()),
("Object 1/styles.xml", b"<office:document-styles/>"),
("ObjectReplacements/Object 1", METAFILE),
("Pictures/a.png", &png),
]);
let check = |bytes: &[u8], what: &str| {
let flow = parse(bytes).unwrap();
let img = paras(&flow)
.into_iter()
.flat_map(|p| &p.inlines)
.find_map(|i| if let Inline::Image(img) = i { Some(img.clone()) } else { None })
.unwrap();
assert_eq!(img.data.as_slice(), png.as_slice(), "{what}: shows the picture it can read");
assert_eq!(img.object.unwrap().replacement.as_deref().map(Vec::as_slice), Some(METAFILE), "{what}: keeps the replacement");
};
check(&bytes, "read");
check(&crate::odt::tests::export_model(&import(&bytes).unwrap()).unwrap(), "written from the model");
}
}
/// With only an unreadable replacement, the placeholder drawn for it isn't written as a picture.
#[test]
fn the_placeholder_for_an_object_is_not_saved_as_its_picture() {
let content = format!(
r#"<office:document-content {NS_T} {NS_D}><office:body><office:text><text:p>Sheet <draw:frame draw:name="Object1" text:anchor-type="as-char" svg:width="3in" svg:height="1in"><draw:object xlink:href="./Object 1"/><draw:image xlink:href="./ObjectReplacements/Object 1"/></draw:frame></text:p></office:text></office:body></office:document-content>"#
);
let bytes = zip_of(&[
("META-INF/manifest.xml", MANIFEST.as_bytes()),
("content.xml", content.as_bytes()),
("Object 1/content.xml", SHEET.as_bytes()),
("Object 1/styles.xml", b"<office:document-styles/>"),
("ObjectReplacements/Object 1", METAFILE),
]);
let saved = crate::odt::tests::export_model(&import(&bytes).unwrap()).unwrap();
assert_eq!(entry(&saved, "content.xml").matches("<draw:image ").count(), 1);
}
+10 -2
View File
@@ -244,11 +244,19 @@ impl Writer {
None => self.pictures.push((replacement.clone(), img.data.clone(), mime_of(&img.ext).to_string())),
}
let el = if o.ole { "draw:object-ole" } else { "draw:object" };
format!(
let mut inner = format!(
"<{el} xlink:href=\"./{}\" xlink:type=\"simple\" xlink:show=\"embed\" xlink:actuate=\"onLoad\"/><draw:image xlink:href=\"./{}\" xlink:type=\"simple\" xlink:show=\"embed\" xlink:actuate=\"onLoad\"/>",
x(&name),
x(&replacement)
)
);
// A picture shown that isn't the replacement (one WordCraft can't read) follows it; a
// placeholder is WordCraft's own, not the file's.
let other = o.replacement.as_ref().is_some_and(|r| r.as_slice() != img.data.as_slice());
if other && crate::odt::objects::placeholder(&o.mime).as_deref() != Some(img.data.as_slice()) {
let href = self.picture(img);
inner.push_str(&format!("<draw:image xlink:href=\"{href}\" xlink:type=\"simple\" xlink:show=\"embed\" xlink:actuate=\"onLoad\"/>"));
}
inner
}
/// Object `o` (a chart as read) in the package under the next object name, with its
+27 -13
View File
@@ -161,19 +161,33 @@ impl Writer {
out.push_str("<table:table-header-rows>");
}
// A row of a set height: exact (`style:row-height`) or at least (`style:min-row-height`).
match t.extra.as_ref().and_then(|x| x.heights.get(ri).copied().flatten()) {
Some((h, exact)) => {
let props = if exact { format!(" style:row-height=\"{}\"", pt(h)) } else { format!(" style:min-row-height=\"{}\"", pt(h)) };
let k = match self.row_styles.iter().position(|s| *s == props) {
Some(k) => k,
None => {
self.row_styles.push(props);
self.row_styles.len() - 1
}
};
out.push_str(&format!("<table:table-row table:style-name=\"{}Row{}\">", self.prefix, k + 1));
}
None => out.push_str("<table:table-row>"),
let height = match t.extra.as_ref().and_then(|x| x.heights.get(ri).copied().flatten()) {
Some((h, true)) => format!(" style:row-height=\"{}\"", pt(h)),
Some((h, false)) => format!(" style:min-row-height=\"{}\"", pt(h)),
None => String::new(),
};
// A row that doesn't break across pages (§20.247 on a row). One that may says so where
// the file's default row style, which stays, would keep it together.
let keep = if t.extra.as_ref().is_some_and(|x| x.keep_rows.get(ri) == Some(&true)) {
" fo:keep-together=\"always\""
} else if self.kept_defs.get("common~style:default-style~table-row").is_some_and(|d| d.contains("keep-together") && d.contains("always"))
{
" fo:keep-together=\"auto\""
} else {
""
};
let props = format!("{height}{keep}");
if props.is_empty() {
out.push_str("<table:table-row>");
} else {
let k = match self.row_styles.iter().position(|s| *s == props) {
Some(k) => k,
None => {
self.row_styles.push(props);
self.row_styles.len() - 1
}
};
out.push_str(&format!("<table:table-row table:style-name=\"{}Row{}\">", self.prefix, k + 1));
}
let mut g = 0usize;
for c in row {
+10 -5
View File
@@ -105,22 +105,27 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
i = end;
continue;
}
if word == "info" {
r.info_depth = Some(r.depth());
}
if r.skip_chars > 0
&& matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl)
&& !matches!(word, "par" | "pard" | "cell" | "row")
&& matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl)
{
r.skip_chars -= 1;
first_in_group = false;
continue;
}
if word == "info" {
r.info_depth = Some(r.depth());
}
r.word(word, param, starred, first_in_group);
first_in_group = false;
starred = false;
continue;
}
if r.skip_chars > 0 && n != b'\'' {
r.skip_chars -= 1;
i += 2;
first_in_group = false;
continue;
}
match n {
b'\'' => {
let h = bytes.get(i + 2..i + 4).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok());
+56
View File
@@ -37,6 +37,62 @@ fn basic_reader() {
assert!(matches!(&l.inlines[0], Inline::Text(_, f) if f.link.as_deref() == Some("http://a.b")));
}
#[test]
fn unicode_fallback_uses_the_declared_count() {
for (rtf, expected) in [
(br"{\rtf1\uc18\u8804less than or equal rest}".as_slice(), "≤ rest"),
(br"{\rtf1\uc10\u233\'30\'31\'32\'33\'34\'35\'36\'37\'38\'39 rest}", "é rest"),
(br"{\rtf1\uc2\u8804\:ab rest}", "≤b rest"),
(br"{\rtf1\uc18\u8804\:12345678901234567 rest}", "≤ rest"),
(br"{\rtf1\uc1\u8804\:ab rest}", "≤ab rest"),
(br"{\rtf1\uc0\u8804\:ab rest}", "≤ab rest"),
(br"{\rtf1\uc9\u233abcdefgh\par VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc1\u233\pard VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc1\u233\cell VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc1\u233\row VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc0\u233\par VISIBLE}", "é\nVISIBLE"),
(b"{\\rtf1\\uc9\\u233abcdefgh\\\nVISIBLE}", "éVISIBLE"),
(b"{\\rtf1\\uc9\\u233abcdefgh\\\rVISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc9\u233abcdefgh\*VISIBLE}", "éVISIBLE"),
(b"{\\rtf1\\uc0\\u233\\\nVISIBLE}", "é\nVISIBLE"),
(b"{\\rtf1\\uc0\\u233\\\rVISIBLE}", "é\nVISIBLE"),
(br"{\rtf1\uc0\u233\*VISIBLE}", "éVISIBLE"),
(br"{\rtf1\uc0\u233X}", "éX"),
(br"{\rtf1\uc1\u233?X}", "éX"),
(br"{\rtf1{\uc2147483647\u233?}X}", "éX"),
(br"{\rtf1\uc-1\u233X}", "éX"),
] {
let doc = import(rtf).unwrap();
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), expected, "{rtf:?}");
}
}
#[test]
fn unicode_fallback_info_words_preserve_following_metadata() {
for (rtf, title, body) in [
(br"{\rtf1{\info{\title\uc9\u233abcdefgh\info X}{\author Alice}} Text}".as_slice(), "éX", "Text"),
(br"{\rtf1{\info{\title\uc1\u233\info X}{\author Alice}} Text}", "éX", "Text"),
(br"{\rtf1{\info{\title\uc0\u233X}{\author Alice}} Text}", "éX", "Text"),
(br"{\rtf1\uc0\u233{\info{\title Plain}{\author Alice}}Text}", "Plain", "éText"),
] {
let doc = import(rtf).unwrap();
assert_eq!(doc.core.title, title, "{rtf:?}");
assert_eq!(doc.core.creator, "Alice", "{rtf:?}");
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), body, "{rtf:?}");
}
}
#[test]
fn unicode_fallback_in_revision_authors_counts_control_words() {
for (rtf, expected) in [
(br"{\rtf1{\*\revtbl{Unknown;}{\uc9\u233abcdefgh\par Zed;}}{\revised\revauth1 text}}".as_slice(), "éZed"),
(br"{\rtf1{\*\revtbl{Unknown;}{\uc0\u233\par Zed;}}{\revised\revauth1 text}}", "éZed"),
] {
let doc = import(rtf).unwrap();
assert_eq!(doc.revisions.first().map(|r| r.author.as_str()), Some(expected), "{rtf:?}");
}
}
#[test]
fn paragraph_borders() {
// A heading with a grey rule under it, as hh.ru résumés write their section titles.
+1 -1
View File
@@ -89,7 +89,7 @@ impl Reader {
}
self.skip_chars = self.st.uc;
}
"uc" => self.st.uc = pv.clamp(0, 8) as usize,
"uc" => self.st.uc = pv.max(0) as usize,
"plain" => {
self.st.fmt = Fmt { link: self.st.fmt.link.clone(), ..Default::default() };
self.st.rev = RevSt::default();
+8 -1
View File
@@ -332,7 +332,14 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
prev_end = push_para(&mut items, (story, &path), &pl, (0, pl.lines.len()), (0.0, y + lead), width, edges);
// Pictures, shapes and text boxes: drawn, their areas and text boxes' text.
if !p.objects.is_empty() {
let at = ObjFrame { page: frame, col: (0.0, width), para_y: para_top, table_chr: table.map(|t| &t.chr), pinned: Some(&pinned) };
let at = ObjFrame {
page: frame,
col: (0.0, width),
para_y: para_top,
table_chr: table.map(|t| &t.chr),
pinned: Some(&pinned),
late: None,
};
let (back, front) = place_objects(
ctx,
&ObjectPlacement {
+86 -4
View File
@@ -59,9 +59,9 @@ pub(crate) struct Ctx<'a> {
pub(crate) pin: Option<Pin>,
pub(crate) footer_wraps: HashMap<(usize, u32, bool), Vec<WrapArea>>,
pub(crate) header_tables: HashMap<(usize, u32, bool), Vec<Rect>>,
/// Heights of text boxes that resize to fit their text, by (story, width), worked out once
/// per layout ([`textbox::fitted_height`]).
pub(crate) fitted: HashMap<(u32, u32), Option<f32>>,
/// Heights of text boxes that resize to fit their text, by story, width and page fields,
/// worked out once per matching layout context ([`textbox::fitted_height`]).
pub(crate) fitted: HashMap<(u32, u32, (u32, u32, u32, u32, u64, wordcraft_doc::section::NumFormat)), Option<f32>>,
/// Each body float's latest placement: on which page and with what wrap area, when it is
/// above its anchor paragraph with text before it ([`EarlyFloats`]).
pub(crate) floats_above: HashMap<(usize, usize), Option<(usize, WrapArea)>>,
@@ -344,7 +344,7 @@ impl Ctx<'_> {
/// The height of text box `id`, `w` wide, when it resizes to fit its text (once per layout).
fn fitted_height(&mut self, id: u32, w: f32) -> Option<f32> {
let key = (id, w.to_bits());
let key = (id, w.to_bits(), self.fields.page_key());
if let Some(h) = self.fitted.get(&key) {
return *h;
}
@@ -455,3 +455,85 @@ pub(crate) fn with_pct_base<T>(ctx: &mut Ctx, base: Option<f32>, f: impl FnOnce(
ctx.pct_base = before;
out
}
#[cfg(test)]
mod tests {
use super::*;
use wordcraft_doc::para::{Float, ShapeKind, Wrap};
use wordcraft_doc::{PartKind, TextBody, para_block};
#[test]
fn fitted_text_box_cache_separates_page_field_contexts() {
let mut doc = Document::from_text("Parent");
let mut story_para = Paragraph::new();
story_para
.insert_object(
0,
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&Default::default(),
)
.unwrap();
let id = doc.add_part(PartKind::TextBox, vec![para_block(story_para)]);
if let Some(part) = doc.parts.get_mut(&id) {
part.body = TextBody { auto_fit: true, ..Default::default() };
}
let mut p = Paragraph::new();
p.insert_object(
0,
InlineObject::Shape {
kind: ShapeKind::TextBox,
w: 100.0,
h: 20.0,
fill: None,
stroke: None,
stroke_width: 0.0,
float: Float { wrap: Wrap::Inline, ..Default::default() },
story: Some(id),
freeform: None,
effects: Default::default(),
extra: Default::default(),
},
&Default::default(),
)
.unwrap();
let opts = LayoutOptions::default();
let mut cache = LayoutCache::new();
let mut ctx = Ctx {
doc: &doc,
cache: &mut cache,
opts: &opts,
counters: Default::default(),
fields: FieldCtx::default(),
notes_hash: 0,
numbers: Default::default(),
eq_count: 0,
boxes: Default::default(),
chains: Default::default(),
grid: Default::default(),
hide_deleted: opts.hides_deleted(StoryRef::Body),
uses_totals: false,
track_totals: false,
pct_base: None,
quiet_marks: Vec::new(),
stacked_marks: Vec::new(),
mirror: false,
table_keys: Vec::new(),
pin: None,
footer_wraps: Default::default(),
header_tables: Default::default(),
fitted: Default::default(),
floats_above: Default::default(),
hf: Arc::new(HfLinks::new(doc.sections())),
alphas: Default::default(),
header_repeats: 0,
};
ctx.fitted_boxes(&p);
assert_eq!(ctx.fitted.len(), 1);
ctx.fields.page_format = wordcraft_doc::section::NumFormat::CardinalText;
ctx.fitted_boxes(&p);
assert_eq!(ctx.fitted.len(), 2, "the same box with a different PAGE format needs its own height");
ctx.fitted_boxes(&p);
assert_eq!(ctx.fitted.len(), 2, "matching field context reuses the fitted height");
}
}
+1 -1
View File
@@ -656,7 +656,7 @@ fn inline_objects(ctx: &LineContext<'_, '_, '_>, out: &mut Vec<Draw>) {
Some(o @ InlineObject::Field { .. }) => {
if let Some(wordcraft_doc::legacy_form::LegacyForm::CheckBox { checked, .. }) = o.legacy_form() {
let color = pl.styles.get(c.style as usize).map_or(Rgb::BLACK, |st| text_color(&st.rc.color, None));
let frame = Rect::new(cx, base - c.obj_h, c.adv, c.obj_h + c.obj_d);
let frame = Rect::new(cx, base - c.obj_h.max(0.0), c.adv, c.obj_h + c.obj_d);
check_box(frame, checked, color, alpha, out);
}
}
+2 -1
View File
@@ -491,7 +491,8 @@ pub(crate) fn effects_in(effects: wordcraft_doc::effects::ShapeEffects, spin: Sp
pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, (obj_h, obj_d): (f32, f32), line: f32) -> Rect {
let frame = obj.and_then(InlineObject::frame);
let [l, t, r, b] = frame.map_or([0.0; 4], |(_, _, float)| float.effect_extent());
let room = Rect::new(cx + l, base - obj_h + t, (adv - l - r).max(0.0), (obj_h + obj_d - t - b).max(0.0));
// Lowered further than its height, it is drawn with its top on the baseline (Word).
let room = Rect::new(cx + l, base - obj_h.max(0.0) + t, (adv - l - r).max(0.0), (obj_h + obj_d - t - b).max(0.0));
// A horizontal rule is as wide as its share of the paragraph's width, whatever narrows its
// line, and is placed by its alignment in what the line has left: it runs past the margin
// beside a float or a first-line indent, as in Word (see `ParaLayout::rule_room`).
+264 -11
View File
@@ -3,8 +3,8 @@ use super::page::{PageBuilder, PageFrame};
use super::placed::Placed;
use super::table_flow::footer_table_wraps;
use super::{
Anchor, Arc, CharProps, Document, Float, FloatAlign, HashMap, HideDeleted, InlineObject, Level, ParaLayout, Paragraph, Path, Point, Rect, Spin,
StoryRef, Wrap, WrapText, contour, display, para, textbox,
Anchor, Arc, CharProps, Document, Float, FloatAlign, HashMap, HideDeleted, InlineObject, Level, LineEnd, ParaLayout, Paragraph, Path, Point,
Rect, Spin, StoryRef, Wrap, WrapText, contour, display, para, textbox,
};
/// Which of `p`'s objects (by index) the layout leaves out ([`para::left_out`]): anchored in hidden
@@ -336,14 +336,135 @@ fn group_members(o: &InlineObject, rect: Rect) -> Vec<(Rect, &InlineObject)> {
/// page's wrap areas (its area as placed now). Without `place` (a later piece of the paragraph
/// broken again for another column) the floats stay where its first piece placed and drew them:
/// they aren't placed again in the new column, and their record for the page they are drawn on
/// ([`EarlyFloats`]) stands.
/// ([`EarlyFloats`]) stands. Floats anchored on a later page ([`late_floats`]), and those in
/// `force`, aren't placed here: the paragraph is laid out without them, and they're returned.
pub(crate) fn anchor_floats(
ctx: &mut Ctx,
pb: &mut PageBuilder,
anchor: FloatAnchor,
force: &[usize],
) -> (HashMap<usize, Rect>, Arc<ParaLayout>, Vec<para::Exclusion>, Vec<usize>) {
let FloatAnchor { p, block, y0, place, .. } = anchor;
ctx.fields.page = pb.number;
// Each layout of it here counts its automatic equation numbers from the same place.
let eq0 = ctx.eq_count;
let (rects, pl, rel) = anchor_floats_but(ctx, pb, anchor, force);
if !place || pb.web || p.objects.is_empty() {
return (rects, pl, rel, force.to_vec());
}
// Not those left out, nor those the page started with (#2048 settled them here).
let page = pb.pages.len().saturating_sub(1);
let seeded: Vec<usize> =
pb.early.get(&page).map(|v| v.iter().take(pb.seeded).filter(|(b, ..)| *b == block).map(|(_, k, _)| *k).collect()).unwrap_or_default();
let (bottom, more, col_top) = (pb.bottom, pb.cols.len().saturating_sub(pb.col + 1), pb.top);
let fit = |pl: &ParaLayout| fitting(pl, 0, y0 + pl.lines.first().map_or(0.0, |l| l.top.max(0.0)), bottom, (more, col_top));
let left_out = left_out_objects(ctx.doc, p, None, ctx.opts.show_hidden, ctx.hide_deleted);
let mut late: Vec<usize> =
late_floats(p, &pl, fit(&pl)).into_iter().chain(force.iter().copied()).filter(|oi| !left_out(*oi) && !seeded.contains(oi)).collect();
late.sort_unstable();
late.dedup();
if late.as_slice() == force {
return (rects, pl, rel, late);
}
ctx.eq_count = eq0;
let (rects, pl, rel) = anchor_floats_but(ctx, pb, anchor, &late);
// Without them in the way more of the paragraph may fit, and some of their anchors with it.
let still = late_floats(p, &pl, fit(&pl));
let fewer: Vec<usize> = late.iter().copied().filter(|oi| force.binary_search(oi).is_ok() || still.binary_search(oi).is_ok()).collect();
if fewer.len() == late.len() {
return (rects, pl, rel, late);
}
ctx.eq_count = eq0;
let (rects, pl, rel) = anchor_floats_but(ctx, pb, anchor, &fewer);
(rects, pl, rel, fewer)
}
/// The floating objects of `p` (laid out as `pl`) whose anchor line ([`anchor_lines`]) is at or
/// after `fit`, in order: Word draws them on the page their anchor line is on (#4250).
pub(crate) fn late_floats(p: &Paragraph, pl: &ParaLayout, fit: usize) -> Vec<usize> {
let lines = anchor_lines(p, pl);
let late =
|(oi, o): (usize, &InlineObject)| (floating(o).is_some() && lines.get(oi).copied().flatten().is_some_and(|li| li >= fit)).then_some(oi);
p.objects.iter().enumerate().filter_map(late).collect()
}
/// The end of the lines of `pl` from `l0` that fit on the page: line `l0`'s top at `top` in the
/// column, whose bottom is at `bottom`, then `more` columns from `col_top` (at least one line; a
/// page break ends them, a column break its column).
pub(crate) fn fitting(pl: &ParaLayout, l0: usize, top: f32, bottom: f32, (mut more, col_top): (usize, f32)) -> usize {
let Some(first) = pl.lines.get(l0) else { return l0 };
// A line's place in its column: `shift` plus its top.
let (mut shift, mut start, mut fit) = (top - first.top, l0, l0 + 1);
for (li, l) in pl.lines.iter().enumerate().skip(l0) {
if li > start && shift + l.top + l.height > bottom + 0.01 {
if more == 0 {
break;
}
(more, start, shift) = (more - 1, li, col_top - l.top);
}
fit = li + 1;
match l.end {
LineEnd::PageBreak => break,
LineEnd::ColumnBreak if more == 0 => break,
LineEnd::ColumnBreak => {
more -= 1;
start = li + 1;
shift = pl.lines.get(start).map_or(shift, |n| col_top - n.top);
}
_ => {}
}
}
fit
}
/// Place late float `oi` of body paragraph `p` ([`LateFloats`]) in the current column,
/// positioned from `top`, where the paragraph continues; its wrap area joins the page's.
pub(crate) fn place_late(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, oi: usize, top: f32, late: &mut LateFloats) {
let Some(o) = p.objects.get(oi) else { return };
let Some((w, h, float)) = floating(o) else { return };
let h = ctx.float_height(o, w, h);
let frame = PageFrame { sect: pb.sect, margin_top: None, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror };
let rect = float_rect(Some(frame), (pb.col_x(), pb.col_w()), top, w, h, float);
let outline = ctx.wrap_outline(o);
let area = wrap_area(rect, float, outline.as_deref().map(Vec::as_slice)).map(|a| {
let bounds = a.0;
pb.excl.push(a);
bounds
});
late.placed.insert(oi, Some(LatePlace { rect, top, spot: (pb.pages.len(), pb.col), drawn: false, area }));
}
/// The line of `pl` each object of `p` goes with (by object index; None if it isn't laid out):
/// the one holding its anchor, or for anchors on a line after a page or column break that holds
/// nothing taking room ([`para::takes_room`]: only anchors and markers such as bookmarks), the
/// break's: Word keeps them on the page before (a cover page's title box, #1632). With text after
/// them they are on the new page with it (#4262).
pub(crate) fn anchor_lines(p: &Paragraph, pl: &ParaLayout) -> Vec<Option<usize>> {
let mut out = vec![None; p.objects.len()];
for (li, l) in pl.lines.iter().enumerate() {
let clusters = pl.clusters.get(l.c0..l.c1).unwrap_or_default();
let lead = li.checked_sub(1).and_then(|k| pl.lines.get(k)).is_some_and(|l| matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak))
&& !clusters.iter().any(para::takes_room);
for c in clusters {
let para::ClKind::Object(oi) = c.kind else { continue };
if let Some(slot) = out.get_mut(oi).filter(|s| s.is_none()) {
*slot = Some(if lead { li.saturating_sub(1) } else { li });
}
}
}
out
}
/// [`anchor_floats`], leaving out the floats in `late` (in order).
fn anchor_floats_but(
ctx: &mut Ctx,
pb: &mut PageBuilder,
anchor: FloatAnchor,
late: &[usize],
) -> (HashMap<usize, Rect>, Arc<ParaLayout>, Vec<para::Exclusion>) {
let FloatAnchor { p, block, y0, before, excl_mark, label, place } = anchor;
ctx.fields.page = pb.number;
ctx.fields.page_format = pb.page_format;
let (col_x, width) = (pb.col_x(), pb.col_w());
let mut rects = HashMap::new();
if !pb.web && place {
@@ -363,6 +484,10 @@ pub(crate) fn anchor_floats(
if left_out(oi) {
continue;
}
if late.binary_search(&oi).is_ok() {
ctx.floats_above.remove(&(block, oi));
continue;
}
let h = ctx.float_height(o, w, h);
// Positions relative to the paragraph start above its space before (Word for Windows).
let mut r = float_rect(Some(frame), (col_x, width), y0 - before, w, h, float);
@@ -414,6 +539,123 @@ pub(crate) struct ObjFrame<'a> {
/// Floats placed on the page from a header or footer cell (by object index, in page
/// coordinates): their items go to [`Ctx::pin`].
pub(crate) pinned: Option<&'a HashMap<usize, Rect>>,
/// A body paragraph's floats that go on a later page or column than its first piece.
pub(crate) late: Option<&'a LateFloats>,
}
/// Where a late float ([`LateFloats`]) was placed: its rect, the top it's positioned from, the
/// page and column, `(pages, col)`, whether it's drawn, and the bounds of its wrap area.
#[derive(Clone, Copy)]
pub(crate) struct LatePlace {
pub(crate) rect: Rect,
pub(crate) top: f32,
pub(crate) spot: (usize, usize),
pub(crate) drawn: bool,
pub(crate) area: Option<Rect>,
}
/// A body paragraph's floating objects anchored on a later page than its first line
/// ([`late_floats`]): Word draws them there, positioned from where the paragraph continues
/// (#4250). Each is placed when the paragraph gets there, and drawn with that piece.
pub(crate) struct LateFloats {
/// By object index, where it was placed, once it is.
pub(crate) placed: HashMap<usize, Option<LatePlace>>,
/// The page and column of the piece being placed.
pub(crate) piece: (usize, usize),
/// [`anchor_lines`], and the paragraph layout they're for.
pub(crate) lines: Option<(Arc<ParaLayout>, Vec<Option<usize>>)>,
/// Those a piece let go ([`LateFloats::release`]), and those it keeps all the same
/// ([`LateFloats::keep_back`]): (piece, object index).
pub(crate) held: Vec<((usize, usize), usize)>,
pub(crate) kept: Vec<((usize, usize), usize)>,
}
impl LateFloats {
pub(crate) fn new(late: &[usize]) -> Self {
LateFloats { placed: late.iter().map(|oi| (*oi, None)).collect(), piece: (0, 0), lines: None, held: Vec::new(), kept: Vec::new() }
}
/// [`anchor_lines`] for `pl`.
pub(crate) fn refresh(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>) {
if !self.lines.as_ref().is_some_and(|(of, _)| Arc::ptr_eq(of, pl)) {
self.lines = Some((pl.clone(), anchor_lines(p, pl)));
}
}
/// The line object `oi` goes with ([`anchor_lines`]).
pub(crate) fn anchor(&self, oi: usize) -> Option<usize> {
self.lines.as_ref().and_then(|(_, v)| v.get(oi).copied().flatten())
}
/// Those placed with this piece whose anchor line is past `end` after all (notes, widow
/// control or a row kept together took lines [`fitting`] counted) wait for the next piece,
/// their wrap areas out of `excl`. Whether there were any.
pub(crate) fn release(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>, end: usize, excl: &mut Vec<WrapArea>) -> bool {
self.refresh(p, pl);
let piece = self.piece;
let out: Vec<usize> = self
.placed
.iter()
.filter(|(oi, at)| at.is_some_and(|a| a.spot == piece && !a.drawn) && !self.kept.contains(&(piece, **oi)))
.filter(|(oi, _)| self.anchor(**oi).is_some_and(|li| li >= end))
.map(|(oi, _)| *oi)
.collect();
for oi in &out {
if let Some(Some(a)) = self.placed.insert(*oi, None)
&& let Some(k) = a.area.and_then(|r| excl.iter().rposition(|w| w.0 == r))
{
excl.remove(k);
}
self.held.push((piece, *oi));
}
!out.is_empty()
}
/// Those this piece let go whose anchor line came back into `lines` without them (their own
/// wrap pushed it on): kept with this piece after all, as before. Whether there were any.
pub(crate) fn keep_back(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>, lines: std::ops::Range<usize>) -> bool {
self.refresh(p, pl);
let piece = self.piece;
let back: Vec<_> =
self.held.iter().filter(|(pc, oi)| *pc == piece && self.anchor(*oi).is_some_and(|li| lines.contains(&li))).copied().collect();
self.held.retain(|h| !back.contains(h));
self.kept.extend(&back);
!back.is_empty()
}
/// Those to place with this piece: not placed yet (nor let go by it), or placed for a column
/// the paragraph then didn't go into, with their anchor line in `lines`, in order.
pub(crate) fn due(&mut self, p: &Paragraph, pl: &Arc<ParaLayout>, lines: std::ops::Range<usize>) -> Vec<usize> {
self.refresh(p, pl);
let mut due: Vec<usize> = self
.placed
.iter()
.filter(|(_, at)| at.is_none_or(|a| a.spot != self.piece && !a.drawn))
.filter(|(oi, _)| !self.held.contains(&(self.piece, **oi)) && self.anchor(**oi).is_some_and(|li| lines.contains(&li)))
.map(|(oi, _)| *oi)
.collect();
due.sort_unstable();
due
}
/// Late float `oi` if it's drawn with this piece.
pub(crate) fn here(&self, oi: usize) -> Option<LatePlace> {
self.placed.get(&oi).copied().flatten().filter(|a| a.spot == self.piece && !a.drawn)
}
/// The lines holding the anchors of those drawn with this piece (an anchor right after a
/// break is on the line after its [`anchor_lines`] one).
pub(crate) fn lines_here(&self) -> Vec<usize> {
self.placed.keys().filter(|oi| self.here(**oi).is_some()).filter_map(|oi| self.anchor(*oi)).flat_map(|li| [li, li + 1]).collect()
}
/// Those drawn with this piece are drawn.
pub(crate) fn drew(&mut self) {
let piece = self.piece;
for a in self.placed.values_mut().flatten().filter(|a| a.spot == piece) {
a.drawn = true;
}
}
}
/// What [`anchor_floats`] anchors: paragraph `p` (body block `block`) starting at `y0` with
@@ -461,15 +703,15 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
let Some(fl) = pl.lines.get(l0) else { return (Vec::new(), Vec::new()) };
// Objects left out of the layout (hidden, or deleted in the final text) aren't placed.
let left_out = left_out_objects(ctx.doc, p, at.table_chr, ctx.opts.show_hidden, ctx.hide_deleted);
// A floating object belongs to its paragraph, not to its line: Word puts it on the page the
// paragraph starts on (a cover page is a paragraph holding a page break and then the text
// box with the title; the box stays on the cover). So the paragraph's first piece places
// every floating object, whichever of its lines holds the anchor, and later pieces none;
// inline objects go with their own line.
// The paragraph's first piece draws its floating objects, whichever of its lines holds the
// anchor, except those anchored on a later page (`at.late`): the piece there draws them
// (#4250). Inline objects go with their own line.
let first = l0 == 0;
let late = at.late.filter(|l| !l.placed.is_empty());
let late_lines = late.map(LateFloats::lines_here).unwrap_or_default();
for li in 0..pl.lines.len() {
let in_piece = (l0..l1).contains(&li);
if !in_piece && !first {
if !in_piece && !first && !late_lines.contains(&li) {
continue;
}
let Some(line) = pl.lines.get(li) else { continue };
@@ -482,12 +724,23 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
let Some(obj) = p.objects.get(oi) else { continue };
let Some((w, h, float)) = obj.frame() else { continue };
let floating = float.wrap != Wrap::Inline;
if if floating { !first } else { !in_piece } {
let is_late = floating && late.is_some_and(|l| l.placed.contains_key(&oi));
let late_here = late.and_then(|l| l.here(oi)).filter(|_| floating);
let skip = if is_late {
late_here.is_none()
} else if floating {
!first
} else {
!in_piece
};
if skip {
continue;
}
let pinned = at.pinned.and_then(|m| m.get(&oi)).copied().filter(|_| floating);
let rect = if let Some(r) = pinned {
r
} else if let Some(l) = late_here {
l.rect
} else if floating {
match floats.get(&oi) {
Some(r) => *r,
@@ -522,7 +775,7 @@ pub(crate) fn place_objects(ctx: &mut Ctx, req: &ObjectPlacement) -> (Vec<Placed
off: c.start,
text_box: shown,
wrap: float.wrap,
origin: Point::new(at.col.0, at.para_y),
origin: Point::new(at.col.0, late_here.map_or(at.para_y, |l| l.top)),
});
// Its text (a group: its text boxes', each where the group's turn carries it; the
// text itself stays upright), unless the box budget says no (a box inside itself,
+116 -11
View File
@@ -1,7 +1,10 @@
use super::boxes::{BoxInput, BoxLayout, Edges, joined, layout_box, push_para};
use super::cache::has_page_number_fields;
use super::ctx::Ctx;
use super::floats::{FloatAnchor, ObjFrame, ObjectPlacement, anchor_floats, body_wraps, continued_exclusions, place_objects};
use super::floats::{
FloatAnchor, LateFloats, ObjFrame, ObjectPlacement, anchor_floats, body_wraps, continued_exclusions, fitting, late_floats, place_late,
place_objects,
};
use super::notes::{DECOR_STORY, NoteBox, para_notes};
use super::page::{PageBuilder, PageFrame, item_bottom};
use super::placed::Placed;
@@ -172,6 +175,15 @@ struct Flow<'a> {
wrap_relays: usize,
/// The first line not placed yet.
l0: usize,
/// Its floats anchored on a later page or column than its first piece (#4250).
late: LateFloats,
/// Whether the floats past the first piece were checked ([`Flow::recheck_late`]), and those
/// it held back.
rechecked: bool,
forced: Vec<usize>,
/// The space before it at the top of the piece being placed (a paragraph starting with a page
/// break has it on the new page): late floats there are positioned from above it.
space_here: f32,
}
impl<'a> Flow<'a> {
@@ -185,8 +197,8 @@ impl<'a> Flow<'a> {
let guess = pb.y + space_before;
let label = ctx.next_label(p);
let eq_start = ctx.eq_count;
let (float_rects, pl, rel) =
anchor_floats(ctx, pb, FloatAnchor { p, block, y0: guess, before: space_before, excl_mark, label: &label, place: true });
let (float_rects, pl, rel, late) =
anchor_floats(ctx, pb, FloatAnchor { p, block, y0: guess, before: space_before, excl_mark, label: &label, place: true }, &[]);
Flow {
p,
block,
@@ -206,6 +218,10 @@ impl<'a> Flow<'a> {
page_relays: 0,
wrap_relays: 0,
l0: 0,
late: LateFloats::new(&late),
rechecked: false,
forced: Vec::new(),
space_here: 0.0,
}
}
@@ -213,16 +229,19 @@ impl<'a> Flow<'a> {
self.pl.lines.len()
}
/// Place the floats and lay the paragraph out again from `y0`, `before` the space before it.
fn reanchor(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, (y0, before): (f32, f32)) {
let (rects, pl, rel) = again(ctx, self.eq_start, |ctx| {
/// Place the floats and lay the paragraph out again from `y0`, `before` the space before it,
/// leaving those in `force` for a later piece.
fn reanchor(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, (y0, before): (f32, f32), force: &[usize]) {
let (rects, pl, rel, late) = again(ctx, self.eq_start, |ctx| {
anchor_floats(
ctx,
pb,
FloatAnchor { p: self.p, block: self.block, y0, before, excl_mark: self.excl_mark, label: &self.label, place: true },
force,
)
});
(self.float_rects, self.pl, self.rel) = (rects, pl, rel);
self.late = LateFloats::new(&late);
(self.laid_page, self.laid_width) = (pb.number, pb.col_w());
}
@@ -473,8 +492,10 @@ impl<'a> Flow<'a> {
pb.advance(self.block, self.body_top);
if self.l0 == 0 {
let y0 = pb.y;
self.reanchor(ctx, pb, (y0, 0.0));
self.reanchor(ctx, pb, (y0, 0.0), &[]);
self.anchor_y = y0;
// Its floats are checked again on the new page.
(self.rechecked, self.forced) = (false, Vec::new());
}
}
@@ -507,6 +528,13 @@ impl<'a> Flow<'a> {
}
let end = push_para(&mut items, (StoryRef::Body, &[self.block as u32]), &self.pl, (l0, l1), (x, y), self.width, edges);
pb.para_end = Some((pb.pages.len(), pb.col, end));
// Late floats whose anchor line came here after all are placed with these lines.
self.late.piece = (pb.pages.len(), pb.col);
if !pb.web && !self.late.placed.is_empty() {
let top = if l0 == 0 { self.anchor_y } else { y - self.space_here };
self.place_due(ctx, pb, 0..l1, top);
}
self.space_here = 0.0;
// Pictures, shapes and text boxes in these lines (web view: floats placed by this piece).
let at = ObjFrame {
page: Some(PageFrame { sect: pb.sect, margin_top: None, origin: (0.0, 0.0), number: pb.number, mirror: ctx.mirror }),
@@ -514,6 +542,7 @@ impl<'a> Flow<'a> {
para_y: if pb.web { y } else { self.anchor_y },
table_chr: None,
pinned: None,
late: Some(&self.late),
};
let (behind, front) = place_objects(
ctx,
@@ -529,6 +558,7 @@ impl<'a> Flow<'a> {
depth: 0,
},
);
self.late.drew();
items.extend(front);
items.extend(line_numbers(ctx, pb, &self.pl, (l0, l1), y));
add_items(pb, behind, items);
@@ -555,7 +585,8 @@ impl<'a> Flow<'a> {
}
Some(LineEnd::PageBreak) if starts_with_break && !pb.web => {
pb.new_page(block, body_top);
pb.y += self.pl.rp.space_before.max(0.0);
self.space_here = self.pl.rp.space_before.max(0.0);
pb.y += self.space_here;
}
Some(LineEnd::PageBreak) => pb.new_page(block, body_top),
Some(LineEnd::ColumnBreak) => {
@@ -578,8 +609,8 @@ impl<'a> Flow<'a> {
let y0 = pb.y;
// The floats were placed and drawn with the first piece: they stay there.
let (p, block, excl_mark, eq_start, label) = (self.p, self.block, self.excl_mark, self.eq_start, &self.label);
let (rects, relaid, relaid_rel) = with_page_map(ctx, &mut self.page_map, |ctx| {
again(ctx, eq_start, |ctx| anchor_floats(ctx, pb, FloatAnchor { p, block, y0, before: 0.0, excl_mark, label, place: false }))
let (rects, relaid, relaid_rel, _) = with_page_map(ctx, &mut self.page_map, |ctx| {
again(ctx, eq_start, |ctx| anchor_floats(ctx, pb, FloatAnchor { p, block, y0, before: 0.0, excl_mark, label, place: false }, &[]))
});
if let Some(k) = relaid.lines.iter().position(|l| l.start == resume) {
(self.float_rects, self.pl, self.rel) = (rects, relaid, relaid_rel);
@@ -589,6 +620,72 @@ impl<'a> Flow<'a> {
}
}
/// Late floats anchored in the lines that fit in this column are placed here, so these lines
/// are broken again around them ([`Flow::relay_wrap`]).
fn place_due_late(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder) {
if self.l0 == 0 || pb.web || self.late.placed.is_empty() {
return;
}
self.late.piece = (pb.pages.len(), pb.col);
let lines = self.l0..fitting(&self.pl, self.l0, pb.y, pb.bottom, (0, pb.top));
self.place_due(ctx, pb, lines, pb.y - self.space_here);
}
/// Place the late floats due with this piece whose anchor line is in `lines`, positioned from
/// `top`.
fn place_due(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, lines: std::ops::Range<usize>, top: f32) {
for oi in self.late.due(self.p, &self.pl, lines) {
place_late(ctx, pb, self.p, oi, top, &mut self.late);
}
}
/// Late floats placed for lines that went on after all (past `l1`) wait for the next piece;
/// one whose own wrap pushed its line on stays, as before. Whether the lines from `l0` are to
/// be broken again without them. Does nothing once the wrap relays are used up
/// ([`MAX_WRAP_RELAYS`]).
fn settle_late(&mut self, pb: &mut PageBuilder, l1: usize) -> bool {
let l0 = self.l0;
if l0 == 0 || pb.web || self.late.placed.is_empty() || self.wrap_relays >= MAX_WRAP_RELAYS {
return false;
}
let end = if l1 == l0 && pb.fresh() { l0 + 1 } else { l1 }; // what the overflow keeps
let moved = self.late.release(self.p, &self.pl, end, &mut pb.excl) || self.late.keep_back(self.p, &self.pl, l0..end);
moved && l1 > l0
}
/// The first piece's lines end at `l1` on this page. Floats anchored past them, on the next
/// page, though [`anchor_floats`] thought they would fit here (footnotes, widow control…),
/// are held back after all, once; and those held back whose anchor came back once they were
/// out of the way are placed here as before, with the text wrapping around them rather than
/// over it (before the notes of these lines are kept: the wrap can push their references on).
/// Whether the paragraph was laid out again.
fn recheck_late(&mut self, ctx: &mut Ctx, pb: &mut PageBuilder, l1: usize) -> bool {
if self.l0 != 0 || pb.web {
return false;
}
if l1 < self.n() && !self.rechecked && pb.col + 1 >= pb.cols.len() {
self.rechecked = true;
self.forced = late_floats(self.p, &self.pl, l1).into_iter().filter(|oi| !self.late.placed.contains_key(oi)).collect();
if !self.forced.is_empty() {
let mut force = self.forced.clone();
force.extend(self.late.placed.keys());
force.sort_unstable();
let top = (pb.y, pb.y - self.anchor_y);
self.reanchor(ctx, pb, top, &force);
return true;
}
}
let back = if self.forced.is_empty() { Vec::new() } else { self.late.due(self.p, &self.pl, 0..l1) };
if !back.iter().any(|oi| self.forced.contains(oi)) {
return false;
}
// The others are still held back.
self.forced.retain(|oi| !back.contains(oi));
let (keep, top) = (std::mem::take(&mut self.forced), (pb.y, pb.y - self.anchor_y));
self.reanchor(ctx, pb, top, &keep);
true
}
/// Leave the space after the paragraph, for the next one to merge with.
fn finish(&self, pb: &mut PageBuilder, rooms: Rooms) {
// A mark that stayed with its page break keeps its space after on that page too, and its
@@ -730,10 +827,11 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
// A different top only matters when there is something to wrap around.
if spot(pb) != placed_at || ((pb.y - f.guess).abs() > 0.01 && !pb.excl.is_empty()) {
let y0 = pb.y;
f.reanchor(ctx, pb, (y0, applied_before));
f.reanchor(ctx, pb, (y0, applied_before), &[]);
f.anchor_y = y0 - applied_before;
}
while f.l0 < f.n() {
f.place_due_late(ctx, pb);
f.relay_wrap(ctx, pb);
f.relay_pages(ctx, pb);
// What is laid out for these lines from here (their footnotes) is on this page.
@@ -744,6 +842,9 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
let notes = para_notes(ctx.doc, p, &f.pl);
let mut fit = f.fit_lines(ctx, pb, &notes, lead);
fit.l1 = f.settled_end(pb, &fit);
if f.settle_late(pb, fit.l1) {
continue;
}
if fit.l1 == f.l0 {
if !pb.fresh() {
f.move_on(ctx, pb);
@@ -751,6 +852,9 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
}
fit.l1 = f.l0 + 1; // can't fit even one line on an empty page: overflow
}
if f.recheck_late(ctx, pb, fit.l1) {
continue;
}
let (forced, mark_with_break) = (fit.forced, fit.mark_with_break);
f.emit(ctx, pb, rooms, (lead, fit, &notes));
f.after_lines(ctx, pb, forced, mark_with_break);
@@ -811,6 +915,7 @@ pub(crate) fn place_outline_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragr
para_y: y,
table_chr: None,
pinned: None,
late: None,
};
let (behind, front) = place_objects(
ctx,
+1
View File
@@ -204,6 +204,7 @@ pub(crate) fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<Rect> {
let fields = ctx.fields.clone();
let grid = std::mem::take(&mut ctx.grid);
let track = std::mem::replace(&mut ctx.track_totals, false);
ctx.fields.page_format = sect.page_num_format;
ctx.fields.page = page.number;
let mut areas = again(ctx, 0, |ctx| {
with_pct_base(ctx, base, |ctx| layout_box(ctx, BoxInput::other(StoryRef::Part(id), &blocks, sect.text_width(), 0, Some(frame)))).table_areas
+4
View File
@@ -72,6 +72,9 @@ pub struct ObjectHit {
pub wrap: Wrap,
/// Where column- and paragraph-relative offsets start (see [`Placed::Object`]).
pub origin: Point,
/// A member of a Drawing Canvas, by its index in the canvas, when the hit is that member
/// (its frame inside the canvas at `pos`) rather than the canvas. Layout gives `None`.
pub member: Option<usize>,
}
/// Object positions in one layout, retaining each page occurrence for page-hinted lookups.
@@ -149,6 +152,7 @@ fn objects_in(items: &[Placed], index: usize) -> impl Iterator<Item = ObjectHit>
text_box: *text_box,
wrap: *wrap,
origin: *origin,
member: None,
}),
_ => None,
})
+2
View File
@@ -9,6 +9,8 @@ use super::{Block, Border, FloatAlign, Page, ParaLayout, Rect, SectionProps, Sto
pub(crate) struct PageBuilder<'a> {
pub(crate) pages: Vec<Page>,
pub(crate) sect: &'a SectionProps,
/// The current document section's numbering format, even when reflow uses first-section page geometry.
pub(crate) page_format: wordcraft_doc::section::NumFormat,
pub(crate) sect_idx: usize,
pub(crate) col: usize,
pub(crate) cols: Vec<(f32, f32)>,
+1
View File
@@ -38,6 +38,7 @@ pub(crate) use chars::{ligature_features, remap_range, thai_run_face};
pub use grid::{Exclusion, Grid, is_east_asian};
#[cfg(test)]
use lines::break_lines;
pub(crate) use lines::takes_room;
pub use model::{CellText, ClKind, Cluster, Enclosed, Glyph, Label, LabelRun, Line, LineEnd, PARA_MARK_WIDTH, ParaEnv, RubyLayout, StyleRun, VisCl};
pub use paragraph::layout_para;
#[cfg(test)]
+8 -3
View File
@@ -109,7 +109,7 @@ fn has_content(pl: &ParaLayout, c0: usize, j: usize) -> bool {
/// control's start, a bookmark) and floating objects' anchors take no room and don't count (Word:
/// a picture a little wider than the line, after an anchor, stays on the first line rather than
/// leaving the anchor a line of its own).
fn takes_room(c: &Cluster) -> bool {
pub(crate) fn takes_room(c: &Cluster) -> bool {
c.kind != ClKind::Marker && !(matches!(c.kind, ClKind::Object(_)) && c.adv == 0.0 && c.obj_h == 0.0)
}
@@ -297,6 +297,9 @@ impl<'e> Breaker<'e> {
};
let end = lab.x + lab.width;
match self.level.map(|l| l.suffix).unwrap_or(LevelSuffix::Tab) {
// An empty label (a "none" level with no text) that ends right at the left indent takes
// no tab: the text starts there, as in Word (#4985), and not at the next stop.
LevelSuffix::Tab if lab.width <= 0.01 && (end - self.rp.indent_left).abs() <= 0.01 => end,
LevelSuffix::Tab => {
let t = next_tab(end, &self.rp.tabs, self.default_tab, self.hanging_at, self.tab_origin).pos;
// The level's own tab stop after the number, when it comes first.
@@ -438,8 +441,10 @@ impl<'e> Breaker<'e> {
if matches!(rp.line_spacing, LineSpacing::Exactly(_)) { (st.ascent, st.descent) } else { (c.obj_h, c.obj_d) }
} else {
ex.obj_asc = ex.obj_asc.max(c.obj_h);
// A lowered picture reaches below the baseline.
ex.obj_desc = ex.obj_desc.max(c.obj_d);
// A lowered picture reaches below the baseline, with exact spacing no further
// than its height (the line's box is fixed; its baseline stays where it was).
let reach = if matches!(rp.line_spacing, LineSpacing::Exactly(_)) { c.obj_d.min(c.obj_h + c.obj_d) } else { c.obj_d };
ex.obj_desc = ex.obj_desc.max(reach);
(st.ascent, st.descent)
}
} else {
+6 -5
View File
@@ -338,7 +338,8 @@ impl<'a> Shaped<'a> {
/// A picture, shape or text box: inline, as wide and high as it is; a floating one takes no room.
/// An inline drawing: it stands on the baseline, raised or lowered by its run's position
/// (`raise`, points, as Word moves an inline picture with `w:position`). Its cluster reaches
/// `obj_h` above the baseline and `obj_d` below it (negative when raised clear of it).
/// `obj_h` above the baseline and `obj_d` below it (negative when raised clear of it; `obj_h`
/// negative when lowered further than its height, drawn with its top on the baseline).
fn drawing(&mut self, cx: &Cx, (k, o): (usize, &InlineObject), slot: Slot, raise: f32) {
let env = cx.env;
// A hidden one takes no room, and with no height its line doesn't draw it.
@@ -368,10 +369,10 @@ impl<'a> Shaped<'a> {
let [el, et, er, eb] = float.effect_extent();
let (px, py) = float.spin_pad(w * s, h * s);
let tall = h * s + et + eb + 2.0 * py;
// Lowered at most until its top is on the baseline. The bound is made finite and
// not above `clamp`'s top whatever the extents give (`clamp` panics otherwise).
let floor = if tall.is_finite() { -tall.max(0.0) } else { 0.0 };
let raise = if raise.is_finite() { raise.clamp(floor, 1584.0) } else { 0.0 };
// Lowered further than its height, its top stays on the baseline while the line
// reaches as far down as the lowering (Word, measured: a 6 pt picture lowered 30 pt
// makes a 30 pt line); `obj_h` is then negative. Word's positions run to 1584 pt.
let raise = if raise.is_finite() { raise.clamp(-1584.0, 1584.0) } else { 0.0 };
self.b.clusters.push(slot.cluster(ClKind::Object(k), w * s + el + er + 2.0 * px, (tall + raise, -raise)));
} else {
self.push(slot, ClKind::Object(k), 0.0, 0.0);
+21 -3
View File
@@ -43,6 +43,7 @@ struct Tops {
struct Sec<'p> {
si: usize,
sect: &'p SectionProps,
page_format: wordcraft_doc::section::NumFormat,
start: SectionStart,
tops: Tops,
}
@@ -158,10 +159,17 @@ fn reflow_sect(first: &SectionProps, opts: &LayoutOptions) -> SectionProps {
s
}
fn new_page_builder<'p>(sect: &'p SectionProps, early: &'p EarlyFloats, web: bool, settle: bool) -> PageBuilder<'p> {
fn new_page_builder<'p>(
sect: &'p SectionProps,
page_format: wordcraft_doc::section::NumFormat,
early: &'p EarlyFloats,
web: bool,
settle: bool,
) -> PageBuilder<'p> {
PageBuilder {
pages: Vec::new(),
sect,
page_format,
sect_idx: 0,
col: 0,
cols: Vec::new(),
@@ -221,6 +229,7 @@ fn turned_sections(ctx: &mut Ctx, sections: &[(usize, &SectionProps)], web: bool
.iter()
.map(|(_, s)| {
(!web && s.text_direction.is_turned()).then(|| {
ctx.fields.page_format = s.page_num_format;
let top = body_top_for(ctx, s, s.headers.default);
vertical::unturned_sect(s, top)
})
@@ -232,6 +241,7 @@ fn turned_sections(ctx: &mut Ctx, sections: &[(usize, &SectionProps)], web: bool
/// previous section's, and take its room. `fixed`: the margins are the room (continuous views and
/// turned sections).
fn section_tops(ctx: &mut Ctx, hf: &HfLinks, (si, sect): (usize, &SectionProps), fixed: bool) -> Tops {
ctx.fields.page_format = sect.page_num_format;
let (heads, feet) = (hf.heads.set(si), hf.feet.set(si));
let hb = |i: Option<u32>| hf.defining_width(false, si, i);
let fb = |i: Option<u32>| hf.defining_width(true, si, i);
@@ -246,6 +256,8 @@ fn section_tops(ctx: &mut Ctx, hf: &HfLinks, (si, sect): (usize, &SectionProps),
fn begin_section_state<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, sec: &Sec<'p>, totals: &Totals, web: bool) {
let (doc, sect, si) = (ctx.doc, sec.sect, sec.si);
pb.sect = sect;
pb.page_format = sec.page_format;
ctx.fields.page_format = sec.page_format;
pb.sect_idx = si;
pb.sect_beneath = doc.note_options(sect, false).pos == wordcraft_doc::section::NotePos::BeneathText;
if !web && !doc.footnote_separators.is_empty() {
@@ -263,6 +275,8 @@ fn begin_section_state<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, sec: &Sec<'p
fn end_previous_section<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, hf: &HfLinks<'p>, si: usize, block: usize) {
let Some((_, prev)) = si.checked_sub(1).and_then(|k| hf.sections.get(k)) else { return };
pb.sect = prev;
pb.page_format = prev.page_num_format;
ctx.fields.page_format = prev.page_num_format;
pb.sect_idx = si - 1;
let (ph, pf) = (hf.heads.set(si - 1), hf.feet.set(si - 1));
let top = body_top_for(ctx, prev, ph.default);
@@ -281,6 +295,8 @@ fn start_section_page<'p>(ctx: &mut Ctx, pb: &mut PageBuilder<'p>, hf: &HfLinks<
if filler && !mode.web {
end_previous_section(ctx, pb, hf, sec.si, block);
pb.sect = sec.sect;
pb.page_format = sec.page_format;
ctx.fields.page_format = sec.page_format;
pb.sect_idx = sec.si;
}
if restart.is_some() {
@@ -492,7 +508,7 @@ pub(crate) fn layout_pass(
let first_sect = sections.first().map_or(&default_sect, |(_, s)| *s);
let web_sect = mode.reflow.then(|| reflow_sect(first_sect, opts));
let sect_ref = web_sect.as_ref().unwrap_or(first_sect);
let mut pb = new_page_builder(sect_ref, early, mode.web, !may_rewrap && !mode.reflow);
let mut pb = new_page_builder(sect_ref, first_sect.page_num_format, early, mode.web, !may_rewrap && !mode.reflow);
let mut endnotes = endnote_queue(&ctx);
// Text boxes laid out in balancing attempts that were thrown away.
let mut boxes_discarded = 0usize;
@@ -500,15 +516,17 @@ pub(crate) fn layout_pass(
let turned = turned_sections(&mut ctx, sections, mode.web);
let mut prev_turn = TextDirection::Horizontal;
for (si, (end, sect)) in sections.iter().enumerate() {
let page_format = sect.page_num_format;
let turn = if mode.reflow { TextDirection::Horizontal } else { sect.text_direction };
let sect: &SectionProps = match turned.get(si) {
Some(Some(t)) => t,
_ if mode.reflow => sect_ref,
_ => sect,
};
ctx.fields.page_format = page_format;
let tops = section_tops(&mut ctx, &hf, (si, sect), mode.reflow || turn.is_turned());
let start = if si == 0 { SectionStart::NextPage } else { sect.start };
let sec = Sec { si, sect, start, tops };
let sec = Sec { si, sect, page_format, start, tops };
if !mode.web && !pb.pages.is_empty() && (start != SectionStart::Continuous || turn != prev_turn) {
// The page the section before ends on is finished with its own margins and footnote
// separators, before this section's replace them: the footnotes pending on it
+74 -17
View File
@@ -125,9 +125,11 @@ struct Entry {
runs: Vec<(String, String)>,
/// The list label as shown (`Chapter 2.`).
label: Option<String>,
/// Each level's value up to the paragraph's, formatted; and whether the label's own
/// template shows that level.
levels: Vec<(String, bool)>,
/// Each level up to the paragraph's that has a number (not a bullet, nor a "none" level):
/// its index, its value formatted, and whether the label's own template shows it.
levels: Vec<(u8, String, bool)>,
/// The paragraph's own list level.
level: u8,
/// How many paragraphs of its style there are up to this one.
ordinal: u32,
}
@@ -180,7 +182,7 @@ impl StyleIndex {
*ord = ord.saturating_add(1);
let ordinal = *ord;
let num = p.props.numbering.or_else(|| doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0);
let (mut label, mut levels) = (None, Vec::new());
let (mut label, mut levels, mut level) = (None, Vec::new(), 0u8);
if let Some(n) = num
&& let Some((l, def)) = counters.next_label(&doc.numbering, n.num, n.level)
&& def.format != NumFormat::Bullet
@@ -193,9 +195,10 @@ impl StyleIndex {
}
let v = if *v == 0 { lv.start } else { *v };
let fmt = if def.legal && lv.format != NumFormat::DecimalZero { NumFormat::Decimal } else { lv.format };
levels.push((fmt.format(v), def.text.contains(&format!("%{}", k + 1))));
levels.push((k as u8, fmt.format(v), def.text.contains(&format!("%{}", k + 1))));
}
label = Some(l);
level = n.level.min(8);
}
let mut runs: Vec<(String, String)> = Vec::new();
let mut off = 0usize;
@@ -221,7 +224,7 @@ impl StyleIndex {
list.push(i);
}
}
entries.push(Entry { path, style, text: clean(&p.plain_text()), runs, label, levels, ordinal });
entries.push(Entry { path, style, text: clean(&p.plain_text()), runs, label, levels, level, ordinal });
}
StyleIndex { entries, by_path, styles, para_entries, char_entries }
}
@@ -323,18 +326,32 @@ impl StyleIndex {
let mut out = match code.number {
Number::Text if code.position => String::new(),
Number::Text => text,
Number::Chapter => e.label.as_deref().map(chapter_number).filter(|s| !s.is_empty()).unwrap_or_else(|| e.ordinal.to_string()),
// The last number the label shows (`3` of `3 Response`, or of a "none" level's `%1`);
// a label of the paragraph's own number that shows none recognisably, its last word;
// else, as for an unnumbered heading or a "none" level's `Note`, the heading's count.
Number::Chapter => {
let own = e.levels.iter().any(|(k, ..)| *k == e.level);
e.label
.as_deref()
.and_then(|l| {
let shown = l.split_whitespace().rev().find_map(|w| number_span(w, &e.levels));
shown.map(|n| n.trim_matches(|c: char| !c.is_alphanumeric()).to_string()).or_else(|| own.then(|| chapter_number(l)))
})
.filter(|s| !s.is_empty())
.unwrap_or_else(|| e.ordinal.to_string())
}
Number::Plain | Number::Full => {
let own = match &e.label {
Some(l) if code.suppress => suppress(l, &e.levels),
Some(l) if code.suppress => suppress(l, &e.levels, e.level),
Some(l) => l.trim().trim_end_matches('.').to_string(),
None => e.ordinal.to_string(),
};
if code.number == Number::Full && e.label.is_some() {
// Levels above that the label's own template doesn't show.
let above: Vec<&str> =
e.levels.iter().take(e.levels.len().saturating_sub(1)).filter(|(_, shown)| !shown).map(|(v, _)| v.as_str()).collect();
if above.is_empty() { own } else { format!("{}.{own}", above.join(".")) }
// Levels above the paragraph's that the label's own template doesn't show. By
// level, not position: a "none" level has no entry, its own (#4985) included.
let above: Vec<&str> = e.levels.iter().filter(|(k, _, shown)| *k < e.level && !shown).map(|(_, v, _)| v.as_str()).collect();
// An empty label of its own (a "none" level with no text) adds no part: `3`, not `3.`.
above.into_iter().chain(Some(own.as_str()).filter(|o| !o.is_empty())).collect::<Vec<_>>().join(".")
} else {
own
}
@@ -356,13 +373,46 @@ fn chapter_number(label: &str) -> String {
word.trim_matches(|c: char| !c.is_alphanumeric()).to_string()
}
/// `\t`: the label without its words that hold no number (`Chapter 2.` → `2`).
fn suppress(label: &str, levels: &[(String, bool)]) -> String {
let kept: Vec<&str> = label.split_whitespace().filter(|w| levels.iter().any(|(v, _)| !v.is_empty() && w.contains(v.as_str()))).collect();
let s = if kept.is_empty() { label.trim().to_string() } else { kept.join(" ") };
/// `\t`: the label without its words that hold no number (`Chapter 2.` → `2`). A "none" level's
/// label (`level` has no number of its own) holding none is left with nothing (`Note`, #4985);
/// any other label that holds none is kept whole.
fn suppress(label: &str, levels: &[(u8, String, bool)], level: u8) -> String {
// Each word's numbers, without the text attached to them (`Section3` → `3`).
let kept: Vec<&str> = label.split_whitespace().filter_map(|w| number_span(w, levels)).collect();
let none = !levels.iter().any(|(k, ..)| *k == level);
let s = match kept.is_empty() {
true if none => String::new(),
true => label.trim().to_string(),
false => kept.join(" "),
};
s.trim_end_matches('.').to_string()
}
/// The part of `word` that holds the numbers the label's template shows (`levels` flagged shown),
/// with the delimiters around it (`(3)`, `1.1`, `2.`), without the text attached (`Section3` →
/// `3`); `None` when it holds none. A number written in letters (`a`, `iv`) counts only standing
/// apart from other letters: the `a` of `Chapter` isn't one.
fn number_span<'w>(word: &'w str, levels: &[(u8, String, bool)]) -> Option<&'w str> {
let alpha = |c: Option<char>| c.is_some_and(char::is_alphabetic);
let mut span: Option<(usize, usize)> = None;
for (_, v, _) in levels.iter().filter(|(_, v, shown)| *shown && !v.is_empty()) {
let letters = v.chars().any(char::is_alphabetic);
for (i, _) in word.match_indices(v.as_str()) {
let end = i.saturating_add(v.len());
let before = word.get(..i).and_then(|s| s.chars().next_back());
let after = word.get(end..).and_then(|s| s.chars().next());
if letters && (alpha(before) || alpha(after)) {
continue;
}
span = Some(span.map_or((i, end), |(a, b)| (a.min(i), b.max(end))));
}
}
let (a, b) = span?;
let lead: usize = word.get(..a)?.chars().rev().take_while(|c| !c.is_alphanumeric()).map(char::len_utf8).sum();
let trail: usize = word.get(b..)?.chars().take_while(|c| !c.is_alphanumeric()).map(char::len_utf8).sum();
word.get(a.saturating_sub(lead)..b.saturating_add(trail))
}
/// Does `p` hold a STYLEREF field?
pub fn has_styleref(p: &wordcraft_doc::Paragraph) -> bool {
p.objects.iter().any(is_styleref)
@@ -441,7 +491,14 @@ mod tests {
#[test]
fn suppress_keeps_the_number() {
assert_eq!(suppress("Chapter 2.", &[("2".into(), true)]), "2");
assert_eq!(suppress("Chapter 2.", &[(0, "2".into(), true)], 0), "2");
assert_eq!(suppress("Chapter", &[(0, "a".into(), false)], 1), "", "an ancestor's number the label doesn't show");
assert_eq!(suppress("Section3", &[(0, "3".into(), true)], 1), "3", "the text attached to the number");
assert_eq!(suppress("Chapter a.", &[(0, "a".into(), true)], 0), "a", "a letter number standing apart, not Chapter's a");
assert_eq!(suppress("(iv)", &[(0, "iv".into(), true)], 0), "(iv)", "delimiters stay");
assert_eq!(suppress("1.2", &[(0, "1".into(), true), (1, "2".into(), true)], 1), "1.2");
assert_eq!(suppress("Note", &[(0, "3".into(), false)], 1), "", "a none level's label");
assert_eq!(suppress("Appendix", &[(0, "3".into(), false)], 0), "Appendix", "kept whole when the level has a number");
assert_eq!(chapter_number("Chapter IV."), "IV");
}
}
+401 -6
View File
@@ -1820,6 +1820,30 @@ fn lists_get_labels_and_indent() {
assert!((x - 108.0).abs() < 0.5, "{x}");
}
#[test]
fn a_none_format_label_shows_its_text_and_tabs_to_the_indent() {
// #4985: Word draws a "none" level's literal text ("RESPONSE:"), and an empty one still tabs
// the text to a hanging indent, as a numbered item's. An empty label already at the left indent
// takes no tab (Word's Chapter 1 list: Heading 2 starts at the margin, not at 0.5").
use wordcraft_doc::section::NumFormat;
for (text, indent, hanging, want) in [("RESPONSE:", 72.0, 72.0, 144.0), ("", 72.0, 72.0, 144.0), ("", 0.0, 0.0, 72.0), ("", 50.0, 0.0, 122.0)] {
let mut d = Document::from_text("X");
let id = std::sync::Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered);
for a in &mut std::sync::Arc::make_mut(&mut d.numbering).abstracts {
if let Some(l) = a.levels.first_mut() {
l.format = NumFormat::None;
l.text = text.into();
(l.indent, l.hanging) = (indent, hanging);
}
}
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 1), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap();
let l = lay(&d);
assert_eq!(labels(&l), [text]);
let x = l.caret(&Pos::body(0, 0)).unwrap().x;
assert!((x - want).abs() < 0.5, "{text:?} at {indent}/{hanging}: {x}, not {want}");
}
}
#[test]
fn break_only_list_has_no_label() {
use wordcraft_doc::ListKind;
@@ -5598,8 +5622,13 @@ fn an_inline_picture_moves_with_its_runs_position() {
assert!(line.top + line.height - line.baseline >= 30.0 - 0.01, "{line:?}");
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv);
assert_eq!((r.y, r.h), (100.0, 30.0));
// Lowered further than that: still only to its top.
assert_eq!(line_of(-500.0).0.obj_d, 30.0);
// Lowered further than that: the line reaches as far down, the picture's top staying on the
// baseline (Word).
let (c, line, _) = line_of(-500.0);
assert_eq!((c.obj_h, c.obj_d), (-470.0, 500.0));
assert!(line.top + line.height - line.baseline >= 500.0 - 0.01, "{line:?}");
let r = display::inline_rect(Some(&picture(40.0, 30.0, Default::default())), 0.0, 100.0, c.adv, (c.obj_h, c.obj_d), c.adv);
assert_eq!((r.y, r.h), (100.0, 30.0));
// Raised: it stands clear of the baseline, as tall as before.
let (c, _, _) = line_of(6.0);
assert_eq!((c.obj_h, c.obj_d), (36.0, -6.0));
@@ -6036,9 +6065,8 @@ fn float_follows_its_paragraph_to_the_next_page() {
assert!(para.lines[0].left >= 144.0, "text wraps beside it: {}", para.lines[0].left);
}
/// A floating object belongs to its paragraph, not to the line holding its anchor: Word draws
/// it on the page the paragraph starts on. A Word cover page is one paragraph holding a page
/// break and then the page-anchored text box with the title; the box stays on the cover.
/// A Word cover page is one paragraph holding a page break and then the page-anchored text box
/// with the title: with nothing after its anchor, the box stays on the cover.
#[test]
fn a_float_anchored_after_a_page_break_stays_on_its_paragraph_s_first_page() {
let mut d = Document::from_text(&format!("{}\nAfter the cover", wordcraft_doc::para::PAGE_BREAK));
@@ -6051,6 +6079,347 @@ fn a_float_anchored_after_a_page_break_stays_on_its_paragraph_s_first_page() {
assert!(shapes(&l.pages[1].items).is_empty(), "and not again on page two");
}
/// #4262: a bookmark after the box's anchor (or a comment's end, a field's) takes no room: the box
/// still stays on the cover. An inline picture after it does take room: it goes on to page two,
/// and the box with it.
#[test]
fn a_cover_box_stays_on_the_cover_with_a_bookmark_after_it_but_not_with_a_picture() {
let cover = |after: InlineObject| {
let mut d = Document::from_text(&format!("{}\nAfter the cover", wordcraft_doc::para::PAGE_BREAK));
d.insert_object(&Pos::body(0, 1), after, &Default::default()).unwrap();
d.insert_object(&Pos::body(0, 1), rect_shape(200.0, 100.0, page_float(100.0, 300.0)), &Default::default()).unwrap();
let l = lay(&d);
// The floating box; the inline picture is 20 pt wide.
let boxes = |pi: usize| shapes(&l.pages[pi].items).iter().filter(|r| r.w > 100.0).count();
(boxes(0), boxes(1))
};
assert_eq!(cover(InlineObject::BookmarkEnd { name: "_GoBack".into() }), (1, 0), "with a bookmark: on the cover");
assert_eq!(cover(rect_shape(20.0, 20.0, Default::default())), (0, 1), "with an inline picture: on page two");
}
/// #4262: with text after the anchor, though, the line after the break is on the next page, and
/// so is the float anchored at its start. Observed in Word for Mac: a shape dragged beside the
/// first line after a page break inside a paragraph is anchored right after the break and stays
/// on the new page, positioned from the paragraph's top there.
#[test]
fn a_float_anchored_before_text_after_a_page_break_is_drawn_on_the_next_page() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let text = "The quick brown fox jumps over the lazy dog. ";
let mut d = Document::from_text(&format!("{}{}{}", text.repeat(5), wordcraft_doc::para::PAGE_BREAK, text.repeat(5)));
let at = text.len() * 5 + wordcraft_doc::para::PAGE_BREAK.len_utf8();
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 60.0, y: 1.0, ..Default::default() };
d.insert_object(&Pos::body(0, at), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let l = lay(&d);
assert_eq!(l.pages.len(), 2);
assert!(shapes(&l.pages[0].items).is_empty(), "not on page one");
let (top, ..) = lines_on(&l, 1)[0];
assert_eq!(shapes(&l.pages[1].items).len(), 1, "drawn once");
let r = shapes(&l.pages[1].items)[0];
assert!((r.y - (top + 1.0)).abs() < 0.5, "1 pt below the paragraph's top on page two ({top}): {r:?}");
}
/// The lines of body paragraph 0 on page `pi`: each one's (top on the page, left, start).
fn lines_on(l: &DocLayout, pi: usize) -> Vec<(f32, f32, usize)> {
let mut out = Vec::new();
for it in &l.pages[pi].items {
if let Placed::Lines { para, l0, l1, y, story: StoryRef::Body, path, .. } = it
&& path.0 == [0]
{
let first = para.lines[*l0].top;
out.extend(para.lines[*l0..*l1].iter().map(|li| (y + li.top - first, li.left, li.start)));
}
}
out
}
/// #4250: a floating object anchored in a line of a long paragraph on a later page is drawn on
/// that page, as Word does (its anchor line's page), not on the page the paragraph starts on.
/// That page's text wraps around it; the page before is laid out as without it.
#[test]
fn a_float_anchored_on_a_later_page_of_its_paragraph_is_drawn_on_that_page() {
use wordcraft_doc::para::Wrap;
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let plain = lay(&d);
assert!(plain.pages.len() >= 3, "pages {}", plain.pages.len());
let anchor = lines_on(&plain, 1)[5].2;
let f = wordcraft_doc::para::Float { wrap: Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let l = lay(&d);
assert!(shapes(&l.pages[0].items).is_empty(), "not on the page the paragraph starts on");
assert_eq!(l.pages.iter().map(|p| shapes(&p.items).len()).sum::<usize>(), 1, "drawn once");
let on_two = shapes(&l.pages[1].items);
assert_eq!(on_two.len(), 1, "{on_two:?}");
assert!((on_two[0].x - 72.0).abs() < 0.5 && (on_two[0].y - 300.0).abs() < 0.5, "{:?}", on_two[0]);
let beside: Vec<_> = lines_on(&l, 1).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 350.0).collect();
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 100.0), "the text beside it wraps: {beside:?}");
let starts = |l: &DocLayout| lines_on(l, 0).iter().map(|(_, left, start)| (*left, *start)).collect::<Vec<_>>();
assert_eq!(starts(&l), starts(&plain), "page one is as without it");
// Its hit area is there too, for selecting and dragging it.
let hit = l.object(&Pos::body(0, anchor), 0).expect("laid out");
assert_eq!(hit.page, 1);
}
/// #4250: positioned from its paragraph, a float anchored on a later page of the paragraph is
/// measured from where the paragraph continues on that page (Word saves such a picture dragged
/// onto page two as `relativeFrom="paragraph"` with its offset from that page's top line).
#[test]
fn a_float_from_its_paragraph_on_a_later_page_is_measured_from_the_paragraph_s_top_there() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let plain = lay(&d);
let (top, _, _) = lines_on(&plain, 1)[0];
let anchor = lines_on(&plain, 1)[8].2;
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 120.0, y: 150.0, dist: 9.0, ..Default::default() };
d.insert_object(&Pos::body(0, anchor), rect_shape(130.0, 100.0, f), &Default::default()).unwrap();
let l = lay(&d);
assert!(shapes(&l.pages[0].items).is_empty());
let r = shapes(&l.pages[1].items)[0];
assert!((r.y - (top + 150.0)).abs() < 0.5, "150 pt below the paragraph's top on page two ({top}): {r:?}");
assert!((r.x - 192.0).abs() < 0.5, "{r:?}");
let hit = l.object(&Pos::body(0, anchor), 1).expect("laid out");
assert!((hit.origin.y - top).abs() < 0.5, "dragging measures from there too: {:?}", hit.origin);
}
/// #4250: a paragraph starting with a page break has its space before on the new page; a float
/// from its paragraph anchored there is still measured from the paragraph's top, above that
/// space, as on its first page.
#[test]
fn a_float_after_a_leading_page_break_is_measured_from_above_the_space_before() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let text = format!("Page one.\n{}{}", wordcraft_doc::para::PAGE_BREAK, "The quick brown fox jumps over the lazy dog. ".repeat(20));
let mut d = Document::from_text(&text);
if let Some(wordcraft_doc::Block::Para(p)) = d.body.get_mut(1).map(std::sync::Arc::make_mut) {
p.props.space_before = Some(24.0);
}
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, y: 150.0, dist: 9.0, ..Default::default() };
d.insert_object(&Pos::body(1, 200), rect_shape(130.0, 100.0, f), &Default::default()).unwrap();
let l = lay(&d);
assert!(shapes(&l.pages[0].items).is_empty());
let r = shapes(&l.pages[1].items)[0];
assert!((r.y - (72.0 + 150.0)).abs() < 0.5, "150 pt below the top margin: {r:?}");
}
/// #4250, as observed in Word for Mac: lines added at the top of page one push the text down,
/// but a picture positioned from its paragraph on page two stays where it is there.
#[test]
fn text_added_on_page_one_leaves_a_float_on_page_two_where_it_is() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let anchor = lines_on(&lay(&d), 1)[8].2;
let f = Float { wrap: Wrap::Square, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, y: 150.0, ..Default::default() };
d.insert_object(&Pos::body(0, anchor), rect_shape(130.0, 100.0, f), &Default::default()).unwrap();
let before = shapes(&lay(&d).pages[1].items);
for _ in 0..3 {
d.body.insert(0, wordcraft_doc::para_block(wordcraft_doc::Paragraph::new()));
}
let l = lay(&d);
assert!(shapes(&l.pages[0].items).is_empty());
assert_eq!(shapes(&l.pages[1].items), before);
}
/// #4250: anchored on the third page of a paragraph, a float is drawn on the third page.
#[test]
fn a_float_anchored_two_pages_into_its_paragraph_is_drawn_on_that_page() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(600));
let plain = lay(&d);
assert!(plain.pages.len() >= 4, "pages {}", plain.pages.len());
let anchor = lines_on(&plain, 2)[3].2;
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let l = lay(&d);
let per_page: Vec<usize> = l.pages.iter().map(|p| shapes(&p.items).len()).collect();
assert_eq!(&per_page[..3], [0, 0, 1], "{per_page:?}");
assert_eq!(per_page.iter().sum::<usize>(), 1);
}
/// A long paragraph with a long footnote on page one, and a page-placed square float anchored at
/// line `k` of page two as laid out without it.
fn footnoted_float(k: usize) -> DocLayout {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let mut note = wordcraft_doc::Paragraph::with_text(&"The note text runs on for a while. ".repeat(30), Default::default()).styled("FootnoteText");
note.props.space_after = Some(0.0);
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
d.insert_object(
&Pos::body(0, 4),
InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() },
&Default::default(),
)
.unwrap();
let anchor = lines_on(&lay(&d), 1)[k].2;
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
lay(&d)
}
/// #4250: a footnote on page one leaves less room there than its lines alone suggest; a float
/// anchored a few lines into page two is drawn on page two all the same.
#[test]
fn a_float_anchored_just_past_a_page_of_footnotes_is_drawn_on_the_next_page() {
let l = footnoted_float(3);
assert!(shapes(&l.pages[0].items).is_empty(), "not on page one");
assert_eq!(shapes(&l.pages[1].items).len(), 1);
}
/// #4250: anchored in page two's first line, which goes back to page one once the float no longer
/// wraps the text there, a float stays on page one with the text wrapping around it, not over it.
#[test]
fn a_float_whose_anchor_comes_back_to_page_one_stays_there_with_the_text_around_it() {
let l = footnoted_float(0);
assert_eq!(l.pages.iter().map(|p| shapes(&p.items).len()).collect::<Vec<_>>()[..2], [1, 0]);
let beside: Vec<_> = lines_on(&l, 0).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 350.0).collect();
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 100.0), "the text wraps around it: {beside:?}");
}
/// #4250: the text wrapping around that float again pushes a footnote reference beside its anchor
/// back to page two; the note goes with it, once, not left on page one as well.
#[test]
fn a_footnote_pushed_on_by_a_float_coming_back_is_on_its_reference_s_page_only() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let note = |text: &str| {
let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default()).styled("FootnoteText");
p.props.space_after = Some(0.0);
vec![wordcraft_doc::para_block(p)]
};
let noteref = |id| InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() };
let long = d.add_part(wordcraft_doc::PartKind::Footnote, note(&"The note text runs on for a while. ".repeat(30)));
d.insert_object(&Pos::body(0, 4), noteref(long), &Default::default()).unwrap();
let anchor = lines_on(&lay(&d), 1)[0].2;
let short = d.add_part(wordcraft_doc::PartKind::Footnote, note("A short note."));
d.insert_object(&Pos::body(0, anchor), noteref(short), &Default::default()).unwrap();
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let l = lay(&d);
assert_eq!(shapes(&l.pages[0].items).len(), 1, "the float stays on page one");
let at = anchor + wordcraft_doc::para::OBJ.len_utf8();
let ref_page = (0..l.pages.len()).rfind(|&pi| lines_on(&l, pi).first().is_some_and(|f| f.2 <= at));
assert_eq!(ref_page, Some(1), "the reference is pushed on to page two");
let notes: Vec<usize> = l
.pages
.iter()
.map(|pg| pg.items.iter().filter(|i| matches!(i, Placed::Lines { story: StoryRef::Part(p), .. } if *p == short)).count())
.collect();
assert_eq!(notes[..2], [0, 1], "the note is on its reference's page, once");
}
/// #4250: of two floats held back for page two, the one whose anchor comes back to page one stays
/// there; the other is still drawn on page two.
#[test]
fn of_two_floats_held_back_only_the_one_whose_anchor_comes_back_stays_on_page_one() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let mut note = wordcraft_doc::Paragraph::with_text(&"The note text runs on for a while. ".repeat(30), Default::default()).styled("FootnoteText");
note.props.space_after = Some(0.0);
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
let noteref = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() };
d.insert_object(&Pos::body(0, 4), noteref, &Default::default()).unwrap();
let lines = lines_on(&lay(&d), 1);
let f = |y| wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, y) };
d.insert_object(&Pos::body(0, lines[3].2), rect_shape(100.0, 50.0, f(400.0)), &Default::default()).unwrap();
d.insert_object(&Pos::body(0, lines[0].2), rect_shape(100.0, 50.0, f(300.0)), &Default::default()).unwrap();
let l = lay(&d);
let ys = |pi: usize| shapes(&l.pages[pi].items).iter().map(|r| r.y).collect::<Vec<_>>();
assert_eq!((ys(0).len(), ys(1).len()), (1, 1), "one on each page: {:?} {:?}", ys(0), ys(1));
}
/// #4250: a footnote referenced on page two leaves less room there than its lines alone suggest;
/// a float anchored on the first line it pushes on to page three is drawn on page three.
#[test]
fn a_float_anchored_on_a_line_a_footnote_pushes_on_is_drawn_on_the_next_page() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(600));
let mut note = wordcraft_doc::Paragraph::with_text(&"The note text runs on for a while. ".repeat(30), Default::default()).styled("FootnoteText");
note.props.space_after = Some(0.0);
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
let noteref = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() };
let at = lines_on(&lay(&d), 1)[1].2;
d.insert_object(&Pos::body(0, at), noteref, &Default::default()).unwrap();
// A word into the line: an anchor at its very start goes with the line before.
let anchor = lines_on(&lay(&d), 2)[0].2 + "The ".len();
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let l = lay(&d);
let anchor_page = (0..l.pages.len()).rfind(|&pi| lines_on(&l, pi).first().is_some_and(|f| f.2 <= anchor));
let per_page: Vec<usize> = l.pages.iter().map(|p| shapes(&p.items).len()).collect();
assert_eq!(anchor_page, Some(2), "the anchor is on page three");
assert_eq!(&per_page[..3], [0, 0, 1], "{per_page:?}");
let beside: Vec<_> = lines_on(&l, 1).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 350.0).collect();
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left < 1.0), "page two doesn't wrap around it: {beside:?}");
}
/// #4250: widow control takes the line before a paragraph's last on to the next page with it; a
/// float anchored there is drawn on that page.
#[test]
fn a_float_anchored_on_a_line_widow_control_takes_on_is_drawn_on_the_next_page() {
// A paragraph of three pages whose last two lines are on the third only by widow control.
let (mut d, l) = (150..300)
.map(|k| Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(k)))
.map(|d| {
let l = lay(&d);
(d, l)
})
.find(|(_, l)| l.pages.len() == 3 && lines_on(l, 2).len() == 2 && lines_on(l, 1).len() < lines_on(l, 0).len())
.expect("a paragraph ending in a widow");
let anchor = lines_on(&l, 2)[0].2 + "The ".len();
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, page_float(72.0, 300.0)), &Default::default()).unwrap();
let per_page: Vec<usize> = lay(&d).pages.iter().map(|p| shapes(&p.items).len()).collect();
assert_eq!(per_page, [0, 0, 1]);
}
/// #4250: a float whose own wrap pushes its anchor line on to the next page stays on the page
/// whose lines it was placed with, as before, with the text there wrapping around it.
#[test]
fn a_float_whose_wrap_pushes_its_own_line_on_stays_with_the_text_around_it() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(600));
let last = *lines_on(&lay(&d), 1).last().unwrap();
let anchor = last.2 + "The ".len();
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(200.0, 100.0, f), &Default::default()).unwrap();
let l = lay(&d);
let anchor_page = (0..l.pages.len()).rfind(|&pi| lines_on(&l, pi).first().is_some_and(|f| f.2 <= anchor));
assert_eq!(anchor_page, Some(2), "its wrap pushes the anchor on to page three");
let per_page: Vec<usize> = l.pages.iter().map(|p| shapes(&p.items).len()).collect();
assert_eq!(&per_page[..3], [0, 1, 0], "{per_page:?}");
let beside: Vec<_> = lines_on(&l, 1).into_iter().filter(|(y, ..)| *y > 300.0 && *y < 400.0).collect();
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 200.0), "the text wraps around it: {beside:?}");
}
/// #4250: holding back a float for a later page lays its paragraph out again; its automatic
/// equation numbers aren't counted twice: they read (1), (2).
#[test]
fn a_paragraph_with_a_float_for_a_later_page_keeps_its_equation_numbers() {
let eq = |s: &str| InlineObject::Equation {
linear: s.into(),
display: true,
math: wordcraft_doc::math::Math { nodes: wordcraft_doc::math::parse_linear(s), ..Default::default() },
};
let mut d = Document::from_text(&format!("{}\n", "The quick brown fox jumps over the lazy dog. ".repeat(400)));
let anchor = lines_on(&lay(&d), 1)[5].2;
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 300.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let n = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().len();
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().insert_object(n, eq("a=b#"), &Default::default()).unwrap();
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().insert_object(0, eq("c=d#"), &Default::default()).unwrap();
let l = lay(&d);
assert_eq!(shapes(&l.pages[1].items).len(), 1, "held back for page two");
assert_eq!(equation_numbers(&l), ["(1)", "(2)"]);
}
/// #4250: in two columns, a float anchored where the paragraph continues in the second column of
/// the same page is placed with its first piece, as before, so the first column wraps around it.
#[test]
fn a_float_anchored_in_the_next_column_of_the_page_stays_with_its_first_piece() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(120));
d.last_section.columns.count = 2;
let plain = lay(&d);
let lines = lines_on(&plain, 0);
let second = lines.iter().position(|w| w.0 < lines[0].0 + 0.5 && w.2 > 0).expect("a second column on page one");
let anchor = lines[second + 2].2;
let f = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..page_float(72.0, 200.0) };
d.insert_object(&Pos::body(0, anchor), rect_shape(100.0, 50.0, f), &Default::default()).unwrap();
let l = lay(&d);
assert_eq!(shapes(&l.pages[0].items).len(), 1, "on page one");
let beside: Vec<_> = lines_on(&l, 0).into_iter().filter(|(y, left, start)| *y > 200.0 && *y < 250.0 && *start < anchor && *left >= 0.0).collect();
assert!(!beside.is_empty() && beside.iter().all(|(_, left, _)| *left >= 100.0), "the first column wraps around it: {beside:?}");
}
/// Floating objects are drawn in their stacking order (`relativeHeight`), not in the order of
/// their anchors: a panel anchored after the text box it frames, but lower in the stack, is
/// drawn first so the box stays visible over it. Objects without an order keep theirs.
@@ -13354,7 +13723,7 @@ fn lowered_pictures_and_check_boxes_take_words_line_heights() {
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
let c = para.clusters.iter().find(|c| matches!(c.kind, para::ClKind::Object(_))).unwrap();
let (line, st) = (&para.lines[0], &para.styles[c.style as usize]);
(line.height, line.baseline - c.obj_h - line.top, st.ascent + st.descent)
(line.height, line.baseline - c.obj_h.max(0.0) - line.top, st.ascent + st.descent)
};
for m in [1.0, 1.15, 1.5] {
let share = |font: f32| (m - 1.0) * font;
@@ -13371,10 +13740,36 @@ fn lowered_pictures_and_check_boxes_take_words_line_heights() {
let (h, top, font) = line_of("", m, 6.0, raise);
assert!((h - font * m).abs() < 0.01 && (top - (font - reach)).abs() < 0.01, "{raise} at {m}: {h}, {top}");
}
// Lowered further than its height: as far down as it's lowered, the font's height at least,
// its top on the baseline (Word: a 6 pt picture lowered 10 pt takes the font's 11.5 pt,
// lowered 30 pt 30 pt; beside text, the text's ascent and the 30 pt).
let (h, top, font) = line_of("", m, 6.0, -10.0);
assert!((h - font * m).abs() < 0.01 && (top - (font - 10.0)).abs() < 0.01, "6 lowered 10 at {m}: {h}, {top}");
let (h, top, font) = line_of("", m, 6.0, -30.0);
assert!((h - 30.0 - share(font)).abs() < 0.01 && top.abs() < 0.01, "6 lowered 30 at {m}: {h}, {top}");
let (h, top, font) = line_of("x", m, 6.0, -30.0);
let asc = h - 30.0 - share(font);
assert!(asc > 0.0 && asc < font && (top - asc).abs() < 0.01, "6 lowered 30 beside text at {m}: {h}, {top}");
// Beside text, lowered: the text's ascent is no matter, the picture reaching further.
let (h, top, font) = line_of("x", m, 30.0, -10.0);
assert!((h - 30.0 - share(font)).abs() < 0.01 && top.abs() < 0.01, "beside text at {m}: {h}, {top}");
}
// Exact spacing keeps the line's box and its baseline: a picture lowered past its height sits
// where one lowered by its height does.
let exact = |raise: f32| {
let mut d = Document::from_text("x");
let props = wordcraft_doc::CharProps { position: Some(raise), ..Default::default() };
let p = d.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
p.props.line_spacing = Some(LineSpacing::Exactly(20.0));
let at = p.len();
p.insert_object(at, picture(40.0, 6.0, Default::default()), &props).unwrap();
let l = lay(&d);
let Some(Placed::Lines { para, .. }) = l.pages[0].items.iter().find(|i| matches!(i, Placed::Lines { .. })) else { panic!() };
(para.lines[0].height, para.lines[0].baseline - para.lines[0].top)
};
let (h, base) = exact(-6.0);
assert!((h - 20.0).abs() < 0.01, "{h}");
assert_eq!(exact(-30.0), (h, base));
// A check box sizes its line as text of its size, alone or beside text.
let box_line = |lead: &str, m: f32, size: u32| {
let mut d = Document::from_text(lead);
+90
View File
@@ -612,3 +612,93 @@ fn a_page_ends_when_a_multiple_spaced_lines_text_no_longer_fits() {
assert!(past_margin > 0, "{multiple}: some shift should leave the last line's leading past the margin");
}
}
/// A body PAGE field shows the page number in the section's number format, as Word does (a
/// cardinal-text section shows "one"), and `\*` overrides it.
#[test]
fn body_page_field_uses_the_section_number_format() {
let mut d = Document::from_text("Page \nPage ");
d.last_section.page_num_format = wordcraft_doc::section::NumFormat::CardinalText;
for (i, instr) in ["PAGE", "PAGE \\* Upper"].into_iter().enumerate() {
let p = d.para_mut(StoryRef::Body, &Path::top(i)).unwrap();
let off = p.len();
let field = InlineObject::Field { instr: instr.into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() };
p.insert_object(off, field, &Default::default()).unwrap();
}
let l = lay(&d);
let shown: Vec<String> = l.pages[0]
.items
.iter()
.filter_map(|it| if let Placed::Lines { para, .. } = it { Some(para.shown.iter().map(|x| x.1.clone()).collect::<String>()) } else { None })
.collect();
assert_eq!(shown, ["one", "ONE"], "{shown:?}");
}
#[test]
fn reflow_page_field_uses_the_current_section_number_format() {
let mut d = Document::from_text("First section\nSecond section");
let first = d.last_section.clone();
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().section = Some(Box::new(first));
d.last_section.page_num_format = wordcraft_doc::section::NumFormat::CardinalText;
let p = d.para_mut(StoryRef::Body, &Path::top(1)).unwrap();
let off = p.len();
p.insert_object(
off,
InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() },
&Default::default(),
)
.unwrap();
for view in [ViewMode::Web, ViewMode::Draft, ViewMode::Read, ViewMode::Outline] {
let mut cache = LayoutCache::new();
let layout = layout(&d, &mut cache, &LayoutOptions { view, ..Default::default() });
let shown: Vec<_> = layout
.pages
.iter()
.flat_map(|page| {
page.items.iter().filter_map(move |item| {
if let Placed::Lines { para, path, .. } = item {
(path == &Path::top(1)).then(|| (page.number, para.shown.iter().map(|x| x.1.as_str()).collect::<String>()))
} else {
None
}
})
})
.collect();
assert!(!shown.is_empty(), "{view:?}: no second-section text");
for (number, text) in shown {
let expected = wordcraft_doc::section::NumFormat::CardinalText.format(number).to_lowercase();
assert!(text.ends_with(&expected), "{view:?}, page {number}: {text:?}, expected {expected:?}");
}
}
}
/// A PAGE field in a table that opens the body uses the section's number format too: the table
/// is placed without the paragraph path that sets it.
#[test]
fn table_cell_page_field_uses_the_section_number_format() {
use wordcraft_doc::Table;
let mut d = Document::from_text("after");
d.last_section.page_num_format = wordcraft_doc::section::NumFormat::CardinalText;
let mut t = Table::new(1, 1, 200.0);
let mut p = wordcraft_doc::Paragraph::with_text("", Default::default());
let field = InlineObject::Field { instr: "PAGE".into(), result: "1".into(), locked: false, dirty: false, code: false, form: String::new() };
p.insert_object(0, field, &Default::default()).unwrap();
if let Some(c) = t.rows.first_mut().and_then(|r| r.cells.first_mut()) {
c.blocks = vec![para_block(p)];
}
d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap();
let l = lay(&d);
let shown: String = l.pages[0]
.items
.iter()
.filter_map(|it| {
if let Placed::Lines { para, path, .. } = it {
(path.0.len() > 1).then(|| para.shown.iter().map(|x| x.1.clone()).collect::<String>())
} else {
None
}
})
.collect();
assert_eq!(shown, "one");
}
+43
View File
@@ -97,6 +97,49 @@ fn character_styles_and_paragraph_numbers() {
assert_eq!(h[3], ["two", "3", "3.a", "3"]);
}
/// #4985: a heading on a "none" list level (its label is its text, `Note`) shows its parent's
/// number in full context (`\w`): the parent is not taken for the heading's own level, which
/// has no number to list.
#[test]
fn full_context_keeps_the_parent_of_a_none_level_heading() {
let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 2" \r"#]);
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == num) {
let note = wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::None, text: "Note".into(), ..Default::default() };
n.level_overrides = vec![(1, note)];
}
for (i, lvl) in [(0, 0u8), (2, 0), (5, 0), (6, 1)] {
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.numbering = Some(NumRef { num, level: lvl });
}
assert_eq!(headers(&d)[3], ["3.Note", "3.Note"]);
}
/// #4985 review: `\t` leaves a "none" level's label, which holds no number, with nothing, and an
/// empty label of its own adds no part in full context: `3`, not `3.`.
#[test]
fn a_none_level_heading_s_number_has_no_text_and_no_trailing_period() {
let numbered = |text: &str, codes: &[&str]| {
let mut d = doc(CHAPTERS, codes);
let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered);
if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == num) {
let level = wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::None, text: text.into(), ..Default::default() };
n.level_overrides = vec![(1, level)];
}
for (i, lvl) in [(0, 0u8), (2, 0), (5, 0), (6, 1)] {
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.numbering = Some(NumRef { num, level: lvl });
}
headers(&d)[3].clone()
};
assert!(numbered("Note", &[r#"STYLEREF "Heading 2" \n \t"#]).is_empty(), "nothing is drawn");
// `\s` is a chapter number: a "none" label isn't one, so the heading's count stands in; a
// label showing its parent's number (`%1`, `%1 Response`) gives that number.
assert_eq!(numbered("Note", &[r#"STYLEREF "Heading 2" \s"#]), ["1"]);
assert_eq!(numbered("%1", &[r#"STYLEREF "Heading 2" \s"#]), ["3"]);
assert_eq!(numbered("%1 Response", &[r#"STYLEREF "Heading 2" \s"#, r#"STYLEREF "Heading 2" \n \t"#]), ["3", "3"]);
assert_eq!(numbered("Section%1", &[r#"STYLEREF "Heading 2" \n \t"#]), ["3"]);
assert_eq!(numbered("", &[r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 2" \r"#]), ["3", "3"]);
}
/// Editing a heading updates the headers that show it, through the paragraph cache.
#[test]
fn headers_follow_heading_edits() {
+14
View File
@@ -324,6 +324,20 @@ fn floating_box_that_resizes_to_fit_takes_its_texts_height() {
assert!((shrunk.h - grown.h).abs() < 0.5, "{shrunk:?} {grown:?}");
}
/// #4250: on a later page of its paragraph, a floating box that resizes to fit its text takes its
/// text's height too.
#[test]
fn a_floating_box_on_a_later_page_of_its_paragraph_fits_its_text() {
let mut d = Document::from_text(&"The quick brown fox jumps over the lazy dog. ".repeat(400));
let float = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, ..Default::default() };
let id = text_box_with(&mut d, 0, "Inside", 144.0, 1.0, TextBody { auto_fit: true, ..Default::default() }, float);
let l = lay(&d);
assert!(l.pages.len() >= 3, "pages {}", l.pages.len());
let hit = l.find_object(0, |o| o.text_box == Some(id)).unwrap();
assert!(hit.page > 0, "drawn on the page of its anchor");
assert!(hit.rect.h > 15.0 && hit.rect.h < 30.0, "one line and the margins: {:?}", hit.rect);
}
#[test]
fn a_turned_linked_box_turns_only_its_own_lines() {
// Linked boxes show the first box's story; turning the second box turns the lines it shows,
+7 -1
View File
@@ -13,6 +13,8 @@ use super::{
mod commands;
mod control;
mod files;
#[cfg(test)]
pub(crate) use files::suggested_stem;
mod fonts;
mod frame;
mod input;
@@ -439,7 +441,11 @@ impl WordApp {
return;
}
let t = theme::Tokens::get(&ctx);
self.escape_free = self.dialog.is_none() && !self.ui.backstage && self.ui.keytips == keytips::Phase::Off && !egui::Popup::is_any_open(&ctx);
self.escape_free = self.dialog.is_none()
&& !self.ui.backstage
&& self.ui.keytips == keytips::Phase::Off
&& !egui::Popup::is_any_open(&ctx)
&& !crate::chrome::title_search_open(self, &ctx);
// Keytips are read before layout so the frame paints the new state; badges paint after
// the ribbon so they sit on top of it.
crate::keytips::logic(self, &ctx);
+4 -2
View File
@@ -318,7 +318,8 @@ impl WordApp {
file_dialogs::Asked::Busy => Ok(json!({"saved": false})),
};
}
let retry = (id == "file.open").then(|| params.clone());
// Opening a file, or a new document from a template, reads a file that may be protected.
let retry = matches!(id, "file.open" | "file.newFromTemplate").then(|| params.clone());
let retry_path = retry.clone();
let r = self.execute(id, params);
if let (Err(e), Some(params)) = (&r, retry)
@@ -326,7 +327,8 @@ impl WordApp {
{
let path = params.get("path").and_then(Value::as_str).unwrap_or("");
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
self.dialog = Some(dialogs::Dialog::Password { name, params, password: Default::default(), message: String::new() });
self.dialog =
Some(dialogs::Dialog::Password { name, then: id.to_string(), params, password: Default::default(), message: String::new() });
return Ok(json!({"pending": "password"}));
}
// Whoever asked, a failed open names the file and the cause (#1103).
+37 -3
View File
@@ -298,12 +298,16 @@ impl WordApp {
}
/// The name Save suggests: the open file's own Word format (so a .docm keeps its macros),
/// otherwise .docx.
/// otherwise .docx. A document never saved and without a title of its own is named after its
/// first words, as Word suggests them (#5793), instead of `Document1`.
pub(crate) fn default_save_name(&self) -> String {
let ext = self.session.path.as_ref().and_then(|p| p.extension()).map(|e| e.to_string_lossy().to_string());
let unnamed = self.session.path.is_none() && self.session.file_name.is_none() && self.session.doc.core.title.trim().is_empty();
let first = || self.session.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).find_map(|t| suggested_stem(&t));
let stem = unnamed.then(first).flatten().unwrap_or_else(|| self.title_stem());
match ext {
Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{}.{e}", self.title_stem()),
_ => format!("{}.docx", self.title_stem()),
Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{stem}.{e}"),
_ => format!("{stem}.docx"),
}
}
@@ -463,3 +467,33 @@ impl WordApp {
}
}
}
/// The most characters a name taken from the text keeps ([`suggested_stem`]).
const SUGGESTED_MAX: usize = 50;
/// A file name from a paragraph's text (#5793): up to its first sentence or clause end
/// (`.`, `!`, `?`, `;`, `:` and their full-width forms) or line break, without the characters a
/// file name can't hold (`\ / : * ? " < > |` and controls), white space collapsed, at most
/// [`SUGGESTED_MAX`] characters cut at a word's end, and no dot or space at either end (Windows
/// drops them). `None` when nothing is left, so the next paragraph, or `Document1`, is used.
pub(crate) fn suggested_stem(text: &str) -> Option<String> {
let end = |c: char| matches!(c, '.' | '!' | '?' | ';' | ':' | '\n' | '\r' | '\u{0B}' | '\u{2029}' | '。' | '!' | '?' | ';' | ':');
let head = text.split(end).next().unwrap_or("");
let kept: String = head.chars().map(|c| if c.is_control() || matches!(c, '\\' | '/' | '*' | '"' | '<' | '>' | '|') { ' ' } else { c }).collect();
let mut words = String::new();
for w in kept.split_whitespace() {
let next = if words.is_empty() { w.chars().count() } else { words.chars().count() + 1 + w.chars().count() };
if next > SUGGESTED_MAX {
if words.is_empty() {
words = w.chars().take(SUGGESTED_MAX).collect();
}
break;
}
if !words.is_empty() {
words.push(' ');
}
words.push_str(w);
}
let words = words.trim_matches(|c: char| c == '.' || c.is_whitespace()).to_string();
(!words.is_empty()).then_some(words)
}
+12 -1
View File
@@ -55,6 +55,17 @@ fn back_chevron_c(band_min_x: f32, center_y: f32, fullscreen: bool, content_left
pos2(band_min_x + x, center_y)
}
/// The bottom of the backstage menu: About, and the community link unless the user turned the
/// community button off — the title bar pill honors the same setting, so the menu hides the
/// link with it.
fn bottom_links(app: &WordApp) -> Vec<(&'static str, &'static str)> {
let mut links = vec![("About", "about")];
if app.ui.show_discord {
links.push(("Discord community", "discord"));
}
links
}
/// Draws the Backstage side menu and the active page. Without a document only the Home, New, Open
/// and Options pages work, and it handles Back, Escape, Save, Save As and Close routing.
pub fn show(app: &mut WordApp, ui: &mut Ui) {
@@ -155,7 +166,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
}
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
for (label, id) in [("About", "about"), ("Discord community", "discord")] {
for (label, id) in bottom_links(app) {
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 32.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(label)));
if resp.hovered() {
+23
View File
@@ -90,6 +90,24 @@ const TILE_HEIGHT_PX: usize = 1024;
/// are rendered again when they come back.
const TEXTURE_KEEP_SCREENS: f32 = 3.0;
/// The document's scroll bars (#5746): solid, so they show wherever the pointer is (egui's
/// floating ones are invisible until the pointer nears them), and with their margin exactly as
/// wide as the [`SCROLLBAR`] room the pages leave, so they neither cover nor move the pages.
fn canvas_scroll_style() -> egui::style::ScrollStyle {
let solid = egui::style::ScrollStyle::solid();
egui::style::ScrollStyle { bar_width: SCROLLBAR - solid.bar_inner_margin - solid.bar_outer_margin, ..solid }
}
/// The document's scroll bar thumb (#5746): egui paints a solid bar's track in
/// `extreme_bg_color` and its thumb in the widget fills, which our palette sets alike (`input`), so
/// the thumb would vanish into its track. Grey at rest, darker (lighter in the dark theme) under
/// the pointer and while dragged. Only the scroll area's own visuals: the pages' contents keep theirs.
fn canvas_scroll_thumb(visuals: &mut egui::Visuals, t: &Tokens) {
visuals.widgets.inactive.bg_fill = t.border_strong;
visuals.widgets.hovered.bg_fill = t.input_border;
visuals.widgets.active.bg_fill = t.text_dim;
}
pub struct CanvasState {
textures: HashMap<usize, (u64, TextureHandle)>,
/// Visible full-resolution tiles of pages too tall for one GPU texture.
@@ -1390,9 +1408,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
let CanvasView { area, hruler, vruler, paper, geo, dim_body, grid_story, caret, .. } = view;
let mut origin = area.min;
let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area));
// The bars are drawn with `ui_area`'s style; what the pages draw gets the inherited one back.
let inherited = std::sync::Arc::clone(ui_area.style());
ui_area.spacing_mut().scroll = canvas_scroll_style();
canvas_scroll_thumb(ui_area.visuals_mut(), &t);
let mut ime_geometry: Option<(Rect, Rect)> = None;
let mut ime_interrupt = false;
let out = scroll_area.show_viewport(&mut ui_area, |ui, viewport| {
ui.set_style(inherited);
origin = ui.min_rect().min - viewport.min.to_vec2();
let content = Rect::from_min_size(ui.min_rect().min, geo.size);
let resp = canvas_response(ui, content, scroll_target);
+80
View File
@@ -755,6 +755,86 @@ fn dragging_the_scrollbar_names_the_page_at_the_top() {
}
}
/// #5746: the document's scroll bars are drawn wherever the pointer is: before it has moved and
/// with it over the page, not only at the window's edge, with a thumb that stands out from its
/// track in the light and the dark theme. The vertical one fills the room the pages leave for it,
/// so it covers none of them; zoomed in, the horizontal one shows the same way.
#[test]
fn the_document_scroll_bars_show_with_the_pointer_away() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let bar = |h: &H, vertical: bool| {
h.query_all_by_role(Role::ScrollBar)
.map(|n| n.rect())
.filter(|r| (r.height() > r.width()) == vertical)
.max_by(|a, b| a.area().total_cmp(&b.area()))
};
// The opaque fills across the bar's whole width, longest first: its track, then its thumb.
let drawn = |h: &H, bar: Rect, vertical: bool| {
let across = |r: &Rect| if vertical { r.width() } else { r.height() };
let along = |r: &Rect| if vertical { r.height() } else { r.width() };
let mut fills: Vec<(f32, egui::Color32)> = h
.output()
.shapes
.iter()
.filter_map(|c| match &c.shape {
egui::Shape::Rect(r) if r.fill.a() > 0 && bar.expand(0.5).contains_rect(r.rect) && across(&r.rect) >= across(&bar) - 0.5 => {
Some((along(&r.rect), r.fill))
}
_ => None,
})
.collect();
fills.sort_by(|a, b| b.0.total_cmp(&a.0));
fills
};
// The thumb shows on its track: a clear step in grey, not the same fill.
let thumb_shows = |fills: &[(f32, egui::Color32)], what: &str| {
let [(track_len, track), (thumb_len, thumb), ..] = fills else { panic!("{what}: a track and a thumb are drawn: {fills:?}") };
assert!(thumb_len < track_len, "{what}: the thumb is shorter than its track: {fills:?}");
let step = (0..3).map(|i| (i32::from(track[i]) - i32::from(thumb[i])).abs()).max().unwrap_or(0);
assert!(step >= 40, "{what}: the thumb {thumb:?} stands out from its track {track:?}");
};
for theme in ["light", "dark"] {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
for _ in 0..3 {
a.run("text.insert", json!({"text": "Page text"})).unwrap();
a.run("layout.break", json!({"kind": "page"})).unwrap();
}
a.run("view.commentsPane", json!({"value": false})).unwrap();
a.run("ui.theme", json!({"value": theme})).unwrap();
let mut h: H = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
assert_eq!(Tokens::get(&h.ctx).dark, theme == "dark", "the {theme} theme is on");
let v = bar(&h, true).expect("the document's vertical scroll bar");
thumb_shows(&drawn(&h, v, true), &format!("{theme}, before the pointer has moved"));
let viewport = h.state().canvas.viewport.expect("the canvas is shown");
assert!((v.right() - viewport.right() - SCROLLBAR).abs() < 0.5, "the bar fills the room left for it: {v:?} beside {viewport:?}");
assert!(h.state().canvas.page_rects.iter().all(|p| p.right() <= v.left()), "the bar covers no page");
let page = h.state().canvas.page_rects.first().copied().expect("a page on screen");
h.event(egui::Event::PointerMoved(page.center()));
for _ in 0..30 {
h.step();
}
let v = bar(&h, true).expect("the document's vertical scroll bar");
thumb_shows(&drawn(&h, v, true), &format!("{theme}, the pointer over the page"));
h.state_mut().run("view.zoom", json!({"value": 400.0})).unwrap();
for _ in 0..30 {
h.step();
}
let across = bar(&h, false).expect("zoomed in, a horizontal scroll bar");
thumb_shows(&drawn(&h, across, false), &format!("{theme}, the horizontal bar"));
}
}
/// #3880: the status bar names the page being looked at. With the caret scrolled out of view, that
/// is the page at the top of the window; while the caret is on screen, its page.
#[test]
+138 -2
View File
@@ -446,12 +446,148 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
});
}
/// The title bar's Search box while it is typed in (#5398): the query, whether the text of the
/// shortcut that opened it (Alt+Q's `q` on some platforms) is still to be dropped, and where the
/// results showed last frame.
#[derive(Clone, Default)]
struct TitleSearch {
query: String,
swallow: bool,
results: Option<Rect>,
}
/// Width of the Search box, and of its results under it.
const SEARCH_BOX_W: f32 = 260.0;
const SEARCH_RESULTS_W: f32 = 380.0;
/// Search in the title bar's box, as Word's: when this window drew the box in its last frame
/// (the title bar, or Full Screen's Commands bar), Search opens there, typed into in place with
/// its results under it, instead of in the Search Commands dialog. `true` when it did.
pub(crate) fn open_title_search(app: &mut WordApp) -> bool {
let Some(ctx) = app.ctx.clone() else { return false };
// Focus hides the box even when the last frame, before Focus came on, drew it.
if Screen::of(&app.session.view) == Screen::Focus || !search_box_drawn(app, &ctx) {
return false;
}
let id = app.window_id("title_search");
// Only Alt+Q may send its `q` as text after it; a click sends none. Alt is read from the
// shortcut's key event too: the box takes the keys from its first frame, that same text
// included.
let swallow =
ctx.input(|i| i.modifiers.alt || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, modifiers, .. } if modifiers.alt)));
ctx.data_mut(|d| d.insert_temp(id, TitleSearch { swallow, ..Default::default() }));
ctx.request_repaint();
true
}
/// What is typed in the title bar's Search, while it is open.
pub(crate) fn title_search_query(app: &WordApp, ctx: &egui::Context) -> Option<String> {
ctx.data(|d| d.get_temp::<TitleSearch>(app.window_id("title_search"))).map(|s| s.query)
}
/// Whether this window drew the Search box in its last frame.
fn search_box_drawn(app: &WordApp, ctx: &egui::Context) -> bool {
let shown = ctx.data(|d| d.get_temp::<u64>(app.window_id("title_search_shown")));
shown.is_some_and(|f| f.saturating_add(1) >= ctx.cumulative_frame_nr())
}
/// Whether the title bar's Search is being typed in (Escape closes it, not Full Screen). Search
/// whose box is no longer drawn (Focus came on) is dropped, so it can't keep Escape.
pub(crate) fn title_search_open(app: &WordApp, ctx: &egui::Context) -> bool {
if title_search_query(app, ctx).is_none() {
return false;
}
if search_box_drawn(app, ctx) {
return true;
}
ctx.data_mut(|d| d.remove::<TitleSearch>(app.window_id("title_search")));
false
}
/// The Search box being typed in: a text box in its place and the results under it. `false`
/// once it closes: a command or the find ran, Escape, or a click outside both.
fn typing_search(app: &mut WordApp, ui: &mut Ui, control_h: f32, s: &mut TitleSearch) -> bool {
if s.swallow {
ui.ctx().input_mut(|i| i.events.retain(|e| !crate::dialogs::swallow_shortcut_text(&mut s.swallow, e)));
}
let ph = tl!("Search commands and help");
// The box takes the keys from its first frame: focused before it reads this frame's input,
// so what is typed straight after Alt+Q goes into it. It takes the focus back whenever it lost
// it, except to one of its results (Tab moves there, so a keyboard user can pick one), and
// not while a press on a result is under way (#2986).
let id = app.window_id("title_search_edit");
let on_result = ui
.memory(|m| m.focused())
.filter(|f| *f != id)
.and_then(|f| ui.ctx().read_response(f))
.is_some_and(|r| s.results.is_some_and(|x| x.contains_rect(r.rect)));
if !on_result && !ui.memory(|m| m.has_focus(id)) && !ui.input(|i| i.pointer.any_down()) {
ui.memory_mut(|m| m.request_focus(id));
}
let typing = ui.memory(|m| m.has_focus(id));
let edit = ui.add_sized(
vec2(SEARCH_BOX_W, control_h),
egui::TextEdit::singleline(&mut s.query).id(id).hint_text(ph).align(if crate::rtl::on() {
Align2::RIGHT_CENTER
} else {
Align2::LEFT_CENTER
}),
);
let r = edit.rect;
// Enter in the box runs the first result, and is used up: a dialog that result opens (Word
// Count) mustn't take the same Enter as its OK. A result Tab moved to takes Enter or Space
// itself.
let (enter, escape, pressed_at) = ui.input_mut(|i| {
let pressed = i.pointer.any_pressed().then(|| i.pointer.press_origin()).flatten();
(typing && i.consume_key(egui::Modifiers::NONE, egui::Key::Enter), i.key_pressed(egui::Key::Escape), pressed)
});
let inside = |p: egui::Pos2| r.contains(p) || s.results.is_some_and(|x| x.contains(p));
if escape || pressed_at.is_some_and(|p| !inside(p)) {
return false;
}
let rtl = crate::rtl::on();
let (pivot, at) = if rtl { (Align2::RIGHT_TOP, r.right_bottom()) } else { (Align2::LEFT_TOP, r.left_bottom()) };
let mut done = false;
let shown = egui::Area::new(app.window_id("title_search_results"))
.order(egui::Order::Foreground)
.pivot(pivot)
.fixed_pos(at + vec2(0.0, 2.0))
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_width(SEARCH_RESULTS_W);
// Rows of a list, as a menu's, not a stack of framed buttons.
ui.style_mut().visuals.button_frame = false;
done = crate::dialogs::search_results(app, ui, &s.query, enter, SEARCH_RESULTS_W);
});
});
s.results = Some(shown.response.rect);
if done {
// A result run with Enter from the keyboard: the dialog it opens mustn't take that Enter.
ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
}
!done
}
/// Search ("Tell me"): a box that opens command search. In the title bar, and in the Commands bar
/// of Full Screen (#1678).
/// of Full Screen (#1678). Clicked (or Alt+Q), it takes the typing in place (#5398).
fn search_box(app: &mut WordApp, ui: &mut Ui, control_h: f32) {
let frame = ui.ctx().cumulative_frame_nr();
ui.ctx().data_mut(|d| d.insert_temp(app.window_id("title_search_shown"), frame));
let id = app.window_id("title_search");
if let Some(mut s) = ui.ctx().data(|d| d.get_temp::<TitleSearch>(id)) {
let open = typing_search(app, ui, control_h, &mut s);
ui.ctx().data_mut(|d| {
if open {
d.insert_temp(id, s);
} else {
d.remove::<TitleSearch>(id);
}
});
return;
}
let t = Tokens::get(ui.ctx());
let rtl = crate::rtl::on();
let (r, resp) = ui.allocate_exact_size(vec2(260.0, control_h), Sense::click());
let (r, resp) = ui.allocate_exact_size(vec2(SEARCH_BOX_W, control_h), Sense::click());
let ph = tl!("Search commands and help");
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, ph));
ui.painter().rect(r, 6.0, if resp.hovered() { t.input } else { t.ribbon }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
+11 -3
View File
@@ -35,9 +35,11 @@ use bookmarks::bookmark_at_caret;
use bookmarks::bookmark_names;
pub use chrome::show;
pub(crate) use chrome::{block_behind, buttons, cancel_key_pressed, color_menu, forget_nudge, ok_cancel_responses, release_keyboard};
pub(crate) use dispatch::search_results;
pub(crate) use find::swallow_shortcut_text;
pub use find::{FindExtra, FindTab, GoToWhat};
#[cfg(test)]
use find::{command_matches, goto_page, swallow_shortcut_text};
use find::{command_matches, goto_page};
#[cfg(test)]
use font::{apply_font, preview_size};
use font::{east_asian_char, font_props};
@@ -414,10 +416,12 @@ pub enum Dialog {
#[serde(skip)]
basis: Box<CellMarginsForm>,
},
/// Opening a password-protected document (#55): the password, then `file.open` with
/// `params` again. The password is never serialised or printed.
/// Opening a password-protected document (#55): the password, then the command that asked
/// for it (`then`: `file.open`, or `file.newFromTemplate` for a protected template, #3433)
/// with `params` again. The password is never serialised or printed.
Password {
name: String,
then: String,
#[serde(skip)]
params: Value,
#[serde(skip)]
@@ -575,6 +579,10 @@ impl Dialog {
}
pub fn open(name: &str, app: &mut WordApp) -> Option<Dialog> {
// Search opens in the title bar's box when the window shows it (#5398).
if name == "commands" && crate::chrome::open_title_search(app) {
return None;
}
if let Some(d) = crate::dialogs_insert::open(name, app) {
return Some(Dialog::Insert(Box::new(d)));
}

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