Compare commits

..
Author SHA1 Message Date
FredApps d4bbe8ba9a Merge pull request #4367 from helenkwok/web/host-page
feat(web): page host mode, so a page can host WordCraft in its own document (#4366)
2026-11-05 14:11:02 +01:00
1397669dd5 Table formulas: read the Panamanian balboa B/. in numbers (#3546)
* Table formulas: read the Panamanian balboa B/. in numbers

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

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

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

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

---------

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

* Update lockfile for DOCX test dependency

* Assert frame geometry survives document round trips

* Preserve text frame locks on paragraph updates

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

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

* Update lockfile for DOCX test dependency

* Format DOCX ordering assertion

* Keep main lockfile for paragraph property PR

* PR 5384: tighten grid indent docs and regression

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

* docs: record chapter style byte range

* fix: accept XML integer syntax for tray codes

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

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

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

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

* M6: reuse bookmark lookup for numbered REF updates

* REF: use explicit imports for the workspace clippy gate

* #4609: adapt synthetic fixtures to shared document data

* #4609: checkpoint review fixes and isolated validation notes

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

---------

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

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

* Keep mini toolbar stable during alignment preview

* Track live mini toolbar anchor during preview

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

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

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

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

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

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

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

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

* Skip hover previews in context toolbar

* Pass preview mode in toolbar test

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

* Update ink lookup status date (#3614)

---------

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 04:33:16 +01:00
Dc-xp 6f552abf8e chore: follow craft-libs c5a1cbf for craft-pdf and krilla (#5281)
Same rev the other editors are on; cargo check -p wordcraft-ui-egui is clean.
2026-11-05 02:10:16 +01:00
Dennis Klappe 5f87d7461b packaging: RPM installs on openSUSE (library sonames, no shared directories) (#5369) 2026-11-05 02:10:10 +01:00
Rahulandrahul05ranjan 6a50c1e374 #5289: a section's page start flushes the page before with its own margins and separators (#5291)
The page the section before ends on is finished with its own margins and
footnote separators before the starting section's replace them, so the
footnotes pending on it are placed where the page they sit on says.

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

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

* contributors: add entropybeater

---------

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

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

* contributors: add entropybeater

---------

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

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

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

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

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

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

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

wordcraft-doc now counts, in tests only, the characters its paragraph
object lookups walk (objects_before, object_offsets and
objects_with_offsets), through a work::count helper behind the existing
test-support feature. The test asserts that 8x the markers take at most
12x the steps, as the layout linear-time tests do: the walk takes 2001 ->
16001 steps (8.0x), and the object_offsets + object_at walk it replaced
takes 2003001 -> 128024001 (63.9x).
2026-11-05 01:32:10 +01:00
Dmitrii KhlapovandClaude Sonnet 5.5 1c7b5013fa Drop Cap follow-ups: whole selection, script fonts, margin gap, menu keeps options (#4077)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:26:57 +01:00
114 changed files with 4521 additions and 583 deletions
Generated
+13 -2
View File
@@ -1081,7 +1081,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -1157,6 +1157,15 @@ dependencies = [
"zip 9.1.0",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "craft-window"
version = "0.1.0"
@@ -2960,7 +2969,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -6880,6 +6889,7 @@ dependencies = [
"log",
"proptest",
"quick-xml 0.41.0",
"serde_json",
"sha1 0.11.0",
"sha2 0.11.1",
"thiserror 2.0.21",
@@ -7084,6 +7094,7 @@ dependencies = [
name = "wordcraft-web"
version = "0.15.1"
dependencies = [
"craft-web-host",
"eframe",
"egui",
"js-sys",
+2 -2
View File
@@ -67,7 +67,7 @@ wuff = "0.2.9"
brotli-decompressor = "5"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
quick-xml = "0.41"
@@ -134,7 +134,7 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+11 -2
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -16,6 +16,8 @@ path = "src/main.rs"
[dependencies]
wordcraft-engine = { workspace = true }
wordcraft-ui-egui = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
@@ -29,4 +31,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
+65 -107
View File
@@ -1,14 +1,20 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
//! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`:
//! Host modes: a page that runs WordCraft (as an `<iframe>`, or in its own document) opens
//! documents in it and stores what it saves instead of the browser downloading. The messages and
//! the two transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the
//! other Craft apps' bridges, with the `wordcraft:` prefix. This module is WordCraft's part of it.
//!
//! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
//! listening; send a file now.
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
//! every file WordCraft would otherwise download. The parent stores it and reports failures
//! itself. `saveAs` says whether the file becomes the document's file:
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
//! - `wordcraft:ready` once the app is running and listening; send a file now. WordCraft reads
//! bytes, so it says `file: false` and a host sends `bytes`.
//! - `wordcraft:open` (from the host): `{ name, bytes }`. It opens like a document picked with
//! File › Open or dropped on the window; a file over [`MAX_OPEN_BYTES`] is refused before it is
//! copied.
//! - `wordcraft:save` (to the host): `{ name, saveAs, bytes }` for every file WordCraft would
//! otherwise download. The host stores it and reports failures itself. `saveAs` says whether
//! the file becomes the document's file:
//! - Save: `false`. It writes the opened file under its own name and format, as the desktop
//! app does. A name the parent never sent (an untitled document, an imported .doc saved as
//! .docx) is a new file: `true`, as for Save As.
@@ -17,70 +23,57 @@
//! is now that file; the next Save sends its name with `false`.
//! - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and
//! Export Page as PNG: `false`. They are copies; the document's file stays the same.
//! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
//! - `wordcraft:dirty` whenever unsaved changes appear or go away, `wordcraft:failed` when the
//! app couldn't start.
//!
//! Only the parent window on the page's own origin is heard or answered. In host mode the parent
//! guards leaving the page, and `?author=` names the user for comments and tracked changes.
//! In both host modes the host sends the document to edit, so no sample opens, saves are handed
//! to the host rather than downloaded, the host guards leaving the page, and `?author=` names the
//! user for comments and tracked changes.
use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use craft_web_host::web::{FileData, Host, Message};
use wordcraft_ui_egui::{Inbox, Services, WordApp};
/// The app id in the host messages (`wordcraft:ready`).
const APP: &str = "wordcraft";
/// The name a document gets when the host sent none (the crate says `document` for a blank name).
const DEFAULT_NAME: &str = "Document.docx";
/// The largest document taken from the host page, refused before it is copied.
const MAX_OPEN_BYTES: u64 = 512 << 20;
/// The longest user name taken from `?author=`.
const MAX_AUTHOR_CHARS: usize = 200;
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
host: Rc<Host>,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
/// on another origin: messages only go to this origin, so the browser would drop every save
/// without an error while the document showed as saved.
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
if origin == "null" {
return None;
}
// Reading another origin's location throws, which is `None` too.
if parent.location().origin().ok()? != origin {
return None;
}
Some(Self { window, parent, origin })
/// The bridge for this page, or `None` when WordCraft isn't hosted: no `?host=parent` and no
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
/// with downloads), or a parent on another origin.
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
}
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
/// Hand saved and exported files to the host instead of downloading them, report unsaved
/// changes to it, and hear the files it sends.
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
self.listen(inbox, ctx);
let bridge = self.clone();
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
// Anything else that would download goes to the parent too.
// Anything else that would download goes to the host too.
let bridge = self.clone();
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
// The browser's save picker is never used, so no save finishes later.
services.async_saves = None;
let bridge = self.clone();
let reported: Rc<Cell<Option<bool>>> = Rc::default();
services.on_dirty = Some(Box::new(move |dirty: bool| {
if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
reported.set(Some(dirty));
}
}));
// The crate does not repeat a state it already reported.
let host = self.host.clone();
services.on_dirty = Some(Box::new(move |dirty: bool| host.post_dirty(dirty)));
}
/// `?author=`: the host's name for the user signs comments and tracked changes.
@@ -92,78 +85,43 @@ impl Bridge {
}
pub fn post_ready(&self) {
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
let _ = self.host.post_failed(error, None);
}
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let message = message(&[
("type", "wordcraft:save".into()),
("name", name.as_str().into()),
("saveAs", save_as.into()),
("bytes", buffer.clone().into()),
])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
self.host.post_save(&name, bytes, None, save_as)
}
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
/// Hear `wordcraft:open` and queue the file like a dropped one.
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
self.host.listen(move |message| {
if let Message::Open { name, data } = message {
deliver_open(&inbox, &ctx, &name, &data);
}
let data = event.data();
if string(&data, "type").as_deref() != Some("wordcraft:open") {
return;
}
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
let Some(bytes) = bytes(&data) else { return };
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
/// Post a message to the parent; false if it couldn't be sent.
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
}
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(array.to_vec())
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(&bytes).to_vec())
} else {
None
/// The file an open message carries, pushed onto the inbox for the next frame, like a dropped
/// file. A host's file is untrusted input: an oversized one is refused before it is copied into
/// the app's memory (the data's own length, not the host's claim). The crate has cleaned the name
/// (and cut it to 255 characters); a blank one comes as `document` and becomes `Document.docx`.
fn deliver_open(inbox: &Inbox, ctx: &egui::Context, name: &str, data: &FileData) {
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
let FileData::Bytes(array) = data else {
log::warn!("host file {name} came as a File; WordCraft reads bytes only; not opened");
return;
};
if data.size() > MAX_OPEN_BYTES as f64 {
log::warn!("host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20);
return;
}
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, array.to_vec()));
ctx.request_repaint();
}
+4 -2
View File
@@ -9,8 +9,10 @@
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
//! comments and tracked changes.
//! document to open and stores what WordCraft saves (`host`). A page can also host WordCraft in
//! its own document (no iframe) with `data-host="page"` on the canvas, talking over DOM
//! `CustomEvent`s on the canvas. `&author=` names the user for comments and tracked changes in
//! either host mode.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
+6 -3
View File
@@ -28,9 +28,12 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
// what it saves (`host`). That page guards leaving it, so this one doesn't.
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing WordCraft, opens documents in
// it and stores what it saves (`host`); unframed there is no parent, so that URL on its
// own stays the normal app with downloads. `data-host="page"` on the canvas opts a page
// in instead: it runs WordCraft in its own document and talks over DOM `CustomEvent`s
// on the canvas (`host`). The host guards leaving the page, so this one doesn't.
let bridge = crate::host::Bridge::detect(&canvas);
let app_bridge = bridge.clone();
let dirty = Rc::new(Cell::new(false));
let saves = AsyncSaves::default();
+50 -3
View File
@@ -15,9 +15,25 @@ use eframe::{
/// compiler aborts on them, the device is lost and eframe exits before the window opens. WordCraft
/// issues no indirect draws or dispatches, so Intel Macs skip them; every other GPU keeps wgpu's
/// default. Same fix as PhotoCraft's storytold/PhotoCraft#3171.
///
/// `x86_64` is the build's architecture. An x86_64 build also runs on Apple silicon under Rosetta
/// (#5199), so it skips only when the machine itself isn't Apple silicon.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool {
macos && intel
pub fn skips_indirect_validation(macos: bool, x86_64: bool) -> bool {
// Short-circuits: only x86_64 macOS builds ask the system.
macos && x86_64 && !runs_on_apple_silicon()
}
/// Whether the machine is Apple silicon, also when this process is translated by Rosetta:
/// `hw.optional.arm64` describes the hardware, not the process. Assumes not (an Intel Mac) when
/// the system can't tell.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn runs_on_apple_silicon() -> bool {
cfg!(target_os = "macos")
&& std::process::Command::new("/usr/sbin/sysctl")
.args(["-n", "hw.optional.arm64"])
.output()
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
}
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
@@ -181,7 +197,8 @@ mod tests {
#[test]
fn intel_macs_skip_the_indirect_validation_shaders() {
// #2652: Metal's Intel compiler aborted on them, losing the device before the window opened.
assert!(skips_indirect_validation(true, true));
// An x86_64 build on Apple silicon (Rosetta) isn't an Intel Mac (#5199).
assert_eq!(skips_indirect_validation(true, true), !host_is_apple_silicon());
// Apple silicon, Windows and Linux keep wgpu's validation.
for (macos, intel) in [(true, false), (false, true), (false, false)] {
assert!(!skips_indirect_validation(macos, intel), "macos={macos} intel={intel}");
@@ -205,6 +222,36 @@ mod tests {
}
}
/// Whether the machine running the tests is Apple silicon, whatever this build's architecture.
fn host_is_apple_silicon() -> bool {
cfg!(target_os = "macos")
&& std::process::Command::new("/usr/sbin/sysctl")
.args(["-n", "hw.optional.arm64"])
.output()
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
}
/// #5199: an x86_64 build running under Rosetta on Apple silicon isn't an Intel Mac and keeps
/// wgpu's indirect-call validation.
#[test]
fn x86_64_builds_on_apple_silicon_keep_indirect_validation() {
if !host_is_apple_silicon() {
return;
}
// What main.rs passes from an x86_64 build.
assert!(!skips_indirect_validation(true, true), "Rosetta build on Apple silicon must keep validation");
let mut options = NativeOptions::default();
let WgpuSetup::CreateNew(before) = &options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
let before = before.instance_descriptor.flags;
configure_indirect_validation(&mut options, skips_indirect_validation(true, true));
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert_eq!(create.instance_descriptor.flags, before, "indirect-call validation kept");
}
fn configured_backends(backends: Backends) -> Backends {
let mut options = NativeOptions::default();
configure_backends(&mut options, backends);
+2 -1
View File
@@ -486,7 +486,8 @@ fn main() -> eframe::Result {
// FXC, which needs no DLL of ours (#603; see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652).
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652). An x86_64
// build under Rosetta on Apple silicon keeps them (#5199).
#[cfg(target_os = "macos")]
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
load_user_fonts();
+9 -1
View File
@@ -35,7 +35,9 @@ sync_github_name = false
real_name = "Saeed Azarehgar"
[people.vangel76]
sync_github_name = true
real_name = "Valentin Klein"
display_name = "vangel76"
sync_github_name = false
[people.dkhlapov]
real_name = "Dmitrii Khlapov"
@@ -187,3 +189,9 @@ sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.entropybeater]
display_name = "entropybeater"
[people.helenkwok]
real_name = "Helen Kwok"
+3 -2
View File
@@ -10,8 +10,9 @@ workspace = true
[features]
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`,
# `zip_guard::zip64_entry_count_sample`); turned on only as a
# dev-dependency, so never built into the app.
# `zip_guard::zip64_entry_count_sample`) and the step count of
# `work::count`; turned on only as a dev-dependency, so never built into
# the app.
test-support = []
[dependencies]
+1
View File
@@ -49,6 +49,7 @@ pub mod styles;
pub mod symbol_fonts;
pub mod table;
pub mod wordart;
pub mod work;
pub mod wrap;
pub mod zip_guard;
+51 -2
View File
@@ -22,10 +22,29 @@ pub fn decimal_separator(lang: Option<&str>) -> char {
const SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\u{2009}'];
const CURRENCY: &[char] = &['$', '€', '£', '¥', '¢', '₽', '₴', '₺', '₹', '₩', '₪', '₫', '฿', '₱', '₸', '₼', '₾'];
/// Currency signs spelled with more than one character: the Panamanian balboa (`B/.`).
const CURRENCY_SYMBOLS: &[&str] = &["B/."];
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`) from either end.
/// Trim spaces and single-character currency signs from either end.
fn trim_currency(s: &str) -> &str {
s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c))
}
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`, `B/.`) from either end.
/// As Word does, a multi-character sign is a word of its own (`B/. 1,250.50`, `1,250.50 B/.`);
/// `B/.1,250.50` and `B/ 1,250.50` are not numbers.
fn strip_currency(s: &str) -> &str {
let mut s = s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c));
let mut s = trim_currency(s);
if let Some(rest) =
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_prefix(sym).filter(|rest| rest.chars().next().is_some_and(char::is_whitespace)))
{
s = trim_currency(rest);
}
if let Some(rest) =
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_suffix(sym).filter(|rest| rest.chars().next_back().is_some_and(char::is_whitespace)))
{
s = trim_currency(rest);
}
if let Some((head, word)) = s.rsplit_once(SPACES)
&& (1..=3).contains(&word.chars().count())
&& word.chars().all(|c| c.is_alphabetic() || c == '.')
@@ -137,6 +156,7 @@ mod tests {
assert_eq!(parse_number("1.5", pl), Some(1.5));
assert_eq!(parse_number("-2,25", pl), Some(-2.25));
assert_eq!(parse_number("PLN 12", pl), Some(12.0));
assert_eq!(parse_number("-B/. 1 250,50", pl), Some(-1250.5));
assert_eq!(parse_number("abc", pl), None);
assert_eq!(parse_number("1,2,3", pl), None);
// From review: ordinals, a leading zero, percentages and an en dash minus.
@@ -174,11 +194,40 @@ mod tests {
assert_eq!(format_number(5.75, Some("es-ES")), "5,75");
}
#[test]
fn balboa_numbers() {
let en = Some("en-US");
// Word 16.0 reads `B/.` as a word of its own, before or after the number.
assert_eq!(parse_number("B/. 1,234.50", en), Some(1234.5));
assert_eq!(parse_number("1,234.50 B/.", en), Some(1234.5));
assert_eq!(parse_number("B/.\u{a0}1,234.50", en), Some(1234.5), "a no-break space after the sign");
assert_eq!(parse_number("B/. 1,234.50 B/.", en), Some(1234.5), "the sign at both ends");
assert_eq!(parse_number("-B/. 1,234.50", en), Some(-1234.5), "a minus before the sign");
assert_eq!(parse_number("B/. 1,234.50", Some("es-PA")), Some(1234.5), "the balboa's own region");
// The sign has no space of its own in Word: touching the number is text.
assert_eq!(parse_number("B/.1,234.50", en), None);
assert_eq!(parse_number("1,234.50B/.", en), None);
assert_eq!(parse_number("B/ 1,234.50", en), None, "the balboa is B/. in Word");
assert_eq!(parse_number("B/.", en), None);
// The slash and the full stop make `B/.` ambiguous with a fraction: the sign is matched at
// the ends of the text only, so a fraction is never read as a balboa amount and a sign in
// the middle is not a sign.
assert_eq!(parse_number("1/.5", en), None, "a fraction is not a price");
assert_eq!(parse_number("1/.5", Some("es-PA")), None);
assert_eq!(parse_number("1/.5 B/.", en), None, "the sign at the end does not rescue a fraction");
assert_eq!(parse_number("B/. 1/.5", en), None);
assert_eq!(parse_number("2 B/. 1,234.50", en), None, "the sign sits before the number");
assert_eq!(parse_number("1,234.50 B/. 1,234.50", en), None, "the sign sits between two numbers");
assert_eq!(parse_number("1,234.50/2", en), None, "a division is not a balboa amount");
assert_eq!(parse_number("B/. 1,234.50,50", en), None, "a second decimal separator");
}
#[test]
fn english_numbers() {
let en = Some("en-US");
assert_eq!(parse_number("1,250.50", en), Some(1250.5));
assert_eq!(parse_number("$1,250", en), Some(1250.0));
assert_eq!(parse_number("PAB 1,250.50", en), Some(1250.5), "the ISO code");
assert_eq!(parse_number("1.5", en), Some(1.5));
assert_eq!(parse_number("12", None), Some(12.0));
assert_eq!(parse_number(".5", None), Some(0.5));
+137 -30
View File
@@ -294,6 +294,14 @@ pub struct Counters {
/// Per abstract list: whether each level has counted yet (a value of 0 can be a real count when a
/// level starts at 0).
flags: HashMap<u32, [LevelFlags; 9]>,
/// The last explicitly rendered label and template at each abstract-list level.
labels: HashMap<(u32, u8), CountedLabel>,
}
#[derive(Debug, Clone)]
struct CountedLabel {
label: Option<String>,
levels: Vec<usize>,
}
/// Whether a level has counted yet, and the value its next item takes if a start override says so.
@@ -320,6 +328,7 @@ impl Counters {
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]);
@@ -366,42 +375,101 @@ impl Counters {
if restarts {
*deeper = 0;
*f = LevelFlags::default();
self.labels.remove(&(abs.id, d as u8));
}
}
let vals = *st;
let started = *fl;
if def.format == NumFormat::Bullet {
return Some((def.text.clone(), def));
}
let mut label = String::new();
// Word's level text is short; .docx and the lists command already cap it at 64.
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
{
chars.next();
let k = (d as usize).saturating_sub(1).min(8);
let val = vals.get(k).copied().unwrap_or(0);
let counted = started.get(k).is_some_and(|f| f.started);
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
// format (二 of a Chinese outline becomes 2 in "2.1").
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
NumFormat::DecimalZero => NumFormat::DecimalZero,
_ if def.legal => NumFormat::Decimal,
f => f,
};
label.push_str(&fmt.format(val));
if label.len() > MAX_LABEL_BYTES {
break;
}
} else {
label.push(c);
}
}
let numeric = def.format != NumFormat::Bullet;
let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() };
self.labels.insert((abs.id, lv as u8), CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) });
Some((label, def))
}
/// Which levels of `num`'s list have counted a paragraph yet (a level never used has no
/// number of its own to show in a reference).
pub fn started(&self, numbering: &Numbering, num: u32) -> Option<[bool; 9]> {
let abs = numbering.abstract_of(num)?;
Some(std::array::from_fn(|k| self.labels.get(&(abs.id, k as u8)).is_some_and(|last| last.label.is_some())))
}
/// The label `level` of `num`'s list shows at this point of the walk, without counting: what
/// the last paragraph at that level showed (`"14."` while walking clause 14's sub-clauses).
/// `None` for a bullet or "none" level, or a level the list doesn't define.
pub fn current_label(&self, numbering: &Numbering, num: u32, level: u8) -> Option<String> {
let abs = numbering.abstract_of(num)?;
if let Some(last) = self.labels.get(&(abs.id, level.min(8))) {
return last.label.clone();
}
// A deeper item can count a level implicitly without rendering a paragraph there.
let def = numbering.level(num, level.min(8))?;
if matches!(def.format, NumFormat::None | NumFormat::Bullet) {
return None;
}
let vals = self.state.get(&abs.id).copied().unwrap_or([0; 9]);
let started = self.flags.get(&abs.id).copied().unwrap_or_default();
Some(render_label(numbering, num, def, &vals, &started))
}
/// Levels shown by the last rendered label, or the current definition for an implicit level.
pub fn current_template_levels(&self, numbering: &Numbering, num: u32, level: u8) -> Vec<usize> {
if let Some(abs) = numbering.abstract_of(num)
&& let Some(last) = self.labels.get(&(abs.id, level.min(8)))
{
return last.levels.clone();
}
numbering.level(num, level.min(8)).map(|def| template_levels(&def.text)).unwrap_or_default()
}
}
/// The zero-based levels a label template shows (`"%1.%2"` → `[0, 1]`, `"(%3)"` → `[2]`).
pub fn template_levels(text: &str) -> Vec<usize> {
let mut out = Vec::new();
let mut chars = text.chars().take(MAX_LEVEL_TEXT).peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
{
chars.next();
let k = (d as usize).saturating_sub(1).min(8);
if !out.contains(&k) {
out.push(k);
}
}
}
out
}
/// Render a list label without advancing counters, retaining the existing hostile-input caps.
fn render_label(numbering: &Numbering, num: u32, def: &Level, vals: &[u32; 9], started: &[LevelFlags; 9]) -> String {
let level_def = |k: usize| numbering.level(num, k as u8);
let mut label = String::new();
// Word's level text is short; .docx and the lists command already cap it at 64.
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
{
chars.next();
let k = (d as usize).saturating_sub(1).min(8);
let val = vals.get(k).copied().unwrap_or(0);
let counted = started.get(k).is_some_and(|f| f.started);
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
// format (二 of a Chinese outline becomes 2 in "2.1").
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
NumFormat::DecimalZero => NumFormat::DecimalZero,
_ if def.legal => NumFormat::Decimal,
f => f,
};
label.push_str(&fmt.format(val));
if label.len() > MAX_LABEL_BYTES {
break;
}
} else {
label.push(c);
}
}
label
}
/// Longest level text (in characters) expanded into a list label.
@@ -413,6 +481,45 @@ const MAX_LABEL_BYTES: usize = 256;
mod tests {
use super::*;
/// Read-only counter APIs retain the renderer's bounds and never advance a list.
#[test]
fn reference_labels_are_bounded_and_do_not_advance_counters() {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.level_overrides = vec![(0, Level { text: "%1".repeat(30_000), ..Level::default() })];
}
let mut c = Counters::default();
let label = c.next_label(&n, id, 0).unwrap().0;
let before = c.values(&n, id);
assert_eq!(c.current_label(&n, id, 0).as_deref(), Some(label.as_str()));
assert_eq!(c.values(&n, id), before);
assert!(label.len() <= 512);
assert_eq!(template_levels(&"%1%9".repeat(30_000)), [0, 8]);
assert!(c.started(&n, id).unwrap()[0]);
}
#[test]
fn last_ancestor_label_keeps_the_instance_that_rendered_it() {
let mut numbering = Numbering::default();
let first = numbering.add_list(ListKind::Numbered);
numbering.abstracts[0].levels[0].text = "Clause %1.".into();
let mut second = numbering.num(first).unwrap().clone();
second.id = first + 1;
second.level_overrides = vec![
(0, Level { text: "Part %1.%2.".into(), ..Level::default() }),
(1, Level { format: NumFormat::LowerLetter, text: "(%2)".into(), ..Level::default() }),
];
let second_id = second.id;
numbering.nums.push(second);
let mut counters = Counters::default();
assert_eq!(counters.next_label(&numbering, first, 0).unwrap().0, "Clause 1.");
assert_eq!(counters.next_label(&numbering, second_id, 1).unwrap().0, "(a)");
assert_eq!(counters.current_label(&numbering, second_id, 0).as_deref(), Some("Clause 1."));
assert_eq!(counters.current_template_levels(&numbering, second_id, 0), vec![0]);
assert_eq!(counters.started(&numbering, second_id).unwrap()[..2], [true, true]);
}
#[test]
fn a_huge_level_text_gives_a_bounded_label() {
let mut n = Numbering::default();
+3 -3
View File
@@ -231,7 +231,7 @@ impl Paragraph {
/// Number of objects in `text[..off]`.
fn objects_before(&self, off: usize) -> usize {
self.text.get(..off).map(|t| t.chars().filter(|c| *c == OBJ).count()).unwrap_or(0)
self.text.get(..off).map(|t| t.chars().inspect(|_| crate::work::steps(1)).filter(|c| *c == OBJ).count()).unwrap_or(0)
}
/// The object at byte `off` (which must hold U+FFFC).
@@ -250,13 +250,13 @@ impl Paragraph {
}
/// Byte offsets of all objects, in order.
pub fn object_offsets(&self) -> Vec<usize> {
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
}
/// (byte offset, object) for every object, in text order, in one pass. Prefer it to
/// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before
/// each offset again, so a loop over a paragraph's objects is quadratic (#3614).
pub fn objects_with_offsets(&self) -> impl Iterator<Item = (usize, &InlineObject)> + '_ {
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
self.text.char_indices().inspect(|_| crate::work::steps(1)).filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
}
/// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length.
+3 -1
View File
@@ -26,7 +26,9 @@ pub use char_props::{
CharProps, CombineBrackets, DEFAULT_FONT_SIZE, EastAsianLayout, EmphasisMark, Highlight, Ligatures, NumChange, PropChange, Underline, VertAlign,
};
pub use color::{Rgb, TextColor};
pub use para_props::{Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextFrame};
pub use para_props::{
Align, Border, BorderStyle, Borders, LineSpacing, NumRef, ParaProps, TAB_POS_EPS, TabAlign, TabLeader, TabStop, TextAlignment, TextFrame,
};
pub use table_props::{
CellPicture, CellProps, HeightRule, PictureRepeat, RowProps, TableFloat, TableLook, TableProps, TextDirection, TextVert, VAlign, VMerge,
anchor_ooxml, valign_from_anchor,
+76 -2
View File
@@ -26,6 +26,40 @@ impl Align {
}
}
/// Vertical alignment of characters of different sizes on a line (`w:textAlignment`,
/// ECMA-376 §17.3.1.39).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TextAlignment {
Auto,
Top,
Center,
Baseline,
Bottom,
}
impl TextAlignment {
pub fn ooxml(self) -> &'static str {
match self {
TextAlignment::Auto => "auto",
TextAlignment::Top => "top",
TextAlignment::Center => "center",
TextAlignment::Baseline => "baseline",
TextAlignment::Bottom => "bottom",
}
}
pub fn from_ooxml(s: &str) -> Option<TextAlignment> {
match s {
"auto" => Some(TextAlignment::Auto),
"top" => Some(TextAlignment::Top),
"center" => Some(TextAlignment::Center),
"baseline" => Some(TextAlignment::Baseline),
"bottom" => Some(TextAlignment::Bottom),
_ => None,
}
}
}
/// Line spacing rule.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "rule", content = "value")]
@@ -107,6 +141,18 @@ pub enum BorderStyle {
Triple,
DotDash,
Wave,
/// The compound lines of thin and thick strokes (`thinThickSmallGap` to
/// `thinThickThinLargeGap`), kept so a file writes back the line it read; drawn as a single
/// line of the border's width.
ThinThickSmallGap,
ThickThinSmallGap,
ThinThickThinSmallGap,
ThinThickMediumGap,
ThickThinMediumGap,
ThinThickThinMediumGap,
ThinThickLargeGap,
ThickThinLargeGap,
ThinThickThinLargeGap,
}
impl BorderStyle {
@@ -121,6 +167,15 @@ impl BorderStyle {
BorderStyle::Triple => "triple",
BorderStyle::DotDash => "dotDash",
BorderStyle::Wave => "wave",
BorderStyle::ThinThickSmallGap => "thinThickSmallGap",
BorderStyle::ThickThinSmallGap => "thickThinSmallGap",
BorderStyle::ThinThickThinSmallGap => "thinThickThinSmallGap",
BorderStyle::ThinThickMediumGap => "thinThickMediumGap",
BorderStyle::ThickThinMediumGap => "thickThinMediumGap",
BorderStyle::ThinThickThinMediumGap => "thinThickThinMediumGap",
BorderStyle::ThinThickLargeGap => "thinThickLargeGap",
BorderStyle::ThickThinLargeGap => "thickThinLargeGap",
BorderStyle::ThinThickThinLargeGap => "thinThickThinLargeGap",
}
}
pub fn from_ooxml(s: &str) -> BorderStyle {
@@ -133,6 +188,15 @@ impl BorderStyle {
"triple" => BorderStyle::Triple,
"dotDash" | "dotDotDash" => BorderStyle::DotDash,
"wave" | "doubleWave" => BorderStyle::Wave,
"thinThickSmallGap" => BorderStyle::ThinThickSmallGap,
"thickThinSmallGap" => BorderStyle::ThickThinSmallGap,
"thinThickThinSmallGap" => BorderStyle::ThinThickThinSmallGap,
"thinThickMediumGap" => BorderStyle::ThinThickMediumGap,
"thickThinMediumGap" => BorderStyle::ThickThinMediumGap,
"thinThickThinMediumGap" => BorderStyle::ThinThickThinMediumGap,
"thinThickLargeGap" => BorderStyle::ThinThickLargeGap,
"thickThinLargeGap" => BorderStyle::ThickThinLargeGap,
"thinThickThinLargeGap" => BorderStyle::ThinThickThinLargeGap,
_ => BorderStyle::Single,
}
}
@@ -241,6 +305,9 @@ pub struct TextFrame {
pub v_anchor: String,
/// `around`, `auto`, `notBeside`, `none`, `tight` or `through`.
pub wrap: String,
/// Whether the frame stays fixed to its page anchor when its paragraph moves.
#[serde(skip_serializing_if = "Option::is_none")]
pub anchor_lock: Option<bool>,
/// Distance from the text left and right, and above and below.
pub h_space: f32,
pub v_space: f32,
@@ -295,6 +362,9 @@ pub struct ParaProps {
/// outside ones, so they swap sides on even (left-hand) pages.
#[serde(skip_serializing_if = "Option::is_none")]
pub mirror_indents: Option<bool>,
/// `w:adjustRightInd`: automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
#[serde(skip_serializing_if = "Option::is_none")]
pub adjust_right_indent: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keep_next: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -368,6 +438,10 @@ pub struct ParaProps {
/// `w:snapToGrid`: lines snap to the section's document grid. Unset = on.
#[serde(skip_serializing_if = "Option::is_none")]
pub snap_to_grid: Option<bool>,
/// `w:textAlignment`: how characters of different sizes line up on a line. Kept for the
/// file (not laid out yet); Word sizes a line by it, so dropping it changes Word's pages.
#[serde(skip_serializing_if = "Option::is_none")]
pub text_alignment: Option<TextAlignment>,
}
impl ParaProps {
@@ -379,7 +453,7 @@ impl ParaProps {
fn overlay_layout(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars,
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders);
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, adjust_right_indent, tabs, shading, borders);
}
fn overlay_pagination(&mut self, patch: &ParaProps) {
@@ -388,7 +462,7 @@ impl ParaProps {
}
fn overlay_script_layout(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn);
overlay_fields!(self, patch; bidi, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, text_alignment);
}
pub fn overlaid(mut self, patch: &ParaProps) -> ParaProps {
self.overlay(patch);
+8
View File
@@ -153,6 +153,10 @@ impl ResolvedChar {
}
/// Concrete paragraph formatting.
fn default_adjust_right_indent() -> bool {
true
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedPara {
@@ -173,6 +177,9 @@ pub struct ResolvedPara {
pub contextual_spacing: bool,
/// Left/right indents are inside/outside: they swap on even pages.
pub mirror_indents: bool,
/// Automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
#[serde(default = "default_adjust_right_indent")]
pub adjust_right_indent: bool,
pub keep_next: bool,
pub keep_lines: bool,
pub page_break_before: bool,
@@ -264,6 +271,7 @@ impl ResolvedPara {
},
contextual_spacing: p.contextual_spacing.unwrap_or(false),
mirror_indents: p.mirror_indents.unwrap_or(false),
adjust_right_indent: p.adjust_right_indent.unwrap_or(true),
keep_next: p.keep_next.unwrap_or(false),
keep_lines: p.keep_lines.unwrap_or(false),
page_break_before: p.page_break_before.unwrap_or(false),
+50
View File
@@ -115,6 +115,40 @@ pub enum NumFormat {
DollarText,
}
/// Chapter separator for page numbers (`w:chapSep`, ECMA-376 §17.18.12).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ChapterSep {
Hyphen,
Period,
Colon,
EmDash,
EnDash,
}
impl ChapterSep {
pub fn from_ooxml(value: &str) -> Option<Self> {
match value {
"hyphen" => Some(Self::Hyphen),
"period" => Some(Self::Period),
"colon" => Some(Self::Colon),
"emDash" => Some(Self::EmDash),
"enDash" => Some(Self::EnDash),
_ => None,
}
}
pub const fn ooxml(self) -> &'static str {
match self {
Self::Hyphen => "hyphen",
Self::Period => "period",
Self::Colon => "colon",
Self::EmDash => "emDash",
Self::EnDash => "enDash",
}
}
}
impl NumFormat {
/// Every format, in the order ST_NumberFormat lists them (which is also the order of the
/// binary .doc number format codes up to 0x27).
@@ -749,6 +783,9 @@ pub struct SectionProps {
pub title_page: bool,
pub page_num_start: Option<u32>,
pub page_num_format: NumFormat,
/// Chapter heading level and separator in `w:pgNumType`.
pub chapter_style: Option<u8>,
pub chapter_sep: Option<ChapterSep>,
/// The file carries a `w:pgNumType` element that sets nothing else (no start, decimal
/// format), so saving writes it back rather than dropping it.
#[serde(skip_serializing_if = "std::ops::Not::not")]
@@ -779,6 +816,13 @@ pub struct SectionProps {
pub footnote_pr: NoteProps,
#[serde(skip_serializing_if = "NoteProps::is_empty")]
pub endnote_pr: NoteProps,
/// First-page and other-page printer trays (`w:paperSrc`).
pub paper_src_first: Option<u16>,
pub paper_src_other: Option<u16>,
/// Section-specific suppression of endnotes (`w:noEndnote`).
pub no_endnote: Option<bool>,
/// Independent footnote column count (`w15:footnoteColumns`).
pub footnote_columns: Option<u32>,
/// Whether its columns end balanced (an ODF section's `text:dont-balance-text-columns`):
/// `None` is Word's rule, balanced when a continuous section follows; `Some(true)` also at
/// the end of the document, `Some(false)` never.
@@ -809,6 +853,8 @@ impl Default for SectionProps {
title_page: false,
page_num_start: None,
page_num_format: NumFormat::Decimal,
chapter_style: None,
chapter_sep: None,
page_num_set: false,
line_numbers: None,
valign: PageVAlign::Top,
@@ -821,6 +867,10 @@ impl Default for SectionProps {
doc_grid: None,
footnote_pr: NoteProps::default(),
endnote_pr: NoteProps::default(),
paper_src_first: None,
paper_src_other: None,
no_endnote: None,
footnote_columns: None,
balance_columns: None,
}
}
+35 -1
View File
@@ -12,6 +12,8 @@
use serde::{Deserialize, Serialize};
use crate::props::Rgb;
/// Most items in a SmartArt graphic.
pub const MAX_ITEMS: usize = 100;
/// Longest item text, characters.
@@ -198,11 +200,14 @@ pub struct SmartArtItem {
pub text: String,
#[serde(default)]
pub level: u8,
/// Fill of the item's shape in place of the style's colour.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<Rgb>,
}
impl SmartArtItem {
pub fn new(text: impl Into<String>, level: u8) -> SmartArtItem {
SmartArtItem { text: text.into(), level }
SmartArtItem { text: text.into(), level, color: None }
}
}
@@ -257,6 +262,35 @@ impl SmartArtSpec {
}
}
/// Whether any item has its own colour.
pub fn has_custom_colors(&self) -> bool {
self.items.iter().any(|it| it.color.is_some())
}
/// Give every item the style's colour again.
pub fn clear_custom_colors(&mut self) {
for it in &mut self.items {
it.color = None;
}
}
/// The theme accent (1–6) the style gives item `i`; `None` when the item has no shape of
/// its own (it is text inside another item's shape).
pub fn style_accent(&self, i: usize) -> Option<usize> {
let it = self.items.get(i)?;
let tops = || self.items.iter().take(i).filter(|o| o.level == 0).count();
match self.layout {
SmartArtLayout::Hierarchy => Some(self.colors.accent(i)),
SmartArtLayout::Radial if i == 0 => Some(self.colors.accent(0)),
SmartArtLayout::Radial => {
let parents = self.parents();
let spoke = |j: usize| j > 0 && matches!(parents.get(j), Some(Some(0)) | Some(None));
spoke(i).then(|| self.colors.accent((1..i).filter(|j| spoke(*j)).count() + 1))
}
_ => (it.level == 0).then(|| self.colors.accent(tops())),
}
}
/// The graphic after `sanitize`.
pub fn sanitized(mut self) -> SmartArtSpec {
self.sanitize();
+25
View File
@@ -966,6 +966,31 @@ fn table_styles() -> Vec<Style> {
#[cfg(test)]
mod tests {
use super::*;
use crate::resolve::ResolvedPara;
#[test]
fn adjust_right_indent_inherits_and_can_be_overridden() {
let resolved = StyleSheet::empty().resolve_para(&ParaProps::default());
assert!(resolved.adjust_right_indent);
let mut older_json = serde_json::to_value(&resolved).unwrap();
older_json.as_object_mut().unwrap().remove("adjustRightIndent");
assert!(serde_json::from_value::<ResolvedPara>(older_json).unwrap().adjust_right_indent);
let mut s = StyleSheet::empty();
s.styles.push(Style {
id: "Normal".into(),
name: "Normal".into(),
para: ParaProps { adjust_right_indent: Some(true), ..Default::default() },
..Default::default()
});
assert!(s.resolve_para(&ParaProps::default()).adjust_right_indent);
assert!(!s.resolve_para(&ParaProps { adjust_right_indent: Some(false), ..Default::default() }).adjust_right_indent);
let json = serde_json::to_value(ParaProps { adjust_right_indent: Some(false), ..Default::default() }).unwrap();
assert_eq!(json["adjustRightIndent"], false);
assert_eq!(serde_json::from_value::<ParaProps>(json).unwrap().adjust_right_indent, Some(false));
}
#[test]
fn builtins_present() {
+29
View File
@@ -0,0 +1,29 @@
//! A count of the characters a paragraph's object lookups walk, for the linear-time tests of the
//! crates that use them: an input 8x the size must cost about 8x the steps, which a busy machine
//! can't skew the way it skews a timing. Counted only in tests (this crate's, and other crates'
//! through the `test-support` feature, which they turn on as a dev-dependency); in the app the
//! count is nothing.
#[cfg(any(test, feature = "test-support"))]
thread_local! {
/// The steps counted on this thread.
static STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// Count `n` steps of a walk (nothing outside tests). Public for the walks other crates do over a
/// paragraph's text themselves (Markdown and HTML comment export, for one).
#[inline(always)]
pub fn steps(n: usize) {
#[cfg(any(test, feature = "test-support"))]
STEPS.with(|s| s.set(s.get().saturating_add(n as u64)));
#[cfg(not(any(test, feature = "test-support")))]
let _ = n;
}
/// The steps `f` takes.
#[cfg(any(test, feature = "test-support"))]
pub fn count(f: impl FnOnce()) -> u64 {
let before = STEPS.with(std::cell::Cell::get);
f();
STEPS.with(std::cell::Cell::get).saturating_sub(before)
}
+106 -19
View File
@@ -51,6 +51,14 @@ const MAX_PARTS: usize = 50_000;
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
const MAX_TOTAL_CHARS: u64 = 64 << 20;
/// Most paragraph and cell marks across every story walk of one document. The character
/// budget does not see them: a mark is one byte of the file but about 1 KB of document, so a
/// file of nothing but marks would grow memory about a thousand times faster than its size.
/// The cap bounds a flood to about 2 GB while reading (about 4 GB at the very cap, when the
/// blocks are assembled). Past it the read fails with [`DocbinError::Limit`]. Real documents stay far below it:
/// 50 paragraphs on each of 10,000 pages is 500,000, and a 10,000-page one-column table (46
/// rows, each a cell mark plus a row mark) is about 920,000.
const MAX_PARAGRAPHS: usize = 2_000_000;
/// Bytes of repeated work per document beyond the text itself: piece Prm grpprls (Prcs, up
/// to 64 KB each) interpreted (#3692), hyperlink targets (#3708), picture alt text (#3709)
/// and floating objects (#3710) copied into every run or anchor. Past it Prms are skipped,
@@ -195,6 +203,12 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
/// verifier ([`DocbinError::WrongPassword`] when it does not match) and then decrypted. The
/// password is ignored for an unprotected file.
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Document, DocbinError> {
read_limited(bytes, password, MAX_PARAGRAPHS)
}
/// [`read_with_password`] with the paragraph cap as a parameter, so tests need not build a
/// file of [`MAX_PARAGRAPHS`] marks.
fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) -> Result<Document, DocbinError> {
let mut comp = open_container(bytes)?;
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
if word.len() as u64 > MAX_STREAM {
@@ -270,6 +284,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
// tight against aliasing — and never past the absolute ceiling.
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
let work = Work::new(MAX_WORK);
let paragraphs = ParaBudget { left: std::cell::Cell::new(max_paragraphs), exceeded: std::cell::Cell::new(false) };
let ctx = WalkCtx {
word: &word,
@@ -285,6 +300,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
drawn: &[],
sect_ends: &[],
budget: &budget,
paragraphs: &paragraphs,
work: &work,
};
@@ -304,7 +320,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
if doc.parts.len() >= MAX_PARTS || tb_stories.contains_key(&spid) {
continue;
}
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
tb_stories.insert(spid, doc.add_part(PartKind::TextBox, blocks));
}
}
@@ -329,7 +345,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
0 | 1 | 4 => PartKind::Header,
_ => PartKind::Footer,
};
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
if !blocks.is_empty() {
let id = doc.add_part(kind, blocks);
hf_ids.push((si, k, id));
@@ -342,7 +358,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
let mut note_links: Vec<(u32, NoteKind, u32)> = Vec::new();
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_FND_REF, fib::pair::PLCF_FND_TXT) {
let Some((a, b)) = story(ftn_base, fib.ccp.ftn, ab) else { continue };
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
let id = doc.add_part(PartKind::Footnote, blocks);
note_links.push((cp, NoteKind::Footnote, id));
@@ -350,7 +366,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
}
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_END_REF, fib::pair::PLCF_END_TXT) {
let Some((a, b)) = story(edn_base, fib.ccp.edn, ab) else { continue };
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
let id = doc.add_part(PartKind::Endnote, blocks);
note_links.push((cp, NoteKind::Endnote, id));
@@ -393,6 +409,9 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
doc.last_section = l;
}
if paragraphs.exceeded.get() {
return Err(paragraph_limit(max_paragraphs));
}
doc.media.extend(pics.media);
doc.body = table::assemble(paras);
doc.ensure_nonempty();
@@ -400,6 +419,20 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
Ok(doc)
}
fn paragraph_limit(max_paragraphs: usize) -> DocbinError {
DocbinError::Limit(format!("the document has more than {max_paragraphs} paragraphs and table cells"))
}
/// Walks one story into blocks. A walk that spent the shared paragraph budget is refused before
/// its partial list is assembled, so a flood does not build blocks it will throw away.
fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, max_paragraphs: usize) -> Result<wordcraft_doc::Blocks, DocbinError> {
let paras = walk(ctx, a, b, pics);
if ctx.paragraphs.exceeded.get() {
return Err(paragraph_limit(max_paragraphs));
}
Ok(table::assemble(paras))
}
/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len`
/// CPs, as absolute CPs clamped to that subdocument; `None` when empty.
fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> {
@@ -433,10 +466,35 @@ struct WalkCtx<'a> {
/// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of
/// them could otherwise materialise the same text thousands of times; this bounds the total.
budget: &'a std::cell::Cell<u64>,
/// Paragraph and cell marks still allowed across every story walk ([`MAX_PARAGRAPHS`]).
paragraphs: &'a ParaBudget,
/// Repeated-work bytes left ([`MAX_WORK`]), and what they left out.
work: &'a Work,
}
/// The paragraph and cell marks still allowed in this document, and whether a walk ran into
/// the end of them (the read then fails).
struct ParaBudget {
left: std::cell::Cell<usize>,
exceeded: std::cell::Cell<bool>,
}
impl ParaBudget {
/// Spends one mark; false (and the overrun noted) when none are left.
fn spend(&self) -> bool {
match self.left.get().checked_sub(1) {
Some(left) => {
self.left.set(left);
true
}
None => {
self.exceeded.set(true);
false
}
}
}
}
/// State while assembling one paragraph from per-CP formatting.
#[derive(Default)]
struct ParaBuild {
@@ -527,6 +585,9 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
let section_mark = c == '\u{C}' && ctx.sect_ends.binary_search(&cp.saturating_add(1)).is_ok();
match c {
'\r' | '\u{7}' | '\u{C}' if c != '\u{C}' || section_mark => {
if !ctx.paragraphs.spend() {
break;
}
// Paragraph mark: its PAPX covers the FC range of the paragraph it ends, so
// querying at the mark's own FC finds that paragraph's properties.
let fc_mark = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
@@ -536,6 +597,9 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
}
let mut row = table::decode(papx);
row.cell_mark = c == '\u{7}';
// Only table marks keep their table context; a plain paragraph would carry
// a `RowInfo` (about 300 bytes) it never uses.
let row = row.is_table().then(|| Box::new(row));
let mut ilvl = 0u8;
let mut ilfo = 0i32;
for prl in sprm::iter(papx) {
@@ -589,7 +653,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
&& f.depth == 1
&& f.sep.is_none()
{
f.sep = Some(Vec::new());
f.sep = Some(FieldResult::default());
}
cp = cp.saturating_add(1);
continue;
@@ -669,7 +733,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
out.push(ParaOut {
para: Paragraph { text: done.text, runs: done.runs, props: done.props, mark: done.mark, objects: done.objects, ..Default::default() },
end_cp: cp_end,
row: table::RowInfo::default(),
row: None,
});
}
out
@@ -680,7 +744,28 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
struct FieldBuild {
depth: u32,
instr: String,
sep: Option<Vec<(char, CharProps)>>,
sep: Option<FieldResult>,
}
/// A field's cached result: its text and, as runs, the formatting of that text. A run is added
/// only when the formatting changes, so a result of millions of equally formatted characters
/// costs one `CharProps`, not one per character (365 bytes each).
#[derive(Default)]
struct FieldResult {
text: String,
/// (byte length in `text`, formatting) in order; adjacent runs differ.
runs: Vec<(usize, CharProps)>,
}
impl FieldResult {
fn push(&mut self, c: char, props: CharProps) {
let len = c.len_utf8();
match self.runs.last_mut() {
Some((n, last)) if *last == props => *n += len,
_ => self.runs.push((len, props)),
}
self.text.push(c);
}
}
/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all
@@ -705,24 +790,26 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
let result = f.sep.unwrap_or_default();
if let Some(target) = hyperlink_target(&instr) {
let cost = target.len() as u64;
let mut run: Option<CharProps> = None;
for (c, props) in result {
if run.as_ref() == Some(&props) {
pb.extend(c);
continue;
}
let mut linked = props.clone();
let mut chars = result.text.chars();
for (len, props) in result.runs {
let mut linked = props;
if work.charge(cost, Cost::LinkTarget) {
linked.link = Some(target.clone());
}
pb.push(c, linked);
run = Some(props);
let mut left = len;
let Some(first) = chars.next() else { break };
pb.push(first, linked);
left = left.saturating_sub(first.len_utf8());
while left > 0 {
let Some(c) = chars.next() else { break };
pb.extend(c);
left = left.saturating_sub(c.len_utf8());
}
}
return;
}
let result_text: String = result.iter().map(|(c, _)| *c).collect();
pb.push('\u{FFFC}', CharProps::default());
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
pb.objects.push(InlineObject::Field { instr, result: result.text, locked: false, dirty: false, code: false, form: String::new() });
}
/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream.
@@ -806,7 +893,7 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f
Some(f) => match f.sep.as_mut() {
// Inside a field, characters are recorded for the field object instead of shown;
// `finish_field` emits the visible text (hyperlink runs or one object anchor).
Some(result) => result.push((c, props)),
Some(result) => result.push(c, props),
None => f.instr.push(c),
},
None => pb.push(c, props),
+9 -6
View File
@@ -83,6 +83,11 @@ pub(crate) struct RowInfo {
/// Read the table context out of a paragraph mark's PAPX grpprl.
impl RowInfo {
/// Does the mark belong to a table: inside one, ending a row, or ending a cell?
pub(crate) fn is_table(&self) -> bool {
self.in_table || self.ttp || self.cell_mark
}
/// The row height from `sprmTDyaRowHeight`, for tests.
#[cfg(test)]
pub(crate) fn height(&self) -> Option<f32> {
@@ -263,8 +268,8 @@ pub(crate) struct ParaOut {
pub(crate) para: Paragraph,
/// CP just past the terminating mark.
pub(crate) end_cp: u32,
/// Table context from the mark's PAPX (empty default = not in a table).
pub(crate) row: RowInfo,
/// Table context from the mark's PAPX; `None` for a paragraph outside a table.
pub(crate) row: Option<Box<RowInfo>>,
}
/// Group consecutive in-table paragraphs into [`Table`] blocks; the rest pass through.
@@ -369,9 +374,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
}
for p in paras {
let info = p.row;
let is_table = info.in_table || info.ttp || info.cell_mark;
if !is_table {
let Some(info) = p.row else {
if open {
flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);
grid.clear();
@@ -386,7 +389,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
}
out.push(para_block(p.para));
continue;
}
};
if !open {
open = true;
}
+136 -3
View File
@@ -116,6 +116,12 @@ fn piece_bytes(p: &Piece) -> Vec<u8> {
/// A document whose main text is the given pieces (extra subdocument text may follow).
fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec<u8> {
text_doc_with(main, extra, FibSpec { ccp_hdd, ..Default::default() }, &[])
}
/// [`text_doc`] with the FIB knobs given (`ccp_text` is filled in from `main`; `pairs` must be empty) and extra
/// Table-stream data placed after the piece table: `(pair index, bytes)` for each.
fn text_doc_with(main: &[Piece], extra: &[Piece], spec: FibSpec, table_data: &[(usize, &[u8])]) -> Vec<u8> {
let all: Vec<Piece> = main.iter().chain(extra.iter()).cloned().collect();
let ccp_text: u32 = main.iter().map(|p| p.text.chars().count() as u32).sum();
let mut word = vec![0u8; TEXT_FC as usize];
@@ -128,7 +134,13 @@ fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec<u8> {
clx.extend_from_slice(&plc);
let mut table = vec![0u8; CLX_AT as usize];
table.extend_from_slice(&clx);
let spec = FibSpec { ccp_text, ccp_hdd, pairs: vec![(33, CLX_AT, clx.len() as u32)], ..Default::default() };
let mut pairs = vec![(33, CLX_AT, clx.len() as u32)];
for (pair, bytes) in table_data {
pairs.push((*pair, table.len() as u32, bytes.len() as u32));
table.extend_from_slice(bytes);
}
debug_assert!(spec.pairs.is_empty(), "text_doc_with sets the pairs itself");
let spec = FibSpec { ccp_text, pairs, ..spec };
word[..fib_bytes(&spec).len()].copy_from_slice(&fib_bytes(&spec));
cfb_file(&[("WordDocument", &word), ("0Table", &table)])
}
@@ -247,6 +259,77 @@ fn empty_paragraphs_kept() {
assert_eq!(body_texts(&doc), ["a", "", "b"]);
}
/// A document of `n` copies of `mark` as one compressed piece.
fn marks_doc(mark: char, n: usize) -> Vec<u8> {
text_doc(&[Piece { compressed: true, text: mark.to_string().repeat(n) }], &[], 0)
}
/// Paragraph marks are one byte each in the file but about 1 KB each in memory, so they have
/// their own cap ([`super::MAX_PARAGRAPHS`]); a file past it is refused instead of growing
/// until the machine runs out. A document exactly at the cap still opens.
#[test]
fn a_flood_of_paragraph_marks_is_refused() {
let at_cap = super::read_limited(&marks_doc('\r', 1000), None, 1000).expect("opens");
assert_eq!(at_cap.body.len(), 1000);
let over = super::read_limited(&marks_doc('\r', 1001), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
}
/// The paragraph cap is one budget for the whole document, not one per story: marks in the
/// body and in a footnote add up. (A walk that got its own budget would open both over-cap files.)
#[test]
fn the_paragraph_cap_is_shared_by_the_body_and_a_footnote() {
// One footnote at CP 0 whose text is `note` marks, stored right after the body in the stream.
let doc_of = |body: usize, note: usize| {
let cps = |v: &[u32]| v.iter().flat_map(|c| c.to_le_bytes()).collect::<Vec<u8>>();
let mut refs = cps(&[0, 1]);
refs.extend_from_slice(&[0, 0]); // the one reference's data
let txt = cps(&[0, note as u32, note as u32]); // story 0..note, then the limit
let spec = FibSpec { ccp_ftn: note as u32, ..Default::default() };
text_doc_with(
&[Piece { compressed: true, text: "\r".repeat(body) }],
&[Piece { compressed: true, text: "\r".repeat(note) }],
spec,
&[(2, &refs), (3, &txt)],
)
};
let at_cap = super::read_limited(&doc_of(600, 400), None, 1000).expect("opens");
assert_eq!(at_cap.body.len(), 600);
assert_eq!(at_cap.parts.len(), 1);
let over = super::read_limited(&doc_of(600, 401), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
let over = super::read_limited(&doc_of(601, 400), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
}
/// Cell marks are paragraph marks too: a never-closed row of them is refused the same way.
#[test]
fn a_flood_of_cell_marks_is_refused() {
let over = super::read_limited(&marks_doc('\u{7}', 5000), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
}
/// The budget counts down across spends and notes the overrun (`walk` receives one shared by
/// every story of the document).
#[test]
fn the_paragraph_budget_counts_down_and_notes_the_overrun() {
let budget = super::ParaBudget { left: std::cell::Cell::new(2), exceeded: std::cell::Cell::new(false) };
assert!(budget.spend() && budget.spend());
assert!(!budget.exceeded.get());
assert!(!budget.spend());
assert!(budget.exceeded.get());
}
/// The cap is far above any real document (10,000 pages of 50 paragraphs is 500,000), and a
/// paragraph outside a table no longer carries a table context (a `ParaOut` was 1,224 bytes).
#[test]
fn the_paragraph_cap_leaves_room_and_a_paragraph_stays_small() {
const { assert!(super::MAX_PARAGRAPHS >= 1_000_000) };
assert!(std::mem::size_of::<crate::table::ParaOut>() < 1000, "{}", std::mem::size_of::<crate::table::ParaOut>());
let doc = read(&marks_doc('\r', 10)).expect("opens");
assert_eq!(doc.body.len(), 10);
}
#[test]
fn empty_document_gets_a_paragraph() {
let f = text_doc(&[Piece { compressed: false, text: "".into() }], &[], 0);
@@ -2660,7 +2743,8 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
assert_eq!(linked.first().map(|r| r.len), Some(n));
// One copy of the target for the one run, not one per character (~2.5 s here): counted by
// what the copies charge to the work budget, which a busy machine can't skew.
let result = vec![('y', wordcraft_doc::props::CharProps::default()); n];
let mut result = super::FieldResult::default();
(0..n).for_each(|_| result.push('y', wordcraft_doc::props::CharProps::default()));
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) };
let work = super::Work::new(u64::MAX);
super::finish_field(&mut super::ParaBuild::default(), f, &work);
@@ -2673,7 +2757,8 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
fn hyperlink_target_copies_are_charged_to_the_work_budget() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let result: Vec<(char, CharProps)> = (0..1000).map(|i| ('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })).collect();
let mut result = super::FieldResult::default();
(0..1000).for_each(|i| result.push('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() }));
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) };
let mut pb = super::ParaBuild::default();
let budget = super::Work::new(10_000);
@@ -2747,3 +2832,51 @@ fn prm_grpprls_past_the_work_budget_are_skipped() {
assert_eq!(work.left.get(), 1);
assert_eq!(work.dropped(), [super::Cost::Formatting]);
}
/// A field's cached result keeps one `CharProps` per formatting run, not one per character
/// (365 bytes each: 4 million result characters took 1.4 GB).
#[test]
fn a_field_result_stores_one_props_per_run() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut r = super::FieldResult::default();
for _ in 0..200_000 {
r.push('x', CharProps::default());
}
assert_eq!(r.runs.len(), 1);
assert_eq!(r.runs.first().map(|(n, _)| *n), Some(200_000));
r.push('\u{e9}', bold.clone());
r.push('y', bold);
r.push('z', CharProps::default());
assert_eq!(r.runs.iter().map(|(n, _)| *n).collect::<Vec<_>>(), [200_000, 3, 1], "a two-byte char counts its bytes");
assert_eq!(r.text.len(), 200_004);
}
/// A hyperlink whose result changes formatting, with multi-byte text, keeps every run's text and
/// links all of it; a plain field keeps its result text whole.
#[test]
fn field_results_keep_their_text_and_runs() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut result = super::FieldResult::default();
for (c, p) in
[('a', CharProps::default()), ('\u{e9}', CharProps::default()), ('\u{20ac}', bold.clone()), ('b', bold), ('c', CharProps::default())]
{
result.push(c, p);
}
let f = super::FieldBuild { depth: 1, instr: "HYPERLINK \"http://x\"".into(), sep: Some(result) };
let mut pb = super::ParaBuild::default();
super::finish_field(&mut pb, f, &super::Work::new(u64::MAX));
assert_eq!(pb.text, "a\u{e9}\u{20ac}bc");
assert_eq!(pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(), [3, 4, 1]);
assert!(pb.runs.iter().all(|r| r.props.link.as_deref() == Some("http://x")));
let doc = read(&text_doc(&[Piece { compressed: false, text: "\u{13} PAGE \u{14}1\u{e9}2\u{15}\r".into() }], &[], 0)).expect("opens");
let p = doc.body.first().and_then(|b| b.as_para()).expect("paragraph");
assert!(
p.objects
.iter()
.any(|o| matches!(o, wordcraft_doc::para::InlineObject::Field { instr, result, .. } if instr == "PAGE" && result == "1\u{e9}2")),
"{:?}",
p.objects
);
}
+1
View File
@@ -32,6 +32,7 @@ getrandom = { workspace = true, features = ["wasm_js"] }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
serde_json = { workspace = true }
proptest = { workspace = true }
image = { workspace = true }
zip = { workspace = true }
+23 -2
View File
@@ -5,8 +5,8 @@ use std::collections::HashMap;
use wordcraft_doc::para::cap_alt;
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CellProps, CharProps, CombineBrackets, EastAsianLayout, HeightRule, Highlight, LineSpacing, NumRef,
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge,
VertAlign,
ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextAlignment, TextColor, TextDirection, Underline,
VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{
Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, NotePos, NoteProps, NoteRestart, NumFormat,
@@ -33,6 +33,7 @@ fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame {
h_anchor: word("w:hAnchor", &["text", "margin", "page"], "page"),
v_anchor: word("w:vAnchor", &["text", "margin", "page"], "page"),
wrap: word("w:wrap", &["around", "auto", "notBeside", "none", "tight", "through"], "auto"),
anchor_lock: k.attr("w:anchorLock").map(|v| !matches!(v.trim(), "0" | "false" | "off" | "False" | "FALSE")),
h_space: len("w:hSpace").unwrap_or(0.0).max(0.0),
v_space: len("w:vSpace").unwrap_or(0.0).max(0.0),
}
@@ -333,6 +334,7 @@ impl PropCtx {
"w:widowControl" => p.widow_control = Some(on_off(k)),
"w:contextualSpacing" => p.contextual_spacing = Some(on_off(k)),
"w:mirrorIndents" => p.mirror_indents = Some(on_off(k)),
"w:adjustRightInd" => p.adjust_right_indent = Some(on_off(k)),
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
"w:bidi" => p.bidi = Some(on_off(k)),
@@ -343,6 +345,7 @@ impl PropCtx {
"w:autoSpaceDE" => p.auto_space_de = Some(on_off(k)),
"w:autoSpaceDN" => p.auto_space_dn = Some(on_off(k)),
"w:snapToGrid" => p.snap_to_grid = Some(on_off(k)),
"w:textAlignment" => p.text_alignment = k.attr("w:val").and_then(TextAlignment::from_ooxml),
"w:framePr" => {
if let Some(kind @ ("drop" | "margin")) = k.attr("w:dropCap") {
p.drop_cap = Some(k.attr("w:lines").and_then(u32_of).unwrap_or(3).clamp(1, 10) as u8);
@@ -762,8 +765,26 @@ pub fn sectpr(pc: &PropCtx, e: &El) -> (SectionProps, Vec<HfRef>) {
if let Some(f) = k.attr("w:fmt") {
s.page_num_format = NumFormat::from_ooxml(f);
}
s.chapter_style = k.attr("w:chapStyle").and_then(u32_of).filter(|v| *v <= u8::MAX as u32).map(|v| v as u8);
s.chapter_sep = k.attr("w:chapSep").and_then(wordcraft_doc::section::ChapterSep::from_ooxml);
s.page_num_set = s.page_num_start.is_none() && s.page_num_format == NumFormat::Decimal;
}
"w:paperSrc" => {
let tray = |name| {
let value = k.attr(name)?.trim();
let digits = value.strip_prefix('+').unwrap_or(value);
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
u32_of(digits).filter(|v| *v <= u16::MAX as u32).map(|v| v as u16)
};
s.paper_src_first = tray("w:first");
s.paper_src_other = tray("w:other");
}
"w:noEndnote" => s.no_endnote = Some(on_off(k)),
"w15:footnoteColumns" => {
s.footnote_columns = k.attr("w:val").and_then(u32_of).filter(|v| (1..=45).contains(v));
}
"w:lnNumType" => {
let mut l = LineNumbering::default();
if let Some(v) = k.attr("w:countBy").and_then(u32_of) {
+1 -1
View File
@@ -31,7 +31,7 @@ impl Reader<'_> {
let cap_font = first.runs.first().and_then(|r| r.props.font.clone());
let text_font = p.runs.first().and_then(|r| r.props.font.clone());
if cap_font.is_some() && cap_font != text_font {
for r in &mut first.runs {
for r in first.runs.iter_mut().filter(|r| r.props.font == cap_font) {
r.props.font = text_font.clone();
}
props.drop_cap_font = cap_font;
+83 -19
View File
@@ -86,6 +86,8 @@ enum Paint {
None,
/// Theme accent `n` (1–6), lightened to `tint` (thousandths of a percent kept) when set.
Accent(usize, Option<u32>),
/// A colour of its own, lightened to `tint` when set.
Rgb(Rgb, Option<u32>),
/// The theme's light background colour.
Light,
}
@@ -150,6 +152,22 @@ impl Sh {
}
}
/// Accent fills and outlines in item `i`'s own colour, when it has one. Light outlines
/// keep the theme's background colour so adjacent nodes remain separated.
fn recolor(&mut self, spec: &SmartArtSpec, i: usize) {
let Some(c) = spec.items.get(i).and_then(|it| it.color) else { return };
for p in [&mut self.fill, &mut self.line] {
if let Paint::Accent(_, tint) = *p {
*p = Paint::Rgb(c, tint);
}
}
}
fn item(mut self, spec: &SmartArtSpec, i: usize) -> Sh {
self.recolor(spec, i);
self
}
fn with_text(mut self, text: Vec<(String, bool)>) -> Sh {
self.text = text;
self
@@ -226,7 +244,7 @@ fn basic_list(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let in_row = if r + 1 == rows { n - r * cols } else { cols };
let x0 = (w - (in_row as f32 * bw + (in_row - 1) as f32 * gx)) / 2.0;
let b = [x0 + c as f32 * (bw + gx), y0 + r as f32 * (bh + gy), bw, bh];
out.push(Sh::node("rect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
out.push(Sh::node("rect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
}
out
}
@@ -247,11 +265,14 @@ fn process(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let mut a = Sh::node("rightArrow", [gx + (gap - aw) / 2.0, h / 2.0 - ah / 2.0, aw, ah], spec.colors.accent(k - 1));
a.fill = Paint::Accent(spec.colors.accent(k - 1), Some(60_000));
a.line = Paint::None;
if let Some(&i) = tops.get(k - 1) {
a.recolor(spec, i);
}
out.push(a);
}
for (k, &i) in tops.iter().enumerate() {
let b = [k as f32 * (nw + gap), y, nw, nh];
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)));
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)));
}
out
}
@@ -294,6 +315,9 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
a.rot = (mid + 90.0).rem_euclid(360.0);
a.fill = Paint::Accent(spec.colors.accent(k), Some(60_000));
a.line = Paint::None;
if let Some(&i) = tops.get(k) {
a.recolor(spec, i);
}
out.push(a);
}
}
@@ -301,7 +325,7 @@ fn cycle(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
for (k, &i) in tops.iter().enumerate() {
let (x, y) = if n == 1 { (cx, cy) } else { polar(cx, cy, r, angle(k, n)) };
let b = [x - d / 2.0, y - d / 2.0, d, d];
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
}
out
}
@@ -360,19 +384,19 @@ fn hierarchy(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let (px, pb) = (cx(i), top(i) + nh);
let ct = ks.first().map_or(pb, |&k| top(k));
let mid = (pb + ct) / 2.0;
out.push(Sh::line(px, pb, px, mid, accent));
out.push(Sh::line(px, pb, px, mid, accent).item(spec, i));
let xs: Vec<f32> = ks.iter().map(|&k| cx(k)).collect();
let (lo, hi) = xs.iter().fold((px, px), |(lo, hi), &x| (lo.min(x), hi.max(x)));
if hi - lo > 0.5 {
out.push(Sh::line(lo, mid, hi, mid, accent));
out.push(Sh::line(lo, mid, hi, mid, accent).item(spec, i));
}
for x in xs {
out.push(Sh::line(x, mid, x, ct, accent));
out.push(Sh::line(x, mid, x, ct, accent).item(spec, i));
}
}
for i in 0..n {
let b = [cx(i) - nw / 2.0, top(i), nw, nh];
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).with_text(paras(spec, i, false)));
out.push(Sh::node("roundRect", b, spec.colors.accent(i)).item(spec, i).with_text(paras(spec, i, false)));
}
out
}
@@ -400,7 +424,7 @@ fn pyramid(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let mid_w = (top_w + bot_w) / 2.0 * 0.8;
s.text_box([w / 2.0 - mid_w / 2.0, b[1] + bh * 0.08, mid_w, bh * 0.84])
};
out.push(sh.with_text(paras(spec, i, true)));
out.push(sh.item(spec, i).with_text(paras(spec, i, true)));
}
out
}
@@ -418,7 +442,7 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
if n == 0 {
let d = m * 0.5;
let b = [cx - d / 2.0, cy - d / 2.0, d, d];
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(b, ROUND_INSET)));
return out;
}
let step = if n == 1 { 1.0 } else { (std::f32::consts::PI / n as f32).sin() };
@@ -432,14 +456,14 @@ fn radial(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
let at = |k: usize| polar(cx, cy, r, angle(k, n));
for k in 0..n {
let (x, y) = at(k);
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)));
out.push(Sh::line(cx, cy, x, y, spec.colors.accent(0)).item(spec, 0));
}
let bc = [cx - dc / 2.0, cy - dc / 2.0, dc, dc];
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
out.push(Sh::node("ellipse", bc, spec.colors.accent(0)).item(spec, 0).with_text(paras(spec, 0, false)).text_box(inset(bc, ROUND_INSET)));
for (k, &i) in spokes.iter().enumerate() {
let (x, y) = at(k);
let b = [x - ds / 2.0, y - ds / 2.0, ds, ds];
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
out.push(Sh::node("ellipse", b, spec.colors.accent(k + 1)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, ROUND_INSET)));
}
out
}
@@ -457,7 +481,7 @@ fn matrix(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
for (k, &i) in tops.iter().enumerate() {
let (r, c) = (k / cols, k % cols);
let b = [x0 + c as f32 * cell + gap / 2.0, y0 + r as f32 * cell + gap / 2.0, cell - gap, cell - gap];
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
out.push(Sh::node("roundRect", b, spec.colors.accent(k)).item(spec, i).with_text(paras(spec, i, true)).text_box(inset(b, 0.1)));
}
out
}
@@ -478,22 +502,22 @@ fn venn(spec: &SmartArtSpec, w: f32, h: f32) -> Vec<Sh> {
(x, y, deg)
};
let mut out = Vec::new();
for (j, _) in tops.iter().enumerate() {
for (j, &i) in tops.iter().enumerate() {
let (x, y, _) = place(j);
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
s.fill = Paint::Accent(spec.colors.accent(j), Some(40_000));
s.alpha = Some(60_000);
s.line = Paint::None;
out.push(s);
out.push(s.item(spec, i));
}
// Every outline over every fill, so each circle shows whole.
for (j, _) in tops.iter().enumerate() {
for (j, &i) in tops.iter().enumerate() {
let (x, y, _) = place(j);
let mut s = Sh::node("ellipse", [x - r, y - r, 2.0 * r, 2.0 * r], spec.colors.accent(j));
s.fill = Paint::None;
s.line = Paint::Accent(spec.colors.accent(j), None);
s.line_w = 1.5;
out.push(s);
out.push(s.item(spec, i));
}
for (j, &i) in tops.iter().enumerate() {
let (x, y, deg) = place(j);
@@ -554,6 +578,23 @@ fn paint(w: &mut W, p: Paint, alpha: Option<u32>) {
w.empty("a:schemeClr", &[("val", "lt1")]);
w.close("a:solidFill");
}
Paint::Rgb(c, tint) => {
w.open("a:solidFill", &[]);
let val = c.hex();
if tint.is_none() && alpha.is_none() {
w.empty("a:srgbClr", &[("val", &val)]);
} else {
w.open("a:srgbClr", &[("val", &val)]);
if let Some(t) = tint {
w.empty("a:tint", &[("val", &t.to_string())]);
}
if let Some(a) = alpha {
w.empty("a:alpha", &[("val", &a.to_string())]);
}
w.close("a:srgbClr");
}
w.close("a:solidFill");
}
Paint::Accent(n, tint) => {
w.open("a:solidFill", &[]);
let val = format!("accent{}", n.clamp(1, 6));
@@ -743,7 +784,16 @@ pub fn data_xml(spec: &SmartArtSpec, drawing_rel: &str) -> String {
let (node, cxn, par, sib) = item_ids(i);
w.open("dgm:pt", &[("modelId", &node)]);
w.empty("dgm:prSet", &[]);
w.empty("dgm:spPr", &[]);
match it.color {
Some(c) => {
w.open("dgm:spPr", &[]);
w.open("a:solidFill", &[]);
w.empty("a:srgbClr", &[("val", &c.hex())]);
w.close("a:solidFill");
w.close("dgm:spPr");
}
None => w.empty("dgm:spPr", &[]),
}
w.open("dgm:t", &[]);
w.empty("a:bodyPr", &[]);
w.empty("a:lstStyle", &[]);
@@ -1062,7 +1112,9 @@ pub(crate) fn spec_of(data: &El) -> Option<SmartArtSpec> {
.zip(&levels)
.map(|(p, l)| {
let text: String = p.child("dgm:t")?.child("a:p")?.children("a:r").filter_map(|r| r.child("a:t")).map(|t| t.text()).collect();
Some(SmartArtItem { text, level: (*l)? })
let color =
p.child("dgm:spPr").and_then(|s| s.child("a:solidFill")).and_then(|f| f.child("a:srgbClr")).and_then(|c| Rgb::parse(c.attr("val")?));
Some(SmartArtItem { text, level: (*l)?, color })
})
.collect::<Option<Vec<_>>>()?;
let spec = SmartArtSpec { layout, items, colors }.sanitized();
@@ -1138,6 +1190,18 @@ mod tests {
}
}
#[test]
fn an_items_own_colour_is_saved_drawn_and_read_back() {
let mut spec = SmartArtSpec::sample(SmartArtLayout::Process);
spec.items[1].color = Some(Rgb(0x12, 0x34, 0x56));
let spec = spec.sanitized();
assert_eq!(spec_of(&parse(&data_xml(&spec, "rId9"))).as_ref(), Some(&spec));
let drawing = drawing_xml(&spec, 400.0, 250.0);
assert_eq!(drawing.matches("<a:srgbClr val=\"123456\"").count(), 2, "the step and the arrow after it");
let plain = drawing_xml(&SmartArtSpec::sample(SmartArtLayout::Process), 400.0, 250.0);
assert!(!plain.contains("srgbClr"));
}
#[test]
fn a_diagram_with_more_than_the_model_stays_read_only() {
let spec = SmartArtSpec::sample(SmartArtLayout::Process);
+7
View File
@@ -287,6 +287,9 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha
if let Some(a) = p.align {
w.val("w:jc", align_val(a));
}
if let Some(t) = p.text_alignment {
w.val("w:textAlignment", t.ooxml());
}
if let Some(l) = p.outline_level {
w.val("w:outlineLvl", &n(l.min(9) as i64));
}
@@ -323,6 +326,9 @@ fn write_frame(w: &mut W, p: &ParaProps, framed: bool) {
a.push(("w:vSpace", &vs));
}
a.push(("w:wrap", &f.wrap));
if let Some(anchor_lock) = f.anchor_lock {
a.push(("w:anchorLock", if anchor_lock { "1" } else { "0" }));
}
a.push(("w:vAnchor", &f.v_anchor));
a.push(("w:hAnchor", &f.h_anchor));
match &f.x_align {
@@ -370,6 +376,7 @@ fn write_text_flow(w: &mut W, p: &ParaProps) {
toggle(w, "w:autoSpaceDE", p.auto_space_de);
toggle(w, "w:autoSpaceDN", p.auto_space_dn);
toggle(w, "w:bidi", p.bidi);
toggle(w, "w:adjustRightInd", p.adjust_right_indent);
toggle(w, "w:snapToGrid", p.snap_to_grid);
}
+45 -8
View File
@@ -87,6 +87,7 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
("w:gutter", &twips(s.gutter.max(0.0))),
],
);
paper_source(w, s);
if let Some(b) = &s.page_borders {
borders(w, "w:pgBorders", b, None, &[("w:offsetFrom", "text")]);
}
@@ -107,14 +108,7 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:lnNumType", &refs);
}
if s.page_num_set || s.page_num_start.is_some() || s.page_num_format != wordcraft_doc::section::NumFormat::Decimal {
let mut a = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
if let Some(st) = s.page_num_start {
a.push(("w:start", st.to_string()));
}
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:pgNumType", &refs);
}
page_number_type(w, s);
let c = &s.columns;
let count = c.count.clamp(1, 45);
let mut ca = vec![("w:space", twips(c.space.max(0.0))), ("w:num", n(count as i64))];
@@ -134,6 +128,9 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
let refs: Vec<(&str, &str)> = ca.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:cols", &refs);
}
if let Some(count) = s.footnote_columns.filter(|count| *count > 0) {
w.empty("w15:footnoteColumns", &[("w:val", &count.clamp(1, 45).to_string())]);
}
if let Some(on) = s.form_prot {
w.empty("w:formProt", if on { &[] } else { &[("w:val", "0")] });
}
@@ -143,6 +140,9 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
wordcraft_doc::section::PageVAlign::Justify => w.val("w:vAlign", "both"),
wordcraft_doc::section::PageVAlign::Bottom => w.val("w:vAlign", "bottom"),
}
if let Some(on) = s.no_endnote {
w.empty("w:noEndnote", if on { &[] } else { &[("w:val", "0")] });
}
if s.title_page {
w.empty("w:titlePg", &[]);
}
@@ -167,6 +167,43 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
}
}
fn paper_source(w: &mut W, s: &SectionProps) {
let mut attrs = Vec::new();
if let Some(first) = s.paper_src_first {
attrs.push(("w:first", first.to_string()));
}
if let Some(other) = s.paper_src_other {
attrs.push(("w:other", other.to_string()));
}
if !attrs.is_empty() {
let refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:paperSrc", &refs);
}
}
fn page_number_type(w: &mut W, s: &SectionProps) {
if !(s.page_num_set
|| s.page_num_start.is_some()
|| s.page_num_format != wordcraft_doc::section::NumFormat::Decimal
|| s.chapter_style.is_some()
|| s.chapter_sep.is_some())
{
return;
}
let mut attrs = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
if let Some(start) = s.page_num_start {
attrs.push(("w:start", start.to_string()));
}
if let Some(style) = s.chapter_style {
attrs.push(("w:chapStyle", style.to_string()));
}
if let Some(separator) = s.chapter_sep {
attrs.push(("w:chapSep", separator.ooxml().to_string()));
}
let refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:pgNumType", &refs);
}
/// The children of a `w:footnotePr` / `w:endnotePr` in schema order (`w:pos`, `w:numFmt`,
/// `w:numStart`, `w:numRestart`; ECMA-376 §17.11.11), for the fields that are set.
pub(in super::super) fn note_pr_inner(w: &mut W, pr: &NoteProps) {
+76
View File
@@ -0,0 +1,76 @@
//! Compound border lines of thin and thick strokes (`thinThickSmallGap` to
//! `thinThickThinLargeGap`, ECMA-376 Part 1 §17.18.2 ST_Border) on paragraphs, table cells and
//! pages: written back as read, not as a single line. Fixtures are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::BorderStyle;
const LINES: [(&str, BorderStyle); 9] = [
("thinThickSmallGap", BorderStyle::ThinThickSmallGap),
("thickThinSmallGap", BorderStyle::ThickThinSmallGap),
("thinThickThinSmallGap", BorderStyle::ThinThickThinSmallGap),
("thinThickMediumGap", BorderStyle::ThinThickMediumGap),
("thickThinMediumGap", BorderStyle::ThickThinMediumGap),
("thinThickThinMediumGap", BorderStyle::ThinThickThinMediumGap),
("thinThickLargeGap", BorderStyle::ThinThickLargeGap),
("thickThinLargeGap", BorderStyle::ThickThinLargeGap),
("thinThickThinLargeGap", BorderStyle::ThinThickThinLargeGap),
];
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
fn side(name: &str, val: &str) -> String {
format!(r#"<w:{name} w:val="{val}" w:sz="36" w:space="1" w:color="2F5496"/>"#)
}
#[test]
fn compound_paragraph_borders_are_written_back_as_read() {
let body: String = LINES
.iter()
.map(|(val, _)| {
format!(r#"<w:p><w:pPr><w:pBdr>{}{}</w:pBdr></w:pPr><w:r><w:t>{val}</w:t></w:r></w:p>"#, side("top", val), side("bottom", val))
})
.collect();
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let got: Vec<Option<BorderStyle>> =
d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style)).collect();
assert_eq!(got, LINES.iter().map(|(_, s)| Some(*s)).collect::<Vec<_>>());
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
for (val, _) in LINES {
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 2, "{val}: {xml}");
}
assert!(!xml.contains(r#"w:val="single""#), "{xml}");
}
#[test]
fn compound_cell_and_page_borders_are_written_back_as_read() {
let body = format!(
r#"<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders>{}</w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:tcBorders>{}</w:tcBorders></w:tcPr><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p/><w:sectPr><w:pgBorders w:offsetFrom="text">{}</w:pgBorders></w:sectPr>"#,
side("top", "thickThinLargeGap"),
side("left", "thinThickSmallGap"),
side("right", "thinThickThinMediumGap")
);
let xml = document_xml(&wordcraft_docx::write(&wordcraft_docx::read(&docx(&body)).unwrap()).unwrap());
for val in ["thickThinLargeGap", "thinThickSmallGap", "thinThickThinMediumGap"] {
assert_eq!(xml.matches(&format!(r#"w:val="{val}""#)).count(), 1, "{val}: {xml}");
}
}
+29
View File
@@ -80,3 +80,32 @@ fn automatic_spacing_reads_and_round_trips() {
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(props(&again)[..3], p[..3]);
}
#[test]
fn adjust_right_indent_is_preserved() {
let body = r#"<w:p><w:pPr><w:adjustRightInd w:val="0"/><w:snapToGrid w:val="0"/><w:spacing w:after="120"/><w:ind w:right="120"/></w:pPr><w:r><w:t>false</w:t></w:r></w:p>
<w:p><w:pPr><w:adjustRightInd w:val="1"/></w:pPr><w:r><w:t>true</w:t></w:r></w:p>
<w:p><w:r><w:t>absent</w:t></w:r></w:p>"#;
let mut d = wordcraft_docx::read(&docx(body)).unwrap();
assert_eq!(props(&d).iter().map(|p| p.adjust_right_indent).collect::<Vec<_>>(), [Some(false), Some(true), None]);
for _ in 0..2 {
let out = wordcraft_docx::write(&d).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(out.clone())).unwrap();
let mut xml = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
assert!(xml.contains(r#"<w:adjustRightInd w:val="0""#), "explicit false was lost: {xml}");
assert!(xml.contains("<w:adjustRightInd/>"), "explicit true was lost: {xml}");
let tags = ["<w:adjustRightInd", "<w:snapToGrid", "<w:spacing", "<w:ind"];
let pprs = xml.split("<w:pPr>").skip(1).filter_map(|rest| rest.split("</w:pPr>").next()).collect::<Vec<_>>();
let first = pprs.first().expect("paragraph properties must be present");
for &tag in &tags {
assert!(first.contains(tag), "{tag} missing: {xml}");
}
for ppr in pprs {
let order = tags.iter().filter_map(|tag| ppr.find(*tag)).collect::<Vec<_>>();
assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "paragraph properties are out of schema order: {xml}");
}
d = wordcraft_docx::read(&out).unwrap();
assert_eq!(props(&d).iter().map(|p| p.adjust_right_indent).collect::<Vec<_>>(), [Some(false), Some(true), None]);
}
}
+54
View File
@@ -247,6 +247,46 @@ fn frame_zero_side_is_automatic() {
}
}
#[test]
fn locked_frame_anchor_survives_two_saves() {
for (lock, expected) in [("", None), (" w:anchorLock=\"1\"", Some(true)), (" w:anchorLock=\"0\"", Some(false))] {
let mut document = read_body(&format!(r#"<w:p><w:pPr><w:framePr w:w="1440" w:h="720"{lock}/></w:pPr><w:r><w:t>Locked</w:t></w:r></w:p>"#));
let paragraph = paras(&document)[0];
assert_eq!(paragraph.props.frame.as_ref().and_then(|f| f.anchor_lock), expected);
if expected == Some(true) {
let Some(wordcraft_doc::Block::Para(paragraph)) = document.body.get_mut(0).map(std::sync::Arc::make_mut) else {
panic!("first block is the framed paragraph");
};
let frame = paragraph.props.frame.as_mut().unwrap();
frame.x = 36.0;
frame.y = 72.0;
frame.w = Some(108.0);
}
let json = serde_json::to_vec(&document).unwrap();
document = serde_json::from_slice(&json).unwrap();
if expected == Some(true) {
let frame = paras(&document)[0].props.frame.as_ref().unwrap();
assert_eq!((frame.x, frame.y, frame.w), (36.0, 72.0, Some(108.0)));
}
for pass in 0..2 {
let bytes = wordcraft_docx::write(&document).unwrap();
let xml = text(&unzip(&bytes), "word/document.xml");
let expected_xml = match expected {
Some(true) => "w:anchorLock=\"1\"",
Some(false) => "w:anchorLock=\"0\"",
None => "w:anchorLock=",
};
assert_eq!(xml.contains(expected_xml), expected.is_some(), "lock changed on save {}: {xml}", pass + 1);
document = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(paras(&document)[0].props.frame.as_ref().and_then(|f| f.anchor_lock), expected);
if expected == Some(true) {
let frame = paras(&document)[0].props.frame.as_ref().unwrap();
assert_eq!((frame.x, frame.y, frame.w), (36.0, 72.0, Some(108.0)));
}
}
}
}
#[test]
fn omitted_paragraph_defaults_can_be_edited() {
let styles =
@@ -4514,6 +4554,20 @@ fn vml_textbox_style_anchor_and_fit() {
assert!(tb.body.auto_fit);
}
/// #2601: a drop-cap frame of two letters in two fonts. The first letter's font is the cap's; only
/// the runs in that font get the text's font back, the other letter keeps its own.
#[test]
fn drop_cap_frame_keeps_other_fonts() {
let body = r#"<w:p><w:pPr><w:framePr w:dropCap="drop" w:lines="3" w:wrap="around" w:vAnchor="text" w:hAnchor="text"/></w:pPr><w:r><w:rPr><w:rFonts w:ascii="Liberation Mono" w:hAnsi="Liberation Mono"/></w:rPr><w:t>A</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Liberation Sans" w:hAnsi="Liberation Sans"/></w:rPr><w:t>b</w:t></w:r></w:p>
<w:p><w:r><w:rPr><w:rFonts w:ascii="Liberation Serif" w:hAnsi="Liberation Serif"/></w:rPr><w:t>c tale</w:t></w:r></w:p>"#;
let d = read_body(body);
let p = paras(&d);
assert_eq!(p.len(), 1, "the frame merges into its paragraph");
assert_eq!(p[0].props.drop_cap_font.as_deref(), Some("Liberation Mono"));
assert_eq!(p[0].props_of_char(0).font.as_deref(), Some("Liberation Serif"), "the cap letter gets the text's font");
assert_eq!(p[0].props_of_char(1).font.as_deref(), Some("Liberation Sans"), "the other letter keeps its own");
}
/// A VML horizontal rule (`o:hr`, a web page's `<hr>` pasted into Word) is read as one: the whole
/// line when it has no width and no `o:hrpct`, a share of it (`o:hrpct`, tenths of a percent), or
/// its own width (`o:hrpct="0"`), placed by `o:hralign`. It saves back as the same VML rule.
+3
View File
@@ -1,6 +1,7 @@
mod alt_chunks;
mod bidi;
mod bidi_runs;
mod border_lines;
mod cell_margin_inheritance;
mod char_units;
mod citations;
@@ -28,10 +29,12 @@ mod roundtrip;
mod row_property_exceptions;
mod run_props;
mod section_form_protection;
mod section_metadata_preservation;
mod section_page_number_type;
mod section_text_direction;
mod section_valign;
mod settings_merge;
mod text_alignment;
mod text_boxes;
mod toc_ranges;
mod undefined_style;
+4 -1
View File
@@ -7,7 +7,7 @@ use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
TabStop, TableLook, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
TabStop, TableLook, TextAlignment, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{Columns, DocGrid, DocGridType, LineNumberRestart, LineNumbering, NumFormat, PageVAlign, SectionProps, SectionStart};
use wordcraft_doc::styles::{Style, StyleKind};
@@ -176,6 +176,7 @@ fn every_para_prop_round_trips() {
line_spacing: Some(LineSpacing::Multiple(1.5)),
contextual_spacing: Some(true),
mirror_indents: Some(true),
adjust_right_indent: Some(false),
keep_next: Some(true),
keep_lines: Some(false),
page_break_before: Some(true),
@@ -209,6 +210,7 @@ fn every_para_prop_round_trips() {
h_anchor: "margin".into(),
v_anchor: "text".into(),
wrap: "around".into(),
anchor_lock: Some(true),
h_space: 9.0,
v_space: 0.0,
}),
@@ -221,6 +223,7 @@ fn every_para_prop_round_trips() {
num_change: None,
fmt_change: None,
snap_to_grid: Some(false),
text_alignment: Some(TextAlignment::Top),
};
let variants = [
ParaProps { line_spacing: Some(LineSpacing::AtLeast(14.0)), indent_first: Some(24.0), align: Some(Align::Center), ..Default::default() },
+24
View File
@@ -29,6 +29,30 @@ fn part(bytes: &[u8], name: &str) -> String {
xml
}
#[test]
fn paragraph_adjust_right_indent_inherits_from_style() {
let styles =
r#"<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:pPr><w:adjustRightInd/></w:pPr></w:style>"#;
let body = r#"<w:p><w:pPr><w:adjustRightInd w:val="0"/></w:pPr><w:r><w:t>off</w:t></w:r></w:p>
<w:p><w:r><w:t>inherited</w:t></w:r></w:p>"#;
let mut d = wordcraft_docx::read(&docx(body, styles)).unwrap();
let paragraphs: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
assert_eq!(paragraphs[0].props.adjust_right_indent, Some(false));
assert!(!d.styles.resolve_para(&paragraphs[0].props).adjust_right_indent);
assert!(d.styles.resolve_para(&paragraphs[1].props).adjust_right_indent);
for _ in 0..2 {
let out = wordcraft_docx::write(&d).unwrap();
assert!(part(&out, "word/document.xml").contains(r#"<w:adjustRightInd w:val="0""#));
assert!(part(&out, "word/styles.xml").contains("<w:adjustRightInd/>"));
d = wordcraft_docx::read(&out).unwrap();
let paragraphs: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
assert!(!d.styles.resolve_para(&paragraphs[0].props).adjust_right_indent);
assert!(d.styles.resolve_para(&paragraphs[1].props).adjust_right_indent);
assert_eq!(paragraphs[0].props.adjust_right_indent, Some(false));
}
}
#[test]
fn run_and_style_snap_to_grid_and_web_hidden_survive_two_saves() {
let styles = r#"
@@ -0,0 +1,66 @@
//! Section printer, note and chapter-number metadata survives DOCX and native JSON saves.
use std::io::{Read, Write};
fn docx(body: &str) -> Vec<u8> {
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="w15"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
#[test]
fn section_metadata_survives_native_json_and_two_docx_save_reopen_cycles() {
let input = docx(
r#"<w:p><w:pPr><w:sectPr><w:paperSrc w:first=" +261 " w:other=" 260 "/><w:noEndnote/><w15:footnoteColumns w:val="2"/><w:pgNumType w:fmt="decimal" w:chapStyle="1" w:chapSep="hyphen"/></w:sectPr></w:pPr><w:r><w:t>First section</w:t></w:r></w:p><w:p><w:r><w:t>Second section</w:t></w:r></w:p><w:sectPr><w:paperSrc w:first="12" w:other="34"/><w:noEndnote w:val="0"/><w15:footnoteColumns w:val="3"/><w:pgNumType w:fmt="upperRoman" w:chapStyle="10" w:chapSep="colon"/></w:sectPr>"#,
);
let mut document = wordcraft_docx::read(&input).unwrap();
let json = serde_json::to_vec(&document).unwrap();
document = serde_json::from_slice(&json).unwrap();
for cycle in 0..2 {
let bytes = wordcraft_docx::write(&document).unwrap();
let xml = document_xml(&bytes);
assert!(xml.contains(r#"<w:paperSrc w:first="261" w:other="260"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"<w:paperSrc w:first="12" w:other="34"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains("<w:noEndnote/>"), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"<w:noEndnote w:val="0"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"<w15:footnoteColumns w:val="2"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"<w15:footnoteColumns w:val="3"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"w:chapStyle="1" w:chapSep="hyphen""#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"w:chapStyle="10" w:chapSep="colon""#), "cycle {cycle}: {xml}");
assert!(xml.contains("xmlns:w15=") && xml.contains("mc:Ignorable=\"w14 w15 wp14\""), "cycle {cycle}: {xml}");
document = wordcraft_docx::read(&bytes).unwrap();
let json = serde_json::to_vec(&document).unwrap();
document = serde_json::from_slice(&json).unwrap();
}
}
#[test]
fn invalid_section_metadata_is_ignored_and_missing_values_stay_missing() {
let bytes = docx(
r#"<w:p><w:r><w:t>Text</w:t></w:r></w:p><w:sectPr><w:paperSrc w:first="65536" w:other="-1"/><w15:footnoteColumns w:val="0"/><w:pgNumType w:chapStyle="256" w:chapSep="invalid"/></w:sectPr>"#,
);
let mut d = wordcraft_docx::read(&bytes).unwrap();
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
assert!(!xml.contains("paperSrc"), "{xml}");
assert!(!xml.contains("footnoteColumns"), "{xml}");
assert!(!xml.contains("chapStyle") && !xml.contains("chapSep"), "{xml}");
assert!(!xml.contains("noEndnote"), "{xml}");
d.last_section.footnote_columns = Some(0);
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
assert!(!xml.contains("footnoteColumns"), "{xml}");
}
+64
View File
@@ -0,0 +1,64 @@
//! A paragraph's vertical text alignment (`w:textAlignment`, ECMA-376 Part 1 §17.3.1.39, values
//! from ST_TextAlignment): read and written back as read, in the schema's place after `w:jc`.
//! Word sizes a line by it when a list label is larger than the text, so a save that drops it
//! moves every line after. Fixtures are hand-written from the spec.
use std::io::{Read, Write};
use wordcraft_doc::props::TextAlignment;
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
#[test]
fn text_alignment_is_written_back_as_read() {
let para = |ppr: &str, text: &str| format!(r#"<w:p><w:pPr>{ppr}</w:pPr><w:r><w:t>{text}</w:t></w:r></w:p>"#);
let body = [
para(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#, "A"),
para(r#"<w:textAlignment w:val="center"/>"#, "B"),
para(r#"<w:textAlignment w:val="baseline"/>"#, "C"),
para(r#"<w:textAlignment w:val="bottom"/>"#, "D"),
para(r#"<w:textAlignment w:val="auto"/>"#, "E"),
para(r#"<w:textAlignment w:val="sideways"/>"#, "F"),
para("", "G"),
]
.concat();
let d = wordcraft_docx::read(&docx(&body)).unwrap();
let got: Vec<Option<TextAlignment>> = d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect();
let want = vec![
Some(TextAlignment::Top),
Some(TextAlignment::Center),
Some(TextAlignment::Baseline),
Some(TextAlignment::Bottom),
Some(TextAlignment::Auto),
None,
None,
];
assert_eq!(got, want);
let out = wordcraft_docx::write(&d).unwrap();
let xml = document_xml(&out);
assert!(xml.contains(r#"<w:jc w:val="center"/><w:textAlignment w:val="top"/><w:outlineLvl w:val="1"/>"#), "{xml}");
for v in ["center", "baseline", "bottom", "auto"] {
assert_eq!(xml.matches(&format!(r#"<w:textAlignment w:val="{v}"/>"#)).count(), 1, "{v}: {xml}");
}
assert_eq!(xml.matches("w:textAlignment").count(), 5, "an unknown value and none write nothing: {xml}");
let back = wordcraft_docx::read(&out).unwrap();
assert_eq!(back.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.text_alignment).collect::<Vec<_>>(), want);
}
+9 -3
View File
@@ -26,6 +26,9 @@ use super::ink_shape::{ArrowParts, InkShape, arrow_parts, pair_parts, recognise}
use super::pos_json;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
mod page_objects;
use page_objects::PageObjects;
/// What dragging on the page does (Draw tab).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -325,12 +328,13 @@ impl PlacedInk {
/// turn and geometry on the page.
pub fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec<PlacedInk> {
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
let mut objects = PageObjects::new(&s.doc);
pg.items
.iter()
.filter_map(|it| match it {
Placed::Object { rect, spin, story, path, off, .. } => {
let pos = Pos { story: *story, path: path.clone(), off: *off };
match s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) {
match objects.get(&pos) {
Some(InlineObject::Shape { freeform: Some(f), stroke_width, stroke, .. }) if f.is_ink() => {
Some(PlacedInk { pos, rect: *rect, spin: *spin, freeform: f.clone(), color: *stroke, width: *stroke_width })
}
@@ -577,10 +581,11 @@ fn lasso(s: &mut Session, v: &Value) -> CmdResult {
return Err(CmdError::Params("a lasso loop needs at least three points".into()));
}
let mut items = Vec::new();
let mut objects = PageObjects::new(&s.doc);
for it in &pg.items {
let Placed::Object { rect, spin, story, path, off, .. } = it else { continue };
let pos = Pos { story: *story, path: path.clone(), off: *off };
let Some(obj) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
let Some(obj) = objects.get(&pos) else { continue };
let picked = match obj.ink() {
Some(_) if s.view.hide_ink => false,
Some(f) => {
@@ -771,13 +776,14 @@ fn near(a: Rect, b: Rect) -> bool {
fn arrow_pieces(s: &Session, layout: &DocLayout, page: usize, keep: impl Fn(&Pos, &Rect) -> bool) -> Vec<ArrowPiece> {
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
let mut out = Vec::new();
let mut objects = PageObjects::new(&s.doc);
for it in &pg.items {
let Placed::Object { rect, spin, story, path, off, .. } = it else { continue };
let pos = Pos { story: *story, path: path.clone(), off: *off };
if !spin.is_identity() || !keep(&pos, rect) {
continue;
}
let Some(InlineObject::Shape { kind, freeform: Some(f), float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
let Some(InlineObject::Shape { kind, freeform: Some(f), float, .. }) = objects.get(&pos) else { continue };
let Some(fp) = f.paths.first().filter(|_| f.paths.len() == 1) else { continue };
let pen = matches!(f.ink, Some(InkTool::Pen | InkTool::Pencil)) && !s.view.hide_ink;
let shape = f.ink.is_none() && *kind == ShapeKind::Freeform && matches!((fp.pts.len(), fp.closed), (2, false) | (3, true));
@@ -0,0 +1,48 @@
//! Per-call object lookups for drawing-order page walks, which can revisit a paragraph's
//! offsets out of text order. Scan only as far as the furthest requested object, once.
use std::collections::HashMap;
use wordcraft_doc::para::OBJ;
use wordcraft_doc::{Document, InlineObject, Path, Pos, StoryRef};
#[derive(Default)]
struct ParagraphObjects<'a> {
scanned: usize,
ordinal: usize,
objects: Vec<(usize, &'a InlineObject)>,
}
pub(super) struct PageObjects<'a> {
doc: &'a Document,
paragraphs: HashMap<(StoryRef, Path), ParagraphObjects<'a>>,
}
impl<'a> PageObjects<'a> {
pub(super) fn new(doc: &'a Document) -> Self {
Self { doc, paragraphs: HashMap::new() }
}
pub(super) fn get(&mut self, pos: &Pos) -> Option<&'a InlineObject> {
let p = self.doc.para_at(pos)?;
if !p.text.get(pos.off..)?.starts_with(OBJ) {
return None;
}
let end = pos.off.checked_add(OBJ.len_utf8())?;
let cached = self.paragraphs.entry((pos.story, pos.path.clone())).or_default();
if end > cached.scanned {
// Start after the last scanned marker, not at the paragraph's start. In particular,
// a lone object near the start never scans a long text suffix it doesn't need.
for (off, c) in p.text.get(cached.scanned..end)?.char_indices() {
if c == OBJ {
if let Some(object) = p.objects.get(cached.ordinal) {
cached.objects.push((cached.scanned + off, object));
}
cached.ordinal += 1;
}
}
cached.scanned = end;
}
let i = cached.objects.binary_search_by_key(&pos.off, |(off, _)| *off).ok()?;
cached.objects.get(i).map(|(_, object)| *object)
}
}
+2
View File
@@ -5,6 +5,8 @@ use wordcraft_doc::para::{InlineObject, ShapeKind, Wrap};
use crate::Session;
mod page_lookup;
fn session() -> Session {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("document.setText", &json!({"text": "First paragraph of text.\nSecond paragraph of text."})).unwrap();
@@ -0,0 +1,132 @@
use super::super::{PlacedInk, ink_on};
use crate::Session;
use std::sync::Arc;
use wordcraft_doc::freeform::{Freeform, InkTool};
use wordcraft_doc::para::{Float, InlineObject, OBJ, ShapeKind, Wrap};
use wordcraft_doc::{CharProps, Document, Paragraph, Path, Pos, StoryRef, para_block};
use wordcraft_geom::{Rect, Spin};
use wordcraft_layout::{DocLayout, Page, Placed};
fn stroke(order: u32) -> InlineObject {
InlineObject::Shape {
kind: ShapeKind::Freeform,
w: 20.0,
h: 10.0,
fill: None,
stroke: Some(wordcraft_doc::Rgb(12, 34, 56)),
stroke_width: 2.0,
float: Float { wrap: Wrap::InFrontOfText, ..Default::default() },
story: None,
freeform: Some(Arc::new(Freeform { order: Some(order), ..Freeform::ink(InkTool::Pen, 20.0, 10.0, vec![[1.0, 1.0], [19.0, 9.0]]) })),
effects: Default::default(),
extra: Default::default(),
}
}
fn placed(story: StoryRef, path: Path, off: usize, x: f32) -> Placed {
Placed::Object {
rect: Rect::new(x, 30.0, 20.0, 10.0),
spin: Spin::default(),
story,
path,
off,
text_box: None,
wrap: Wrap::InFrontOfText,
origin: Default::default(),
}
}
fn orders(ink: &[PlacedInk]) -> Vec<u32> {
ink.iter().map(|k| k.freeform.order.unwrap()).collect()
}
#[test]
fn page_ink_keeps_item_order_duplicates_story_paths_and_unmatched_storage() {
let mut d = Document::new();
let mut p = Paragraph::new();
p.text = format!("é{OBJ}{OBJ}{OBJ}漢{OBJ}");
p.runs = vec![wordcraft_doc::Run { len: p.text.len(), props: CharProps::default() }];
p.objects = vec![InlineObject::BookmarkStart { name: "original".into() }, stroke(11), stroke(22)];
d.body = vec![para_block(p.clone())];
let h = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(p.clone())]);
let mut table = wordcraft_doc::Table::new(1, 1, 100.0);
p.objects.push(stroke(33));
p.objects.push(stroke(99)); // Surplus storage has no character and is not placed.
table.rows[0].cells[0].blocks = vec![para_block(p)];
d.body.push(Arc::new(wordcraft_doc::Block::Table(table)));
let cell = Path(vec![1, 0, 0, 0]);
let mut items = vec![
placed(StoryRef::Body, Path::top(0), 8, 50.0),
placed(StoryRef::Part(h), Path::top(0), 5, 60.0),
placed(StoryRef::Body, cell.clone(), 14, 70.0),
placed(StoryRef::Body, Path::top(0), 5, 80.0),
placed(StoryRef::Body, Path::top(0), 8, 90.0),
];
items.extend([0, 1, 2, 3, 4, 11, 14, usize::MAX].map(|off| placed(StoryRef::Body, Path::top(0), off, 0.0)));
items.push(placed(StoryRef::Part(999), Path::top(0), 0, 0.0));
let l = DocLayout { pages: vec![Page { items, ..Default::default() }], ..Default::default() };
let mut s = Session::new(d);
let original = s.doc.clone();
let got = ink_on(&s, &l, 0);
assert_eq!(orders(&got), [22, 11, 33, 11, 22]);
assert_eq!(got.iter().map(|k| k.rect.x).collect::<Vec<_>>(), [50.0, 60.0, 70.0, 80.0, 90.0]);
assert_eq!(got[1].pos, Pos::new(StoryRef::Part(h), Path::top(0), 5));
assert_eq!(got[2].pos.path, cell);
assert!(got.iter().all(|k| k.width == 2.0 && k.color == Some(wordcraft_doc::Rgb(12, 34, 56))));
assert!(ink_on(&s, &l, 1).is_empty());
s.view.hide_ink = true;
assert_eq!(orders(&ink_on(&s, &l, 0)), orders(&got), "discovery retains hidden ink; callers decide visibility");
assert_eq!(s.doc, original);
}
#[test]
fn page_ink_lookup_work_does_not_multiply_with_paragraph_objects() {
fn fixture(n: usize) -> (Session, DocLayout) {
let mut d = Document::new();
let mut p = Paragraph::new();
p.text = OBJ.to_string().repeat(n);
p.runs = vec![wordcraft_doc::Run { len: p.text.len(), props: CharProps::default() }];
p.objects = (0..n).map(|i| stroke(i as u32)).collect();
d.body = vec![para_block(p)];
let items = (0..n).map(|i| placed(StoryRef::Body, Path::top(0), i * OBJ.len_utf8(), 50.0)).collect();
(Session::new(d), DocLayout { pages: vec![Page { items, ..Default::default() }], ..Default::default() })
}
// Scheduling delays do not count. Without an advancing Linux thread CPU clock, the
// semantic assertions still run and only the scaling check is skipped.
fn ran() -> Option<u64> {
std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse().ok()
}
fn cost(mut f: impl FnMut()) -> Option<f64> {
let before = ran()?;
for _ in 0..32 {
f();
}
let elapsed = ran()?.checked_sub(before)?;
(elapsed > 0).then_some(elapsed as f64 / 32.0)
}
let (small, a) = fixture(250);
let (big, b) = fixture(2000);
assert_eq!(ink_on(&small, &a, 0).len(), 250);
assert_eq!(ink_on(&big, &b, 0).len(), 2000);
let mut t1 = f64::INFINITY;
let mut t8 = f64::INFINITY;
for _ in 0..5 {
let Some(small_cost) = cost(|| {
for _ in 0..8 {
std::hint::black_box(ink_on(&small, &a, 0));
}
}) else {
return;
};
let Some(big_cost) = cost(|| {
std::hint::black_box(ink_on(&big, &b, 0));
}) else {
return;
};
t1 = t1.min(small_cost / 8.0);
t8 = t8.min(big_cost);
}
let ratio = t8 / t1;
eprintln!("page ink CPU ratio={ratio:.2} for 8x objects");
assert!(ratio < 24.0, "page ink lookup repeats paragraph-prefix work: {ratio:.2}x CPU for 8x objects");
}
+4 -15
View File
@@ -163,7 +163,7 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("edit.formatPainter", "Format Painter", "Home › Clipboard", painter).params(r#"{"sticky"?: bool}"#).pure(),
CommandSpec::new("edit.copyFormat", "Copy Formatting", "Home › Clipboard", |s, _| {
s.copied_format = Some(picked_format(s));
painter_pick(s);
sel_result(s)
})
.key("Mod+Shift+C")
@@ -814,17 +814,11 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// The character and paragraph formatting at the start of the selection.
fn picked_format(s: &Session) -> (wordcraft_doc::props::CharProps, wordcraft_doc::props::ParaProps) {
fn painter_pick(s: &mut Session) {
let f = s.sel.ordered().0;
// A symbol's own font isn't formatting to paint onto text.
let chr = s.doc.para_at(&f).map(|p| p.props_of_char(f.off).clone().without_symbol()).unwrap_or_default();
let para = s.doc.para_at(&f).map(|p| p.props.clone()).unwrap_or_default();
(chr, para)
}
fn painter_pick(s: &mut Session) {
let (chr, para) = picked_format(s);
s.painter = Some((chr, para, false));
}
@@ -840,14 +834,9 @@ fn painter(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"active": true}))
}
/// Apply the format painter's formatting to the selection, or else what Copy Formatting picked
/// up (which stays for the next Paste Formatting).
/// Apply the format painter's formatting to the selection.
fn paste_format(s: &mut Session, _: &Value) -> CmdResult {
let (chr, para, sticky) = match (s.painter.clone(), s.copied_format.clone()) {
(Some(brush), _) => brush,
(None, Some((chr, para))) => (chr, para, true),
(None, None) => return Err(CmdError::Failed("no formatting copied".into())),
};
let Some((chr, para, sticky)) = s.painter.clone() else { return Err(CmdError::Failed("no formatting copied".into())) };
let (a, b) = s.sel.ordered();
if a == b {
// No selection: apply paragraph formatting (and word formatting like Word).
+1 -1
View File
@@ -124,7 +124,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("insert.pageField", "Insert Page Field", "Insert › Text › Quick Parts", |s, _| field(s, &json!({"instr": "PAGE"})))
.key(super::mac_or("Ctrl+Shift+P", "Alt+Shift+P")),
CommandSpec::new("insert.dropCap", "Drop Cap", "Insert › Text", drop_cap)
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font)}"#)
.params(r#"{"position"?: "none"|"dropped"|"margin" (default dropped), "lines"?: 1..10 (default 3; 0 = none), "distance"?: pt from the text (0..1584, default 0), "font"?: family (omit for the text's font), "keep"?: bool (true: each selected paragraph that has a drop cap keeps its own lines, distance and font unless given here)}"#)
.when(drop_cap_off),
CommandSpec::new("insert.horizontalLine", "Horizontal Line", "Home › Paragraph › Borders", |s, _| {
super::para::fmt(s, &|p| {
+39 -8
View File
@@ -241,12 +241,16 @@ pub(super) fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
}
/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph
/// and in one that starts with a space.
/// and in one that starts with a space. A selection must be fit throughout (what Dropped and In
/// margin need), unless a paragraph in it has a drop cap for None to take away.
pub(super) fn drop_cap_off(s: &Session) -> Option<&'static str> {
if s.sel.focus.path.cell().is_some() {
return Some("drop caps can't go in a table");
}
let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next());
let why = drop_cap_selection_off(s)?;
let story = s.sel.focus.story;
let has_cap = crate::cmd::selected_paths(s).iter().any(|path| s.doc.para(story, path).is_some_and(|p| p.props.drop_cap.is_some()));
(!has_cap).then_some(why)
}
fn drop_cap_first_letter(first: Option<char>) -> Option<&'static str> {
match first {
None => Some("the paragraph is empty"),
Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"),
@@ -254,6 +258,21 @@ pub(super) fn drop_cap_off(s: &Session) -> Option<&'static str> {
}
}
/// The same check for every selected paragraph, as the command formats them all.
fn drop_cap_selection_off(s: &Session) -> Option<&'static str> {
let story = s.sel.focus.story;
for path in crate::cmd::selected_paths(s) {
if path.cell().is_some() {
return Some("drop caps can't go in a table");
}
let first = s.doc.para(story, &path).and_then(|p| p.text.chars().next());
if let Some(why) = drop_cap_first_letter(first) {
return Some(why);
}
}
None
}
/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in
/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's).
/// None takes all of it away, the letter's font with it.
@@ -261,15 +280,27 @@ pub(super) fn drop_cap(s: &mut Session, v: &Value) -> CmdResult {
let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite());
let none = p::str(v, "position") == Some("none") || lines == Some(0.0);
let margin = p::str(v, "position") == Some("margin");
let lines_given = lines.is_some();
let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8;
let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE));
// A family name, not a paragraph of text.
let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::<String>());
if !none && let Some(why) = drop_cap_selection_off(s) {
return Err(CmdError::Failed(why.into()));
}
// `keep` (the menu's Dropped / In margin): a paragraph that has a drop cap keeps its own
// lines, distance and font unless given here.
let keep = !none && v.get("keep").and_then(Value::as_bool) == Some(true);
let (distance_given, font_given) = (p::f32(v, "distance").is_some(), v.get("font").is_some());
crate::cmd::para::fmt(s, &|p| {
p.drop_cap = (!none).then_some(lines);
let own = keep && p.drop_cap.is_some();
p.drop_cap = (!none).then(|| if own && !lines_given { p.drop_cap.unwrap_or(lines) } else { lines });
p.drop_cap_margin = (!none && margin).then_some(true);
p.drop_cap_distance = (!none && distance > 0.0).then_some(distance);
p.drop_cap_font = font.clone().filter(|_| !none);
let d = if own && !distance_given { p.drop_cap_distance.unwrap_or(0.0) } else { distance };
p.drop_cap_distance = (!none && d > 0.0).then_some(d);
if !(own && !font_given) {
p.drop_cap_font = font.clone().filter(|_| !none);
}
})
}
+1 -1
View File
@@ -18,7 +18,7 @@ mod styles;
mod tabs_borders;
use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog};
pub use format::{fmt, indent, outdent};
pub use format::{cur as selection_para, fmt, indent, outdent};
pub use lists::{ListButtons, list_buttons, manage_list, touch_all};
use lists::{list, restart_numbering};
pub(crate) use outline::{ensure_heading_style, set_heading};
+11 -2
View File
@@ -1,7 +1,8 @@
use super::{Align, CmdError, CmdResult, LineSpacing, NumRef, ParaProps, Pos, Session, Value, json, p, sel_result};
/// Resolved paragraph props at the caret.
pub(super) fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
/// Resolved paragraph props of the selection: those of the paragraph the caret is in, or of the
/// last paragraph a selection covers when it ends at the next paragraph's start.
pub fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
// A selection ending at the next paragraph's start doesn't format that paragraph (#418), so
// its state comes from the last paragraph it does cover.
let (a, b) = s.sel.ordered();
@@ -122,8 +123,16 @@ pub(super) fn set_props(s: &mut Session, v: &Value) -> CmdResult {
for path in crate::cmd::selected_paths(s) {
let Some(para) = s.doc.para(story, &path) else { continue };
let rtl = s.doc.styles.resolve_para(&para.props.clone().overlaid(&props)).bidi;
let anchor_lock = props
.frame
.as_ref()
.filter(|frame| frame.anchor_lock.is_none())
.and_then(|_| para.props.frame.as_ref().and_then(|frame| frame.anchor_lock));
let para = s.doc.para_mut(story, &path)?;
para.props.overlay(&props);
if let (Some(anchor_lock), Some(frame)) = (anchor_lock, para.props.frame.as_mut()) {
frame.anchor_lock = Some(anchor_lock);
}
if let Some(a) = align {
para.props.align = Some(a.visual(rtl));
}
+1
View File
@@ -12,6 +12,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
mod fields;
mod notes;
mod numbers;
mod properties;
mod toc;
@@ -1,3 +1,4 @@
use super::numbers::{body_bookmark_paths, para_numbers, ref_number};
use super::properties::{
bookmark_ends_in_its_paragraph, bookmark_text, bookmark_text_at, count_text, date_picture, doc_property, doc_property_arg, new_property_value,
set_summary_property, summary_property, text_result,
@@ -335,6 +336,9 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
// The document properties as the fields read and set them (`SUBJECT "New subject"`): a field
// after one that sets a property shows the new value.
let mut core = s.doc.core.clone();
// Build each body lookup once, only when a supported numeric REF needs it.
let mut numbers = None;
let mut bookmark_paths = None;
let paths: Vec<(StoryRef, Path)> = stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect();
// SET <name> "<text>" (ECMA-376 §17.16.5.58): the values it gives, which REF <name> shows; a
// REF before the SET shows the last value set, as Word's bookmark does after an update.
@@ -374,6 +378,14 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
let r = if name == "REF" {
if has_switch(instr, 'p') {
None
} else if let Some(sw) = ['w', 'r', 'n'].into_iter().find(|c| has_switch(instr, *c)) {
if has_switch(instr, 'd') || has_switch(instr, 't') {
continue; // Preserve cached results for unsupported delimiter/text switches.
}
let numbers = numbers.get_or_insert_with(|| para_numbers(&s.doc));
let from = (story == StoryRef::Body).then_some(&path);
let paths = bookmark_paths.get_or_insert_with(|| body_bookmark_paths(&s.doc));
paths.get(target).and_then(|tp| ref_number(numbers, tp, from, sw))
} else {
bookmark_text(&s.doc, target).or_else(|| {
let key = target.to_lowercase();
+135
View File
@@ -0,0 +1,135 @@
//! Numbered REF results, adapted from @Alyosha-dot-shop's patch in #4533.
use super::{Counters, NumFormat, Path, StoryRef};
/// What a numbered body paragraph shows: the counter value of each level down to its own, the
/// label each of those levels shows at that point (`["14.", "(a)"]` for 14(a)), and the levels
/// each label's template shows (`"%1.%2"` shows levels 0 and 1).
#[derive(Clone, Debug, Default)]
pub(super) struct ParaNumber {
/// Relative context is shared only within the same numbering instance.
list: u32,
values: Vec<u32>,
/// Whether each level has counted a paragraph yet: a level the list never used (an `(a)` item
/// with no clause above it in its list) shows no number in a reference.
started: Vec<bool>,
labels: Vec<Option<String>>,
shows: Vec<Vec<usize>>,
}
/// Whether the body paragraph at `path` sits in a table cell merged into the one above
/// (`w:vMerge` continue), at any nesting depth. The layout shows nothing of such a cell, so its
/// paragraphs take no list number.
fn in_merged_cell(doc: &wordcraft_doc::Document, path: &Path) -> bool {
let p = &path.0;
let mut j = 0usize;
// [table, row, cell, block, row, cell, block, …]: a table at p[..=j], its row and cell next.
while j.saturating_add(3) < p.len() {
let (Some(tp), Some(&r), Some(&c)) = (p.get(..=j), p.get(j + 1), p.get(j + 2)) else { return false };
let merged = doc
.table(StoryRef::Body, &Path(tp.to_vec()))
.and_then(|t| t.rows.get(r as usize))
.and_then(|row| row.cells.get(c as usize))
.is_some_and(|cell| cell.props.vmerge == wordcraft_doc::props::VMerge::Continue);
if merged {
return true;
}
j = j.saturating_add(3);
}
false
}
/// Every numbered body paragraph's number, by path, walking the lists in document order as the
/// layout does (cells merged into the one above count nothing). Bullets have no number.
pub(super) fn para_numbers(doc: &wordcraft_doc::Document) -> std::collections::HashMap<Path, ParaNumber> {
let mut counters = Counters::default();
let mut out = std::collections::HashMap::new();
for path in doc.para_paths(StoryRef::Body) {
if in_merged_cell(doc, &path) {
continue;
}
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
let Some(n) = p.props.numbering.or_else(|| doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0) else { continue };
let Some((_, def)) = counters.next_label(&doc.numbering, n.num, n.level) else { continue };
if def.format == NumFormat::Bullet {
continue;
}
let level = usize::from(n.level.min(8));
let values = counters.values(&doc.numbering, n.num).unwrap_or_default();
let started = counters.started(&doc.numbering, n.num).unwrap_or_default();
let mut num = ParaNumber { list: n.num, ..Default::default() };
for k in 0..=level {
num.values.push(values.get(k).copied().unwrap_or(0));
num.started.push(started.get(k).copied().unwrap_or(false));
num.labels.push(counters.current_label(&doc.numbering, n.num, k as u8));
num.shows.push(counters.current_template_levels(&doc.numbering, n.num, k as u8));
}
out.insert(path, num);
}
out
}
/// A paragraph number as a cross-reference shows it: without the trailing period ("14." → "14"),
/// keeping a closing bracket ("(a)").
fn ref_part(label: &str) -> &str {
label.trim().trim_end_matches('.').trim_end()
}
/// The number `REF \n`, `\r` or `\w` shows for the bookmarked paragraph `target` (ECMA-376 Part 1
/// §17.16.5.51), as Word computes it:
/// - `\n` (no context): the paragraph's own label.
/// - `\w` (full context): the own label, with the label of each level above it put in front when
/// no label already in the result shows that level: under `%1.` / `%1.%2` / `(%3)`, clause (a)
/// of 1.1 is `1.1(a)`, not `1.1.1(a)`.
/// - `\r` (relative context): like `\w`, but leaving out the leading levels whose counters the
/// field's own paragraph (`from`) shares with the target, compared level by level within the same list. From inside clause 14, 14(a) is `(a)`; from clause 5 or an unnumbered paragraph it
/// is `14(a)`. Context already displayed by the own or a needed ancestor label stays.
///
/// A level above the target that its list never used adds nothing (Word shows `(a)` for an item
/// whose list has no clause above it, not a made-up `1.1(a)`). `None` when the target paragraph
/// has no number.
pub(super) fn ref_number(numbers: &std::collections::HashMap<Path, ParaNumber>, target: &Path, from: Option<&Path>, switch: char) -> Option<String> {
let t = numbers.get(target)?;
let level = t.values.len().checked_sub(1)?;
let own = t.labels.get(level)?.as_deref()?;
let mut out = ref_part(own).to_string();
if switch == 'n' {
return Some(out);
}
let used = |k: usize| t.started.get(k).copied().unwrap_or(false);
let first = match (switch, from.and_then(|p| numbers.get(p))) {
('r', Some(f)) if f.list == t.list => {
(0..level).take_while(|&k| !used(k) || (f.values.get(k).is_some() && f.values.get(k) == t.values.get(k))).count()
}
_ => 0,
};
// Relative context suppresses added ancestors, never text already rendered in a label.
// Word's measured legal-template cases retain 2.2.2 even when 2 or 2.2 is shared.
let mut shown = t.shows.get(level).cloned().unwrap_or_default();
for k in (first..level).rev() {
if shown.contains(&k) || !used(k) {
continue;
}
if t.labels.get(k).is_some_and(Option::is_some) {
let prefix = ref_part(t.labels.get(k)?.as_deref()?);
// Without a delimiter, joining separate numeric labels would invent a number.
if prefix.chars().next_back().is_some_and(char::is_alphanumeric) && out.chars().next().is_some_and(char::is_alphanumeric) {
return None;
}
out.insert_str(0, prefix);
shown.extend(t.shows.get(k).into_iter().flatten().copied());
}
}
Some(out)
}
/// Index the first body paragraph for each bookmark name, retaining document-order lookup semantics.
pub(super) fn body_bookmark_paths(doc: &wordcraft_doc::Document) -> std::collections::HashMap<String, Path> {
let mut paths = std::collections::HashMap::new();
for (name, pos) in doc.bookmarks() {
if pos.story == StoryRef::Body {
paths.entry(name).or_insert(pos.path);
}
}
paths
}
+33 -5
View File
@@ -24,12 +24,12 @@ pub const DEFAULT_H: f32 = 252.0;
const LAYOUTS: &str = "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn";
const COLORS: &str = "accent1|accent2|accent3|accent4|accent5|accent6|colorful";
const ITEMS: &str = r#"[string | {"text": string, "level"?: 0-7}] (a string's leading tabs are its level)"#;
const ITEMS: &str = r#"[string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}] (a string's leading tabs are its level)"#;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("insert.smartArt", "SmartArt", "Insert › Illustrations", insert).params(
r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#,
r#"{"layout"?: "basicList|process|cycle|hierarchy|pyramid|radial|matrix|venn", "items"?: [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}], "colors"?: "accent1…accent6|colorful", "width"?: pt, "height"?: pt} (no items: sample items for the layout)"#,
),
CommandSpec::new("smartArt.items", "Edit Items", "SmartArt Design › Create Graphic", |s, v| {
let items = items_of(v.get("items").ok_or_else(|| CmdError::Params(format!("`items` is required: {ITEMS}")))?)?;
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(())
})
})
.params(r#"{"items": [string | {"text": string, "level"?: 0-7}]} (replaces the outline)"#)
.params(r#"{"items": [string | {"text": string, "level"?: 0-7, "color"?: RRGGBB}]} (replaces the outline)"#)
.when(has_smart_art),
CommandSpec::new("smartArt.textPane", "Text Pane", "SmartArt Design › Create Graphic", |s, v| {
let on = p::bool(v, "value").unwrap_or(!s.view.smart_art_pane);
@@ -124,14 +124,36 @@ pub fn specs() -> Vec<CommandSpec> {
let colors = colors_of(v)?.ok_or_else(|| CmdError::Params(format!("`variant` is required: {COLORS}")))?;
edit(s, move |g| {
g.colors = colors;
g.clear_custom_colors();
Ok(())
})
})
.params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"}"#)
.params(r#"{"variant": "accent1|accent2|accent3|accent4|accent5|accent6|colorful"} (the items' own colours are dropped)"#)
.when(has_smart_art),
CommandSpec::new("smartArt.itemColor", "Item Color", "SmartArt Design › SmartArt Styles", |s, v| {
let i = index(v, "index")?;
let color = match v.get("color") {
None | Some(Value::Null) => None,
Some(Value::String(c)) => Some(Rgb::parse(c).ok_or_else(|| CmdError::Params("`color`: RRGGBB".into()))?),
Some(_) => return Err(CmdError::Params("`color`: RRGGBB or null".into())),
};
edit(s, move |g| {
let i = item_at(g, i)?;
if g.style_accent(i).is_none() {
return Err(CmdError::Failed("this item has no shape of its own: it is text in another item's shape".into()));
}
if let Some(it) = g.items.get_mut(i) {
it.color = color;
}
Ok(())
})
})
.params(r#"{"index"?: item index (default: the last), "color"?: "RRGGBB" (none: the style's colour)}"#)
.when(has_smart_art),
CommandSpec::new("smartArt.reset", "Reset Graphic", "SmartArt Design › Reset", |s, _| {
edit(s, |g| {
g.colors = SmartArtColors::default();
g.clear_custom_colors();
Ok(())
})
})
@@ -235,7 +257,13 @@ fn items_of(v: &Value) -> Result<Vec<SmartArtItem>, CmdError> {
Some(other) => clean_text(&other.to_string()),
};
let level = p::u64(it, "level").unwrap_or(0).min(u64::from(MAX_LEVEL));
Ok(SmartArtItem::new(text, u8::try_from(level).unwrap_or(MAX_LEVEL)))
let mut item = SmartArtItem::new(text, u8::try_from(level).unwrap_or(MAX_LEVEL));
item.color = match it.get("color") {
None | Some(Value::Null) => None,
Some(Value::String(c)) => Some(Rgb::parse(c).ok_or_else(|| CmdError::Params("`color`: RRGGBB".into()))?),
Some(_) => return Err(CmdError::Params("`color`: RRGGBB or null".into())),
};
Ok(item)
}
_ => Err(CmdError::Params(format!("`items`: {ITEMS}"))),
})
+27
View File
@@ -924,6 +924,33 @@ mod tests {
#[test]
fn numbers_read_currency_and_separators_but_not_letters() {
assert_eq!(parse_number("$1,200", None), Some(1200.0));
assert_eq!(parse_number("B/. 1,234.50", None), Some(1234.5), "the Panamanian balboa");
assert_eq!(parse_number("1,234.50 B/.", None), Some(1234.5));
assert_eq!(parse_number("(B/. 1,234.50)", None), Some(-1234.5), "an accounting negative");
assert_eq!(parse_number("B/.1,234.50", None), None, "Word needs a space after the sign");
assert_eq!(parse_number("1/.5", None), None, "a fraction is not a balboa amount");
// The formula reads the cell in the document's language. With the document's default text
// as es-PA and the editing language as pl-PL the choice is observable: `1,250.50` is a
// number under es-PA and not under pl-PL (whose decimal separator is `,`).
let balboa = table(&[&["B/. 1,234.50"], &["(B/. 1,234.50)"], &[""]]);
let mut doc = wordcraft_doc::Document::new();
std::sync::Arc::make_mut(&mut doc.styles).default_chr.lang = Some("es-PA".into());
assert_eq!(
evaluate_with(&balboa, Some(&doc), Some("pl-PL"), 2, 0, "=SUM(ABOVE)"),
Ok(0.0),
"a balboa cell sums in the document's language, and its negative in parentheses cancels it",
);
assert_eq!(evaluate_with(&balboa, Some(&doc), Some("pl-PL"), 2, 0, "=COUNT(ABOVE)"), Ok(2.0), "both balboa cells are numbers",);
// And the other way around: a balboa cell written with Polish separators is read only when
// the document language reaches the parser, not when it falls back to English.
let mut polish = wordcraft_doc::Document::new();
std::sync::Arc::make_mut(&mut polish.styles).default_chr.lang = Some("pl-PL".into());
let balboa_pl = table(&[&["B/. 1 250,50"], &[""]]);
assert_eq!(
evaluate_with(&balboa_pl, Some(&polish), Some("en-US"), 1, 0, "=SUM(ABOVE)"),
Ok(1250.5),
"the cell's language selects the separators; English does not read `1 250,50`",
);
assert_eq!(parse_number(" 12% ", None), Some(12.0));
assert_eq!(parse_number("(5)", None), Some(-5.0));
assert_eq!(parse_number("x9", None), None);
-3
View File
@@ -85,9 +85,6 @@ pub struct Session {
pub initials: String,
/// Format painter: copied formatting waiting to be applied (and whether it stays on).
pub painter: Option<(CharProps, wordcraft_doc::props::ParaProps, bool)>,
/// Formatting picked up by Copy Formatting (Mod+Shift+C): Paste Formatting applies it as often
/// as asked, as in Word, until the next Copy Formatting. The Format Painter brush is separate.
pub copied_format: Option<(CharProps, wordcraft_doc::props::ParaProps)>,
/// Last message for the status bar / agents.
pub status: String,
/// Headers/footers `insert.createHeader`/`insert.createFooter` made for a page that had none
-1
View File
@@ -249,7 +249,6 @@ impl Session {
author: _,
initials: _,
painter: _,
copied_format: _,
status: _,
last_command: _,
recording: _,
-1
View File
@@ -106,7 +106,6 @@ impl Session {
author: "WordCraft User".into(),
initials: String::new(),
painter: None,
copied_format: None,
status: String::new(),
new_header_parts,
hf_return,
-1
View File
@@ -88,7 +88,6 @@ impl Session {
author: _,
initials: _,
painter: _,
copied_format: _,
status: _,
// Headers just created to edit: closing checks they still exist and are empty.
new_header_parts: _,
+139 -18
View File
@@ -6045,6 +6045,22 @@ fn props_colours_take_hex_strings() {
}
}
#[test]
fn para_set_frame_geometry_keeps_anchor_lock_unless_replaced() {
use wordcraft_doc::props::TextFrame;
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Framed text"}));
let paragraph = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
paragraph.props.frame = Some(TextFrame { anchor_lock: Some(true), ..TextFrame::default() });
run(&mut s, "para.set", json!({"props": {"frame": {"x": 36.0, "y": 72.0, "w": 108.0}}}));
let frame = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.frame.as_ref().unwrap();
assert_eq!((frame.x, frame.y, frame.w, frame.anchor_lock), (36.0, 72.0, Some(108.0), Some(true)));
run(&mut s, "para.set", json!({"props": {"frame": {"x": 54.0, "anchorLock": false}}}));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.frame.as_ref().unwrap().anchor_lock, Some(false));
}
/// #555: Update Heading 2 to Match Selection (the styles pane's context menu) makes Heading 2
/// look like the selected text — its paragraph style and direct formatting — so every Heading 2
/// paragraph changes; the selected paragraph takes Heading 2, and it is one undo step.
@@ -9659,6 +9675,47 @@ fn smart_art_edits_undo_one_step_each() {
run(&mut s, "picture.size", json!({"width": 200, "height": 120, "lockAspect": false}));
}
/// An item's own colour survives item edits and layout changes, and a style colour drops it.
#[test]
fn smart_art_item_colors_stay_until_a_style_is_picked() {
let mut s = s();
run(&mut s, "insert.smartArt", json!({"layout": "process"}));
let colors = |s: &Session| cmd::smart_art::selected_smart_art(s).unwrap().items.iter().map(|i| i.color.map(|c| c.hex())).collect::<Vec<_>>();
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
run(&mut s, "smartArt.itemColor", json!({"index": 1, "color": "FF8800"}));
assert_eq!(colors(&s), [None, Some("FF8800".to_string()), None, None]);
run(&mut s, "smartArt.items", json!({"items": [{"text": "A"}, {"text": "B", "color": "#ff8800"}]}));
run(&mut s, "smartArt.layout", json!({"layout": "cycle"}));
run(&mut s, "smartArt.addShape", json!({"after": 0}));
assert_eq!(colors(&s), [None, None, Some("FF8800".to_string())]);
assert!(s.run("smartArt.itemColor", &json!({"index": 0, "color": "nope"})).is_err());
run(&mut s, "smartArt.itemColor", json!({"index": 2}));
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
run(&mut s, "smartArt.itemColor", json!({"index": 0, "color": "00FF00"}));
run(&mut s, "smartArt.colors", json!({"variant": "accent2"}));
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
run(&mut s, "edit.undo", json!({}));
assert!(cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors(), "undo brings the colours back");
}
/// A malformed colour must not silently replace a coloured item with its style colour.
#[test]
fn smart_art_item_colors_reject_invalid_values_without_changing_the_graphic() {
let mut s = s();
run(&mut s, "insert.smartArt", json!({"items": [{"text": "Keep", "color": "123456"}]}));
let before = cmd::smart_art::selected_smart_art(&s).unwrap();
for command in ["insert.smartArt", "smartArt.items"] {
for color in [json!("nope"), json!(42), json!(true), json!([]), json!({})] {
assert!(s.run(command, &json!({"items": [{"text": "Replace", "color": color}]})).is_err(), "{command}: {color}");
assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before);
}
run(&mut s, command, json!({"items": [{"text": "Default", "color": null}, {"text": "Omitted"}]}));
assert!(!cmd::smart_art::selected_smart_art(&s).unwrap().has_custom_colors());
run(&mut s, "edit.undo", json!({}));
assert_eq!(cmd::smart_art::selected_smart_art(&s).unwrap(), before);
}
}
/// #497: hostile SmartArt params are capped or refused, never a panic.
#[test]
fn smart_art_hostile_params_are_capped() {
@@ -9680,6 +9737,7 @@ fn smart_art_hostile_params_are_capped() {
"smartArt.demote",
"smartArt.layout",
"smartArt.colors",
"smartArt.itemColor",
"smartArt.reset",
"smartArt.textPane",
] {
@@ -11636,6 +11694,87 @@ fn drop_cap_options_set_position_lines_distance_and_font() {
run(&mut s, "text.insert", json!({"text": "Cell"}));
assert!(s.sel.focus.path.cell().is_some());
assert!(s.run("insert.dropCap", &json!({})).is_err(), "off in a table cell");
// A selection is as good as its worst paragraph: an empty one or one starting with a space
// refuses a drop cap for all (the button greys out); None works while there is one to take away.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "First"}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "Third"}));
assert_eq!(s.doc.body.len(), 3);
run(&mut s, "select.all", json!({}));
assert!(s.run("insert.dropCap", &json!({"position": "dropped"})).is_err(), "an empty paragraph in the selection");
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.drop_cap.is_none()), "nothing changed");
assert!(s.why_disabled("insert.dropCap").is_some(), "greyed out: nothing can take a drop cap or lose one");
run(&mut s, "select.range", json!({"anchor": {"block": 2, "off": 0}, "focus": {"block": 2, "off": 0}}));
run(&mut s, "insert.dropCap", json!({"position": "dropped"}));
// From the drop cap up into the empty paragraph (the caret there): None takes it away, the
// other positions are refused, as the enabled check says.
run(&mut s, "select.range", json!({"anchor": {"block": 2, "off": 5}, "focus": {"block": 1, "off": 0}}));
assert_eq!(s.why_disabled("insert.dropCap"), None, "a drop cap to take away");
assert!(s.run("insert.dropCap", &json!({"position": "margin"})).is_err(), "the empty paragraph can't take one");
assert!(s.run("insert.dropCap", &json!({"position": "none"})).is_ok(), "None takes it away");
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.drop_cap.is_none()), "taken away");
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": " first"}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": "Second"}));
run(&mut s, "select.all", json!({}));
assert!(s.run("insert.dropCap", &json!({})).is_err(), "a paragraph starting with a space in the selection");
}
/// #2601: `keep` moves a whole selection to Dropped / In margin without flattening it: each
/// paragraph keeps its own lines, distance and font, one without a drop cap gets the defaults,
/// and it is one undo step. Without `keep` the options not given are reset, as before.
#[test]
fn drop_cap_keep_leaves_each_paragraphs_options() {
let caps = |s: &Session| {
s.doc
.body
.iter()
.filter_map(|b| b.as_para())
.map(|p| (p.props.drop_cap, p.props.drop_cap_margin, p.props.drop_cap_distance, p.props.drop_cap_font.clone()))
.collect::<Vec<_>>()
};
let mut s = s();
run(&mut s, "text.insert", json!({"text": "First"}));
for text in ["Second", "Third"] {
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.insert", json!({"text": text}));
}
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 0}, "focus": {"block": 0, "off": 0}}));
run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"}));
run(&mut s, "select.range", json!({"anchor": {"block": 1, "off": 0}, "focus": {"block": 1, "off": 0}}));
run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 2, "distance": 3.0}));
let before = caps(&s);
run(&mut s, "select.all", json!({}));
run(&mut s, "insert.dropCap", json!({"position": "margin", "keep": true}));
assert_eq!(
caps(&s),
vec![
(Some(5), Some(true), Some(7.0), Some("Liberation Mono".to_string())),
(Some(2), Some(true), Some(3.0), None),
(Some(3), Some(true), None, None),
],
"each keeps its own options; the one without gets the defaults"
);
run(&mut s, "edit.undo", json!({}));
assert_eq!(caps(&s), before, "one undo step");
// Options given explicitly win over the kept ones; a junk `keep` is false.
run(&mut s, "insert.dropCap", json!({"position": "dropped", "keep": true, "lines": 4}));
assert_eq!(caps(&s).iter().map(|c| c.0).collect::<Vec<_>>(), vec![Some(4); 3]);
assert_eq!(caps(&s).first().map(|c| (c.2, c.3.clone())), Some((Some(7.0), Some("Liberation Mono".to_string()))), "distance and font still kept");
// A blank font given with `keep` clears the kept font (the text's own again).
run(&mut s, "insert.dropCap", json!({"position": "dropped", "keep": true, "font": " "}));
assert_eq!(caps(&s).first().map(|c| c.3.clone()), Some(None), "blank font clears the kept one");
run(&mut s, "insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"}));
run(&mut s, "insert.dropCap", json!({"position": "margin", "keep": "yes"}));
assert_eq!(caps(&s), vec![(Some(3), Some(true), None, None); 3], "junk keep: reset what isn't given");
// None ignores keep.
run(&mut s, "insert.dropCap", json!({"position": "none", "keep": true}));
assert_eq!(caps(&s), vec![(None, None, None, None); 3]);
}
#[test]
@@ -12290,21 +12429,3 @@ fn page_down_extends_a_selection_that_ends_in_collapsed_outline_text() {
assert!(s.sel.focus.path.0[0] > 6, "Page Down extends the selection a page on: {:?}", s.sel.focus);
assert!(s.layout().caret(&s.sel.focus).is_some(), "to a place the outline shows");
}
/// #5616: Copy Formatting's formatting stays for every Paste Formatting, as in Word; it doesn't
/// arm the one-shot Format Painter brush.
#[test]
fn copied_formatting_pastes_again_and_again() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Bold one two"}));
run(&mut s, "select.text", json!({"text": "Bold"}));
run(&mut s, "format.bold", json!({}));
run(&mut s, "edit.copyFormat", json!({}));
assert!(s.painter.is_none());
for word in ["one", "two"] {
run(&mut s, "select.text", json!({"text": word}));
run(&mut s, "edit.pasteFormat", json!({}));
assert_eq!(s.run("format.state", &json!({})).unwrap()["bold"], json!(true), "{word}");
}
assert_eq!(s.doc.plain_text(wordcraft_doc::StoryRef::Body), "Bold one two");
}
+8 -1
View File
@@ -14,7 +14,9 @@ fn commented() -> Session {
}
fn invalid_ids_leave_state_untouched(command: &str) {
for id in [json!(-1), json!(0.5), json!(0.0), json!("0"), Value::Null, json!(false), json!(4294967296u64)] {
// Past `u32`: 2^32 truncates to comment 0 and 2^32 + 5 to 5, and `u64::MAX` is the far end.
for id in [json!(-1), json!(0.5), json!(0.0), json!("0"), Value::Null, json!(false), json!(4294967296u64), json!(4294967301u64), json!(u64::MAX)]
{
for at_comment in [true, false] {
let mut s = commented();
if !at_comment {
@@ -28,6 +30,11 @@ fn invalid_ids_leave_state_untouched(command: &str) {
let before = (s.doc.clone(), s.sel.clone(), s.undo_labels(), s.redo_label().map(str::to_owned), s.dirty);
let result = s.run(command, &json!({"id": id, "text": "Replacement"}));
assert!(matches!(result, Err(CmdError::Params(_))), "{command}, id={id}, at_comment={at_comment}: {result:?}");
// Past `u32` it is the range check that refuses, not a lookup of whatever the id
// truncated to: 2^32 + 5 names no comment here, so "no such comment" would hide a cast.
if id.as_u64().is_some_and(|n| n > u64::from(u32::MAX)) {
assert!(format!("{result:?}").contains("32-bit"), "{command}, id={id}: {result:?}");
}
assert_eq!(
(s.doc.clone(), s.sel.clone(), s.undo_labels(), s.redo_label().map(str::to_owned), s.dirty),
before,
+2
View File
@@ -15,3 +15,5 @@ mod rtf_sections;
#[cfg(not(target_arch = "wasm32"))]
mod safe_save;
mod three_column_headers;
mod ref_paragraph_numbers;
@@ -0,0 +1,404 @@
//! Synthetic numbered-reference regression adapted from the patch by @Alyosha-dot-shop in #4533.
//! Switch semantics: Microsoft MS-OI29500, Part 1 Section 17.16.5.51 (REF).
//! All OOXML packages and document text are constructed in memory; no external documents.
use std::io::Write;
use serde_json::json;
use wordcraft_doc::{InlineObject, StoryRef};
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
/// Build an OOXML ZIP entirely in memory.
fn zip(entries: &[(&str, &str)]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, body) in entries {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(body.as_bytes()).unwrap();
}
zw.finish().unwrap().into_inner()
}
/// One synthetic numbering level.
fn lvl(ilvl: u8, fmt: &str, text: &str) -> String {
format!(r#"<w:lvl w:ilvl="{ilvl}"><w:start w:val="1"/><w:numFmt w:val="{fmt}"/><w:lvlText w:val="{text}"/></w:lvl>"#)
}
/// Five independent list schemes for context and restart coverage.
fn numbering() -> String {
let schemes: [&[(&str, &str)]; 5] = [
&[("decimal", "%1."), ("decimal", "%1.%2"), ("lowerLetter", "(%3)")],
&[("decimal", "%1"), ("decimal", "%1.%2"), ("decimal", "%1.%2.%3")],
&[("decimal", "%1"), ("decimal", "%1.%2"), ("lowerLetter", "(%3)")],
&[("decimal", "%1."), ("lowerLetter", "(%2)")],
// Scheme 5: as scheme 1, a list of its own whose first two levels are never used.
&[("decimal", "%1."), ("decimal", "%1.%2"), ("lowerLetter", "(%3)")],
];
let mut out = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:numbering {W}>"#);
for (i, levels) in schemes.iter().enumerate() {
out.push_str(&format!(r#"<w:abstractNum w:abstractNumId="{i}"><w:multiLevelType w:val="multilevel"/>"#));
for (k, (fmt, text)) in levels.iter().enumerate() {
out.push_str(&lvl(k as u8, fmt, text));
}
out.push_str("</w:abstractNum>");
}
for i in 0..schemes.len() {
out.push_str(&format!(r#"<w:num w:numId="{}"><w:abstractNumId w:val="{i}"/></w:num>"#, i + 1));
}
out + "</w:numbering>"
}
/// A paragraph, numbered `(numId, level)` or not, holding `content`.
fn para(num: Option<(u32, u8)>, content: &str) -> String {
let ppr = num.map(|(n, l)| format!(r#"<w:pPr><w:numPr><w:ilvl w:val="{l}"/><w:numId w:val="{n}"/></w:numPr></w:pPr>"#)).unwrap_or_default();
format!("<w:p>{ppr}{content}</w:p>")
}
/// One synthetic text run.
fn text(t: &str) -> String {
format!(r#"<w:r><w:t xml:space="preserve">{t}</w:t></w:r>"#)
}
/// A bookmark surrounding a synthetic text run.
fn marked(name: &str, id: u32, t: &str) -> String {
format!(r#"<w:bookmarkStart w:id="{id}" w:name="{name}"/>{}<w:bookmarkEnd w:id="{id}"/>"#, text(t))
}
/// A complex REF field with an intentionally stale result.
fn field(instr: &str, cached: &str) -> String {
format!(
concat!(
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r>"#,
r#"<w:r><w:instrText xml:space="preserve"> {} </w:instrText></w:r>"#,
r#"<w:r><w:fldChar w:fldCharType="separate"/></w:r>"#,
r#"<w:r><w:t xml:space="preserve">{}</w:t></w:r>"#,
r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
),
instr, cached
)
}
/// Open a synthetic OOXML package through the real document reader.
fn open(body: &str) -> wordcraft_engine::Session {
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc_rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdN" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/></Relationships>"#;
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W}><w:body>{body}</w:body></w:document>"#);
let numbering = numbering();
let bytes =
zip(&[("_rels/.rels", rels), ("word/_rels/document.xml.rels", doc_rels), ("word/document.xml", &doc), ("word/numbering.xml", &numbering)]);
wordcraft_engine::Session::new(wordcraft_engine::io::open_bytes("d.docx", &bytes).expect("opens"))
}
/// Every body field, as (code, result), in document order.
fn fields(s: &wordcraft_engine::Session) -> Vec<(String, String)> {
let mut out = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
for o in &p.objects {
if let InlineObject::Field { instr, result, .. } = o {
out.push((instr.trim().to_string(), result.clone()));
}
}
}
out
}
/// Numeric REF switches replace cached values with list labels, while plain REF and position switches retain their behavior.
#[test]
fn ref_number_switches_show_the_paragraph_number_as_word_does() {
let body = [
// Scheme 1: %1. / %1.%2 / (%3) — clause (a) of 1.1.
para(Some((1, 0)), &text("Definitions")),
para(Some((1, 1)), &text("Defined terms")),
para(Some((1, 2)), &marked("_RefA", 1, "Synthetic target alpha.")),
// Scheme 2: the stock chapter scheme %1 / %1.%2 / %1.%2.%3.
para(Some((2, 0)), &text("Chapter")),
para(Some((2, 1)), &text("Section")),
para(Some((2, 2)), &marked("_RefB", 2, "Point")),
// Scheme 3: %1 / %1.%2 / (%3).
para(Some((3, 0)), &text("Part")),
para(Some((3, 1)), &text("Part point")),
para(Some((3, 2)), &marked("_RefC", 3, "Sub-point")),
// Scheme 4: %1. / (%2), clauses and sub-clauses as in most contracts.
para(Some((4, 0)), &text("Hire")),
para(Some((4, 1)), &text("first")),
para(Some((4, 0)), &marked("_RefD", 4, "Payment")),
para(Some((4, 1)), &text("a")),
para(Some((4, 1)), &marked("_RefE", 5, "b")),
// 2(c), inside clause 2: relative references drop the shared clause number.
para(Some((4, 1)), &format!("{}{}{}", text("See "), field(r"REF _RefE \r \h", "STALE"), field(r"REF _RefD \r \h", "STALE"))),
para(Some((4, 0)), &text("Third")),
// 3(a), another clause: the full number. _RefF's list has no clauses above its items.
para(Some((4, 1)), &format!("{}{}", field(r"REF _RefE \r \h", "STALE"), field(r"REF _RefF \r \h", "STALE"))),
// Scheme 5 items in a table whose second row's cell is merged into the first: the empty
// item in the merged-away cell takes no number, so the next item is (b), not (c).
format!(
concat!(
r#"<w:tbl><w:tblPr/><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid>"#,
r#"<w:tr><w:tc><w:tcPr><w:vMerge w:val="restart"/></w:tcPr>{}</w:tc></w:tr>"#,
r#"<w:tr><w:tc><w:tcPr><w:vMerge/></w:tcPr>{}</w:tc></w:tr></w:tbl>"#,
),
para(Some((5, 2)), &marked("_RefF", 6, "Target F")),
para(Some((5, 2)), ""),
),
para(Some((5, 2)), &marked("_RefG", 7, "Target G")),
// An unnumbered paragraph.
para(
None,
&[
field(r"REF _RefA \w \h", "STALE"),
field(r"REF _RefA \n \h", "STALE"),
field(r"REF _RefA \r \h", "STALE"),
field(r"REF _RefB \w \h", "STALE"),
field(r"REF _RefC \w \h", "STALE"),
field(r"REF _RefE \r \h", "STALE"),
field(r"REF _RefD \n \h", "STALE"),
field(r"REF _RefG \r \h", "STALE"),
// Unchanged: no number switch shows the text; \p (above/below) is left as it is.
field(r"REF _RefB \h", "STALE"),
field(r"REF _RefA \r \p", "KEEP"),
]
.concat(),
),
]
.concat();
let mut s = open(&body);
s.run("references.updateFields", &json!({"all": true})).expect("updates");
let got: Vec<(String, String)> = fields(&s);
let want = [
(r"REF _RefE \r \h", "(b)"),
(r"REF _RefD \r \h", "2"),
(r"REF _RefE \r \h", "2(b)"),
(r"REF _RefF \r \h", "(a)"),
(r"REF _RefA \w \h", "1.1(a)"),
(r"REF _RefA \n \h", "(a)"),
(r"REF _RefA \r \h", "1.1(a)"),
(r"REF _RefB \w \h", "1.1.1"),
(r"REF _RefC \w \h", "1.1(a)"),
(r"REF _RefE \r \h", "2(b)"),
(r"REF _RefD \n \h", "2"),
(r"REF _RefG \r \h", "(b)"),
(r"REF _RefB \h", "Point"),
(r"REF _RefA \r \p", "KEEP"),
];
let want: Vec<(String, String)> = want.iter().map(|(i, r)| (i.to_string(), r.to_string())).collect();
assert_eq!(got, want);
}
/// Word retains context already rendered by the target's own label for relative REF.
#[test]
fn relative_number_keeps_context_already_in_the_target_label() {
let body = [
para(Some((2, 0)), &text("Root")),
para(Some((2, 1)), &text("First section")),
para(Some((2, 2)), &marked("_Target", 20, "Synthetic target")),
para(Some((2, 2)), &field(r"REF _Target \r", "STALE")),
para(Some((2, 1)), &text("Other section")),
para(Some((2, 2)), &field(r"REF _Target \r", "STALE")),
para(None, &field(r"REF _Target \w", "STALE")),
]
.concat();
let mut s = open(&body);
s.run("references.updateFields", &json!({"all": true})).unwrap();
let got: Vec<_> = fields(&s).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["1.1.1", "1.1.1", "1.1.1"]);
}
/// Cached values of unsupported switches, missing targets and locked fields are not replaced with paragraph text.
#[test]
fn unsupported_or_unresolvable_numeric_refs_preserve_their_cache() {
let body = [
para(Some((1, 0)), &marked("_Root", 30, "Synthetic root")),
para(
None,
&[
field(r"REF _Root \w \d /", "KEEP DELIMITER"),
field(r"REF _Root \n \t", "KEEP TEXT"),
field(r"REF _Root \r \p", "KEEP POSITION"),
field(r"REF _Missing \w", "KEEP MISSING"),
field(r"REF _Plain \n", "KEEP UNNUMBERED"),
field(r"REF _Root \n", "KEEP LOCKED").replace(r#"w:fldCharType="begin""#, r#"w:fldCharType="begin" w:fldLock="true""#),
]
.concat(),
),
para(None, &marked("_Plain", 31, "Synthetic unnumbered target")),
]
.concat();
let mut s = open(&body);
let before = fields(&s);
s.run("references.updateFields", &json!({"all":true})).unwrap();
assert_eq!(fields(&s), before);
}
/// Updating, undoing and saving keep the field instructions and their numeric results intact.
#[test]
fn numeric_refs_survive_undo_redo_and_docx_round_trip() {
let body = [
para(Some((4, 0)), &text("Root")),
para(Some((4, 1)), &marked("_Item", 40, "Synthetic item șțăîâ")),
para(None, &field(r"REF _Item \w \h", "STALE")),
]
.concat();
let mut s = open(&body);
s.run("references.updateFields", &json!({"all":true})).unwrap();
assert_eq!(fields(&s), [(r"REF _Item \w \h".into(), "1(a)".into())]);
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(fields(&s)[0].1, "STALE");
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(fields(&s)[0].1, "1(a)");
let bytes = wordcraft_docx::write(&s.doc).unwrap();
let back = wordcraft_engine::Session::new(wordcraft_engine::io::open_bytes("roundtrip.docx", &bytes).unwrap());
assert_eq!(fields(&back), fields(&s));
}
/// Context already displayed by a needed parent label is retained as Word measured it.
#[test]
fn relative_number_from_the_root_keeps_the_parents_rendered_context() {
let body = [
para(Some((1, 0)), &field(r"REF _Target \r", "STALE")),
para(Some((1, 1)), &text("Synthetic section")),
para(Some((1, 2)), &marked("_Target", 50, "Synthetic target")),
]
.concat();
let mut s = open(&body);
s.run("references.updateFields", &json!({"all":true})).unwrap();
assert_eq!(fields(&s)[0].1, "1.1(a)");
}
/// A header can reference a body list without inheriting the body caret's relative context.
#[test]
fn numeric_refs_in_headers_use_the_body_target_number() {
use wordcraft_doc::{Paragraph, PartKind, para_block};
let mut s = open(&[para(Some((4, 0)), &text("Synthetic root")), para(Some((4, 1)), &marked("_Target", 60, "Synthetic target"))].concat());
let mut header = Paragraph::new();
header
.insert_object(
0,
InlineObject::Field {
instr: r"REF _Target \r \h".into(),
result: "STALE".into(),
locked: false,
dirty: false,
code: false,
form: String::new(),
},
&Default::default(),
)
.unwrap();
let id = s.doc.add_part(PartKind::Header, vec![para_block(header)]);
s.doc.last_section.headers.default = Some(id);
s.run("references.updateFields", &json!({"all":true})).unwrap();
let p = s.doc.para(StoryRef::Part(id), &wordcraft_doc::Path::top(0)).unwrap();
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!() };
assert_eq!(result, "1(a)");
}
/// Separate bare numeric level labels cannot be concatenated without inventing a delimiter.
#[test]
fn ambiguous_numeric_label_join_keeps_the_cached_full_context() {
let mut s = open(
&[
para(Some((2, 0)), &text("Root")),
para(Some((2, 1)), &text("Section")),
para(Some((2, 2)), &marked("_Target", 70, "Synthetic target")),
para(None, &field(r"REF _Target \w", "1.1.1")),
para(None, &field(r"REF _Target \n", "STALE")),
]
.concat(),
);
let abs = std::sync::Arc::make_mut(&mut s.doc.numbering).abstracts.iter_mut().find(|a| a.id == 1).unwrap();
abs.levels[1].text = "%2".into();
abs.levels[2].text = "%3".into();
s.run("references.updateFields", &json!({"all":true})).unwrap();
let got: Vec<_> = fields(&s).into_iter().map(|(_, s)| s).collect();
assert_eq!(got, ["1.1.1", "1"]);
}
/// Reusing the lookup keeps the first body bookmark even with duplicate names and many updates.
#[test]
fn repeated_numeric_refs_keep_the_first_body_bookmark() {
use wordcraft_doc::{PartKind, Path};
let mut body = [
para(Some((4, 0)), &marked("_Duplicate", 80, "First synthetic target")),
para(Some((4, 0)), &marked("_Duplicate", 81, "Second synthetic target")),
]
.concat();
for _ in 0..64 {
body.push_str(&para(None, &field(r"REF _Duplicate \w \h", "STALE")));
}
body.push_str(&para(None, &field(r"REF _Duplicate \h", "STALE")));
let mut s = open(&body);
assert_eq!(s.doc.bookmarks().iter().filter(|(name, _)| name == "_Duplicate").count(), 2);
// A same-named bookmark outside the body cannot replace the body target.
let header = open(&para(None, &marked("_Duplicate", 82, "Header synthetic target"))).doc.body[0].clone();
let id = s.doc.add_part(PartKind::Header, vec![header]);
s.doc.last_section.headers.default = Some(id);
s.run("references.updateFields", &json!({"all": true})).unwrap();
let got = fields(&s);
assert_eq!(got.len(), 65);
assert!(got[..64].iter().all(|(_, value)| value == "1"));
assert_eq!(got[64].1, "First synthetic target");
assert_eq!(s.doc.bookmarks().iter().find(|(name, _)| name == "_Duplicate").unwrap().1.path, Path::top(0));
}
/// A target's instance must not invent a different label for its actual ancestor paragraph.
#[test]
fn full_context_keeps_the_actual_ancestor_instance_label() {
let mut s = open(
&[
para(Some((1, 0)), &text("Synthetic ancestor")),
para(Some((6, 1)), &marked("_ActualAncestor", 80, "Synthetic child")),
para(None, &field(r"REF _ActualAncestor \w", "STALE")),
]
.concat(),
);
let numbering = std::sync::Arc::make_mut(&mut s.doc.numbering);
numbering.abstracts.iter_mut().find(|a| a.id == 0).unwrap().levels[0].text = "Clause %1.".into();
let mut child = numbering.nums.iter().find(|n| n.id == 1).unwrap().clone();
child.id = 6;
child.level_overrides = vec![
(0, wordcraft_doc::numbering::Level { text: "Part %1.%2.".into(), ..Default::default() }),
(1, wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::LowerLetter, text: "(%2)".into(), ..Default::default() }),
];
numbering.nums.push(child);
s.run("references.updateFields", &json!({"all":true})).unwrap();
assert_eq!(fields(&s).into_iter().map(|(_, value)| value).collect::<Vec<_>>(), ["Clause 1(a)"]);
}
/// The six legal-list cases measured in Word for Windows by @pakiat:
/// https://github.com/storytold/wordcraft/pull/4609#issuecomment-6557062456
#[test]
fn relative_refs_keep_the_legal_template_in_the_six_measured_contexts() {
let refs = || [field(r"REF _Leaf \r", "STALE"), field(r"REF _Mid \r", "STALE")].concat();
let body = [
para(Some((2, 0)), &text("Synthetic first root")),
para(Some((2, 0)), &refs()),
para(Some((2, 1)), &text("Synthetic first section")),
para(Some((2, 1)), &marked("_Mid", 90, "Synthetic target section")),
para(Some((2, 2)), &text("Synthetic first leaf")),
para(Some((2, 2)), &marked("_Leaf", 91, "Synthetic target leaf")),
para(Some((2, 2)), &refs()),
para(Some((2, 1)), &refs()),
]
.concat();
let mut s = open(&body);
s.run("references.updateFields", &json!({"all":true})).unwrap();
let got: Vec<_> = fields(&s).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["2.2.2", "2.2", "2.2.2", "2.2", "2.2.2", "2.2"]);
}
/// Equal counter values in another list do not provide shared relative context.
#[test]
fn relative_context_is_not_shared_by_different_lists() {
let mut s = open(
&[
para(Some((4, 0)), &text("Synthetic target root")),
para(Some((4, 1)), &marked("_OtherList", 92, "Synthetic target")),
para(Some((1, 0)), &text("Synthetic separate root")),
para(Some((1, 1)), &field(r"REF _OtherList \r", "STALE")),
]
.concat(),
);
s.run("references.updateFields", &json!({"all":true})).unwrap();
assert_eq!(fields(&s)[0].1, "1(a)");
}
+1
View File
@@ -58,6 +58,7 @@ pub fn notes(doc: &Document) -> Vec<Note> {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
let mut objects = p.objects.iter();
for c in p.text.chars() {
wordcraft_doc::work::steps(1);
if c == OBJ {
match objects.next() {
Some(InlineObject::CommentStart { id }) => {
+8 -35
View File
@@ -64,45 +64,18 @@ fn dense_markers(n: usize) -> Document {
doc
}
// Ignore scheduler delays. An absent, failed, decreasing or nonadvancing counter supplies no
// performance evidence; the semantic assertions below still run on every platform.
fn cpu_cost(mut f: impl FnMut()) -> Option<f64> {
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
let before = ran();
f();
match (before, ran()) {
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
_ => None,
}
}
/// Unrelated markers must add one walk, rather than a fresh paragraph-prefix scan per marker.
#[test]
fn comment_export_discovery_scales_with_unrelated_markers() {
let (small, big) = (dense_markers(2_000), dense_markers(16_000));
let expected = vec![Note { author: "Ada".into(), quote: "é漢 quote".into(), text: "Kept note".into(), replies: vec![] }];
assert_eq!(notes(&small), expected);
assert_eq!(notes(&big), expected);
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
for _ in 0..3 {
let a = cpu_cost(|| {
for _ in 0..8 {
std::hint::black_box(notes(&small));
}
});
let b = cpu_cost(|| {
std::hint::black_box(notes(&big));
});
if let (Some(a), Some(b)) = (a, b) {
t1 = t1.min(a / 8.0);
t8 = t8.min(b);
}
}
if t1.is_finite() && t8.is_finite() {
let ratio = t8 / t1;
eprintln!("comment discovery CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
assert!(ratio < 24.0, "8x the markers cost {ratio:.2}x CPU ({t1:.6}s versus {t8:.6}s)");
} else {
eprintln!("comment discovery scaling skipped: no advancing thread CPU samples");
}
// The characters the discovery walks (`wordcraft_doc::work`), not a timing: about 8x for 8x
// the markers when the walk is linear, about 64x when it is quadratic. Timed, this failed on
// busy CI machines with a linear walk (58x for a 17 µs baseline).
let (s, b) = (wordcraft_doc::work::count(|| drop(notes(&small))), wordcraft_doc::work::count(|| drop(notes(&big))));
let ratio = b as f64 / s.max(1) as f64;
eprintln!("comment discovery steps: {s} -> {b}, {ratio:.1}x for 8x markers");
assert!(s > 0, "no steps counted, so the walk this test watches no longer counts them (wordcraft_doc::work)");
assert!(ratio <= 12.0, "8x the markers cost {ratio:.1}x the steps ({s} -> {b})");
}
+9
View File
@@ -153,6 +153,15 @@ fn rtf_border_style(s: wordcraft_doc::props::BorderStyle) -> &'static str {
BorderStyle::Triple => "\\brdrtriple",
BorderStyle::DotDash => "\\brdrdashd",
BorderStyle::Wave => "\\brdrwavy",
BorderStyle::ThinThickSmallGap => "\\brdrtnthsg",
BorderStyle::ThickThinSmallGap => "\\brdrthtnsg",
BorderStyle::ThinThickThinSmallGap => "\\brdrtnthtnsg",
BorderStyle::ThinThickMediumGap => "\\brdrtnthmg",
BorderStyle::ThickThinMediumGap => "\\brdrthtnmg",
BorderStyle::ThinThickThinMediumGap => "\\brdrtnthtnmg",
BorderStyle::ThinThickLargeGap => "\\brdrtnthlg",
BorderStyle::ThickThinLargeGap => "\\brdrthtnlg",
BorderStyle::ThinThickThinLargeGap => "\\brdrtnthtnlg",
}
}
+31
View File
@@ -1672,3 +1672,34 @@ fn rtf_backslash_space_and_special_breaks() {
let p = doc.body.first().unwrap().as_para().unwrap();
assert_eq!(p.text, "A\u{00A0}B\u{00A0}C\u{200B}D\nE\u{000C}F");
}
#[test]
fn compound_borders_round_trip() {
use wordcraft_doc::props::BorderStyle;
let lines = [
("brdrtnthsg", BorderStyle::ThinThickSmallGap),
("brdrthtnsg", BorderStyle::ThickThinSmallGap),
("brdrtnthtnsg", BorderStyle::ThinThickThinSmallGap),
("brdrtnthmg", BorderStyle::ThinThickMediumGap),
("brdrthtnmg", BorderStyle::ThickThinMediumGap),
("brdrtnthtnmg", BorderStyle::ThinThickThinMediumGap),
("brdrtnthlg", BorderStyle::ThinThickLargeGap),
("brdrthtnlg", BorderStyle::ThickThinLargeGap),
("brdrtnthtnlg", BorderStyle::ThinThickThinLargeGap),
];
let body: String = lines.iter().map(|(w, _)| format!("\\pard\\brdrt\\{w}\\brdrw30 {w}\\par\n")).collect();
let rtf = format!("{{\\rtf1\\ansi\n{body}}}");
let tops = |d: &wordcraft_doc::Document| -> Vec<Option<BorderStyle>> {
d.body
.iter()
.filter_map(|b| b.as_para())
.filter(|p| !p.plain_text().is_empty())
.map(|p| p.props.borders.and_then(|b| b.top).map(|t| t.style))
.collect()
};
let want: Vec<Option<BorderStyle>> = lines.iter().map(|(_, s)| Some(*s)).collect();
let d = import(rtf.as_bytes()).unwrap();
assert_eq!(tops(&d), want);
let back = import(export(&d).as_bytes()).unwrap();
assert_eq!(tops(&back), want);
}
+12 -3
View File
@@ -206,15 +206,24 @@ impl Reader {
"clbrdrb" => self.cell_side = Some('b'),
"clbrdrr" => self.cell_side = Some('r'),
"brdrs" | "brdrth" | "brdrsh" | "brdrdb" | "brdrdot" | "brdrdash" | "brdrdashsm" | "brdrdashd" | "brdrdashdd" | "brdrhair"
| "brdrtriple" | "brdrthtnsg" | "brdrtnthsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrwavy" | "brdrwavydb" | "brdrnone" | "brdrnil"
| "brdrtbl"
| "brdrtriple" | "brdrthtnsg" | "brdrtnthsg" | "brdrtnthtnsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrtnthtnmg" | "brdrthtnlg"
| "brdrtnthlg" | "brdrtnthtnlg" | "brdrwavy" | "brdrwavydb" | "brdrnone" | "brdrnil" | "brdrtbl"
if self.cell_side.is_some() || self.st.border_side.is_some() =>
{
use wordcraft_doc::props::{Border, BorderStyle};
let style = match w {
"brdrnone" | "brdrnil" | "brdrtbl" => BorderStyle::None,
"brdrth" => BorderStyle::Thick,
"brdrdb" | "brdrthtnsg" | "brdrtnthsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrwavydb" => BorderStyle::Double,
"brdrdb" | "brdrwavydb" => BorderStyle::Double,
"brdrtnthsg" => BorderStyle::ThinThickSmallGap,
"brdrthtnsg" => BorderStyle::ThickThinSmallGap,
"brdrtnthtnsg" => BorderStyle::ThinThickThinSmallGap,
"brdrtnthmg" => BorderStyle::ThinThickMediumGap,
"brdrthtnmg" => BorderStyle::ThickThinMediumGap,
"brdrtnthtnmg" => BorderStyle::ThinThickThinMediumGap,
"brdrtnthlg" => BorderStyle::ThinThickLargeGap,
"brdrthtnlg" => BorderStyle::ThickThinLargeGap,
"brdrtnthtnlg" => BorderStyle::ThinThickThinLargeGap,
"brdrdot" => BorderStyle::Dotted,
"brdrdash" | "brdrdashsm" => BorderStyle::Dashed,
"brdrdashd" | "brdrdashdd" => BorderStyle::DotDash,
+3
View File
@@ -23,6 +23,9 @@ serde_json = { workspace = true }
image = { workspace = true }
log = { workspace = true }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32-unknown-unknown).
js-sys = "0.3"
+7 -3
View File
@@ -54,9 +54,13 @@ fn anchors_walk_dense_unrelated_markers_in_linear_work() {
const N: usize = 2_000;
let (small, big) = (dense_markers(N), dense_markers(crate::tests::SCALE * N));
let check = |doc: &Document, n| assert_eq!(anchors(doc), vec![(0, Pos::body(0, n * wordcraft_doc::para::OBJ.len_utf8()))]);
let (s, b, ratio) = crate::tests::cost_ratio(crate::tests::SCALE, || check(&small, N), || check(&big, crate::tests::SCALE * N));
eprintln!("balloon anchor CPU: {s:.6}s -> {b:.6}s, {ratio:.2}x for 8x markers");
assert!(ratio < 24.0, "balloon anchor work grew {ratio:.2}x for 8x markers ({s:.6}s -> {b:.6}s)");
// The characters the paragraph's object lookups walk (`wordcraft_doc::work`), not a timing:
// about 8x for 8x the markers when the walk is linear, about 64x when it is quadratic.
let (s, b) = (wordcraft_doc::work::count(|| check(&small, N)), wordcraft_doc::work::count(|| check(&big, crate::tests::SCALE * N)));
let ratio = b as f64 / s.max(1) as f64;
eprintln!("balloon anchor steps: {s} -> {b}, {ratio:.1}x for 8x markers");
assert!(s > 0, "no steps counted, so the walk this test watches no longer counts them (wordcraft_doc::work)");
assert!(ratio <= 1.5 * crate::tests::SCALE as f64, "balloon anchor work grew {ratio:.1}x for 8x markers ({s} -> {b} steps)");
}
#[test]
+9 -1
View File
@@ -296,10 +296,18 @@ impl PageBuilder<'_> {
self.page_items_start = self.pages.last().map_or(0, |p| p.items.len());
true
}
pub(crate) fn new_page(&mut self, first_block: usize, body_top: f32) {
/// Finish the current page: the lines between its columns, its vertical alignment and its
/// footnotes, placed with the section state in effect (margins, gutter, separators).
/// `new_page` starts with this; after it has run, `new_page` finds nothing left to finish.
pub(crate) fn finish_page(&mut self) {
self.close_column_lines();
self.apply_valign();
// The page is aligned once: finishing it again before it is left moves nothing twice.
self.page_valign = wordcraft_doc::section::PageVAlign::Top;
self.flush_notes();
}
pub(crate) fn new_page(&mut self, first_block: usize, body_top: f32) {
self.finish_page();
self.beneath = self.sect_beneath;
self.excl.clear();
// Floats placed above their anchors further down this page are known from its start.
+5 -2
View File
@@ -610,8 +610,11 @@ impl<'e> Breaker<'e> {
let short = lines.len() < dl as usize;
let dy = if short { (dl as f32 - 1.0) * l0.height } else { ln.baseline - l0.baseline };
let lead = self.first_x0.unwrap_or(0.0) - self.first_left.max(rp.indent_left);
// In margin, the letter's right edge stays the distance away from the text's edge.
let dx = if rp.drop_cap_margin { lead.max(0.0) + pl.drop_cap_w + rp.drop_cap_distance } else { dw.min(lead) };
// In margin, the letter's right edge stays the distance left of the text column (or of a
// hanging first line), whatever the alignment or first-line indent: always in the margin.
let edge = self.first_left.min(rp.indent_left);
let in_margin = (self.first_x0.unwrap_or(0.0) - edge).max(0.0) + pl.drop_cap_w + rp.drop_cap_distance;
let dx = if rp.drop_cap_margin { in_margin } else { dw.min(lead) };
// Toward the start edge: left, or right (and from the letter's right edge) when mirrored.
let dx = if self.rtl_para { pl.drop_cap_w - dx } else { dx };
if short {
+2
View File
@@ -266,6 +266,8 @@ impl<'a> Shaped<'a> {
big.size = (((lines - 1.0) * line_h + rc.size * 0.7) / 0.7).min(1000.0).max(rc.size);
if let Some(f) = &rp.drop_cap_font {
big.font = f.clone();
big.font_cs = f.clone();
big.font_ea = Some(f.clone());
}
let before = self.b.clusters.len();
self.b.shape(first, 0, &Arc::new(big), None);
+6
View File
@@ -509,6 +509,12 @@ pub(crate) fn layout_pass(
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 };
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
// belong to the section whose page it is (#5289).
pb.finish_page();
}
begin_section_state(&mut ctx, &mut pb, &sec, totals, mode.web);
if mode.web && si > 0 {
// one long page
+78
View File
@@ -2958,6 +2958,44 @@ fn sections_land_on_the_side_their_break_and_numbering_ask_for() {
assert_eq!(pages(SectionStart::NextPage, None, true), [(0, 1), (1, 2)]);
}
#[test]
fn a_section_starting_a_page_flushes_the_page_before_with_its_own_margins() {
use wordcraft_doc::section::{SectionProps, SectionStart};
// Section one ends its last page with a footnote pending; section two starts a new page
// with a wider left margin. The footnotes on the page before keep section one's margin,
// whichever way the new page starts (#5289).
let note_x = |start: SectionStart| {
let mut d = Document::from_text("Section one text.\nTwo");
let note = wordcraft_doc::Paragraph::with_text("The note text.", Default::default());
let id = d.add_part(wordcraft_doc::PartKind::Footnote, vec![wordcraft_doc::para_block(note)]);
d.insert_object(
&Pos::body(0, 5),
InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Footnote, id, custom: String::new() },
&Default::default(),
)
.unwrap();
d.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().section = Some(Box::new(SectionProps::default()));
d.last_section.start = start;
d.last_section.margin_left = 144.0;
let l = lay(&d);
let x = l
.pages
.iter()
.flat_map(|p| p.items.iter())
.find_map(|it| match it {
Placed::Lines { story: StoryRef::Part(n), x, .. } if *n == id => Some(*x),
_ => None,
})
.unwrap_or_else(|| panic!("the footnote is placed ({start:?})"));
(x, l.pages.len())
};
for start in [SectionStart::NextPage, SectionStart::EvenPage, SectionStart::OddPage] {
let (x, pages) = note_x(start);
assert!((x - 72.0).abs() < 0.01, "{start:?}: the footnote sits at section one's margin, not {x}");
assert!(pages >= 2, "{start:?}: the section starts a page");
}
}
#[test]
fn paragraph_marks_sit_past_text_running_the_other_way() {
// A left-to-right paragraph ending in Hebrew, and a right-to-left one ending in Latin: the
@@ -4392,6 +4430,46 @@ fn drop_cap_options_place_the_letter() {
assert_ne!(text_style.as_deref(), Some("Liberation Mono"), "only the letter");
}
/// #2601: the letter's own font applies to complex-script and East Asian first letters too. In
/// margin, the letter sits in the left margin for any alignment or first-line indent: its right
/// edge is the distance left of the text column (of the first line's start, if that hangs further
/// left), never beside centred text or inside the column.
#[test]
fn drop_cap_font_covers_scripts_and_alignment_keeps_the_gap() {
use wordcraft_doc::props::Align;
let font = drop_cap_layout(|p| p.drop_cap_font = Some("Liberation Mono".into()));
let rc = font.clusters.first().and_then(|c| font.styles.get(c.style as usize)).map(|s| s.rc.clone());
let Some(rc) = rc else { panic!("no style") };
assert_eq!(rc.font_cs, "Liberation Mono");
assert_eq!(rc.font_ea.as_deref(), Some("Liberation Mono"));
// (alignment, left indent, first-line indent, the column's edge the letter hangs from)
let cases = [
(Align::Left, 0.0, 0.0, 0.0),
(Align::Center, 0.0, 0.0, 0.0),
(Align::Right, 0.0, 0.0, 0.0),
(Align::Left, 0.0, 36.0, 0.0),
(Align::Center, 18.0, 36.0, 18.0),
(Align::Left, 36.0, -36.0, 0.0),
];
for (align, left, first, edge) in cases {
let pl = drop_cap_layout(|p| {
p.align = Some(align);
p.indent_left = Some(left);
p.indent_first = Some(first);
p.drop_cap_margin = Some(true);
p.drop_cap_distance = Some(9.0);
});
let text_x = pl.lines.first().and_then(|l| l.xs.first().copied()).unwrap_or(f32::NAN);
let letter_right = text_x + pl.glyphs.first().map(|g| g.dx).unwrap_or(f32::NAN) + pl.drop_cap_w;
assert!(
(letter_right - (edge - 9.0)).abs() < 0.5,
"{align:?}, indents {left}/{first}: letter's right edge {letter_right}, expected {} (text at {text_x})",
edge - 9.0
);
}
}
#[test]
fn auto_hyphenation_breaks_long_words() {
let text = "Internationalization considerations notwithstanding, administrators systematically reconsidered extraordinarily uncharacteristic responsibilities. ".repeat(4);
+358 -43
View File
@@ -1,7 +1,7 @@
//! Device-independent bitmaps. BITMAPINFOHEADER DIBs at 1/4/8/16/24/32 bpp with BI_RGB or BI_BITFIELDS
//! decode to RGBA8 rows, top-down. Compressed (RLE, JPEG, PNG) and V4/V5-only features are not decoded.
use crate::{MAX_PIXELS, bytes::Bytes};
use crate::{MAX_BITMAP_SIDE, MAX_PIXELS, bytes::Bytes};
const BI_RGB: u32 = 0;
const BI_BITFIELDS: u32 = 3;
@@ -88,6 +88,7 @@ pub(crate) fn info_len(dib: Bytes) -> Option<usize> {
/// 32-bit alpha byte is kept only when `keep_alpha` is set (per-pixel alpha blends); otherwise pixels
/// are opaque. Only the clipped rows and columns are decoded, so the work and the memory follow the
/// crop, not the whole bitmap; the whole bitmap is still validated (size cap, palette, bits length).
/// None as well when the clipped width or height is over [`MAX_BITMAP_SIDE`], which no renderer draws.
pub(crate) fn decode_clip(info: Bytes, bits: Bytes, keep_alpha: bool, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> {
let hd = Header::read(info)?;
let pixels = hd.w.checked_mul(hd.h)?;
@@ -98,55 +99,151 @@ pub(crate) fn decode_clip(info: Bytes, bits: Bytes, keep_alpha: bool, src: [i64;
let data = bits.slice(0, stride.checked_mul(hd.h)?)?;
let pal = info.slice(hd.pal_off, hd.pal_n * 4)?;
let (clip, out) = clip_rect(hd.w, hd.h, !hd.top_down, src, dst)?;
// The renderers draw no bitmap with a side over the cap (`Item::take_pixels`); decoding one
// would only fill the parse's memory budget with pixels nobody draws.
let cap = MAX_BITMAP_SIDE as usize;
if clip.x1 - clip.x0 > cap || clip.r1 - clip.r0 > cap {
return None;
}
let (ow, oh) = (clip.x1 - clip.x0, clip.r1 - clip.r0);
let mut px = vec![0u8; ow.checked_mul(oh)?.checked_mul(4)?];
#[cfg(test)]
DECODED.with(|d| d.set(d.get() + ow * oh));
// The depth, palette and masks are the same for every pixel: pick the row decoder once.
let kind = RowKind::new(&hd, pal, keep_alpha);
for (y, line) in (clip.r0..clip.r1).zip(px.chunks_exact_mut(ow * 4)) {
let src_row = if hd.top_down { y } else { hd.h - 1 - y };
let row = src_row * stride;
for (x, dst_px) in (clip.x0..clip.x1).zip(line.as_chunks_mut::<4>().0) {
*dst_px = pixel(data, row, x, &hd, pal, keep_alpha)?;
}
let row = data.get(src_row * stride..(src_row + 1) * stride)?;
kind.decode_row(row, clip.x0..clip.x1, line.as_chunks_mut::<4>().0)?;
}
Some((Rgba { w: ow, h: oh, px }, out))
}
#[cfg(test)]
thread_local! {
/// Pixels decoded on this thread, so the tests can check that the work follows the crop.
/// Pixels asked of the decoder on this thread, so the tests can check that the work follows the crop.
static DECODED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn pixel(data: &[u8], row: usize, x: usize, hd: &Header, pal: &[u8], keep_alpha: bool) -> Option<[u8; 4]> {
#[cfg(test)]
DECODED.with(|d| d.set(d.get() + 1));
let [m_r, m_g, m_b] = hd.masks;
let [r, g, b] = match hd.bpp {
1 => {
let b = *data.get(row + x / 8)?;
palette(pal, usize::from((b >> (7 - x % 8)) & 1))
/// One channel of a masked 16/32 bpp pixel, with the shift and scale worked out once per bitmap.
/// Channels of up to 8 bits (every common 16 bpp layout) go through a table of their values, so a
/// pixel costs no division.
#[derive(Clone)]
struct Channel {
mask: u32,
shift: u32,
max: u64,
table: [u8; 256],
}
impl Channel {
fn new(mask: u32) -> Channel {
let shift = if mask == 0 { 0 } else { mask.trailing_zeros() };
let max = u64::from(mask >> shift);
let mut table = [0u8; 256];
if mask != 0 && max <= 255 {
for (val, t) in table.iter_mut().enumerate().take(max as usize + 1) {
*t = ((val as u64 * 255 + max / 2) / max) as u8;
}
}
4 => {
let b = *data.get(row + x / 2)?;
palette(pal, usize::from(if x.is_multiple_of(2) { b >> 4 } else { b & 0x0F }))
Channel { mask, shift, max, table }
}
/// The channel of `v` scaled to 8 bits; 0 when the mask is empty.
fn get(&self, v: u32) -> u8 {
if self.mask == 0 {
return 0;
}
8 => palette(pal, usize::from(*data.get(row + x)?)),
24 => {
let [b, g, r] = data.get(row + x * 3..row + x * 3 + 3)?.try_into().ok()?;
[r, g, b]
let val = u64::from((v & self.mask) >> self.shift);
if self.max <= 255 {
return self.table[usize::from(val as u8)];
}
16 => {
let o = row + x * 2;
let v = u32::from(u16::from_le_bytes(data.get(o..o + 2)?.try_into().ok()?));
[field(v, m_r), field(v, m_g), field(v, m_b)]
((val * 255 + self.max / 2) / self.max) as u8
}
}
/// How the pixels of one DIB turn into RGBA, decided once from its header.
enum RowKind {
/// 1, 4 or 8 bpp: `bpp` bits per index into a table of every possible index.
Indexed { bpp: usize, table: Box<[[u8; 4]; 256]> },
/// 24 bpp: BGR triples.
Bgr24,
/// 32 bpp with the default masks: BGRA quads, alpha kept or forced opaque.
Bgra32 { keep_alpha: bool },
/// 16 bpp, or 32 bpp with other masks.
Masked { bpp: usize, ch: Box<[Channel; 3]>, keep_alpha: bool },
}
impl RowKind {
fn new(hd: &Header, pal: &[u8], keep_alpha: bool) -> RowKind {
match hd.bpp {
1 | 4 | 8 => {
// An index past the palette reads as black, as in `palette`.
let mut table = Box::new([[0u8, 0, 0, 255]; 256]);
for (i, t) in table.iter_mut().enumerate().take(1 << hd.bpp) {
let [r, g, b] = palette(pal, i);
*t = [r, g, b, 255];
}
RowKind::Indexed { bpp: hd.bpp, table }
}
24 => RowKind::Bgr24,
32 if hd.masks == [0xFF_0000, 0xFF00, 0xFF] => RowKind::Bgra32 { keep_alpha },
_ => RowKind::Masked { bpp: hd.bpp, ch: Box::new(hd.masks.map(Channel::new)), keep_alpha },
}
_ => {
let o = row + x * 4;
let v = u32::from_le_bytes(data.get(o..o + 4)?.try_into().ok()?);
let a = if keep_alpha { (v >> 24) as u8 } else { 255 };
return Some([field(v, m_r), field(v, m_g), field(v, m_b), a]);
}
/// Decodes the columns `cols` of one source row `row` (all of its bytes) into `out`. `None` when
/// the row is shorter than the columns need.
fn decode_row(&self, row: &[u8], cols: std::ops::Range<usize>, out: &mut [[u8; 4]]) -> Option<()> {
match self {
RowKind::Indexed { bpp: 8, table } => {
for (d, s) in out.iter_mut().zip(row.get(cols)?) {
*d = table[usize::from(*s)];
}
}
RowKind::Indexed { bpp: 4, table } => {
for (d, x) in out.iter_mut().zip(cols) {
let b = *row.get(x / 2)?;
*d = table[usize::from(if x.is_multiple_of(2) { b >> 4 } else { b & 0x0F })];
}
}
RowKind::Indexed { table, .. } => {
for (d, x) in out.iter_mut().zip(cols) {
let b = *row.get(x / 8)?;
*d = table[usize::from((b >> (7 - x % 8)) & 1)];
}
}
RowKind::Bgr24 => {
let src = row.get(cols.start.checked_mul(3)?..cols.end.checked_mul(3)?)?;
for (d, s) in out.iter_mut().zip(src.as_chunks::<3>().0) {
*d = [s[2], s[1], s[0], 255];
}
}
RowKind::Bgra32 { keep_alpha } => {
let src = row.get(cols.start.checked_mul(4)?..cols.end.checked_mul(4)?)?;
for (d, s) in out.iter_mut().zip(src.as_chunks::<4>().0) {
*d = [s[2], s[1], s[0], if *keep_alpha { s[3] } else { 255 }];
}
}
RowKind::Masked { bpp: 16, ch, .. } => {
let [r, g, b] = &**ch;
let src = row.get(cols.start.checked_mul(2)?..cols.end.checked_mul(2)?)?;
for (d, s) in out.iter_mut().zip(src.as_chunks::<2>().0) {
let v = u32::from(u16::from_le_bytes(*s));
*d = [r.get(v), g.get(v), b.get(v), 255];
}
}
RowKind::Masked { ch, keep_alpha, .. } => {
let [r, g, b] = &**ch;
let src = row.get(cols.start.checked_mul(4)?..cols.end.checked_mul(4)?)?;
for (d, s) in out.iter_mut().zip(src.as_chunks::<4>().0) {
let v = u32::from_le_bytes(*s);
*d = [r.get(v), g.get(v), b.get(v), if *keep_alpha { (v >> 24) as u8 } else { 255 }];
}
}
}
};
Some([r, g, b, 255])
Some(())
}
}
/// Palette entries are BGRX quads; an index past the table reads as black rather than failing the image.
@@ -154,17 +251,6 @@ fn palette(pal: &[u8], i: usize) -> [u8; 3] {
pal.get(i * 4..i * 4 + 4).and_then(|c| <[u8; 4]>::try_from(c).ok()).map_or([0, 0, 0], |c| [c[2], c[1], c[0]])
}
/// Extracts one channel from a masked pixel value and scales it to 8 bits.
fn field(v: u32, mask: u32) -> u8 {
if mask == 0 {
return 0;
}
let shift = mask.trailing_zeros();
let max = u64::from(mask >> shift);
let val = u64::from((v & mask) >> shift);
((val * 255 + max / 2) / max) as u8
}
/// The columns `x0..x1` and top-down rows `r0..r1` of a bitmap that a clipped source rectangle covers.
struct Clip {
x0: usize,
@@ -343,6 +429,216 @@ mod tests {
(info, bits)
}
/// The per-pixel decoder `decode_clip` used before the depth was chosen once per bitmap. Kept as
/// the reference the fast decoder must match byte for byte.
fn pixel(data: &[u8], row: usize, x: usize, hd: &Header, pal: &[u8], keep_alpha: bool) -> Option<[u8; 4]> {
DECODED.with(|d| d.set(d.get() + 1));
let [m_r, m_g, m_b] = hd.masks;
let [r, g, b] = match hd.bpp {
1 => {
let b = *data.get(row + x / 8)?;
palette(pal, usize::from((b >> (7 - x % 8)) & 1))
}
4 => {
let b = *data.get(row + x / 2)?;
palette(pal, usize::from(if x.is_multiple_of(2) { b >> 4 } else { b & 0x0F }))
}
8 => palette(pal, usize::from(*data.get(row + x)?)),
24 => {
let [b, g, r] = data.get(row + x * 3..row + x * 3 + 3)?.try_into().ok()?;
[r, g, b]
}
16 => {
let o = row + x * 2;
let v = u32::from(u16::from_le_bytes(data.get(o..o + 2)?.try_into().ok()?));
[field(v, m_r), field(v, m_g), field(v, m_b)]
}
_ => {
let o = row + x * 4;
let v = u32::from_le_bytes(data.get(o..o + 4)?.try_into().ok()?);
let a = if keep_alpha { (v >> 24) as u8 } else { 255 };
return Some([field(v, m_r), field(v, m_g), field(v, m_b), a]);
}
};
Some([r, g, b, 255])
}
/// Extracts one channel from a masked pixel value and scales it to 8 bits.
fn field(v: u32, mask: u32) -> u8 {
if mask == 0 {
return 0;
}
let shift = mask.trailing_zeros();
let max = u64::from(mask >> shift);
let val = u64::from((v & mask) >> shift);
((val * 255 + max / 2) / max) as u8
}
/// `decode_clip` as it was: the same clip, then `pixel` for every output pixel.
fn decode_clip_reference(info: Bytes, bits: Bytes, keep_alpha: bool, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> {
let hd = Header::read(info)?;
let pixels = hd.w.checked_mul(hd.h)?;
if pixels == 0 || pixels > MAX_PIXELS {
return None;
}
let stride = hd.w.checked_mul(hd.bpp)?.checked_add(31)? / 32 * 4;
let data = bits.slice(0, stride.checked_mul(hd.h)?)?;
let pal = info.slice(hd.pal_off, hd.pal_n * 4)?;
let (clip, out) = clip_rect(hd.w, hd.h, !hd.top_down, src, dst)?;
let (ow, oh) = (clip.x1 - clip.x0, clip.r1 - clip.r0);
let mut px = vec![0u8; ow.checked_mul(oh)?.checked_mul(4)?];
for (y, line) in (clip.r0..clip.r1).zip(px.chunks_exact_mut(ow * 4)) {
let src_row = if hd.top_down { y } else { hd.h - 1 - y };
let row = src_row * stride;
for (x, dst_px) in (clip.x0..clip.x1).zip(line.as_chunks_mut::<4>().0) {
*dst_px = pixel(data, row, x, &hd, pal, keep_alpha)?;
}
}
Some((Rgba { w: ow, h: oh, px }, out))
}
/// A DIB with chosen header fields: `masks` makes it BI_BITFIELDS (inline after a 40 byte
/// header, or inside a larger header), `clr_used` limits the palette, and the palette, when the
/// depth is indexed, has `pal_entries` entries (fewer than the depth names reads as black).
fn make_dib_with(
w: usize,
h: i32,
bpp: usize,
masks: Option<([u32; 3], usize)>,
clr_used: u32,
pal_entries: usize,
seed: &mut u64,
) -> (Vec<u8>, Vec<u8>) {
let mut rnd = || {
*seed ^= *seed << 13;
*seed ^= *seed >> 7;
*seed ^= *seed << 17;
*seed
};
let size = masks.map_or(40, |(_, s)| s);
let mut info = Vec::new();
info.extend((size as u32).to_le_bytes());
info.extend((w as i32).to_le_bytes());
info.extend(h.to_le_bytes());
info.extend(1u16.to_le_bytes());
info.extend((bpp as u16).to_le_bytes());
info.extend(if masks.is_some() { 3u32 } else { 0 }.to_le_bytes());
info.extend([0u8; 12]);
info.extend(clr_used.to_le_bytes());
info.extend([0u8; 4]);
info.resize(size.max(40), 0);
if let Some((m, _)) = masks {
for (i, v) in m.iter().enumerate() {
let at = 40 + i * 4;
if size == 40 {
info.extend(v.to_le_bytes());
} else if let Some(dst) = info.get_mut(at..at + 4) {
dst.copy_from_slice(&v.to_le_bytes());
}
}
}
for _ in 0..pal_entries * 4 {
info.push(rnd() as u8);
}
let stride = (w * bpp).div_ceil(32) * 4;
let bits = (0..stride * h.unsigned_abs() as usize).map(|_| rnd() as u8).collect();
(info, bits)
}
fn assert_same(info: &[u8], bits: &[u8], what: &str) {
for keep_alpha in [false, true] {
for src in [[0, 0, 0, 0], [3, 1, 20, 3], [-4, -4, 100, 100], [36, 4, 5, 5], [1, 0, 1, 5], [8, 2, 17, 2], [0, 3, 37, 1]] {
let dst = [1.0, 2.0, 30.0, 40.0];
let old = decode_clip_reference(Bytes(info), Bytes(bits), keep_alpha, src, dst);
let new = decode_clip(Bytes(info), Bytes(bits), keep_alpha, src, dst);
match (old, new) {
(None, None) => {}
(Some((a, ad)), Some((b, bd))) => {
assert_eq!((a.w, a.h, &a.px), (b.w, b.h, &b.px), "{what} alpha={keep_alpha} {src:?}");
assert_eq!(ad.map(f64::to_bits), bd.map(f64::to_bits), "{what} alpha={keep_alpha} {src:?}");
}
_ => panic!("old and new disagree on None: {what} alpha={keep_alpha} {src:?}"),
}
}
}
}
#[test]
fn decode_clip_matches_the_per_pixel_reference_at_every_depth_palette_and_mask() {
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
// Widths that leave padding bits in the row, so a row's last byte is only partly used.
for w in [1, 9, 37] {
for h in [6, -6] {
for bpp in [1usize, 4, 8] {
let n = 1usize << bpp;
// Full palette, clrUsed shorter than the depth (the rest reads black), a
// palette cut short of what clrUsed names, and none at all.
for (clr_used, entries) in [(0, n), (3, 3), (n as u32 + 50, n), (0, 1), (0, 0)] {
let (info, bits) = make_dib_with(w, h, bpp, None, clr_used, entries, &mut seed);
assert_same(&info, &bits, &format!("{bpp} bpp w={w} h={h} clrUsed={clr_used} palette={entries}"));
}
}
let (info, bits) = make_dib_with(w, h, 24, None, 0, 0, &mut seed);
assert_same(&info, &bits, &format!("24 bpp w={w} h={h}"));
// A colour table on a deep DIB is carried but unused.
let (info, bits) = make_dib_with(w, h, 24, None, 5, 5, &mut seed);
assert_same(&info, &bits, &format!("24 bpp with palette w={w} h={h}"));
// Masks on depths that ignore them still move the palette and the bits.
for bpp in [1usize, 8, 24] {
let n = if bpp == 24 { 0 } else { 1 << bpp };
let (info, bits) = make_dib_with(w, h, bpp, Some(([0xF800, 0x07E0, 0x001F], 40)), 0, n, &mut seed);
assert_same(&info, &bits, &format!("{bpp} bpp with masks w={w} h={h}"));
}
let masks16: [Option<[u32; 3]>; 5] = [
None,
Some([0xF800, 0x07E0, 0x001F]),
Some([0x7C00, 0x03E0, 0x001F]),
Some([0x0F00, 0x00F0, 0x000F]),
Some([0xF800, 0x0000, 0x001F]),
];
let masks32: [Option<[u32; 3]>; 6] = [
None,
Some([0xFF_0000, 0xFF00, 0xFF]),
Some([0xFF, 0xFF00, 0xFF_0000]),
Some([0x3FF0_0000, 0x000F_FC00, 0x0000_03FF]),
Some([0xFFFF_FFFF, 0x0000_FF00, 0]),
Some([0x00FF_FF00, 0x00FF_FF00, 0x0000_00FF]),
];
for (bpp, sets) in [(16usize, masks16.as_slice()), (32, masks32.as_slice())] {
for m in sets {
// 40: the masks follow the header; 56 and 108: they are inside it.
for hsize in [40usize, 56, 108] {
let inline = m.map(|m| (m, hsize));
let (info, bits) = make_dib_with(w, h, bpp, inline, 0, 0, &mut seed);
assert_same(&info, &bits, &format!("{bpp} bpp masks={m:x?} header={hsize} w={w} h={h}"));
}
}
}
}
}
}
/// Timing of the per-pixel reference against `decode_clip` on a 40 MP bitmap:
/// `cargo test -p wordcraft-metafile --release -- --ignored --nocapture dib_decode_speed`.
#[test]
#[ignore = "timing only"]
fn dib_decode_speed() {
let mut seed = 6u64;
for bpp in [1usize, 4, 8, 16, 24, 32] {
let (info, bits) = make_dib(8000, -5000, bpp, &mut seed);
for round in 0..4 {
let t = std::time::Instant::now();
let a = decode_clip_reference(Bytes(&info), Bytes(&bits), false, [0, 0, 8000, 5000], [0.0, 0.0, 1.0, 1.0]).map(|x| x.0.px);
let slow = t.elapsed().as_secs_f64() * 1e3;
let t = std::time::Instant::now();
let b = decode_clip(Bytes(&info), Bytes(&bits), false, [0, 0, 8000, 5000], [0.0, 0.0, 1.0, 1.0]).map(|x| x.0.px);
let fast = t.elapsed().as_secs_f64() * 1e3;
assert!(a == b);
eprintln!("{bpp:2} bpp round {round}: reference {slow:4.0} ms, decode_clip {fast:4.0} ms");
}
}
}
#[test]
fn decode_clip_matches_decode_then_clip_blit() {
let mut seed = 0x2545_F491_4F6C_DD1Du64;
@@ -401,4 +697,23 @@ mod tests {
decode_whole(Bytes(&info), Bytes(&bits), false).unwrap();
assert_eq!(DECODED.with(|d| d.get()), 400 * 300);
}
#[test]
fn decode_clip_refuses_a_side_no_renderer_draws() {
let cap = MAX_BITMAP_SIDE as usize;
let mut seed = 11;
// 1 bpp, one row longer than the cap: 5 KB of bits, 120 KB of RGBA.
let (info, bits) = make_dib(cap + 1, 1, 1, &mut seed);
assert!(decode_clip(Bytes(&info), Bytes(&bits), false, [0, 0, 0, 0], [0.0, 0.0, 1.0, 1.0]).is_none());
// The same for the height.
let (info, bits) = make_dib(1, cap as i32 + 1, 1, &mut seed);
assert!(decode_clip(Bytes(&info), Bytes(&bits), false, [0, 0, 0, 0], [0.0, 0.0, 1.0, 1.0]).is_none());
// A crop of the wide bitmap within the cap still decodes, and so does the cap itself.
let (info, bits) = make_dib(cap + 1, 1, 1, &mut seed);
let (img, _) = decode_clip(Bytes(&info), Bytes(&bits), false, [10, 0, 100, 1], [0.0, 0.0, 1.0, 1.0]).unwrap();
assert_eq!((img.w, img.h), (100, 1));
let (info, bits) = make_dib(cap, 1, 1, &mut seed);
let (img, _) = decode_clip(Bytes(&info), Bytes(&bits), false, [0, 0, 0, 0], [0.0, 0.0, 1.0, 1.0]).unwrap();
assert_eq!((img.w, img.h), (cap, 1));
}
}
+1 -1
View File
@@ -68,7 +68,7 @@ fn device_px_per_mm(input: Bytes) -> Option<(f64, f64)> {
}
/// The frame from `rclFrame` (offset 24), `szlDevice` (72) and `szlMillimeters` (80) in the header.
fn frame_from_header(input: Bytes) -> Option<Frame> {
pub(crate) fn frame_from_header(input: Bytes) -> Option<Frame> {
let [fl, ft, fr, fb] = rect32(input, 24)?;
if fr <= fl || fb <= ft {
return None;
+2
View File
@@ -15,6 +15,7 @@ mod dib;
mod emf;
mod geom;
mod place;
mod size;
mod wmf;
#[cfg(test)]
@@ -24,6 +25,7 @@ use bytes::Bytes;
use canvas::{Raw, RawText};
use geom::{Op, P};
pub use place::{MAX_BITMAP_SIDE, PlacedItem, TextLayout, crop_window};
pub use size::size;
/// Items kept from one file. Past the cap the parse stops and keeps what it has.
pub(crate) const MAX_ITEMS: usize = 200_000;
+98
View File
@@ -0,0 +1,98 @@
//! The size of a metafile picture without playing its records.
use crate::bytes::Bytes;
use crate::{Error, emf, f, parse, wmf};
/// A placeable WMF header is 22 bytes, followed by the 9-word standard header.
const PLACEABLE_KEY: u32 = 0x9AC6_CDD7;
const PLACEABLE_LEN: usize = 22;
const DEFAULT_INCH: f64 = 1440.0;
/// The size of a WMF or EMF picture in points (device or logical units when the file states no
/// size), the same as `parse(bytes)` would put in the picture's `width` and `height`, or the error
/// `parse` would give.
///
/// An EMF and a placeable WMF carry their frame in the header, so the size is read from there
/// without decoding a bitmap or building a path. Any other file (a plain WMF, an EMF without a
/// usable frame) takes the full parse.
pub fn size(bytes: &[u8]) -> Result<(f32, f32), Error> {
if let Some(size) = header_size(Bytes(bytes)) {
return Ok(size);
}
let pic = parse(bytes)?;
Ok((pic.width, pic.height))
}
/// The size when the header decides it and the file has a first record (otherwise `parse` fails
/// with `NoRecords`, so the header alone is not enough). None sends the caller to the full parse.
pub(crate) fn header_size(b: Bytes) -> Option<(f32, f32)> {
if emf::is_emf(b) {
return emf_size(b);
}
if wmf::is_wmf(b) && b.u32(0) == Some(PLACEABLE_KEY) {
return placeable_wmf_size(b);
}
None
}
/// `rclFrame` mapped to points. The frame must pass the checks `build` applies to it.
fn emf_size(b: Bytes) -> Option<(f32, f32)> {
let hsize = b.u32(4)? as usize;
b.slice(8, 16)?;
let frame = emf::frame_from_header(b)?;
if !frame.rect.iter().all(|v| v.is_finite()) || frame.rect[2] <= 0.0 || frame.rect[3] <= 0.0 {
return None;
}
// The records start after the header; the parse needs one that is not the end record.
let pos = hsize.max(88);
let (typ, len) = (b.u32(pos)?, b.u32(pos.checked_add(4)?)? as usize);
if len < 8 || !len.is_multiple_of(4) || b.slice(pos, len).is_none() || typ == 14 {
return None;
}
let (w, h) = frame.size?;
Some((f(w).unwrap_or(0.0), f(h).unwrap_or(0.0)))
}
/// The placeable header's size in points, for a file whose records leave the target mapping alone
/// (see [`sets_the_mapping`]); the full parse decides the others.
fn placeable_wmf_size(b: Bytes) -> Option<(f32, f32)> {
let [l, t, r, bt] = [6, 8, 10, 12].map(|o| b.i16(o).map(f64::from));
let (l, t, r, bt) = (l?, t?, r?, bt?);
let inch = b.u16(14).filter(|v| *v > 0).map_or(DEFAULT_INCH, f64::from);
let (w, h) = (r - l, bt - t);
if w <= 0.0 || h <= 0.0 {
return None;
}
let hsize = b.u16(PLACEABLE_LEN + 2)? as usize;
if hsize < 9 {
return None;
}
let pos = PLACEABLE_LEN.checked_add(hsize.checked_mul(2)?)?;
let (words, func) = (b.u32(pos)? as usize, b.u16(pos.checked_add(4)?)?);
let len = words.checked_mul(2).filter(|l| *l >= 6)?;
if func == 0 || b.slice(pos, len).is_none() {
return None;
}
if sets_the_mapping(b, pos) {
return None;
}
Some((f(w * 72.0 / inch)?, f(h * 72.0 / inch)?))
}
/// Whether a record from `pos` on is SetMapMode (0x0103) or SetViewportExt (0x020E). Those can change
/// the frame the full parse ends with (a zero-size viewport leaves none), so the header size is not
/// enough. The records are only walked, not played, with the same stops as `wmf::parse`.
fn sets_the_mapping(b: Bytes, mut pos: usize) -> bool {
while let (Some(words), Some(func)) = (b.u32(pos), pos.checked_add(4).and_then(|o| b.u16(o))) {
let Some(len) = (words as usize).checked_mul(2).filter(|l| *l >= 6) else { return false };
if func == 0 || b.slice(pos, len).is_none() {
return false;
}
if matches!(func, 0x0103 | 0x020E) {
return true;
}
let Some(next) = pos.checked_add(len) else { return false };
pos = next;
}
false
}
+117
View File
@@ -948,6 +948,123 @@ fn place_keeps_only_finite_bounded_bitmaps_with_area() {
assert_eq!((*index, *rect), (4, kurbo::Rect::new(20.0, 10.0, 40.0, 30.0)));
}
/// `size` against the full parse: same numbers or same error, and whether the header decided it.
fn assert_size_matches_parse(file: &[u8], from_header: bool, what: &str) {
let full = parse(file).map(|p| (p.width, p.height));
assert_eq!(crate::size(file), full, "{what}");
assert_eq!(crate::size::header_size(crate::bytes::Bytes(file)).is_some(), from_header, "{what}: header path");
if let Some(s) = crate::size::header_size(crate::bytes::Bytes(file)) {
assert_eq!(Ok(s), full, "{what}: header size");
}
}
#[test]
fn size_from_the_header_equals_the_full_parse() {
let rect = || erec(43, &cat(vec![e32(0), e32(0), e32(50), e32(25)]));
let emfs = [
("emf with a frame", emf(&[rect()], [0, 0, 50, 25], [0, 0, 2500, 1250]), true),
("emf with a large frame", emf(&[rect()], [0, 0, 50, 25], [-90_000, 0, 900_000, 1_250_000]), true),
("emf with an empty frame", emf(&[rect()], [0, 0, 50, 25], [0; 4]), false),
("emf with no records", emf(&[], [0, 0, 50, 25], [0, 0, 2500, 1250]), false),
("emf with no frame and no bounds", emf(&[rect()], [0; 4], [0; 4]), false),
];
for (what, file, header) in &emfs {
assert_size_matches_parse(file, *header, what);
}
let draw = [wrec(0x041B, &cat(vec![h16(1440), h16(2880), h16(0), h16(0)]))];
let wmfs = [
("placeable wmf", wmf(&draw, Some([0, 0, 2880, 1440])), true),
(
"placeable wmf with a window mapping",
wmf(
&[wrec(0x0103, &h16(8)), wrec(0x020C, &cat(vec![h16(100), h16(200)])), wrec(0x020E, &cat(vec![h16(50), h16(100)])), draw[0].clone()],
Some([0, 0, 200, 100]),
),
false,
),
(
"placeable wmf with a zero viewport",
wmf(&[wrec(0x0103, &h16(8)), wrec(0x020E, &cat(vec![h16(0), h16(0)])), draw[0].clone()], Some([0, 0, 200, 100])),
false,
),
("placeable wmf with a window only", wmf(&[wrec(0x020C, &cat(vec![h16(100), h16(200)])), draw[0].clone()], Some([0, 0, 200, 100])), true),
("placeable wmf with an empty box", wmf(&draw, Some([0, 0, 0, 0])), false),
("placeable wmf with no records", wmf(&[], Some([0, 0, 2880, 1440])), false),
("plain wmf", wmf(&draw, None), false),
];
for (what, file, header) in &wmfs {
assert_size_matches_parse(file, *header, what);
}
assert_size_matches_parse(b"not a metafile at all", false, "garbage");
assert_size_matches_parse(&[], false, "empty");
}
#[test]
fn size_of_a_truncated_or_mutated_file_equals_the_full_parse() {
let rect = erec(43, &cat(vec![e32(0), e32(0), e32(50), e32(25)]));
let draw = wrec(0x041B, &cat(vec![h16(1440), h16(2880), h16(0), h16(0)]));
let files = [emf(&[rect], [0, 0, 50, 25], [0, 0, 2500, 1250]), wmf(&[draw], Some([0, 0, 2880, 1440]))];
let mut seed = 0x1234_5678_9ABC_DEF0u64;
for file in &files {
for len in 0..=file.len() {
let cut = &file[..len];
assert_eq!(crate::size(cut), parse(cut).map(|p| (p.width, p.height)), "cut at {len}");
}
for _ in 0..3000 {
let mut m = file.clone();
for _ in 0..3 {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
let at = (seed % m.len() as u64) as usize;
m[at] = (seed >> 32) as u8;
}
let full = parse(&m).map(|p| (p.width, p.height));
let fast = crate::size(&m);
assert_eq!(fast, full, "{m:?}");
}
}
}
/// Time of the full parse against the header-only size for an EMF with one 8000 x 5000, 1 bpp bitmap
/// (5 MB, decodes to 160 MB): `cargo test -p wordcraft-metafile --release -- --ignored --nocapture size_speed`.
#[test]
#[ignore = "timing only"]
fn size_speed() {
let (w, h) = (8000i32, 5000i32);
let mut payload = vec![0u8; 16];
payload.extend(cat(vec![
e32(0),
e32(0),
e32(0),
e32(0),
e32(w),
e32(h),
e32(80),
e32(48),
e32(128),
h32((w / 8 * h) as u32),
e32(0),
e32(0x00CC_0020),
e32(100),
e32(50),
]));
payload.extend(info_header(w, h, 1, 0));
payload.extend([0, 0, 0, 0, 255, 255, 255, 0]);
payload.extend((0..(w / 8 * h) as usize).map(|i| (i * 31) as u8));
let file = emf(&[erec(81, &payload)], [0, 0, 100, 50], [0, 0, 2500, 1250]);
for round in 0..4 {
let t = std::time::Instant::now();
let full = parse(&file).map(|p| (p.width, p.height));
let full_ms = t.elapsed().as_secs_f64() * 1e3;
let t = std::time::Instant::now();
let fast = crate::size(&file);
let fast_ms = t.elapsed().as_secs_f64() * 1e6;
assert_eq!(full, fast);
eprintln!("round {round}: parse {full_ms:.1} ms, size {fast_ms:.1} us");
}
}
#[test]
fn take_pixels_returns_only_drawable_bitmaps() {
let mut ok = Item::Bitmap { rect: [0.0; 4], width: 2, height: 1, rgba: vec![7; 8] };
+2 -2
View File
@@ -145,8 +145,8 @@ pub fn metafile_size(bytes: &[u8]) -> Option<(f32, f32, &'static str)> {
if !wordcraft_metafile::is_metafile(bytes) {
return None;
}
let pic = wordcraft_metafile::parse(bytes).ok()?;
let (w, h) = (pic.width, pic.height);
// The frame comes from the header where the file has one, so no bitmap is decoded for it.
let (w, h) = wordcraft_metafile::size(bytes).ok()?;
if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
return None;
}
+2
View File
@@ -443,6 +443,8 @@ impl WordApp {
// 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);
// Alt codes in a text box are read before the box is drawn, so it types the character.
crate::keys::field_alt_codes(self, &ctx);
// A failed Recent open, or a failed Save As, is news only until File closes (#2938).
if !self.ui.backstage {
backstage::forget_open_error(&ctx);
+80 -8
View File
@@ -222,9 +222,6 @@ pub struct CanvasState {
pub(crate) v_pressed: bool,
/// The modifiers last held with Mod, for a Mod+V whose Mod is released before V.
pub(crate) paste_mods: Option<egui::Modifiers>,
/// On the web, Mod+Shift+V pasted formatting from its key press: the browser's paste event
/// that follows is the same keystroke and pastes nothing (#5616). Any other key press clears it.
pub(crate) ignore_next_paste: bool,
/// Draw Table or Eraser is on (`table_pen`): presses draw instead of moving the caret.
pub table_tool: Option<crate::table_pen::TableTool>,
/// The Draw Table stroke (or eraser press) in progress.
@@ -260,6 +257,8 @@ pub struct CanvasState {
/// The character an Alt code just typed: if the platform also sends its own composed
/// character as text on the Alt release (Windows does), it isn't typed twice.
pub(crate) alt_code_echo: Option<crate::keys::AltCodeEcho>,
/// Alt+numpad entry in the focused text box, while the page doesn't have the keyboard (#4403).
pub(crate) field_alt_code: crate::keys::FieldAltCode,
/// Create Link is waiting for a click on the text box to link to: the linking box's object.
pub(crate) link_from: Option<wordcraft_doc::Pos>,
/// What screen readers were last told about the document (#488).
@@ -275,15 +274,23 @@ pub struct CanvasState {
/// The rows and columns hovered in Insert › Table's grid this frame: the page shows that
/// table where it would go (#3931). Set by the grid each frame, taken by [`show`].
pub(crate) table_preview: Option<(usize, usize)>,
/// A command (id, params) the page shows run on the selection, without running it: the
/// formatting button the floating mini toolbar is hovered over. Set each frame, taken by [`show`].
pub(crate) command_preview: Option<(String, serde_json::Value)>,
/// The command preview the page last drew, so the bar repaints only when its hover changes.
pub(crate) command_previewed: Option<(String, serde_json::Value)>,
/// The scroll offset held while a command preview is shown, so the page does not move as
/// the previewed text reflows.
pub(crate) preview_scroll: Option<egui::Vec2>,
/// The last previewed table's layout, the copy of the document it was laid out from and the
/// copy's selection, with
/// what it was laid out for (rows, columns, document id, revision, selection, and the
/// what it was laid out for (command, params, document id, revision, selection, and the
/// document's own layout, which a view change replaces), so a still pointer doesn't lay the
/// document out every frame. Set only while the page shows the preview.
table_preview_layout: Option<(TablePreviewKey, wordcraft_doc::Document, wordcraft_engine::Selection, std::sync::Arc<DocLayout>)>,
}
type TablePreviewKey = ((usize, usize, u64, u64, wordcraft_engine::Selection), std::sync::Arc<DocLayout>);
type TablePreviewKey = ((String, serde_json::Value, u64, u64, wordcraft_engine::Selection), std::sync::Arc<DocLayout>);
impl CanvasState {
/// Another document is shown in the window: everything about the page that was shown (drags,
@@ -396,7 +403,6 @@ impl Default for CanvasState {
pasted: false,
v_pressed: false,
paste_mods: None,
ignore_next_paste: false,
swallow_text: false,
ime_join_text: None,
ime_reset: false,
@@ -407,6 +413,7 @@ impl Default for CanvasState {
ime_viewport: None,
alt_code: String::new(),
alt_code_echo: None,
field_alt_code: Default::default(),
wheel: Default::default(),
auto_scroll: Default::default(),
scroll_offset: egui::Vec2::ZERO,
@@ -429,6 +436,9 @@ impl Default for CanvasState {
tab_drag: None,
drawn_ibeam: crate::ibeam::DRAWN,
table_preview: None,
command_preview: None,
command_previewed: None,
preview_scroll: None,
table_preview_layout: None,
}
}
@@ -1078,6 +1088,23 @@ fn handle_primary_pointer(
geo: &Geometry,
middle_gesture: bool,
) {
// A press that began on a drop-down's list button opens the list (`content_controls::clicks`) and must
// not move the caret: the button hangs over what follows the control, and a caret placed
// there leaves the control, which closes the list in the frame it opens (#5389).
// A press and its release in one frame (a quick click on a slow frame) leave no `press_origin`;
// the press is still among the frame's events. A table tool owns the pointer instead: a stroke
// may start over the button.
let press = ui.input(|i| {
i.pointer.press_origin().or_else(|| {
i.events.iter().find_map(|e| match e {
egui::Event::PointerButton { pos, pressed: true, .. } => Some(*pos),
_ => None,
})
})
});
if app.canvas.table_tool.is_none() && press.is_some_and(|p| app.controls.on_button(p)) {
return;
}
// Double-click the space between two pages (or the line between them) to hide or show it.
if resp.double_clicked()
&& app.session.view.mode == wordcraft_layout::ViewMode::Print
@@ -1311,12 +1338,53 @@ fn finish_canvas_frame(finish: CanvasFinish<'_>, out: egui::scroll_area::ScrollA
/// Lays out and paints the document pages for print, Read and Web views, settles viewports, draw
/// rulers and overlays, anchors zoom and scrolling, and routes pointer, selection, IME and caret
/// input.
/// While the page shows a command preview the view stays where it was when the preview began:
/// no scrolling to the caret, and the offset is held against the previewed text's reflow.
fn hold_scroll_for_preview(app: &mut WordApp) -> bool {
if app.canvas.command_previewed.is_none() {
app.canvas.preview_scroll = None;
return false;
}
let at = *app.canvas.preview_scroll.get_or_insert(app.canvas.scroll_offset);
app.canvas.scroll_request = Some(at);
true
}
/// The live selection's rects on screen, from the document's own layout rather than a command
/// preview's, which can move the lines (Align Right) from under the mini toolbar's pointer.
fn live_selection_screen_rects(app: &mut WordApp, rects: &[Rect], scale: f32) -> Vec<Rect> {
let layout = app.session.layout();
let selected: Vec<_> = if crate::objects::selected(app).is_some() {
Vec::new()
} else if let Some(range) = app.session.cell_range() {
wordcraft_engine::cmd::table_mouse::cell_rects(&layout, &app.session.doc, range)
} else if !app.session.sel.is_collapsed() {
match app.session.column_segments() {
Some(segs) => segs.iter().flat_map(|(a, b)| layout.selection_rects(&app.session.doc, a, b, app.session.page_hint)).collect(),
None => {
let (a, b) = app.session.sel.ordered();
wordcraft_engine::cmd::table_mouse::selection_rects(&layout, &app.session.doc, &a, &b, app.session.page_hint)
}
}
} else {
Vec::new()
};
selected
.into_iter()
.filter_map(|(pi, r)| {
let pr = rects.get(pi)?;
let scale = layout.pages.get(pi).map_or(scale, |page| page_screen_scale(*pr, page, scale));
Some(Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale)))
})
.collect()
}
pub fn show(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let full = ui.available_rect_before_wrap();
let layout = prepare_layout(app, ui, full);
let view = prepare_view(app, ui, full, &layout);
let scroll_target = update_caret_scroll(app, &view);
let scroll_target = if hold_scroll_for_preview(app) { None } else { update_caret_scroll(app, &view) };
let motion = scroll_motion(app, ui, full, &view);
let scroll_area = configure_scroll_area(app, ui, &view, motion);
let CanvasView { area, hruler, vruler, paper, geo, dim_body, grid_story, caret, .. } = view;
@@ -1433,7 +1501,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
// Anchor only to visible selection geometry. A selection can span pages, and its
// document-first line may be scrolled offscreen; using it would pin the toolbar to an
// old screen position unrelated to the visible selection.
app.canvas.mini_anchor = crate::mini_toolbar::visible_anchor(screen_rects, visible_clip);
// Alignment previews can move the selected text while the pointer is still over a mini
// toolbar button. Keep the bar anchored to the live selection until the preview ends, or
// it can move out from under the pointer and flicker between preview and live layouts.
let anchor_rects = if app.canvas.command_previewed.is_some() { live_selection_screen_rects(app, &rects, geo.scale) } else { screen_rects };
app.canvas.mini_anchor = crate::mini_toolbar::visible_anchor(anchor_rects, visible_clip);
// Content controls: the frame of the one the caret is in (all of them in Design Mode).
crate::content_controls::paint(app, &painter, &t, &layout, &rects, geo.scale);
paint_read_aloud_highlight(app, &painter, &layout, &rects, &geo, &t);
+6 -3
View File
@@ -184,20 +184,23 @@ pub(super) fn drawing_grid_step(spacing: f32, scale: f32) -> f32 {
/// hovering over (#3931). The preview is laid out on a copy, so the document and its undo history
/// change only when a square is clicked.
pub(super) fn shown_layout(app: &mut WordApp) -> std::sync::Arc<DocLayout> {
let Some((rows, cols)) = app.canvas.table_preview.take() else {
let table = app.canvas.table_preview.take().map(|(rows, cols)| ("insert.table".to_string(), json!({"rows": rows, "cols": cols})));
let command = app.canvas.command_preview.take();
app.canvas.command_previewed = command.clone().filter(|_| table.is_none());
let Some((id, params)) = table.or(command) else {
app.canvas.table_preview_layout = None;
return app.session.layout();
};
// The caret can move, or the view change, with the grid still hovered: the table then goes
// somewhere else, or the page is laid out to another width.
let key = ((rows, cols, app.session.document_id(), app.session.rev(), app.session.sel.clone()), app.session.layout());
let key = ((id.clone(), params.clone(), app.session.document_id(), app.session.rev(), app.session.sel.clone()), app.session.layout());
if let Some((k, _, _, l)) = &app.canvas.table_preview_layout
&& k.0 == key.0
&& std::sync::Arc::ptr_eq(&k.1, &key.1)
{
return l.clone();
}
match app.session.preview_layout("insert.table", &json!({"rows": rows, "cols": cols})) {
match app.session.preview_layout(&id, &params) {
Some((doc, sel, l)) => {
app.canvas.table_preview_layout = Some((key, doc, sel, l.clone()));
l
+92
View File
@@ -30,6 +30,11 @@ impl ControlUi {
pub(crate) fn popup_open(&self) -> bool {
self.popup.is_some()
}
/// Whether `p` (screen) is on the list/calendar button of the current control.
pub(crate) fn on_button(&self, p: egui::Pos2) -> bool {
self.button.as_ref().is_some_and(|(b, _)| b.contains(p))
}
}
struct Popup {
@@ -250,3 +255,90 @@ fn calendar(ui: &mut Ui, t: &Tokens, month: &mut (i64, u32)) -> Option<u32> {
});
picked
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{Event, PointerButton};
fn press(at: egui::Pos2) -> Event {
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() }
}
fn release(at: egui::Pos2) -> Event {
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Default::default() }
}
/// A harness showing a drop-down control followed by text, with the caret in the control and
/// its list button on screen.
fn dropdown_harness() -> egui_kittest::Harness<'static, WordApp> {
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
app.run("text.insert", json!({"text": "tail text"})).unwrap();
app.run("caret.docStart", json!({})).unwrap();
app.run("developer.dropDown", json!({"items": ["A", "B"]})).unwrap();
app.run("view.commentsPane", json!({"value": false})).unwrap();
let mut 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);
},
app,
);
for _ in 0..6 {
h.step();
}
let before = h.state().session.sel.focus.clone();
assert!(h.state().session.doc.control_at(&before).is_some(), "the caret starts in the control");
h
}
/// #5389: pressing a drop-down's list button opens the list and leaves the caret in the
/// control, even when text follows the control and the button hangs over it.
#[test]
fn pressing_the_list_button_keeps_the_caret_in_the_control() {
let mut h = dropdown_harness();
let at = h.state().controls.button.as_ref().expect("the list button is shown").0.center();
h.event(Event::PointerMoved(at));
h.event(press(at));
h.step();
h.event(release(at));
for _ in 0..3 {
h.step();
}
let after = h.state().session.sel.focus.clone();
assert!(h.state().session.doc.control_at(&after).is_some(), "the caret stays in the control");
assert!(h.state().controls.popup_open(), "the list is open");
}
/// The same click when press and release arrive in one frame: egui has no press origin left
/// by then.
#[test]
fn a_click_that_presses_and_releases_in_one_frame_keeps_the_caret_in_the_control() {
let mut h = dropdown_harness();
let at = h.state().controls.button.as_ref().expect("the list button is shown").0.center();
h.event(Event::PointerMoved(at));
h.step();
// `Harness::event` runs a frame per event, so both go into the frame's input directly.
h.input_mut().events.extend([press(at), release(at)]);
h.step();
for _ in 0..3 {
h.step();
}
let after = h.state().session.sel.focus.clone();
assert!(h.state().session.doc.control_at(&after).is_some(), "the caret stays in the control");
assert!(h.state().controls.popup_open(), "the list is open");
}
/// A table tool owns the pointer: a press over the list button starts a stroke.
#[test]
fn the_draw_table_tool_starts_a_stroke_over_the_list_button() {
let mut h = dropdown_harness();
h.state_mut().canvas.table_tool = Some(crate::table_pen::TableTool::Draw);
let at = h.state().controls.button.as_ref().expect("the list button is shown").0.center();
h.event(Event::PointerMoved(at));
h.event(press(at));
h.step();
assert!(h.state().canvas.table_stroke.is_some(), "the stroke started on the button");
}
}
+133 -16
View File
@@ -293,19 +293,20 @@ fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>)
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
/// give way, down to [`MIN_COLUMN`].
/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
let spare = room - natural.iter().sum::<f32>();
let weight = grow.iter().sum::<f32>();
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| *w).sum();
let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
natural
.iter()
.zip(grow)
.map(|(w, g)| {
if spare >= 0.0 && weight > 0.0 {
if *g > 0.0 && spare >= 0.0 {
w + spare * g / weight
} else if *g > 0.0 && flexible > 0.0 {
(w + spare * w / flexible).max(MIN_COLUMN)
w - (-spare).min(flexible) * slack(*w) / flexible
} else {
*w
}
@@ -314,26 +315,30 @@ fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
}
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
/// sideways. Each column can be dragged wider or narrower at its edge. The last column fills
/// whatever width is left but never shrinks below its opening width, so widening a column grows
/// the table, as in a spreadsheet, instead of crushing the last one: only a table dragged wider
/// than the window scrolls sideways. Text too long for its column is clipped.
/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
/// dropping any dragged widths.
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
let s = ui.spacing();
let gaps = s.item_spacing.x * natural.len() as f32;
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
// The table keeps its own column widths, so a change in what they were fitted to has to clear
// them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
// width, so this compares the natural widths and the clip rect, which scrolling never moves.
let fit = egui::Id::unique(("credits_fit", id));
let measured = (natural.to_vec(), ui.clip_rect().width());
let refit = ui.data_mut(|d| d.get_temp::<(Vec<f32>, f32)>(fit)).as_ref() != Some(&measured);
ui.data_mut(|d| d.insert_temp(fit, measured));
let mut t =
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
let last = widths.len().saturating_sub(1);
for (i, w) in widths.into_iter().enumerate() {
let c = if i == last {
egui_extras::Column::remainder().at_least(w.max(MIN_COLUMN))
} else {
egui_extras::Column::initial(w).at_least(MIN_COLUMN)
};
t = t.column(c.clip(true));
if refit {
t.reset();
}
for w in widths {
t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
}
t
}
@@ -579,4 +584,116 @@ mod tests {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
}
#[test]
fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
assert!((w.iter().sum::<f32>() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
}
const WINDOW: f32 = 640.5;
const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
const GROW: [f32; 3] = [1.0, 0.0, 0.0];
/// One frame of a table long enough to scroll; the rects of its header cells.
fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec<egui::Event>) -> Vec<egui::Rect> {
scrolled_frame(ctx, width, natural, 0.0, events)
}
/// [`table_frame`], scrolled `offset` points sideways.
fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec<egui::Event>) -> Vec<egui::Rect> {
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
let mut cells = Vec::new();
let out = ctx.run_ui(raw, |ui| {
cells.clear();
egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
fitted_table(ui, "test", natural, &GROW)
.header(20.0, |mut header| {
for _ in natural {
header.col(|ui| cells.push(ui.max_rect()));
}
})
.body(|mut body| {
for _ in 0..40 {
body.row(20.0, |mut row| {
for _ in natural {
row.col(|ui| {
ui.label("x");
});
}
});
}
});
});
});
out.drop_without_applying_deltas();
cells
}
/// A new context with the table open and settled, and the rects of its header cells.
fn opened_table() -> (egui::Context, Vec<egui::Rect>) {
let ctx = egui::Context::default();
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
(ctx, cells)
}
/// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
use egui::{Event, Modifiers, PointerButton};
let start = egui::pos2(cell.right() + 4.0, cell.center().y);
let halfway = start + egui::vec2(by / 2.0, 0.0);
let end = start + egui::vec2(by, 0.0);
let moved = Event::PointerMoved;
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
table_frame(ctx, WINDOW, &NATURAL, vec![event]);
}
}
#[test]
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
let (_, opened) = opened_table();
for (dragged, cell) in opened.iter().enumerate() {
let (ctx, _) = opened_table();
drag_edge(&ctx, *cell, -30.0);
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
}
}
}
#[test]
fn a_changed_fit_replaces_the_column_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], -30.0);
let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
}
#[test]
fn scrolling_sideways_keeps_dragged_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], 100.0);
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
for offset in [13.3, 27.77, 41.1, 0.6] {
scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
}
}
}
+15 -2
View File
@@ -764,8 +764,21 @@ fn draw_line(p: &egui::Painter, [a, b]: [egui::Pos2; 2], style: BorderStyle, wid
(0..=n.min(400)).map(|i| a + dir * (len * i as f32 / n as f32) + normal * if i % 2 == 0 { -1.5 } else { 1.5 }).collect();
p.add(egui::Shape::line(pts, Stroke::new(1.0, color)));
}
BorderStyle::Single | BorderStyle::Thick => {
p.line_segment([a, b], if style == BorderStyle::Thick { Stroke::new((px * 1.5).min(6.0), color) } else { stroke });
BorderStyle::Thick => {
p.line_segment([a, b], Stroke::new((px * 1.5).min(6.0), color));
}
// A compound thin and thick line shows as one line of its width, as on the page.
BorderStyle::Single
| BorderStyle::ThinThickSmallGap
| BorderStyle::ThickThinSmallGap
| BorderStyle::ThinThickThinSmallGap
| BorderStyle::ThinThickMediumGap
| BorderStyle::ThickThinMediumGap
| BorderStyle::ThinThickThinMediumGap
| BorderStyle::ThinThickLargeGap
| BorderStyle::ThickThinLargeGap
| BorderStyle::ThinThickThinLargeGap => {
p.line_segment([a, b], stroke);
}
}
}
+125 -6
View File
@@ -12,6 +12,7 @@ use wordcraft_doc::smart_art::{MAX_ITEMS, SmartArtColors, SmartArtLayout, SmartA
use crate::WordApp;
use crate::theme::{Tokens, c32, regular, semibold};
use crate::widgets;
/// The picker's categories (`All` first), in the order shown.
pub const CATEGORIES: [&str; 8] = ["All", "List", "Process", "Cycle", "Hierarchy", "Relationship", "Matrix", "Pyramid"];
@@ -151,6 +152,21 @@ pub fn swatches(p: &Painter, r: Rect, colors: SmartArtColors, theme: &[Rgb]) {
}
}
/// Swatches in `r` of the colours the items of `g` have now: their own or the style's.
pub fn custom_swatches(p: &Painter, r: Rect, g: &SmartArtSpec, theme: &[Rgb]) {
let shown: Vec<Color32> = (0..g.items.len())
.filter_map(|i| match g.items.get(i)?.color {
Some(c) => Some(c32(c)),
None => Some(accent(theme, g.style_accent(i)?)),
})
.take(5)
.collect();
let w = r.width() / shown.len().max(1) as f32;
for (k, c) in shown.into_iter().enumerate() {
p.rect_filled(Rect::from_min_size(pos2(r.left() + k as f32 * w, r.top()), vec2(w - 2.0, r.height())), 2.0, c);
}
}
/// Height of the picker's three columns, points: three rows of 96 pt tiles and the gaps between them.
const PICKER_HEIGHT: f32 = 3.0 * 96.0 + 2.0 * 8.0;
@@ -262,8 +278,12 @@ pub fn picker_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SmartArtForm) -> bool {
/// The `smartArt.items` params for `g` with item `i`'s text replaced by `text`.
pub fn items_with(g: &SmartArtSpec, i: usize, text: &str) -> Value {
let items: Vec<Value> =
g.items.iter().enumerate().map(|(j, it)| json!({"text": if j == i { text } else { it.text.as_str() }, "level": it.level})).collect();
let items: Vec<Value> = g
.items
.iter()
.enumerate()
.map(|(j, it)| json!({"text": if j == i { text } else { it.text.as_str() }, "level": it.level, "color": it.color.map(|c| c.hex())}))
.collect();
json!({"items": items})
}
@@ -275,7 +295,9 @@ pub fn items_without(g: &SmartArtSpec, i: usize) -> Value {
.iter()
.enumerate()
.filter(|(j, _)| *j != i)
.map(|(j, it)| json!({"text": it.text, "level": if under.contains(&j) { it.level.saturating_sub(1) } else { it.level }}))
.map(|(j, it)| {
json!({"text": it.text, "level": if under.contains(&j) { it.level.saturating_sub(1) } else { it.level }, "color": it.color.map(|c| c.hex())})
})
.collect();
json!({"items": items})
}
@@ -353,6 +375,49 @@ pub(crate) fn type_into(app: &mut WordApp, typed: &str) -> SmartArtTypeResult {
SmartArtTypeResult::Changed
}
/// Side of an item's colour button, points.
const SWATCH: f32 = 18.0;
/// Id of item `i`'s colour button.
fn color_button_id(i: usize) -> egui::Id {
egui::Id::unique(("smart_art_color_button", i))
}
/// The button at the right of item `i`'s row: its shape's colour (the style's until changed).
/// A click offers the colour grid; the style's colour is offered back.
fn color_button(app: &mut WordApp, ui: &mut Ui, g: &SmartArtSpec, i: usize) {
let t = Tokens::get(ui.ctx());
let (rect, _) = ui.allocate_exact_size(vec2(SWATCH, SWATCH), Sense::hover());
let resp = ui.interact(rect, color_button_id(i), Sense::click());
let label = g
.items
.get(i)
.filter(|it| !it.text.is_empty())
.map_or_else(|| format!("{} ({})", tl!("Shape color"), i + 1), |it| format!("{}: {}", tl!("Shape color"), it.text));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label.as_str()));
let Some(accent_n) = g.style_accent(i) else { return };
let theme = app.session.doc.settings.theme_colors.clone();
let own = g.items.get(i).and_then(|it| it.color);
let fill = own.map_or_else(|| accent(&theme, accent_n), c32);
ui.painter().rect_filled(rect, 3.0, fill);
let line = if resp.hovered() { t.accent } else { t.border_strong };
ui.painter().rect_stroke(rect, 3.0, Stroke::new(1.0, line), egui::StrokeKind::Inside);
let resp = resp.on_hover_text(tl!("Shape color"));
egui::Popup::menu(&resp).show(|ui| {
let mut pick = widgets::color_grid(ui, &theme).map(|hex| json!({"index": i, "color": hex}));
ui.add_space(2.0);
ui.separator();
if ui.add_enabled(own.is_some(), egui::Button::new(tl!("Style Color")).frame(false)).clicked() {
pick = Some(json!({"index": i}));
}
if let Some(params) = pick {
app.smart_art_typing = None;
let _ = app.run("smartArt.itemColor", params);
ui.close();
}
});
}
/// The Text Pane: a row per item, indented by level. Typing changes the graphic as you go (one
/// undo step per item typed in); Enter adds an item after the current one; Tab demotes the
/// focused item and Shift+Tab promotes it, as Word's Text Pane does.
@@ -372,9 +437,9 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) {
ui.label(egui::RichText::new("•").color(t.text_dim));
let mut text = it.text.clone();
let item_name = format!("{} {}", tl!("Text Pane"), i + 1);
let r = ui
.add(egui::TextEdit::singleline(&mut text).id_salt(("smart_art_item", i)).desired_width(f32::INFINITY))
.accessible_name(item_name);
let width = (ui.available_width() - SWATCH - ui.spacing().item_spacing.x).max(40.0);
let r = ui.add(egui::TextEdit::singleline(&mut text).id_salt(("smart_art_item", i)).desired_width(width)).accessible_name(item_name);
color_button(app, ui, &g, i);
if want_focus == Some(i) {
r.request_focus();
}
@@ -438,6 +503,22 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) {
mod tests {
use super::*;
#[test]
fn color_buttons_expose_item_names_and_empty_row_indices() {
use egui_kittest::kittest::Queryable;
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
let mut spec = SmartArtSpec::sample(SmartArtLayout::Process);
spec.items[0].text = "Named".into();
spec.items[1].text.clear();
let mut h = egui_kittest::Harness::builder().build_ui(move |ui| {
color_button(&mut app, ui, &spec, 0);
color_button(&mut app, ui, &spec, 1);
});
h.run_steps(2);
h.get_by_label("Shape color: Named");
h.get_by_label("Shape color (2)");
}
#[test]
fn removing_an_item_lifts_the_items_under_it() {
let g = SmartArtSpec::sample(SmartArtLayout::Hierarchy);
@@ -447,6 +528,44 @@ mod tests {
assert_eq!(items_with(&g, 0, "Boss")["items"][0]["text"], "Boss");
}
/// The colour button opens a grid: a swatch sets the item's own colour and Style Color clears it.
#[test]
fn the_color_button_sets_and_clears_an_items_colour() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.run("insert.smartArt", json!({"layout": "process"})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, crate::theme::Appearance::Light);
let mut n = 0u32;
let mut frame = |ctx: &egui::Context, events: Vec<egui::Event>, a: &mut WordApp| {
n += 1;
let input = egui::RawInput { time: Some(f64::from(n) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
ctx.run_ui(input, |ui| text_pane(a, ui)).drop_without_applying_deltas();
};
let click = |ctx: &egui::Context, pos: egui::Pos2, a: &mut WordApp, frame: &mut dyn FnMut(&egui::Context, Vec<egui::Event>, &mut WordApp)| {
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(ctx, vec![egui::Event::PointerMoved(pos), button(true)], a);
frame(ctx, vec![button(false)], a);
frame(ctx, vec![], a);
};
let colour =
|a: &WordApp| wordcraft_engine::cmd::smart_art::selected_smart_art(&a.session).and_then(|g| g.items.get(1).and_then(|i| i.color));
frame(&ctx, vec![], &mut a);
let button = ctx.read_response(color_button_id(1)).expect("item 1 has a colour button");
let popup = egui::Popup::default_response_id(&button);
click(&ctx, button.rect.center(), &mut a, &mut frame);
assert!(egui::Popup::is_id_open(&ctx, popup), "the button opens the colour grid");
let area = ctx.memory(|m| m.area_rect(popup)).expect("the grid is drawn");
// The first theme swatch: under the "Theme Colors" label at the popup's top left.
click(&ctx, area.min + vec2(14.0, 38.0), &mut a, &mut frame);
assert!(colour(&a).is_some(), "a swatch gives the item its own colour");
let button = ctx.read_response(color_button_id(1)).unwrap();
click(&ctx, button.rect.center(), &mut a, &mut frame);
let area = ctx.memory(|m| m.area_rect(popup)).expect("the grid is drawn again");
click(&ctx, pos2(area.min.x + 30.0, area.max.y - 14.0), &mut a, &mut frame);
assert_eq!(colour(&a), None, "Style Color clears it");
}
/// #2832: the separators between the picker's columns made the dialog as tall as the window.
#[test]
fn the_picker_row_stays_within_its_height() {
+1 -1
View File
@@ -12,7 +12,7 @@ mod canvas;
mod ime;
mod shortcuts;
pub(crate) use alt_code::AltCodeEcho;
pub(crate) use alt_code::{AltCodeEcho, FieldAltCode, field_alt_codes};
pub use canvas::canvas_events;
pub(crate) use ime::observe_ime;
#[cfg(test)]
+117 -1
View File
@@ -29,11 +29,15 @@ fn alt_code_digit(key: Key) -> Option<char> {
char::from_digit(n, 10)
}
/// Alt is the only modifier held: the one an Alt code's digits are typed with.
fn alt_only(m: &Modifiers) -> bool {
m.alt && !m.ctrl && !m.shift && !m.command && !m.mac_cmd
}
/// Alt+numpad entry (#961): digits typed with Alt alone held collect; releasing Alt types the
/// character their code names (`text.typeCode`: 0233 → é, 233 → Θ in code page 437). Returns
/// whether the event was part of it (and isn't handled otherwise).
pub(super) fn alt_code_event(app: &mut WordApp, e: &egui::Event) -> bool {
let alt_only = |m: &Modifiers| m.alt && !m.ctrl && !m.shift && !m.command && !m.mac_cmd;
match e {
egui::Event::Key { key, pressed: true, repeat, modifiers, .. } if alt_only(modifiers) => match alt_code_digit(*key) {
// A held digit's auto-repeat adds nothing (as on Windows).
@@ -131,3 +135,115 @@ pub(super) fn alt_code_echoed(app: &mut WordApp, echo: &AltCodeEcho, text: &str)
}
true
}
/// Alt+numpad entry in the focused text box (Find what, a dialog's fields, the ribbon's boxes);
/// the page has its own ([`alt_code_event`]), #4403.
#[derive(Clone, Debug, Default)]
pub(crate) struct FieldAltCode {
/// The digits typed while Alt is held.
code: String,
/// The last digit's own text (Linux sends it with Alt held) isn't typed.
swallow_text: bool,
/// What the last code typed, for the platform's own composed character that may follow.
echo: Option<AltCodeEcho>,
}
/// The character an Alt code names in the code pages the page uses for it: the system locale's
/// on Windows, else `text.typeCode`'s defaults (Windows-1252 and the locale's OEM page).
fn field_code_char(code: &str) -> Option<char> {
use wordcraft_engine::cmd::text::{alt_code_char, system_code_pages, system_oem_code_page};
let (ansi, oem) = if cfg!(target_os = "windows") { system_code_pages() } else { (1252, system_oem_code_page()) };
alt_code_char(code, ansi, oem)
}
/// Whether `e` lets go of Alt: its key's release, or modifiers without it.
fn alt_released(e: &egui::Event) -> bool {
match e {
egui::Event::Key { key: Key::AltLeft | Key::AltRight, pressed: false, .. } => true,
egui::Event::ModifiersChanged(m) => !m.alt,
_ => false,
}
}
/// Alt codes in the focused text box, as on the page (#4403). Rewrites this frame's input before
/// the box reads it: the digits typed with Alt held type nothing (Linux sends their text), and
/// letting go of Alt types the code's character. A platform that composes the code itself
/// (Windows) sends its character after that: the same character isn't typed twice, and for a
/// code up to 255 the system's character replaces ours, as on the page ([`alt_code_echoed`]).
pub(crate) fn field_alt_codes(app: &mut WordApp, ctx: &egui::Context) {
if !alt_codes_supported(ctx) || app.canvas.focused || !ctx.text_edit_focused() {
app.canvas.field_alt_code = FieldAltCode::default();
return;
}
let st = &mut app.canvas.field_alt_code;
let events = ctx.input(|i| i.events.clone());
let mut out = Vec::with_capacity(events.len() + 1);
let mut changed = false;
for e in events {
match &e {
egui::Event::Key { key, pressed: true, repeat, modifiers, .. } => {
st.swallow_text = false;
st.echo = None;
match alt_code_digit(*key).filter(|_| alt_only(modifiers)) {
Some(d) => {
// A held digit's auto-repeat adds nothing; past seven digits a code
// names nothing, so stop collecting rather than grow.
if !*repeat && st.code.len() < 8 {
st.code.push(d);
}
st.swallow_text = true;
}
// Another key (Alt itself, a letter, a digit with Ctrl) ends the entry.
None => st.code.clear(),
}
}
egui::Event::Text(_) if std::mem::take(&mut st.swallow_text) => {
changed = true;
continue;
}
egui::Event::Text(t) => {
if let Some(echo) = st.echo.take()
&& (echo.typed == *t || echo.system_composes)
{
changed = true;
if echo.typed != *t && echo.byte_code && t.chars().count() == 1 {
// Ours was typed just before, with no key or click since: take it back.
out.push(egui::Event::Key {
key: Key::Backspace,
physical_key: None,
pressed: true,
repeat: false,
modifiers: Modifiers::NONE,
});
out.push(e);
}
continue;
}
}
egui::Event::PointerButton { pressed: true, .. } => st.echo = None,
// Focus elsewhere (another window) ends an Alt code unfinished.
egui::Event::WindowFocused(false) => st.code.clear(),
_ => {}
}
let released = alt_released(&e);
out.push(e);
if released && !st.code.is_empty() {
let code = std::mem::take(&mut st.code);
let system_composes = cfg!(target_os = "windows");
// A digit's own text comes with its key, before Alt is let go: text after this is
// the system's composed character (Windows), also for a code we name no character
// for (a double-byte lead byte, say), or what is typed next. A digit that sent no
// text must not cost it.
st.swallow_text = false;
if let Some(c) = field_code_char(&code) {
let byte_code = code.trim_start_matches('0').parse::<u32>().is_ok_and(|v| v <= 255);
st.echo = Some(AltCodeEcho { typed: c.to_string(), system_composes, byte_code });
out.push(egui::Event::Text(c.to_string()));
changed = true;
}
}
}
if changed {
ctx.input_mut(|i| i.events = out);
}
}
+1 -11
View File
@@ -158,9 +158,6 @@ fn stale_composition(app: &mut WordApp, e: &egui::Event, b: &mut Batch) -> bool
fn note_key_state(app: &mut WordApp, e: &egui::Event) {
// Which presses reached the canvas, and the modifiers held with Mod, in event order: Mod
// can go down and up within one frame, leaving the frame's modifiers without it.
if matches!(e, egui::Event::Key { pressed: true, key, .. } if !is_modifier_key(*key)) || matches!(e, egui::Event::WindowFocused(_)) {
app.canvas.ignore_next_paste = false;
}
match &e {
egui::Event::ModifiersChanged(m) if m.command => app.canvas.paste_mods = Some(*m),
egui::Event::Key { key: Key::V | Key::Paste, pressed: true, .. } => app.canvas.v_pressed = true,
@@ -511,18 +508,11 @@ fn handle_event(app: &mut WordApp, ctx: &egui::Context, e: egui::Event) {
}
egui::Event::Ime(egui::ImeEvent::Commit(text)) => ime_commit(app, text),
egui::Event::Paste(t) => {
if std::mem::take(&mut app.canvas.ignore_next_paste) {
return;
}
// Mod+Alt+V arrives as a paste too (the shell reads the clipboard): Paste Special.
// The modifiers held with Mod in event order: Shift may be up by the frame's end.
let m = app.canvas.paste_mods.unwrap_or_else(|| ctx.input(|i| i.modifiers));
// A browser sends Mod+Shift+V as a paste too: Keep Text Only, as the key does on the
// desktop (#1537). (egui-winit sends that chord as a key, and Shift+Insert has no Mod.)
let m = ctx.input(|i| i.modifiers);
let id = match (m.alt, m.shift) {
(true, true) => "edit.pasteText",
(true, false) => "edit.pasteSpecial",
(false, true) if m.command => "edit.pasteText",
_ => "edit.paste",
};
let _ = app.run(id, json!({"text": t}));
+1 -16
View File
@@ -132,22 +132,7 @@ pub(super) fn dispatch(app: &mut WordApp, key: Key, m: Modifiers) -> bool {
if m.alt && !crate::widgets::enabled(app, spec.id) {
return false;
}
// Mod+Shift+V pastes the formatting Mod+Shift+C (or the Format Painter) picked up; with
// none picked up it pastes the clipboard as text only, as Word for Windows does (#1537,
// #4943). Another key bound to Paste Formatting only pastes formatting.
let id = if spec.id == "edit.pasteFormat" {
let formatting = app.session.painter.is_some() || app.session.copied_format.is_some();
// A browser sends a paste event after the key (the desktop doesn't): after pasting
// formatting it's ignored; with none, it pastes the text there, once.
app.canvas.ignore_next_paste = formatting;
if !formatting && cfg!(target_arch = "wasm32") {
return true;
}
if !formatting && key == Key::V && m.command && m.shift && !m.alt { "edit.pasteText" } else { spec.id }
} else {
spec.id
};
let _ = app.run(id, json!({}));
let _ = app.run(spec.id, json!({}));
return true;
}
let Some(name) = name else { return false };
+102 -37
View File
@@ -133,43 +133,6 @@ fn only_plain_paste_releases_paste_a_clipboard_picture() {
assert!(!is_paste_special_release(Key::V, m(true, true, true)) && !is_paste_special_release(Key::V, m(true, false, false)));
}
/// #1537: a browser sends Mod+Shift+V as a paste event; it pastes text only, in the destination's
/// formatting, though the clipboard also holds bold HTML. Plain Mod+V keeps the HTML's bold.
#[test]
fn a_mod_shift_paste_event_keeps_text_only() {
for (modifiers, bold) in [(Modifiers::COMMAND | Modifiers::SHIFT, false), (Modifiers::COMMAND, true)] {
let services = crate::Services { clipboard_html: Some(Box::new(|| Some("<p><b>external</b></p>".into()))), ..Default::default() };
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services);
let ctx = egui::Context::default();
mods_frame(&ctx, &mut a, modifiers, vec![egui::Event::Paste("external".into())]);
assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body), "external", "{modifiers:?}");
let state = a.session.run("format.state", &json!({})).unwrap();
assert_eq!(state.get("bold").and_then(serde_json::Value::as_bool) == Some(true), bold, "{modifiers:?}: {state}");
}
}
/// #5616: a browser sends Mod+Shift+V as its key and then a paste event. After Mod+Shift+C the
/// key pastes the formatting and the paste event that follows doesn't replace the text; a later
/// Mod+V still pastes.
#[test]
fn a_paste_event_after_mod_shift_v_pasted_formatting_is_ignored() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
a.run("text.insert", json!({"text": "source target"})).unwrap();
a.run("select.text", json!({"text": "source"})).unwrap();
a.run("format.bold", json!({})).unwrap();
a.run("edit.copyFormat", json!({})).unwrap();
a.run("select.text", json!({"text": "target"})).unwrap();
let ctx = egui::Context::default();
let chord = Modifiers::COMMAND | Modifiers::SHIFT;
mods_frame(&ctx, &mut a, chord, vec![key_event(Key::V, true, chord), egui::Event::Paste("clipboard text".into())]);
let body = |a: &WordApp| a.session.doc.plain_text(wordcraft_doc::StoryRef::Body);
assert_eq!(body(&a), "source target", "the paste event didn't replace the formatted text");
let bold = a.session.run("format.state", &json!({})).unwrap().get("bold").and_then(serde_json::Value::as_bool);
assert_eq!(bold, Some(true), "the key pasted the formatting");
mods_frame(&ctx, &mut a, Modifiers::COMMAND, vec![key_event(Key::V, true, Modifiers::COMMAND), egui::Event::Paste("pasted".into())]);
assert_eq!(body(&a), "source pasted", "the next Mod+V pastes");
}
/// An empty document whose clipboard holds a 4 × 4 picture.
fn picture_app() -> WordApp {
use crate::paste_picture::ClipboardPicture;
@@ -413,6 +376,108 @@ fn altgr_types_its_characters_without_keytips() {
assert_eq!(a.ui.keytips, crate::keytips::Phase::Tabs);
}
/// The Find dialog's Find what box, after the frames that open the dialog and focus the box.
fn find_box_harness() -> egui_kittest::Harness<'static, WordApp> {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.dialog = crate::dialogs::Dialog::open("find", &mut a);
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
h.run_steps(4);
h
}
/// What the Find dialog's Find what box holds.
fn find_box(h: &egui_kittest::Harness<'static, WordApp>) -> String {
match &h.state().dialog {
Some(crate::dialogs::Dialog::Find { query, .. }) => query.clone(),
_ => panic!("the Find dialog is open"),
}
}
/// One frame: Alt held, `digits` pressed and released (with their text, as Linux sends it, when
/// `digit_text`), Alt let go; then `after`, which a platform may send once Alt is up.
fn alt_code_frame(h: &mut egui_kittest::Harness<'static, WordApp>, digits: &str, digit_text: bool, after: &[&str]) {
let alt = Modifiers { alt: true, ..Default::default() };
let key = |key, pressed, modifiers| egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers };
h.event(egui::Event::ModifiersChanged(alt));
h.event(key(Key::AltLeft, true, alt));
for d in digits.chars() {
let k = Key::from_name(&d.to_string()).expect("a digit key");
h.event(key(k, true, alt));
if digit_text {
h.event(egui::Event::Text(d.to_string()));
}
h.event(key(k, false, alt));
}
h.event(key(Key::AltLeft, false, alt));
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
for t in after {
h.event(egui::Event::Text((*t).to_string()));
}
h.step();
}
/// #4403: an Alt code typed in a text box (here Find what) types its character there, as on the
/// page. Before, the box got the digits (Linux sends their text with Alt held), or on Windows only
/// what the system composes, a byte for a code above 255. The page's text is left alone.
#[test]
fn alt_code_types_its_character_in_a_text_box() {
if cfg!(target_os = "macos") {
return;
}
// The code pages the page's Alt codes use (`text.typeCode`).
let (ansi, oem) = if cfg!(target_os = "windows") {
wordcraft_engine::cmd::text::system_code_pages()
} else {
(1252, wordcraft_engine::cmd::text::system_oem_code_page())
};
let ch = |code| wordcraft_engine::cmd::text::alt_code_char(code, ansi, oem).expect("a character code").to_string();
let mut h = find_box_harness();
assert_eq!(find_box(&h), "");
// Linux: each digit's text arrives too; none of it is typed.
alt_code_frame(&mut h, "0233", true, &[]);
let mut want = ch("0233");
assert_eq!(find_box(&h), want);
// Above 255 the code is the Unicode character's (Word's €), and a platform's echo of the
// same character isn't typed twice.
alt_code_frame(&mut h, "8364", false, &["\u{20AC}"]);
want.push('\u{20AC}');
assert_eq!(find_box(&h), want);
// Windows composes a code itself: up to 255 its character replaces ours (it knows the code
// pages), above that ours stays (it reduces the code to a byte). Elsewhere text after the
// release is typed as it comes.
alt_code_frame(&mut h, "0199", false, &["\u{1E9E}"]);
alt_code_frame(&mut h, "9731", false, &["X"]);
if cfg!(target_os = "windows") {
want.push_str("\u{1E9E}\u{2603}");
} else {
want.push_str(&format!("{}\u{1E9E}\u{2603}X", ch("0199")));
}
assert_eq!(find_box(&h), want);
// A code we name no character for (past Unicode here; a double-byte lead byte on Windows)
// types nothing of ours, and the character the system composes for it is typed, not taken
// for a digit's text. Windows sends no digit text; Linux does.
assert_eq!(wordcraft_engine::cmd::text::alt_code_char("1114112", ansi, oem), None);
alt_code_frame(&mut h, "1114112", !cfg!(target_os = "windows"), &["Y"]);
want.push('Y');
assert_eq!(find_box(&h), want);
// Digits without Alt type themselves; a bare Alt tap types nothing and opens no keytips.
h.event(egui::Event::Key { key: Key::Num5, physical_key: Some(Key::Num5), pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.event(egui::Event::Text("5".into()));
h.step();
alt_code_frame(&mut h, "", false, &[]);
want.push('5');
assert_eq!(find_box(&h), want);
assert_eq!(h.state().ui.keytips, crate::keytips::Phase::Off);
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), "", "the page typed nothing");
}
fn alt_frame_app() -> (egui::Context, WordApp) {
let a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
(egui::Context::default(), a)
+102 -35
View File
@@ -165,6 +165,9 @@ pub fn track_dismissal(app: &mut WordApp, ctx: &egui::Context, viewport: Rect) {
/// canvas area it must stay inside.
pub fn show(app: &mut WordApp, ctx: &egui::Context, selection: Rect, viewport: Rect) {
if !visible(app) {
if app.canvas.command_previewed.is_some() {
ctx.request_repaint();
}
return;
}
let Some(bar) = anchor_rect(selection.min.y, selection.min.x, viewport) else { return };
@@ -192,7 +195,11 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context, selection: Rect, viewport: R
let inner = bar.shrink2(Vec2::new(PAD_X, PAD_Y));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(Layout::top_down(Align::Min)));
child.spacing_mut().item_spacing = Vec2::new(SPACING, ROW_GAP);
content(app, &mut child, &t);
content(app, &mut child, &t, true);
// The page was drawn before the bar this frame: another frame shows or clears the preview.
if app.canvas.command_preview != app.canvas.command_previewed {
ctx.request_repaint();
}
let used = child.min_rect();
ctx.data_mut(|d| d.insert_temp(shown_id(), [bar, used]));
});
@@ -219,7 +226,7 @@ pub fn show_in_context_menu(app: &mut WordApp, ui: &mut egui::Ui) {
ui.set_style(ui.ctx().global_style());
ui.set_width(ROW2_W);
ui.spacing_mut().item_spacing = Vec2::new(SPACING, ROW_GAP);
content(app, ui, &t);
content(app, ui, &t, false);
if app.dialog.is_some() || app.colors.is_some() {
ui.close();
}
@@ -229,7 +236,7 @@ pub fn show_in_context_menu(app: &mut WordApp, ui: &mut egui::Ui) {
/// The controls in two rows like Word's mini toolbar, reusing the ribbon's widgets so the bar
/// matches the ribbon exactly.
fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens, preview_on_hover: bool) {
let st: Value = app.session.run("format.state", &json!({})).unwrap_or_default();
let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
// As on the ribbon, the buttons show alignment as seen on the page (#3579).
@@ -237,8 +244,8 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
.session
.doc
.para_at(&app.session.sel.focus)
.map(|p| {
let r = app.session.doc.styles.resolve_para(&p.props);
.map(|_| {
let r = wordcraft_engine::cmd::para::selection_para(&app.session);
r.align.visual(r.bidi)
})
.unwrap_or_default();
@@ -256,6 +263,7 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
let fams = app.previews.families();
let recent: Vec<String> = app.ui.recent_fonts.iter().map(|f| app.previews.font_shown(f)).collect();
let prev = app.previews.font_preview_fn();
let _ = widgets::take_hover(ui.ctx());
let options = widgets::FontComboOptions {
id: "mini_font",
width: COMBO_FONT_W,
@@ -266,7 +274,16 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
tip: tl!("Font"),
shown_as: warning.as_deref(),
};
if let Some(f) = widgets::font_combo_with_recent_hint(ui, options) {
let picked = widgets::font_combo_with_recent_hint(ui, options);
if preview_on_hover
&& picked.is_none()
&& let Some(f) = widgets::take_hover(ui.ctx())
&& f != crate::widgets::ADD_FONT
&& f != crate::widgets::LOAD_SYSTEM_FONTS
{
app.canvas.command_preview = Some(("format.font".to_string(), json!({"name": app.previews.font_stored(&f)})));
}
if let Some(f) = picked {
let _ = if f == crate::widgets::ADD_FONT {
app.run("font.add", json!({}))
} else if f == crate::widgets::LOAD_SYSTEM_FONTS {
@@ -280,33 +297,34 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
st.get("size").and_then(Value::as_f64).map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).unwrap_or_default();
let sizes: Vec<String> =
wordcraft_engine::cmd::format::SIZES.iter().map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).collect();
if let Some(v) = widgets::combo_hint(ui, "mini_size", COMBO_SIZE_W, &size, &sizes, None, tl!("Font Size"))
let _ = widgets::take_hover(ui.ctx());
let picked = widgets::combo_hint(ui, "mini_size", COMBO_SIZE_W, &size, &sizes, None, tl!("Font Size"));
if preview_on_hover
&& picked.is_none()
&& let Some(x) = widgets::take_hover(ui.ctx()).and_then(|v| v.trim().parse::<f64>().ok())
{
app.canvas.command_preview = Some(("format.size".to_string(), json!({"size": x})));
}
if let Some(v) = picked
&& let Ok(x) = v.trim().parse::<f64>()
{
let _ = app.run("format.size", json!({"size": x}));
}
ui.add_space(GAP);
widgets::small(ui, app, "grow", None, "Increase Font Size", crate::widgets::ButtonAction::new("format.growFont", json!({}), false));
widgets::small(ui, app, "shrink", None, "Decrease Font Size", crate::widgets::ButtonAction::new("format.shrinkFont", json!({}), false));
hover_small(ui, app, "grow", "Increase Font Size", "format.growFont", false, preview_on_hover);
hover_small(ui, app, "shrink", "Decrease Font Size", "format.shrinkFont", false, preview_on_hover);
});
row(ui, app, &|ui, app| {
widgets::small(ui, app, "bold", None, "Bold", crate::widgets::ButtonAction::new("format.bold", json!({}), flag("bold")));
widgets::small(ui, app, "italic", None, "Italic", crate::widgets::ButtonAction::new("format.italic", json!({}), flag("italic")));
widgets::small(ui, app, "underline", None, "Underline", crate::widgets::ButtonAction::new("format.underline", json!({}), flag("underline")));
widgets::small(
ui,
app,
"strike",
None,
"Strikethrough",
crate::widgets::ButtonAction::new("format.strikethrough", json!({}), flag("strike")),
);
hover_small(ui, app, "bold", "Bold", "format.bold", flag("bold"), preview_on_hover);
hover_small(ui, app, "italic", "Italic", "format.italic", flag("italic"), preview_on_hover);
hover_small(ui, app, "underline", "Underline", "format.underline", flag("underline"), preview_on_hover);
hover_small(ui, app, "strike", "Strikethrough", "format.strikethrough", flag("strike"), preview_on_hover);
sep(ui, t);
let hl = app.canvas.last_highlight.clone();
let (icon, sw, color) = highlight_button_appearance(&hl);
widgets::split(
let main = widgets::split(
ui,
app,
icon,
@@ -314,12 +332,20 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
crate::widgets::ButtonAction::new("format.highlight", json!({"color": color}), false),
sw,
|ui, app| {
let _ = widgets::take_hover(ui.ctx());
let in_effect = st.get("highlight").and_then(Value::as_str).unwrap_or("none").to_string();
widgets::set_current_pick(ui.ctx(), Some(in_effect));
highlight_menu(ui, app);
widgets::set_current_pick(ui.ctx(), None);
if preview_on_hover && let Some(c) = widgets::take_hover(ui.ctx()) {
app.canvas.command_preview = Some(("format.highlight".to_string(), json!({"color": c})));
}
},
);
preview_on_hover_if(app, &main, "format.highlight", json!({"color": color}), preview_on_hover);
let fc = app.canvas.last_font_color.clone();
let sw = wordcraft_doc::Rgb::parse(&fc).map(crate::theme::c32);
widgets::split(
let main = widgets::split(
ui,
app,
"fontcolor",
@@ -327,26 +353,50 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
crate::widgets::ButtonAction::new("format.color", json!({"color": fc}), false),
sw,
|ui, app| {
let _ = widgets::take_hover(ui.ctx());
widgets::set_current_pick(ui.ctx(), st.get("color").and_then(Value::as_str).map(str::to_string));
font_color_menu(ui, app);
widgets::set_current_pick(ui.ctx(), None);
if preview_on_hover && let Some(c) = widgets::take_hover(ui.ctx()) {
app.canvas.command_preview = Some(("format.color".to_string(), json!({"color": c})));
}
},
);
preview_on_hover_if(app, &main, "format.color", json!({"color": fc}), preview_on_hover);
sep(ui, t);
use wordcraft_doc::Align as A;
widgets::small(ui, app, "alignLeft", None, "Align Left", crate::widgets::ButtonAction::new("para.alignLeft", json!({}), align == A::Left));
widgets::small(ui, app, "alignCenter", None, "Center", crate::widgets::ButtonAction::new("para.alignCenter", json!({}), align == A::Center));
widgets::small(
ui,
app,
"alignRight",
None,
"Align Right",
crate::widgets::ButtonAction::new("para.alignRight", json!({}), align == A::Right),
);
widgets::small(ui, app, "justify", None, "Justify", crate::widgets::ButtonAction::new("para.justify", json!({}), align == A::Justify));
hover_small(ui, app, "alignLeft", "Align Left", "para.alignLeft", align == A::Left, preview_on_hover);
hover_small(ui, app, "alignCenter", "Center", "para.alignCenter", align == A::Center, preview_on_hover);
hover_small(ui, app, "alignRight", "Align Right", "para.alignRight", align == A::Right, preview_on_hover);
hover_small(ui, app, "justify", "Justify", "para.justify", align == A::Justify, preview_on_hover);
});
}
/// A bar button that, while hovered, makes the page show its command run on the selection. The
/// preview runs on a copy, so the document, its undo history and the selection stay as they are.
fn hover_small(ui: &mut egui::Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, checked: bool, preview: bool) {
let action = crate::widgets::ButtonAction::new(id, json!({}), checked);
let r = widgets::small(ui, app, icon, None, tip, action);
preview_on_hover_if(app, &r, id, json!({}), preview);
}
/// Previews command `id` with `params` while its button `r` is hovered and not pressed. After a
/// click the preview stays off until the pointer leaves the button: a toggle would otherwise
/// preview its other state under the pointer and look as if it flashed.
fn preview_on_hover_if(app: &mut WordApp, r: &egui::Response, id: &str, params: Value, preview: bool) {
let hold = r.id.with("hold_preview");
if r.clicked() {
r.ctx.data_mut(|d| d.insert_temp(hold, true));
} else if !r.hovered() {
r.ctx.data_mut(|d| d.remove::<bool>(hold));
}
let held = r.ctx.data(|d| d.get_temp::<bool>(hold)).unwrap_or(false);
if preview && !held && r.hovered() && !r.is_pointer_button_down_on() && !r.clicked() && widgets::enabled(app, id) {
app.canvas.command_preview = Some((id.to_string(), params));
}
}
/// A hairline divider between the bar's groups.
fn sep(ui: &mut egui::Ui, t: &Tokens) {
let (r, _) = ui.allocate_exact_size(Vec2::new(SEP_W, 20.0), Sense::hover());
@@ -370,6 +420,13 @@ pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
let Some(c) = h.rgb() else { continue };
let (r, resp) = ui.allocate_exact_size(Vec2::new(18.0, 18.0), Sense::click());
ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c.0, c.1, c.2));
if resp.hovered() {
widgets::note_hover(ui.ctx(), h.ooxml().to_string());
}
if widgets::current_pick(ui.ctx()).is_some_and(|cur| cur.eq_ignore_ascii_case(&c.hex())) {
let accent = Tokens::get(ui.ctx()).accent;
ui.painter().rect_stroke(r.expand(1.0), 2.0, egui::Stroke::new(2.0, accent), egui::StrokeKind::Outside);
}
if resp.on_hover_text(tl!(h.name())).clicked() {
app.canvas.last_highlight = h.ooxml().to_string();
widgets::run_from_ribbon(app, "format.highlight", json!({"color": h.ooxml()}));
@@ -381,7 +438,12 @@ pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
}
});
let enabled = widgets::enabled(app, "format.highlight");
if ui.add_enabled(enabled, egui::Button::new(tl!("No Color")).min_size(Vec2::new(200.0, 0.0))).clicked() {
let none_in_effect = widgets::current_pick(ui.ctx()).is_some_and(|cur| cur == "none");
let no_color = ui.add_enabled(enabled, egui::Button::new(tl!("No Color")).selected(none_in_effect).min_size(Vec2::new(200.0, 0.0)));
if no_color.hovered() {
widgets::note_hover(ui.ctx(), "none".to_string());
}
if no_color.clicked() {
app.canvas.last_highlight = "none".into();
widgets::run_from_ribbon(app, "format.highlight", json!({"color": "none"}));
ui.close();
@@ -391,7 +453,12 @@ pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
/// Font colour: Automatic, the colour grid, Recent Colors and More Colors… (#606).
fn font_color_menu(ui: &mut egui::Ui, app: &mut WordApp) {
egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| {
if ui.selectable_label(false, "Automatic").clicked() {
let auto_in_effect = widgets::current_pick(ui.ctx()).is_some_and(|cur| cur == "auto");
let automatic = ui.selectable_label(auto_in_effect, "Automatic");
if automatic.hovered() {
widgets::note_hover(ui.ctx(), "auto".to_string());
}
if automatic.clicked() {
widgets::run_from_ribbon(app, "format.color", json!({"color": "auto"}));
ui.close();
return;
+289 -1
View File
@@ -198,6 +198,211 @@ fn format_state(h: &mut egui_kittest::Harness<'static, WordApp>) -> Value {
h.state_mut().session.run("format.state", &json!({})).unwrap()
}
/// Hovering an entry of the bar's size list previews that size; the pick is not made.
#[test]
fn hovering_a_size_in_the_bars_list_previews_it() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let (doc, size) = (h.state().session.doc.clone(), format_state(&mut h).get("size").cloned());
let viewport = h.state().canvas.viewport.unwrap();
let size_box = h
.get_all_by_role(Role::TextInput)
.map(|n| n.rect())
.filter(|r| viewport.contains_rect(*r))
.max_by(|a, b| a.min.x.total_cmp(&b.min.x))
.expect("the bar has a size box");
click_at(&mut h, pos2(size_box.max.x + 8.0, size_box.center().y));
let twenty = h.get_by_label("20").rect().center();
h.event(egui::Event::PointerMoved(twenty));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, Some(("format.size".to_string(), json!({"size": 20.0}))));
assert_ne!(crate::canvas::page_doc(h.state()), &doc, "the page shows the 20 pt text");
assert_eq!(&h.state().session.doc, &doc);
assert_eq!(format_state(&mut h).get("size").cloned(), size);
}
/// Hovering a family of the bar's font list previews it; clicking it applies it.
#[test]
fn hovering_and_clicking_a_font_in_the_bars_list() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let doc = h.state().session.doc.clone();
let viewport = h.state().canvas.viewport.unwrap();
let font_box = h
.get_all_by_role(Role::TextInput)
.map(|n| n.rect())
.filter(|r| viewport.contains_rect(*r))
.min_by(|a, b| a.min.y.total_cmp(&b.min.y).then(a.min.x.total_cmp(&b.min.x)))
.expect("the bar has a font box");
click_at(&mut h, pos2(font_box.max.x + 8.0, font_box.center().y));
let family = h.state_mut().previews.families().into_iter().find(|f| f != "Aptos Display" && f != "Aptos").expect("another family is installed");
let at = h.get_all_by_label(&family).map(|n| n.rect().center()).find(|p| p.y > font_box.max.y).expect("the list shows the family");
h.event(egui::Event::PointerMoved(at));
steps(&mut h);
let previewed = h.state().canvas.command_previewed.clone();
assert!(previewed.is_some_and(|(id, _)| id == "format.font"), "hovering a family previews the font");
assert_eq!(&h.state().session.doc, &doc);
click_at(&mut h, at);
assert_ne!(&h.state().session.doc, &doc, "clicking the family applies the font");
}
/// A picked family joins Recently Used Fonts when the list next opens, not in the frame after the
/// pick, when the list is still on screen and would shift under the pointer.
#[test]
fn a_picked_font_joins_the_recent_section_on_the_next_open() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let viewport = h.state().canvas.viewport.unwrap();
let font_box = h
.get_all_by_role(Role::TextInput)
.map(|n| n.rect())
.filter(|r| viewport.contains_rect(*r))
.min_by(|a, b| a.min.y.total_cmp(&b.min.y).then(a.min.x.total_cmp(&b.min.x)))
.expect("the bar has a font box");
let arrow = pos2(font_box.max.x + 8.0, font_box.center().y);
click_at(&mut h, arrow);
let family = h.state_mut().previews.families().into_iter().find(|f| f != "Aptos Display" && f != "Aptos").expect("another family is installed");
let at = h.get_all_by_label(&family).map(|n| n.rect().center()).find(|p| p.y > font_box.max.y).expect("the list shows the family");
h.event(egui::Event::PointerMoved(at));
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.step();
h.step();
h.step();
assert_eq!(h.query_all_by_label("Recently Used Fonts").count(), 0, "the list does not shift under the pointer");
assert_eq!(h.query_all_by_label("Add Font…").count(), 0, "picking a family closes the list");
steps(&mut h);
click_at(&mut h, arrow);
assert_eq!(h.query_all_by_label("Recently Used Fonts").count(), 1, "the next open lists the family first");
}
/// Hovering the highlight and font colour buttons previews their last colour.
#[test]
fn hovering_the_colour_buttons_previews_their_colour() {
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let viewport = h.state().canvas.viewport.unwrap();
for (label, id) in [("Text Highlight Color", "format.highlight"), ("Font Color", "format.color")] {
let r = h.get_all_by_label(label).map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has the button");
h.event(egui::Event::PointerMoved(r.center()));
steps(&mut h);
let previewed = h.state().canvas.command_previewed.clone();
assert!(previewed.is_some_and(|(p, _)| p == id), "{label} previews {id}");
}
}
/// Hovering a colour of the bar's highlight list previews it on the selection.
#[test]
fn hovering_a_highlight_colour_previews_it() {
use egui_kittest::kittest::{By, Queryable};
let mut h = bar_over("brown fox");
let doc = h.state().session.doc.clone();
let [bar, _] = h.ctx.data(|d| d.get_temp::<[Rect; 2]>(shown_id())).expect("the bar is shown over a selection");
let arrow = h
.query_all(By::new().predicate(|n| n.raw_bounds().is_some_and(|r| ((r.x1 - r.x0) - 11.0).abs() < 0.5 && ((r.y1 - r.y0) - 22.0).abs() < 0.5)))
.map(|n| n.rect())
.filter(|r| bar.contains_rect(*r))
.min_by(|a, b| a.min.x.total_cmp(&b.min.x))
.expect("the bar has the Highlight split button");
click_at(&mut h, arrow.center());
let no_color = h.get_by_label("No Color").rect();
h.event(egui::Event::PointerMoved(pos2(no_color.min.x + 10.0, no_color.center().y - 30.0)));
steps(&mut h);
let previewed = h.state().canvas.command_previewed.clone();
assert!(previewed.is_some_and(|(id, _)| id == "format.highlight"), "a swatch previews the highlight");
assert_eq!(&h.state().session.doc, &doc);
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, None);
}
/// Hovering a bar button previews its command on the selection; leaving the bar restores the
/// page. The document, its selection and its revision stay as they were.
#[test]
fn hovering_a_bar_button_previews_it_on_the_selection() {
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let (doc, sel, rev) = (h.state().session.doc.clone(), h.state().session.sel.clone(), h.state().session.rev());
let viewport = h.state().canvas.viewport.unwrap();
let bold = h.get_all_by_label("Bold").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Bold");
h.event(egui::Event::PointerMoved(bold.center()));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, Some(("format.bold".to_string(), json!({}))));
assert_ne!(crate::canvas::page_doc(h.state()), &doc, "the page shows the bold text");
assert_eq!((&h.state().session.doc, &h.state().session.sel, h.state().session.rev()), (&doc, &sel, rev));
assert_eq!(format_state(&mut h).get("bold"), Some(&json!(false)));
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, None);
assert_eq!(crate::canvas::page_doc(h.state()), &doc, "off the bar the page is as it was");
// Alignment moves the previewed selection. The toolbar must stay anchored to the live
// selection so the hovered button remains under the pointer until the pointer leaves.
let anchor = h.state().canvas.mini_anchor.unwrap();
let align_right = h.get_all_by_label("Align Right").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Align Right");
h.event(egui::Event::PointerMoved(align_right.center()));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, Some(("para.alignRight".to_string(), json!({}))));
assert_eq!(h.state().canvas.mini_anchor, Some(anchor), "the alignment preview must not move the toolbar");
}
/// While a preview is shown the page holds its scroll position, though the previewed text
/// reflows and the caret would be scrolled into view.
#[test]
fn the_page_holds_its_place_while_a_preview_is_shown() {
let mut h = bar_over("brown fox");
h.state_mut().run("caret.docStart", json!({})).unwrap();
h.state_mut().run("text.insert", json!({"text": "x\n".repeat(150)})).unwrap();
h.state_mut().run("select.text", json!({"text": "brown fox"})).unwrap();
steps(&mut h);
let page = h.state().canvas.viewport.unwrap();
h.event(egui::Event::PointerMoved(pos2(page.min.x + 20.0, page.center().y)));
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -60.0),
phase: egui::TouchPhase::Start,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
});
steps(&mut h);
let held = h.state().canvas.scroll_offset;
for _ in 0..4 {
h.state_mut().canvas.command_preview = Some(("format.size".to_string(), json!({"size": 72.0})));
h.state_mut().canvas.scroll_to_caret = true;
h.step();
}
assert!(h.state().canvas.command_previewed.is_some());
assert_eq!(h.state().canvas.scroll_offset, held, "the preview does not move the page");
steps(&mut h);
assert_eq!(h.state().canvas.preview_scroll, None, "the hold ends with the preview");
}
/// With no preview shown the bar's anchor follows the selection as the page scrolls.
#[test]
fn the_bar_anchor_follows_the_selection_as_the_page_scrolls() {
let mut h = bar_over("brown fox");
h.state_mut().run("caret.docStart", json!({})).unwrap();
h.state_mut().run("text.insert", json!({"text": "x\n".repeat(150)})).unwrap();
h.state_mut().run("select.text", json!({"text": "brown fox"})).unwrap();
steps(&mut h);
let anchored = h.state().canvas.mini_anchor.unwrap();
let page = h.state().canvas.viewport.unwrap();
h.event(egui::Event::PointerMoved(pos2(page.min.x + 20.0, page.center().y)));
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, 120.0),
phase: egui::TouchPhase::Start,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
});
steps(&mut h);
assert_ne!(h.state().canvas.mini_anchor, Some(anchored), "the toolbar anchor follows the live selection as the viewport scrolls");
}
/// #2959: formatting must be available alongside the text's right-click menu, using the
/// selection that was clicked. Its lists must remain usable inside the context popup.
#[test]
@@ -219,6 +424,11 @@ fn context_menu_toolbar_formats_the_selection_and_opens_its_size_list() {
assert!(bar_layer(&h).is_none(), "the standalone selection toolbar must not double up");
let viewport = h.state().canvas.viewport.unwrap();
let bold = h.get_all_by_label("Bold").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the context menu has Bold");
let doc = h.state().session.doc.clone();
h.event(egui::Event::PointerMoved(bold.center()));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, None, "the context toolbar does not start the hidden floating bar's preview repaint loop");
assert_eq!(&h.state().session.doc, &doc, "hovering a context-menu control does not preview a document change");
click_at(&mut h, bold.center());
assert_eq!(format_state(&mut h).get("bold"), Some(&json!(true)));
assert_eq!(h.state().session.sel, selection);
@@ -602,7 +812,7 @@ fn alignment_buttons_mark_the_alignment_seen_on_the_page_in_a_right_to_left_para
}
egui::Area::new(egui::Id::unique("mini_align_probe")).fixed_pos(pos2(10.0, 10.0)).show(ui.ctx(), |ui| {
let t = Tokens::get(ui.ctx());
content(app, ui, &t);
content(app, ui, &t, true);
});
},
a,
@@ -626,3 +836,81 @@ fn alignment_buttons_mark_the_alignment_seen_on_the_page_in_a_right_to_left_para
assert!(!is_marked(unlit), "rtl={rtl} {cmd:?}: {unlit} is not marked");
}
}
#[test]
fn the_alignment_buttons_align_the_paragraph() {
use egui_kittest::kittest::Queryable;
for (label, align) in [("Center", "center"), ("Align Right", "right"), ("Justify", "both"), ("Align Left", "left")] {
let mut h = bar_over("brown fox");
let viewport = h.state().canvas.viewport.unwrap();
let b = h.get_all_by_label(label).map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has the button");
h.event(egui::Event::PointerMoved(b.center()));
steps(&mut h);
click_at(&mut h, b.center());
let got = h.state().session.doc.para_at(&h.state().session.sel.focus).map(|p| format!("{:?}", p.props.align)).unwrap_or_default();
assert!(got.to_lowercase().contains(align) || (align == "both" && got.to_lowercase().contains("justif")), "{label}: {got}");
}
}
/// After a click the button's preview stays off while the pointer is on it, so a toggle does not
/// flash its other state; leaving and coming back previews again.
#[test]
fn a_clicked_button_holds_its_preview_until_the_pointer_leaves() {
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let viewport = h.state().canvas.viewport.unwrap();
let size = format_state(&mut h).get("size").cloned();
let grow = h.get_all_by_label("Increase Font Size").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Grow").center();
h.event(egui::Event::PointerMoved(grow));
steps(&mut h);
assert!(h.state().canvas.command_previewed.is_some(), "hovering previews");
click_at(&mut h, grow);
steps(&mut h);
assert_ne!(format_state(&mut h).get("size").cloned(), size, "the click grew the text");
assert_eq!(h.state().canvas.command_previewed, None, "no preview of the next step under the pointer");
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
steps(&mut h);
h.event(egui::Event::PointerMoved(grow));
steps(&mut h);
assert!(h.state().canvas.command_previewed.is_some(), "coming back previews again");
}
/// After a click the preview stays still while the pointer is on the button. Leaving and coming
/// back previews the undo: Bold over bold text shows it unbolded.
#[test]
fn coming_back_to_a_toggle_previews_its_undo() {
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let viewport = h.state().canvas.viewport.unwrap();
let bold = h.get_all_by_label("Bold").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Bold").center();
click_at(&mut h, bold);
steps(&mut h);
assert_eq!(format_state(&mut h).get("bold"), Some(&json!(true)));
assert_eq!(h.state().canvas.command_previewed, None, "the page holds still after the click");
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
steps(&mut h);
h.event(egui::Event::PointerMoved(bold));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, Some(("format.bold".to_string(), json!({}))), "coming back previews the undo");
let doc = h.state().session.doc.clone();
assert_ne!(crate::canvas::page_doc(h.state()), &doc, "the page shows the text unbolded");
}
/// A selection that ends at the next paragraph's start (Select Paragraph, a triple-click) shows
/// the alignment of the paragraph it covers, not the next one's.
#[test]
fn a_selected_paragraph_shows_its_own_alignment() {
let mut h = bar_over("brown fox");
h.state_mut().session = wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("First paragraph\nSecond paragraph"));
h.state_mut().session.sel.anchor = wordcraft_doc::Pos::body(0, 2);
h.state_mut().session.sel.focus = wordcraft_doc::Pos::body(0, 2);
h.state_mut().run("para.alignRight", json!({})).unwrap();
h.state_mut().session.sel.anchor = wordcraft_doc::Pos::body(0, 0);
h.state_mut().session.sel.focus = wordcraft_doc::Pos::body(1, 0);
steps(&mut h);
let s = &h.state().session;
let focus_align = s.doc.para_at(&s.sel.focus).map(|p| s.doc.styles.resolve_para(&p.props).align);
assert_eq!(focus_align, Some(wordcraft_doc::Align::Left), "the next paragraph is left aligned");
let r = wordcraft_engine::cmd::para::selection_para(s);
assert_eq!(r.align, wordcraft_doc::Align::Right, "the buttons read the selected paragraph");
}
+15 -7
View File
@@ -530,6 +530,14 @@ fn shows_pixels(d: &ObjectDrag) -> bool {
(d.picture && d.object.spin.is_identity()) || d.drag.grab == Grab::Move || (d.drag.grab == Grab::Rotate && d.object.text_box.is_none())
}
/// The pixels-per-page-point for a `w` x `h` preview, lowered so neither side exceeds the GPU's
/// `max_side` (the texture is painted into the object's rect, so a smaller one is only stretched).
fn preview_scale(px: f32, w: f32, h: f32, max_side: usize) -> f32 {
let room = (max_side.min(wordcraft_render::MAX_SIDE as usize).max(2) - 1) as f32;
let px = if px.is_finite() { px } else { 1.0 };
px.min(room / w.max(h).max(1.0))
}
/// `o` drawn alone over its rotated bounds, as a texture, at the canvas's `scale` (screen points
/// per page point).
fn preview(app: &WordApp, ctx: &egui::Context, layout: &DocLayout, o: &ObjectHit, scale: f32) -> Option<egui::TextureHandle> {
@@ -539,13 +547,13 @@ fn preview(app: &WordApp, ctx: &egui::Context, layout: &DocLayout, o: &ObjectHit
opts.dark = crate::canvas::dark_page(app);
let items = wordcraft_layout::display::object_display(&app.session.doc, page, obj, o.rect, o.text_box, &opts.display);
let b = o.bounds();
let img = wordcraft_render::render_items(
&app.session.doc,
&items,
wordcraft_geom::Rect::new(b.x, b.y, b.w, b.h),
scale * ctx.pixels_per_point(),
&opts,
);
let max_side = ctx.input(|i| i.max_texture_side);
let px = preview_scale(scale * ctx.pixels_per_point(), b.w, b.h, max_side);
let img = wordcraft_render::render_items(&app.session.doc, &items, wordcraft_geom::Rect::new(b.x, b.y, b.w, b.h), px, &opts);
// A texture over the device limit panics wgpu outside `Session::run`; fall back to the outline.
if img.width as usize > max_side || img.height as usize > max_side {
return None;
}
let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_straight());
Some(ctx.load_texture("object-drag", ci, egui::TextureOptions::LINEAR))
}
+61
View File
@@ -914,3 +914,64 @@ fn rotation_drag_keeps_text_boxes_outline_only_until_their_text_can_rotate() {
assert_eq!(selected(h.state()).unwrap().1.frame().unwrap().2.rot, 90.0);
assert_eq!(h.state().session.undo_depth(), depth + 1);
}
#[test]
fn drag_preview_scale_stays_within_the_gpu_texture_limit() {
// A 14,400 pt picture at 2x on a GPU limited to 8,192 px.
let px = preview_scale(2.0, 14_400.0, 100.0, 8192);
assert!((14_400.0f32 * px).ceil() <= 8192.0);
assert_eq!(preview_scale(2.0, 100.0, 50.0, 8192), 2.0);
assert_eq!(preview_scale(2.0, 0.0, 0.0, 8192), 2.0);
assert!((100.0f32 * preview_scale(2.0, 100.0, 50.0, 1)).ceil() <= 1.0);
for bad in [f32::NAN, f32::INFINITY, 0.0] {
assert!((100.0f32 * preview_scale(bad, 100.0, 100.0, 0)).ceil() <= 2.0);
}
}
/// A large picture's drag preview remains a legal device texture.
#[test]
fn dragging_a_large_picture_renders_a_preview_within_the_gpu_texture_limit() {
use crate::table_resize::tests::{H, button, steps};
use egui::Event;
let image = image::RgbaImage::from_pixel(16, 16, image::Rgba([30, 120, 210, 255]));
let mut bytes = Vec::new();
image.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
let picture = InlineObject::Image {
media: "large-preview-test".into(),
w: 14_400.0,
h: 200.0,
alt: String::new(),
float: wordcraft_doc::para::Float { wrap: Wrap::InFrontOfText, x: 100.0, y: 100.0, ..Default::default() },
crop: [0.0; 4],
ole: None,
fx: Default::default(),
};
let (mut app, _) = app_with(picture);
app.session.doc.media.insert("large-preview-test".into(), Arc::new(bytes));
app.session.touch();
app.run("view.commentsPane", json!({"value": false})).unwrap();
app.run("select.range", json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, OBJ.len_utf8())})).unwrap();
let mut h: H = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(0.25).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
steps(&mut h, 6);
let hit = h.state_mut().session.layout().object(&Pos::body(0, 0), 0).expect("laid out");
let max_side = h.ctx.input(|i| i.max_texture_side);
assert!(hit.bounds().w * h.ctx.pixels_per_point() > max_side as f32, "unclamped picture exceeds the texture limit");
let at = crate::canvas::page_to_screen(h.state_mut(), hit.page, hit.rect.x + 10.0, hit.rect.y + 10.0).expect("the picture center is visible");
h.event(Event::PointerMoved(at));
button(&h, at, true);
h.step();
let moved = at + egui::vec2(24.0, 0.0);
h.event(Event::PointerMoved(moved));
steps(&mut h, 2);
let drag = h.state().canvas.obj_drag.as_ref().expect("picture drag started");
let preview = drag.preview.as_ref().expect("large picture preview is generated");
let [width, height] = preview.size();
assert!(width <= max_side && height <= max_side, "preview is {width}×{height}, texture limit is {max_side}");
}
+9 -1
View File
@@ -59,8 +59,9 @@ pub(super) fn smart_art_design(app: &mut WordApp, ui: &mut Ui) {
app,
crate::widgets::MenuButtonOptions::new("smartArtColors", Some("Change\nColors"), "Change Colors", true, ""),
|ui, app| {
let custom = g.has_custom_colors();
for colors in SmartArtColors::ALL {
let on = g.colors == colors;
let on = g.colors == colors && !custom;
let resp = ui.add(egui::Button::new(crate::dialogs_smart_art::colors_label(colors)).selected(on).min_size(vec2(200.0, 24.0)));
let sw = Rect::from_min_size(pos2(resp.rect.right() - 74.0, resp.rect.center().y - 6.0), vec2(66.0, 12.0));
crate::dialogs_smart_art::swatches(ui.painter(), sw, colors, &theme);
@@ -69,6 +70,13 @@ pub(super) fn smart_art_design(app: &mut WordApp, ui: &mut Ui) {
ui.close();
}
}
// Active while items carry their own colours; set in the Text Pane.
let resp = ui.add_enabled(custom, egui::Button::new(tl!("Custom")).selected(custom).min_size(vec2(200.0, 24.0)));
let sw = Rect::from_min_size(pos2(resp.rect.right() - 74.0, resp.rect.center().y - 6.0), vec2(66.0, 12.0));
crate::dialogs_smart_art::custom_swatches(ui.painter(), sw, &g, &theme);
if !custom {
resp.on_disabled_hover_text(tl!("Set the colors in the Text Pane to use custom colors."));
}
},
);
});
+1 -1
View File
@@ -169,7 +169,7 @@ pub(super) fn home(app: &mut WordApp, ui: &mut Ui) {
});
});
group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| {
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props));
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|_| wordcraft_engine::cmd::para::selection_para(&app.session));
// The buttons show alignment as seen on the page (Align Right is a right-to-left paragraph's start).
let align = rp.as_ref().map(|r| r.align.visual(r.bidi)).unwrap_or_default();
let rtl = rp.as_ref().is_some_and(|r| r.bidi);
+8 -2
View File
@@ -229,12 +229,18 @@ pub(super) fn insert(app: &mut WordApp, ui: &mut Ui) {
});
}
/// What a Drop Cap menu choice sends: the position, and `keep` so each selected paragraph that has
/// a drop cap keeps its own lines, distance and font (None takes it all away).
pub(super) fn drop_cap_payload(position: &str) -> serde_json::Value {
if position == "none" { json!({"position": position}) } else { json!({"position": position, "keep": true}) }
}
/// Insert › Drop Cap's menu (#2601): None, Dropped and In margin (the current one checked),
/// then the Drop Cap dialog.
fn drop_cap_items(ui: &mut Ui, app: &mut WordApp) {
let current = wordcraft_engine::cmd::insert::drop_cap_state(&app.session).position;
let state = wordcraft_engine::cmd::insert::drop_cap_state(&app.session);
for (label, v) in [("None", "none"), ("Dropped", "dropped"), ("In margin", "margin")] {
mi_check(ui, app, label, current == v, "insert.dropCap", json!({"position": v}));
mi_check(ui, app, label, state.position == v, "insert.dropCap", drop_cap_payload(v));
}
ui.separator();
mi(ui, app, "Drop Cap Options…", "ui.dialog", json!({"name": "dropCap"}));
+1 -1
View File
@@ -121,7 +121,7 @@ pub(super) fn layout(app: &mut WordApp, ui: &mut Ui) {
});
});
group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| {
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props));
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|_| wordcraft_engine::cmd::para::selection_para(&app.session));
let Some(rp) = rp else { return };
// #654: header + two rows of 20 px fields fit the content height above the group title
// (1.5 pt between rows; 3 ran 2.5 pt into it); labels and fields sit on shared columns,
+15
View File
@@ -2,6 +2,21 @@ use super::arrange::group_menu_command;
use super::*;
use wordcraft_engine::Session;
/// #2601: Dropped / In margin send just the position and `keep`, so every selected paragraph keeps
/// its own lines, distance and font; None sends only the position.
#[test]
fn drop_cap_menu_keeps_the_options() {
use super::insert::drop_cap_payload;
assert_eq!(drop_cap_payload("dropped"), json!({"position": "dropped", "keep": true}));
assert_eq!(drop_cap_payload("margin"), json!({"position": "margin", "keep": true}));
assert_eq!(drop_cap_payload("none"), json!({"position": "none"}), "None takes it all away");
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::from_text("Once")), crate::Services::default());
app.run("insert.dropCap", json!({"position": "dropped", "lines": 5, "distance": 7.0, "font": "Liberation Mono"})).unwrap();
app.run("insert.dropCap", drop_cap_payload("margin")).unwrap();
let state = wordcraft_engine::cmd::insert::drop_cap_state(&app.session);
assert_eq!((state.position, state.lines, state.distance, state.font.as_deref()), ("margin", 5, 7.0, Some("Liberation Mono")));
}
/// #4349: Insert › Shapes offers the basic shapes as a grid of drawn tiles, each named for
/// screen readers and by its tooltip; a click inserts that shape.
#[test]

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