Merge upstream/main into feat/322-view-windows

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76andClaude Sonnet 5.5 committed 2026-10-26 00:55:58 +01:00
commit 33070c575c
147 files changed
+14031 -1474

No files matched your search

+2
View File
@@ -21,6 +21,8 @@ jobs:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
+2
View File
@@ -27,3 +27,5 @@ jobs:
freebsd:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
+1
View File
@@ -24,6 +24,7 @@ jobs:
with:
app-name: wordcraft
app-title: WordCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
+2
View File
@@ -8,6 +8,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| Asset | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `egui` dependency: `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full copyright and permission text in NOTICE) | Small egui special-icon subset; embedded by default_fonts, retained in FontDefinitions::default() |
| `egui` dependency: `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full copyright and permission text in NOTICE) | Parent monochrome icon/emoji face embedded by monochrome_emoji_fonts; separate from the egui-icons subset |
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) |
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation |
| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~240 lowercase English base forms supplementing spelling (#520), including British spellings (#40) |
Generated
+381 -164
View File
File diff suppressed because it is too large. Load diff
+26 -5
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.10.0"
version = "0.11.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -55,6 +55,7 @@ wuff = "0.2.9"
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 = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
@@ -73,12 +74,17 @@ hmac = "0.13"
base64 = "0.22"
getrandom = "0.3"
zeroize = "1"
# Unix open flags (O_NONBLOCK for the engine's checked file reads, #3666); already in the lock as a
# dependency of other crates.
libc = "0.2"
# ui
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
# A monotonic clock that works on native and in the browser (`performance.now()`).
web-time = "1.1"
rfd = "0.17"
arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] }
@@ -111,3 +117,18 @@ 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 = "17744430be327d0257496a6a31c8d38fe20a6756" }
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" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+20 -2
View File
@@ -95,14 +95,32 @@ list (author, source, license) is in ATTRIBUTION.md.
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
- Application-drawn UI icons (crates/ui-egui/src/icons.rs) are original work, MIT OR Apache-2.0;
the bundled egui font icons have the separate notice below.
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources.
The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision
c3487320b2d1de657b860b3c8abb44e1f2b32bd8. The backend retains its MIT OR Apache-2.0 licensing
17744430be327d0257496a6a31c8d38fe20a6756. The backend retains its MIT OR Apache-2.0 licensing
and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md.
The backend patch prevents finalization during an unwind and rejects a writer after failed
cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled.
No Microsoft, Adobe, Avid or Autodesk icons, images, templates or artwork are included.
Bundled egui font icons
The canonical egui0.37 dependency includes egui-icons.ttf (a small subset of emoji-icon-font)
through default_fonts, and emoji-icon-font.ttf through monochrome_emoji_fonts. Both are embedded
by the UI font definitions. Their upstream project is https://github.com/jslegers/emoji-icon-font;
the exact copyright and permission notice from the pinned egui dependency follows.
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+19 -1
View File
@@ -4,7 +4,7 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and NuLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: egui 0.37 migration records the `craft-i18n.rev` catalog pin (9205108…; 27 languages) and the input-method handling; managed CI passed; native acceptance and native-speaker review remain open; previously trivial: progress log: regex Replace expands capture groups, #4177; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: progress log: justified lines ending in a manual line break reach the margin, #3924; previously trivial: progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes toLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -99,6 +99,8 @@ the two partial ones (real-world .docx, printing) complete, roughly.
Weights and evidence per area: [docs/target-app-parity.md](docs/target-app-parity.md#feature-areas).
> **Three-column preset update:** 2026-10-15 · headers and footers use three editable, borderless table cells with guides while editing (#2197). Readiness estimates unchanged.
## Languages
| Language | Code | Status | UI strings |
@@ -138,12 +140,15 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
## Progress log
- 2026-10-25: Interface catalogs are still the shared storytold/craft-i18n ones embedded from `craft-i18n.rev` (`9205108c…`): 27 WordCraft languages with English fallback for missing strings, and the equation “Delete Around Caret in Equation” translation preserved. Native-speaker review remains human.
- 2026-10-25: Input methods (egui 0.37): character- and byte-counted surrounding deletions, a fresh composition after body/equation replacement, rejection of queued stale replacements before mutation, transactional equation deletes and one undo group, with tracked and placeholder length mapping. Synthetic regressions document these paths; managed CI passed, and native IME acceptance (#155–#164) plus browser IME behavior remain open.
- 2026-10-25: Text Highlight Color shows the last colour as the button's bar in the ribbon and mini toolbar; after No Color it shows the pen without a misleading yellow bar, and the button removes highlighting (#4087).
- 2026-10-23: cu20we: DrawingML flat, square and round line caps are imported, saved, displayed and exported to PDF; absent or unknown caps default to flat. VML end caps (including shapetype inheritance) are preserved through DOCX save. Ink and metafile stroke handling remain separate. Synthetic DOCX, raster and PDF regressions cover the change; no new native Word comparison.
- 2026-10-23: cu20we: paragraph-relative box floats start above effective space before; header, footer, cell and text-box layout regressions pass. Framed paragraph positioning is unchanged; no new native Word comparison.
- 2026-10-23: AutoFormat As You Type: `*Bold* and _italic_ as real formatting`. With the option on, `*word*` typed and finished becomes bold and `_word_` italic, without the marks. It is off by default (`tools.autocorrect {"boldItalic"}`).
- 2026-10-24: Parity catalog: Read Mode's View menu has its own rows. Column Width (`view.readColumnWidth`) and Page Color (`view.readPageColor`) count as live; Layout (`view.readLayout`) is listed as missing. Catalog 504/520 (96.9%).
- 2026-10-24: Find and Replace: with Regular expressions on, Replace with expands capture groups instead of inserting them as typed. `(\w+), (\w+)` replaced with `$2 $1` or `\2 \1` swaps the words, and `(?<y>\d{4})-` with `${y}/` names a group; `$0` is the whole match, `$$` a dollar sign, `\t` a tab and `\n` a paragraph mark that splits the paragraph, as `^p` does. Missing or out-of-range groups expand to nothing, and the `^` codes still work (#4177).
- 2026-10-23: DOCX: a document whose default font size is unset keeps its size when saved and reopened. The size was left out of the file, and the reader read the missing size as 10 pt while the document resolved it as 11 pt; the file now says 11 pt (`DEFAULT_FONT_SIZE`). Files from other programs without a size are read as before.
- 2026-10-23: Insert › Table: hovering the grid shows the table on the page where it would go, as Word's Live Preview does, and the preview follows the pointer from square to square. It is laid out on a copy of the document, so nothing changes, and Undo has nothing new, until a square is clicked (#3931).
- 2026-10-24: Exact line spacing fixes the floating-table contact boundary independently of paragraph-mark metrics (#3977). Both row selection and word skipping use it; original regressions cover oversized and undersized marks, contact and one-twip offsets in modes 11/12/14/15. Shape behavior is unchanged.
@@ -154,12 +159,14 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-10-24: AutoFormat: a paragraph numbered through its style (a heading in a heading-linked Multilevel List) keeps `---` and `+---+` as text on Enter, as a paragraph numbered directly does. At the top of the document the border-line AutoFormat removed the characters and switched off the heading's number, and left no border.
- 2026-10-23: Lists: an empty paragraph whose mark is hidden takes no room and draws no list number, in the body and in table cells, as in Word; the number is still counted, so the next item keeps its place (#3982).
- 2026-10-24: Spelling: with Ignore web and file addresses on, a web or e-mail address after a no-break space or an ideographic space is skipped, as it is after a plain space. Its words were marked as misspelled.
- 2026-10-23: Justified paragraphs: a line ending in a manual line break (Shift+Enter) is stretched to the right margin like a wrapped line, as Word does; it stayed short like the paragraph's last line. A document with Word's `w:doNotExpandShiftReturn` compatibility option keeps such lines short, and the option is read and written back (#3924).
- 2026-10-23: Font dialog: "NaN" or "inf" typed in the Size box previews the text at 12 pt. "NaN" reads as a number, and a font size that is not a number stopped the app while drawing the preview (#3663).
- 2026-10-24: AutoFormat As You Type has "Set left- and first-indent with tabs and backspaces" (off by default): Tab at the start of a paragraph with text indents its first line, Tab at the start of a later line indents the whole paragraph, and Backspace at the start takes the indents back.
- 2026-10-23: Tables: a cell span read from a `.json` document (any 32-bit number) no longer overflows when a column is inserted through the cell or the cell is merged: the span is counted from its clamped value and stays within 63 columns. It panicked in debug builds and wrapped to a small span in release (#3637).
- 2026-10-22: Spanish proofing (#3360, #3553): text in Spanish was never checked (it fell through to the languages without tools). Now Spain (`es-ES`), Mexico (`es-MX`), Latin America (`es-419` and the other countries of the Americas) and Uruguay (`es-UY`) each read their own installed dictionary, with fallbacks; suggestions restore accents first (`prueva` → `prueba`, `tambien` → `también`); Uruguayan Spanish accepts the `vos` forms, imperatives included (`vení`, `decime`, `decímelo`); repeated-word, spacing and missing `¿`/`¡` checks explain in Spanish; Review › Language, the status bar and File › Options › Proofing list the four varieties, and a Spanish interface writes new documents in the region's Spanish. No dictionary data bundled; parity estimates unchanged
- 2026-10-24: AutoCorrect: a word typed after a no-break space or an ideographic space is corrected as after a plain space (replacements, sentence capitals, ordinals and links). AutoCorrect left such a word as typed.
- 2026-10-23: Table of contents: Update Table on a TOC set in columns (here Word's own two-column TOC, written by Word in Portuguese) keeps it in its columns (#3889). The page numbers stand at the column's right edge rather than the page's, so entries no longer run over the next column and push its page numbers off the page; the TOC styles Word stores under translated ids (`Sumrio1` for `toc 1`) are read as the TOC styles, so the entries keep their font, indents and spacing; and no blank line tops the first column. Update, save and reopen keep it so. The same keeps a Portuguese Word's TOC formatted as its TOC styles say through Update Table (#3891).
- 2026-10-22: Markdown inline code replaces each line ending with a space before export, including code in lists and table cells. A following heading, list marker or HTML tag stays literal code (#3738).
- 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen.
- 2026-10-24: MCP `type_text` with `paragraphs: true` is one command (#3973): it was a `text.newParagraph` and a `text.insert` per line, each snapshotting the whole document for Undo, so 10,000 lines took 2.8 s (doubling the lines cost 2.4–3.2×) and left an Undo step per line; now they take ~10 ms (linear) and one Undo takes them all back. A CRLF is one break, not two, and the breaks no longer run AutoCorrect (the per-line way capitalised every line but the last). Typed line breaks are the Enter key, so in a one-line content control, where Enter does nothing, they no longer split it into paragraphs.
- 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459).
@@ -188,6 +195,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
| Date | What landed |
| 2026-10-17 | Password-protected Word files from Word 2007 (Standard encryption: AES-128/192/256, SHA-1) open with their password, also when named `.doc` or `.dot`; a wrong or missing password is reported as for newer files. Word 97-2003 files with an AES header, which the public spec doesn't define, are refused with a clear message (gap 9) |
| 2026-10-23 | A command that changes nothing is not an edit: it adds no undo step, marks no unsaved changes and forces no relayout, so the redo stack survives it and five hundred of them can no longer push the user's own edits out of the undo history. |
| 2026-10-23 | Interface translations come from storytold/craft-i18n, shared with the other Crafting Apps: the catalogs are embedded at build time from the commit in `craft-i18n.rev` and looked up through craft-libs' `craft-i18n` crate; `crates/ui-egui/src/i18n/*.tsv` are gone (translations now go to craft-i18n). Strings other apps already translate show translated; a build without `CRAFT_I18N_DIR` is English only |
| 2026-10-25 | #4085 mark review: whole-cell and ordinary highlight targets include their selected paragraph marks; empty, inherited and partial-column controls added. |
@@ -213,6 +221,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-19 | A table-of-contents entry whose text just fits no longer leaves its page number alone at the start of the next line: the tab goes there with it, leader and all (#1840). |
| 2026-10-19 | A Thai run with Latin letters and digits in a missing Thai font is drawn in one face on Linux too (#1549): the fallback no longer stops at Noto Sans Thai, which has no Latin letters, but finds an installed font covering the whole run (IBM Plex Sans Thai, say) and loads its whole family, so regular text gets the Regular face. Concurrent lookups no longer cache a miss. |
| 2026-10-19 | Word signature lines (Insert › Signature Line) survive a save: a .docx saved by WordCraft keeps each one as a signature line, still signable in Word, with its suggested signer, instead of turning it into a plain picture. |
| 2026-10-25 | Custom document properties (#2982, split from #1798): File › Info › Properties › Advanced Properties… opens a Custom Document Properties dialog that lists the document's custom properties and adds, modifies and deletes them through the name/value form (text properties only; Word's number/date/yes-no types not offered). OK is one undoable `file.properties` step and marks the document changed; Cancel discards everything; names and values are validated against the engine's limits with the error shown in the dialog. Saved properties survive a DOCX round trip. |
| 2026-10-15 | A picture anchored further down a page but placed higher up it (fixed on the page, or moved up from its paragraph) has the paragraphs above its anchor wrap around it too, instead of running behind it, also when that wrap pushes the anchor onto the next page (#2048) |
| 2026-10-20 | Table sorting and formulas read and write decimal points for Cuban and Peruvian Spanish (`es-CU`, `es-PE`), so `1.234` is a decimal instead of 1234; grouped-number and result-formatting regressions retain Spain's decimal comma. |
| 2026-10-20 | Set Proofing Language › Set As Default persists for future blank documents and plain-text files, independently of the interface language; existing DOCX and templates keep their languages, and rejected commands retain the previous saved default (#2882). |
@@ -301,6 +310,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-11 | View › Window across WordCraft windows (#322): Switch Windows, Arrange All, View Side by Side, Synchronous Scrolling |
| 2026-10-18 | Picture Format › Size (#2573): width and height in the interface unit, and a size typed in any unit works, pixels too (`300 px` keeps the picture's proportions, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels) in one undo step. The group's launcher opens a Size dialog: height and width, scale of the original size (or of the size it opened with), Lock aspect ratio, the original size with its pixels, and Reset. `picture.size {"original": true, "scale"?}` sizes from the original; sizes past 4000 pt that keep the proportions shrink as a whole. |
| 2026-10-16 | Save Changes gives assistive technology a named alert dialog with the loss warning and any save failure; visual layout and save policy unchanged (#1015). Native screen-reader verification remains pending. |
| 2026-10-17 | `design.scaleFontSizes` (Design › Document Formatting): grow or shrink every style's font size at once, by points (`delta`) or percent, rounded to half points and clamped to 1–1638 pt; inherited sizes follow, runs are untouched, one undo step (#1475) |
| 2026-10-15 | The font family caches are bounded (4096 families for text, 1024 for math, names over 256 bytes aren't remembered), so a document naming thousands of missing fonts can't grow memory; equation fonts are looked up again after a font is added or removed instead of keeping the old choice (#1354) |
| 2026-10-15 | AutoCorrect recognizes `-->` as `→` (#2223), respecting replacement settings and undo without changing paragraph-start arrow bullets. |
| 2026-10-15 | Numbered headings (#1793): the Multilevel List gallery offers four lists linked to Heading 1–9 (1 / 1.1 / 1.1.1, Article I. / Section 1.01 / (a), I. / A. / 1., Chapter 1; `list.multilevelPreset`). The heading styles carry the numbers, so paragraphs already in a Heading style keep their style and level, any other selected paragraph (Normal, Quote, Title) becomes Heading 1 (checked in Word), a new Heading 2 is numbered 2.1 by itself and body text stays unnumbered; Numbering or typing `1. ` keeps a heading a heading |
@@ -327,6 +337,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
| 2026-10-15 | Layout › Columns with text selected puts only that text in columns, as in Word (#2024): a continuous section break goes where the selection starts and one where it ends (splitting a paragraph there; none at the document's start or end, or where a section already begins or ends), and the columns go on the section between, one undo step; More Columns… offers "Selected text" and picks it. Before, the whole section took the columns |
| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture |
| 2026-10-15 | The Three columns header/footer preset inserts a borderless one-row, three-cell table spanning the section text width, with left/center/right alignment (#2197). Tab moves between the cells. Cell guides show while editing the story and disappear when it is closed, unless View Gridlines is enabled. |
| 2026-10-12 | Table formulas: cell references and ranges (`=B2+B3`, `=SUM(B2:C3)`), arithmetic and comparisons, IF, AND, OR, NOT, TRUE, FALSE, DEFINED and the `\#` number format; ABOVE and LEFT sum the run of numbers next to the cell as Word does; an unknown function or a syntax error is an error instead of a sum; Update Field (F9) recalculates formula fields |
| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 |
| 2026-10-18 | Different First Page works from page 1 (#2826): Insert › Header and Edit Header with the caret on page 1, and a double-click there while another header is being edited, go into page 1's own header instead of the default one page 1 no longer shows. |
@@ -627,6 +638,8 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | Progress log: egui 0.37 migration records the `craft-i18n.rev` catalog pin (9205108…; 27 languages) and the input-method handling; managed CI passed; native acceptance and native-speaker review remain open |
| 2026-10-17 | trivial | Progress log: Standard-encrypted (AES) Word files open (gap 9) |
| 2026-10-25 | trivial | Progress log: Text Highlight Color buttons show the remembered colour, or no colour bar after No Color (#4087) |
| 2026-10-23 | minor | A command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it. |
| 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged |
@@ -639,6 +652,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-24 | trivial | Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). |
| 2026-10-24 | trivial | Progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting |
| 2026-10-24 | trivial | Progress log: the parity catalog counts Read Mode's View menu (Column Width, Page Color, Layout) |
| 2026-10-24 | trivial | Progress log: regex Replace expands capture groups (#4177) |
| 2026-10-23 | trivial | Progress log: interface translations from storytold/craft-i18n through the `craft-i18n` crate |
| 2026-10-23 | trivial | Progress log: comment times in local time (#2322, #2337) |
| 2026-10-22 | trivial | Progress log: mail merge record navigation and record ranges for Finish & Merge and Print Documents (#3028) |
@@ -654,6 +668,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-20 | minor | Restrict Editing, three more gaps closed: the protection of a protected document only changes through the explicit stop, the session author can't change through `file.setAuthor` under protection, and the post-check compares the settings and the metadata as well as the text and the formatting |
| 2026-10-20 | minor | Restrict Editing: a file command that changes the open document is checked like any other — the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version no longer lifts the protection or drops its password |
| 2026-10-17 | trivial | Progress log: disabled-control reasons translated (#2678) |
| 2026-10-22 | trivial | Markdown code spans normalize line endings before export, keeping block markers literal (#3738); format readiness unchanged |
| 2026-10-22 | trivial | Progress log: a click in the footer while editing the header moves there |
| 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. |
| 2026-10-17 | trivial | Progress log: Home › Underline drop-down (#2738) |
@@ -776,8 +791,10 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-16 | trivial | Progress log: a text frame in the paragraph defaults frames nothing (#2360) |
| 2026-10-16 | trivial | Progress log: a text file saved over itself closes without asking (#2376) |
| 2026-10-15 | trivial | Progress log: recovery copies and .json files store pictures as base64 (#586 follow-up) |
| 2026-10-17 | trivial | Progress log: Scale Font Sizes command (#1475) |
| 2026-10-14 | trivial | Progress log: row table border exceptions |
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
| 2026-10-15 | trivial | Three-column header/footer presets use editable table cells and screen-only editing guides (#2197); readiness unchanged |
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
| 2026-10-12 | trivial | Progress log: one selected cell's border drags in its row only (#962) |
| 2026-10-16 | trivial | Progress log: opening a DOCX no longer copies its pictures |
@@ -976,5 +993,6 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-17 | trivial | Progress log: saving into a folder that refuses a temporary file (#2138) |
| 2026-10-19 | trivial | Progress log: personal templates in File › New, Save as Word Template (#2852) |
| 2026-10-20 | trivial | Progress log: saving to a NAS share mounted by GVFS (#3003) |
| 2026-10-23 | trivial | Progress log: justified lines ending in a manual line break reach the margin (#3924) |
| 2026-10-23 | trivial | Progress log: "NaN" in the Font dialog's Size box no longer closes the app (#3663) |
| 2026-10-23 | trivial | Progress log: a table cell span from a `.json` file no longer overflows (#3637) |
+28 -17
View File
@@ -146,22 +146,10 @@ fn stored_prefs() -> Option<String> {
}
impl eframe::App for WebShell {
/// Runs one browser frame: forwards queued host messages to the status bar, advances the app,
/// then writes changed preferences to browser storage immediately, because a reload or closed
/// tab never runs the exit path.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
let msgs = std::mem::take(&mut *self.messages.borrow_mut());
for msg in msgs {
self.app.status(msg);
@@ -180,11 +168,34 @@ impl eframe::App for WebShell {
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
/// Forwards this frame's raw browser input to the app before widgets run.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(ctx, raw);
}
/// Draws the app. On the first pass of a visible frame it takes the browser's dropped files and
/// spawns asynchronous byte reads into the inbox; later passes only draw.
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
// Browser drops are input: take them only in the first real UI pass, never in a hidden
// `run_logic` tick that still sees the previous shown frame's RawInput.
if ui.ctx().current_pass_index() == 0 {
let ctx = ui.ctx().clone();
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
}
self.app.ui(ui);
}
}
+4 -1
View File
@@ -19,7 +19,7 @@ wordcraft-fonts = { workspace = true }
wordcraft-ui-egui = { workspace = true }
# The control key file's location (shared with the MCP bridge).
wordcraft-control-key = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11", "links"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
@@ -83,3 +83,6 @@ pollster = "0.4"
arboard = { workspace = true, features = ["image-data"] }
# The control server's random key.
getrandom = "0.3"
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
+12 -1
View File
@@ -36,7 +36,8 @@ const DESKTOP_ID: &str = "ai.storyteller.wordcraft.desktop";
/// The types the button makes WordCraft the default for on Linux and BSD: Word documents and
/// templates (also macro-enabled and legacy), RTF and ODT. The same set the Windows installer
/// lists under WordCraft's Capabilities; Markdown and plain text are only offered under Open With.
/// lists under WordCraft's Capabilities; Markdown is only offered under Open With, and plain
/// text only on Windows and macOS (the `.desktop` file leaves it out, #4137).
/// Every one is in the `.desktop` file's `MimeType=` (a test checks).
#[cfg(any(all(unix, not(target_os = "macos")), test))]
const WORD_MIME_TYPES: &[&str] = &[
@@ -164,6 +165,16 @@ mod tests {
assert!(entry.lines().any(|l| l == "Exec=wordcraft %F"), "files are passed on the command line");
}
/// Declaring `text/plain` makes WordCraft a handler for every config, script and log on systems with no default set.
#[test]
fn the_desktop_entry_does_not_declare_plain_text() {
let entry = include_str!("../../../packaging/linux/ai.storyteller.wordcraft.desktop");
let declared: Vec<&str> =
entry.lines().find_map(|l| l.strip_prefix("MimeType=")).map(|v| v.split(';').filter(|s| !s.is_empty()).collect()).unwrap_or_default();
assert!(!declared.is_empty(), "MimeType= line not found");
assert!(!declared.contains(&"text/plain"), "text/plain must not be declared");
}
#[cfg(unix)]
#[test]
fn application_dirs_follow_the_xdg_spec() {
+1 -1
View File
@@ -208,7 +208,7 @@ fn save_exts(name: &str) -> Vec<String> {
let suggested = std::path::Path::new(name)
.extension()
.and_then(std::ffi::OsStr::to_str)
.map(|e| e.to_ascii_lowercase())
.map(str::to_ascii_lowercase)
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()))
.unwrap_or_else(|| "docx".to_string());
let mut exts: Vec<String> = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len());
+34 -11
View File
@@ -28,41 +28,49 @@ mod windows;
use wordcraft_engine::Session;
use wordcraft_ui_egui::{
Services, UiState, WordApp,
window_geometry::{WindowGeometry, take_rescue},
window_geometry::{RawWindowState, WindowGeometry, take_rescue},
};
/// The app, the restored window geometry (or, with none, the default: no position) until the
/// first frame that knows the monitor has checked it, the control server's key file (removed on
/// exit), the file dialogs the app asked for, this window's entry in the window registry
/// (removed on exit; View › Window, #322) and, on macOS, the documents opened from Finder.
/// exit), the file dialogs the app asked for, this frame's raw host window state, this window's
/// entry in the window registry (removed on exit; View › Window, #322) and, on macOS, the
/// documents opened from Finder.
struct App(
WordApp,
Option<WindowGeometry>,
Option<control_server::KeyFile>,
file_dialogs::Launcher,
// The host's answer for this frame. `InputState` still describes the last painted frame on
// a hidden logic tick.
RawWindowState,
Option<windows::RegistryFile>,
#[cfg(target_os = "macos")] fmv_macos_events::Inbox,
);
impl eframe::App for App {
/// Advances one desktop frame: polls Finder events on macOS, runs the app and its file dialogs,
/// fits or rescues the window and maximizes it after opening, then gates a close or quit request
/// on unsaved changes and active printing.
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
apple_events::poll(&self.5, &mut self.0, ctx);
apple_events::poll(&self.6, &mut self.0, ctx);
self.0.logic(ctx);
self.3.show(frame);
let window = self.4;
// A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit
// there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first
// knows the monitor: the off-screen check below takes the pending geometry then.
if let Some((pos, size)) = ctx.input(|i| self.1.and_then(|g| g.fit_on_monitor(i.viewport()))) {
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
}
let prev = self.0.ui.window;
self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size()));
self.0.ui.window = WindowGeometry::track(prev, &window);
// The first frame, once the monitor is known (as `take_rescue` waits for): maximize a
// window that opened at its normal size so it never flashed on screen half-opened.
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| ctx.input(|i| i.viewport().monitor_size.is_some()));
if let Some(pos) = ctx.input(|i| take_rescue(&mut self.1, i.viewport())) {
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| window.monitor_size.is_some());
if let Some(pos) = take_rescue(&mut self.1, &window) {
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
}
if let Some(command) = maximize {
@@ -88,8 +96,13 @@ impl eframe::App for App {
}
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
/// Records this frame's native window state before forwarding the hook, so logic never reads a
/// stale inner rectangle from a hidden tick.
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
// Keep this frame's native window state. `logic` must not read the last painted
// `InputState` when a hidden Wayland tick reports no fresh inner rect.
self.4 = RawWindowState::from_raw(raw);
self.0.raw_input_hook(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.0.ui(ui);
@@ -102,7 +115,7 @@ impl eframe::App for App {
if let Some(key_file) = &self.2 {
key_file.remove();
}
if let Some(entry) = &self.4 {
if let Some(entry) = &self.5 {
entry.remove();
}
}
@@ -326,7 +339,15 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
}
}
/// Parses the command line, installs logging and the panic hook, restores preferences and window
/// geometry, opens requested documents or the Start screen, starts the control server when
/// asked, and runs the eframe event loop.
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::WORDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
logging::install_panic_hook();
@@ -442,6 +463,7 @@ fn main() -> eframe::Result {
file
});
let mut app = WordApp::new(session, services);
app.set_startup_update_check(true);
load_prefs(&mut app);
load_user_dictionary(&mut app);
// A region chosen in File › Options replaces the system's for the first blank
@@ -493,6 +515,7 @@ fn main() -> eframe::Result {
Some(restored.unwrap_or_default()),
key_file,
dialogs,
RawWindowState::default(),
registry,
#[cfg(target_os = "macos")]
apple_events.connect(&cc.egui_ctx),
+6 -1
View File
@@ -1,11 +1,16 @@
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
/// Returns a discovery callback that enumerates spooler printers on a named worker thread and
/// sends the list through a channel, keeping spooler calls off the UI thread.
pub fn discover() -> Discover {
Box::new(|| {
let (sender, receiver) = std::sync::mpsc::channel();
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
let printers = craft_print::printers()
.into_iter()
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
.collect();
let _ = sender.send(printers);
});
receiver
+1 -1
View File
@@ -1 +1 @@
04600cbb1508f1b15927ec574a5e32e5f376fd8b
9205108cfdced5177706264038848b68b2422d2b
+1 -1
View File
@@ -238,7 +238,7 @@ pub(crate) fn ideograph_zodiac_traditional(n: u32) -> String {
/// The `n`th (1-based) item of a sequence, or decimal past its end.
fn nth(set: &[char], n: u32) -> String {
n.checked_sub(1).and_then(|i| set.get(i as usize)).map(|c| c.to_string()).unwrap_or_else(|| n.to_string())
n.checked_sub(1).and_then(|i| set.get(i as usize)).map(ToString::to_string).unwrap_or_else(|| n.to_string())
}
/// Code points `first..` enclosing 1 to `count`, decimal beyond.
+712
View File
@@ -0,0 +1,712 @@
//! Visible logical text for IME surrounding-text queries.
//!
//! The platform input method works on what the reader sees, not on the stored run text: tracked
//! deletions are hidden, and an inline object stands for its plain-text result. The helpers here
//! build a bounded window around a selection without cloning the paragraph or the document, and
//! map both ends in both directions so a query and a delete use exactly the same text.
use std::ops::Range;
use crate::para::{OBJ, Paragraph};
use crate::{Document, Pos};
/// A bounded visible-logical window around a selection.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeWindow {
/// At most [`MAX_IME_TEXT_BYTES`] bytes of visible committed text.
pub text: String,
/// UTF-8 byte offset of the primary end in [`Self::text`].
pub cursor: usize,
/// UTF-8 byte offset of the other end in [`Self::text`].
pub anchor: usize,
}
/// The largest visible window an IME query copies.
pub const MAX_IME_TEXT_BYTES: usize = 4000;
/// Which unit an input method's delete-surrounding counts use.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DeleteUnit {
/// Unicode scalar values of the visible text.
Chars,
/// UTF-8 bytes of the visible text.
Bytes,
}
/// A deletion request in the paragraph's stored byte offsets.
///
/// The selection itself is never part of `before` or `after`; both ranges are outside it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeDelete {
/// Stored byte range to erase before the selection.
pub before: Option<Range<usize>>,
/// Stored byte range to erase after the selection.
pub after: Option<Range<usize>>,
/// Stored byte offset of the selection's anchor **before** the deletions.
///
/// The ranges are returned in the paragraph's stored text as it is now; the caller applies
/// them (and shifts these offsets past `before`) itself.
pub anchor: usize,
/// Stored byte offset of the selection's focus **before** the deletions.
pub focus: usize,
}
/// One paragraph's visible logical text.
///
/// Deleted runs are skipped and inline objects contribute their plain-text result. Iteration is
/// lazy: no full visible copy is made.
pub struct VisibleParagraph<'a> {
para: &'a Paragraph,
deleted: Vec<Range<usize>>,
}
impl<'a> VisibleParagraph<'a> {
/// A view of `para` with tracked deletions hidden.
pub fn new(para: &'a Paragraph) -> Self {
let mut deleted = para.deleted_ranges();
deleted.sort_unstable_by_key(|r| r.start);
Self { para, deleted }
}
/// Whether `off` falls inside a tracked deletion, advancing `next` past the runs that end
/// before it.
///
/// The ranges are sorted, so one monotone cursor makes a whole scan O(text + ranges) instead
/// of O(text × ranges): alternating tracked revisions used to make every character scan every
/// range.
fn is_deleted_at(&self, next: &mut usize, off: usize) -> bool {
while let Some(r) = self.deleted.get(*next) {
if r.end > off {
break;
}
*next = next.saturating_add(1);
}
self.deleted.get(*next).is_some_and(|r| r.start <= off && off < r.end)
}
/// Call `f` for every visible piece: `(stored byte start, piece, stored byte len)`.
fn for_each_piece(&self, mut f: impl FnMut(usize, &str, usize)) {
self.for_each_piece_while(|start, piece, stored| {
f(start, piece, stored);
true
});
}
/// Call `f` for visible pieces until it returns `false`.
fn for_each_piece_while(&self, mut f: impl FnMut(usize, &str, usize) -> bool) {
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let keep_going = if is_object {
let piece = self.para.objects.get(object - 1).map_or("", |o| o.plain_text());
f(i, piece, OBJ.len_utf8())
} else {
let mut buf = [0u8; 4];
f(i, c.encode_utf8(&mut buf), c.len_utf8())
};
if !keep_going {
break;
}
}
}
/// Total visible UTF-8 bytes.
pub fn visible_len(&self) -> usize {
let mut len = 0usize;
self.for_each_piece(|_, piece, _| len = len.saturating_add(piece.len()));
len
}
/// Visible offset of a stored paragraph byte offset.
///
/// `None` when the stored offset is not a character boundary, points inside a tracked
/// deletion, or would split an inline object's plain-text result.
pub fn visible_offset(&self, stored: usize) -> Option<usize> {
if stored > self.para.text.len() || !self.para.text.is_char_boundary(stored) {
return None;
}
let mut visible = 0usize;
let mut found = false;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
if i == stored {
found = true;
break;
}
if i > stored {
break;
}
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
// A position at the start of a deleted run is still a visible position; one
// inside it is not.
continue;
}
if is_object {
let piece = self.para.objects.get(object - 1).map_or("", |o| o.plain_text());
visible = visible.saturating_add(piece.len());
} else {
visible = visible.saturating_add(c.len_utf8());
}
}
if !found {
// The end of the paragraph, or a deleted run at its end.
let mut next = 0usize;
return (!self.is_deleted_at(&mut next, stored)).then_some(visible);
}
if stored < self.para.text.len() && self.is_deleted_at(&mut deleted, stored) {
// The start of a deleted run is still a visible caret position; only a position
// strictly inside one is unrepresentable.
let at_start = self.deleted.iter().any(|r| r.start == stored);
return at_start.then_some(visible);
}
Some(visible)
}
/// Stored paragraph byte offset for a visible offset.
///
/// `None` when the visible offset falls inside an inline object's plain-text result or is
/// not a boundary of the visible representation.
pub fn stored_offset(&self, visible: usize) -> Option<usize> {
let mut acc = 0usize;
let mut answer = None;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
if visible == acc {
answer = Some(i);
break;
}
if visible < acc.saturating_add(piece_len) {
// Inside a character (or an object's expansion): no stored boundary there.
return None;
}
acc = acc.saturating_add(piece_len);
}
answer.or_else(|| (visible == acc).then_some(self.para.text.len()))
}
/// Stored offset for the **start** of a visible range, skipping zero-length objects at
/// the same boundary.
///
/// A content control's markers are inline objects with no plain text, so they share a
/// visible offset with the content just inside (or after) them. A range start must map
/// after a control-start marker and after a control-end marker, never onto the marker
/// itself: an IME deletion must not break the control. Non-empty object expansions still
/// map to the object's own stored offset, so a range that reaches one deletes it whole.
pub fn stored_offset_after(&self, visible: usize) -> Option<usize> {
let mut acc = 0usize;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
if visible == acc && piece_len > 0 {
return Some(i);
}
if visible < acc.saturating_add(piece_len) {
return None;
}
acc = acc.saturating_add(piece_len);
}
(visible == acc).then_some(self.para.text.len())
}
/// Stored offset for a visible offset, preferring the position **before** a tracked
/// deletion that starts there. Use this for the end of a range so a deletion run just
/// after the caret is never drawn into it.
pub fn stored_offset_before(&self, visible: usize) -> Option<usize> {
let mut acc = 0usize;
let mut object = 0usize;
let mut deleted = 0usize;
for (i, c) in self.para.text.char_indices() {
if visible == acc {
return Some(i);
}
let is_object = c == OBJ;
if is_object {
object += 1;
}
if self.is_deleted_at(&mut deleted, i) {
continue;
}
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
if visible < acc.saturating_add(piece_len) {
return None;
}
acc = acc.saturating_add(piece_len);
}
(visible == acc).then_some(self.para.text.len())
}
/// Round a visible byte offset forward to the next visible boundary (inward for a start).
pub fn ceil_boundary(&self, visible: usize) -> usize {
let mut acc = 0usize;
let mut out = None;
self.for_each_piece(|_, piece, _| {
if out.is_some() {
return;
}
if acc >= visible {
out = Some(acc);
return;
}
acc = acc.saturating_add(piece.len());
if acc >= visible {
out = Some(acc);
}
});
out.unwrap_or(acc)
}
/// Round a visible byte offset backward to the previous visible boundary (inward for an end).
pub fn floor_boundary(&self, visible: usize) -> usize {
let mut acc = 0usize;
let mut out = 0usize;
self.for_each_piece(|_, piece, _| {
let next = acc.saturating_add(piece.len());
if next > visible {
return;
}
acc = next;
out = acc;
});
out
}
/// A bounded window with `cursor` and `anchor` as visible byte offsets.
///
/// The selection is never split and the window never exceeds [`MAX_IME_TEXT_BYTES`]. A
/// selection wider than that, an invalid offset, or an offset that cannot be represented as
/// visible text returns `None` (unavailable, not fabricated).
pub fn window(&self, cursor: usize, anchor: usize, max_bytes: usize) -> Option<ImeWindow> {
// The public API caps its own budget: a caller cannot ask for an unbounded copy.
let max_bytes = max_bytes.min(MAX_IME_TEXT_BYTES);
let (sel_start, sel_end) = (cursor.min(anchor), cursor.max(anchor));
let selection_len = sel_end - sel_start;
if selection_len > max_bytes {
return None;
}
// Spend the budget around the selection and stop walking at its end: the paragraph is
// never copied and the work is bounded by the caret's distance from the paragraph start.
let before = (max_bytes - selection_len) / 2;
let window_start = sel_start.saturating_sub(before);
let window_end = window_start.saturating_add(max_bytes);
let mut text = String::with_capacity(window_end.saturating_sub(window_start).min(max_bytes));
let mut first_kept = None;
let mut acc = 0usize;
self.for_each_piece_while(|_, piece, _| {
for (k, c) in piece.char_indices() {
let start = acc.saturating_add(k);
let end = start.saturating_add(c.len_utf8());
if end > window_end {
return false;
}
if start >= window_start {
if first_kept.is_none() {
first_kept = Some(start);
}
text.push(c);
}
}
acc = acc.saturating_add(piece.len());
acc < window_end
});
let Some(first) = first_kept else {
// An empty document is an available empty context, never unavailable.
return (cursor == 0 && anchor == 0).then_some(ImeWindow { text: String::new(), cursor: 0, anchor: 0 });
};
Some(ImeWindow { text, cursor: cursor.saturating_sub(first), anchor: anchor.saturating_sub(first) })
}
}
impl Document {
/// Bounded visible logical text around `cursor`/`anchor` in one paragraph.
///
/// A selection across paragraphs or blocks is unrepresentable and returns `None`.
pub fn ime_surrounding(&self, cursor: &Pos, anchor: &Pos, max_bytes: usize) -> Option<ImeWindow> {
if cursor.story != anchor.story || cursor.path != anchor.path {
return None;
}
let para = self.para_at(cursor)?;
let view = VisibleParagraph::new(para);
let c = view.visible_offset(cursor.off)?;
let a = view.visible_offset(anchor.off)?;
view.window(c, a, max_bytes.min(MAX_IME_TEXT_BYTES))
}
/// Delete-surrounding ranges in the stored paragraph text.
///
/// Counts exclude the selection, tracked deletions and any pre-edit (which never lives in
/// the model here). Byte counts that end inside a scalar leave it whole: a start rounds
/// forward and an end rounds backward; a byte count beyond the visible text is refused.
/// Character counts clamp at the paragraph's ends, as the web text agent's do. A range that
/// cannot be represented in stored text returns `None` instead of guessing; so does a
/// request with nothing to erase (`0` on both sides).
pub fn ime_delete_surrounding(&self, cursor: &Pos, anchor: &Pos, before: usize, after: usize, unit: DeleteUnit) -> Option<ImeDelete> {
if cursor.story != anchor.story || cursor.path != anchor.path {
return None;
}
if before == 0 && after == 0 {
// Nothing to erase: don't build the boundary table (or walk a long paragraph) to
// discover it.
return None;
}
let para = self.para_at(cursor)?;
let view = VisibleParagraph::new(para);
let c = view.visible_offset(cursor.off)?;
let a = view.visible_offset(anchor.off)?;
let (sel_start, sel_end) = (c.min(a), c.max(a));
let (before_start, after_end) = match unit {
DeleteUnit::Bytes => {
let total = view.visible_len();
if before > sel_start || after > total - sel_end {
return None;
}
(view.ceil_boundary(sel_start.saturating_sub(before)), view.floor_boundary(sel_end.saturating_add(after)))
}
DeleteUnit::Chars => char_boundaries(&view, sel_start, sel_end, before, after)?,
};
// Start a range after a deletion run and end it before one, so tracked deletions are
// never part of the erased stored range.
let before_start = view.stored_offset_after(before_start)?;
let before_end = view.stored_offset_before(sel_start)?;
let after_start = view.stored_offset_after(sel_end)?;
let after_end = view.stored_offset_before(after_end)?;
let before_range = (before_start < before_end).then_some(before_start..before_end);
let after_range = (after_start < after_end).then_some(after_start..after_end);
Some(ImeDelete { before: before_range, after: after_range, anchor: anchor.off, focus: cursor.off })
}
}
/// The visible byte boundaries `before` characters back from `sel_start` and `after`
/// characters on from `sel_end`.
///
/// Two walks and constant memory replace the old per-character `Vec` that held every boundary
/// (a hostile count could make it allocate the whole paragraph). Character counts clamp at the
/// paragraph's ends; `None` when either selection end is not a boundary of the visible text.
fn char_boundaries(view: &VisibleParagraph<'_>, sel_start: usize, sel_end: usize, before: usize, after: usize) -> Option<(usize, usize)> {
let mut acc = 0usize;
let mut chars = 0usize;
let mut start_index = None;
let mut end_index = None;
view.for_each_piece(|_, piece, _| {
for (k, _) in piece.char_indices() {
let at = acc.saturating_add(k);
if at == sel_start {
start_index = Some(chars);
}
if at == sel_end {
end_index = Some(chars);
}
chars = chars.saturating_add(1);
}
acc = acc.saturating_add(piece.len());
});
if sel_start == acc {
start_index = Some(chars);
}
if sel_end == acc {
end_index = Some(chars);
}
let start_index = start_index?;
let end_index = end_index?;
let target_before = start_index.saturating_sub(before);
let target_after = end_index.saturating_add(after).min(chars);
let mut acc = 0usize;
let mut index = 0usize;
let mut before_start = None;
let mut after_end = (target_after == 0).then_some(0);
view.for_each_piece(|_, piece, _| {
for (k, _) in piece.char_indices() {
let at = acc.saturating_add(k);
if index == target_before && before_start.is_none() {
before_start = Some(at);
}
if index == target_after && after_end.is_none() {
after_end = Some(at);
}
index = index.saturating_add(1);
}
acc = acc.saturating_add(piece.len());
});
if index == target_before && before_start.is_none() {
before_start = Some(acc);
}
if index == target_after && after_end.is_none() {
after_end = Some(acc);
}
Some((before_start?, after_end?))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::para::Run;
use crate::props::CharProps;
use crate::{InlineObject, StoryRef};
/// Builds a paragraph with one run per `(text, deleted)` part; deleted parts carry revision 0.
fn para_with_runs(parts: &[(&str, bool)]) -> Paragraph {
let mut p = Paragraph::new();
for (text, deleted) in parts {
let mut props = CharProps::default();
if *deleted {
props.del = Some(0);
}
p.text.push_str(text);
p.runs.push(Run { len: text.len(), props });
}
p
}
/// A tracked deletion is hidden from the IME view but still maps stored offsets: its start is a
/// visible caret position, its interior is not, and a window concatenates only visible text.
#[test]
fn tracked_deletions_are_hidden_and_offsets_still_map() {
let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]);
let view = VisibleParagraph::new(&p);
assert_eq!(view.visible_len(), 4);
assert_eq!(view.visible_offset(0), Some(0));
assert_eq!(view.visible_offset(2), Some(2), "the start of a deleted run is a visible caret position");
assert_eq!(view.visible_offset(3), None, "inside a tracked deletion");
assert_eq!(view.visible_offset(5), Some(2));
assert_eq!(view.visible_offset(7), Some(4));
assert_eq!(view.stored_offset(2), Some(5));
assert_eq!(view.stored_offset(4), Some(7));
assert_eq!(view.window(2, 2, 4000).map(|w| w.text), Some("abcd".into()));
}
/// An inline object contributes its plain text to visible length and boundary mapping; offsets
/// strictly inside that expansion have no stored boundary.
#[test]
fn objects_contribute_their_plain_text_and_split_mapping_stops() {
let mut p = Paragraph::new();
p.text = format!("a{OBJ}b");
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
p.objects.push(InlineObject::Equation { linear: "x+y".into(), display: false, math: Default::default() });
let view = VisibleParagraph::new(&p);
assert_eq!(view.visible_len(), 5);
assert_eq!(view.visible_offset(1), Some(1));
assert_eq!(view.visible_offset(4), Some(4));
assert_eq!(view.stored_offset(1), Some(1));
assert_eq!(view.stored_offset(4), Some(4));
assert_eq!(view.stored_offset(2), None, "inside the object's text");
}
/// An empty paragraph yields an available empty IME context at offset 0; an empty document
/// yields an available empty context too, though this test asserts only its text, not its offsets.
#[test]
fn an_empty_document_is_available_and_empty() {
let p = Paragraph::new();
let view = VisibleParagraph::new(&p);
let w = view.window(0, 0, MAX_IME_TEXT_BYTES).expect("an available empty context");
assert_eq!((w.text.as_str(), w.cursor, w.anchor), ("", 0, 0));
let doc = Document::new();
let w = doc.ime_surrounding(&Pos::body(0, 0), &Pos::body(0, 0), MAX_IME_TEXT_BYTES).expect("available");
assert_eq!(w.text, "");
}
/// A window is capped at the IME byte budget while keeping cursor and anchor inside its text; a
/// selection wider than the budget is unavailable.
#[test]
fn bounded_window_keeps_the_selection_and_rejects_too_wide_ones() {
let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default());
let view = VisibleParagraph::new(&p);
let w = view.window(5000, 5000, MAX_IME_TEXT_BYTES).expect("a bounded window");
assert!(w.text.len() <= MAX_IME_TEXT_BYTES);
assert_eq!(w.cursor, w.anchor);
assert!(w.cursor <= w.text.len());
assert!(view.window(0, 4001, MAX_IME_TEXT_BYTES).is_none());
}
/// Byte deletion rounds its start to a visible UTF-8 boundary, leaves partially covered scalar
/// values whole, and refuses counts that run past the paragraph.
#[test]
fn byte_deletion_rounds_inward_and_leaves_partial_scalars_whole() {
let doc = Document::from_text("aé👍b");
let caret = Pos::body(0, 8);
let one = doc.ime_delete_surrounding(&caret, &caret, 1, 0, DeleteUnit::Bytes).expect("b");
assert_eq!(one.before, Some(7..8));
// Two bytes start inside the emoji: it is left whole, only `b` goes.
let two = doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Bytes).expect("b");
assert_eq!(two.before, Some(7..8));
// Six bytes start inside `é`: it stays whole, the emoji and `b` go.
let six = doc.ime_delete_surrounding(&caret, &caret, 6, 0, DeleteUnit::Bytes).expect("emoji and b");
assert_eq!(six.before, Some(3..8));
// A count beyond the paragraph is refused, not clipped to delete a whole side.
assert!(doc.ime_delete_surrounding(&caret, &caret, 0, 1, DeleteUnit::Bytes).is_none());
assert!(doc.ime_delete_surrounding(&caret, &caret, 99, 0, DeleteUnit::Bytes).is_none());
}
/// Surrounding deletion skips the selected range on both sides and returns the selection's
/// original reversed anchor and focus.
#[test]
fn byte_deletion_excludes_a_reversed_selection() {
let doc = Document::from_text("abcd");
let cursor = Pos::body(0, 1);
let anchor = Pos::body(0, 3);
let del = doc.ime_delete_surrounding(&cursor, &anchor, 1, 1, DeleteUnit::Bytes).expect("a deletion");
assert_eq!(del.before, Some(0..1), "only the character before the selection");
assert_eq!(del.after, Some(3..4), "only the character after the selection");
assert_eq!((del.anchor, del.focus), (3, 1), "the reversed selection is preserved");
}
/// Tracked deletions are absent from windows and skipped when deleting around a caret, so an IME
/// edit cannot remove them.
#[test]
fn tracked_deletions_are_not_counted_for_deletion_or_query() {
let mut doc = Document::from_text("abDELcd");
let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]);
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
let view = VisibleParagraph::new(doc.para_at(&Pos::body(0, 0)).expect("paragraph"));
assert_eq!(view.window(2, 2, 100).map(|w| w.text), Some("abcd".into()));
let caret = Pos::body(0, 7);
let del = doc.ime_delete_surrounding(&caret, &caret, 1, 0, DeleteUnit::Bytes).expect("c");
assert_eq!(del.before, Some(6..7), "the stored deletion run is skipped");
}
/// A selection spanning paragraphs has no single surrounding context: both the query and
/// surrounding deletion return `None`.
#[test]
fn cross_paragraph_selection_is_unavailable() {
let doc = Document::from_text("one\ntwo");
let cursor = Pos::body(0, 1);
let anchor = Pos::body(1, 1);
assert!(doc.ime_surrounding(&cursor, &anchor, MAX_IME_TEXT_BYTES).is_none());
assert!(doc.ime_delete_surrounding(&cursor, &anchor, 1, 0, DeleteUnit::Chars).is_none());
}
/// An object's plain-text result stands for the object in the visible text; a deletion
/// that starts inside that expansion leaves the object whole (byte) or is refused (chars).
#[test]
fn object_expansion_is_never_partly_deleted() {
let mut doc = Document::from_text("");
let mut p = Paragraph::new();
p.text = format!("a{OBJ}b");
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
p.objects.push(InlineObject::Equation { linear: "x+y".into(), display: false, math: Default::default() });
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
let caret = Pos::body(0, 5);
// Two bytes before the caret end inside `x+y` (`y` is the last byte): the start rounds
// forward past the object, so only `b` goes.
let two = doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Bytes).expect("b only");
assert_eq!(two.before, Some(4..5), "stored UTF-8 offsets keep the object whole");
let mut remaining = format!("a{OBJ}b");
remaining.replace_range(two.before.clone().unwrap(), "");
assert_eq!(remaining, format!("a{OBJ}"));
// A character that lands inside the expansion has no stored boundary: refused.
assert!(doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Chars).is_none());
// A count reaching before the object erases the object as a whole.
let five = doc.ime_delete_surrounding(&caret, &caret, 5, 0, DeleteUnit::Bytes).expect("object and b");
assert_eq!(five.before, Some(0..5));
}
/// Character counts on the right side clamp at the paragraph's end and never cross into the
/// next paragraph.
#[test]
fn trailing_character_deletion_clamps_at_the_paragraph_end() {
let doc = Document::from_text("ab\ncd");
let caret = Pos::body(0, 1);
let del = doc.ime_delete_surrounding(&caret, &caret, 0, 99, DeleteUnit::Chars).expect("b only");
assert_eq!(del.after, Some(1..2));
let empty = doc.ime_delete_surrounding(&caret, &caret, 0, 0, DeleteUnit::Chars);
assert!(empty.is_none(), "nothing to erase");
}
/// Sorted tracked deletions are scanned with one cursor: alternating revisions stay correct
/// (and linear) instead of each character scanning every range.
#[test]
fn alternating_tracked_revisions_map_correctly() {
// Equal-length visible/deleted pieces, so the expected visible byte count is exact.
let parts: Vec<(String, bool)> =
(0..2000).map(|i| (if i % 2 == 0 { format!("v{i:04};") } else { format!("D{i:04};") }, i % 2 == 1)).collect();
let parts: Vec<(&str, bool)> = parts.iter().map(|(t, d)| (t.as_str(), *d)).collect();
let p = para_with_runs(&parts);
let view = VisibleParagraph::new(&p);
assert_eq!(view.visible_len(), p.text.len() - 6 * 1000);
assert_eq!(view.visible_offset(p.text.len()), Some(view.visible_len()));
let w = view.window(0, 0, 100).expect("a window");
assert!(w.text.len() <= 100);
assert!(!w.text.contains('D'), "tracked deletions never show");
}
/// The public window never copies more than the IME cap, whatever a caller asks for.
#[test]
fn window_budget_is_capped() {
let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default());
let view = VisibleParagraph::new(&p);
let w = view.window(0, 0, usize::MAX).expect("a bounded window");
assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES);
let doc = Document::from_text(&"a".repeat(10_000));
let w = doc.ime_surrounding(&Pos::body(0, 0), &Pos::body(0, 0), usize::MAX).expect("bounded");
assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES);
}
/// A caret in a table-cell paragraph maps against that cell's text, not the body around the table.
#[test]
fn table_cell_paragraph_maps_inside_its_cell() {
let mut doc = Document::from_text("outside");
let mut table = crate::Table::new(1, 1, 100.0);
table.rows[0].cells[0].blocks = vec![crate::para_block(Paragraph::with_text("cell", CharProps::default()))];
doc.body.push(std::sync::Arc::new(crate::Block::Table(table)));
let path = crate::Path(vec![1, 0, 0, 0]);
let cursor = Pos { story: StoryRef::Body, path: path.clone(), off: 4 };
let anchor = Pos { story: StoryRef::Body, path, off: 2 };
let w = doc.ime_surrounding(&cursor, &anchor, MAX_IME_TEXT_BYTES).expect("the cell text");
assert_eq!(w.text, "cell");
assert_eq!(w.cursor, 4);
assert_eq!(w.anchor, 2);
}
/// Content-control markers are zero-length visible objects. A deletion range starts after
/// the marker and ends before the next one, so an ordinary IME edit never removes a marker
/// or leaves a half-paired control.
#[test]
fn control_markers_are_not_included_in_delete_ranges() {
use crate::control::{ContentControl, ControlKind};
let mut p = Paragraph::new();
p.text = format!("pre{OBJ}Ada{OBJ}X");
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
let mut ctl = ContentControl::new(ControlKind::RichText);
ctl.placeholder_text = "Enter name".into();
p.objects.push(InlineObject::ControlStart { control: Box::new(ctl) });
p.objects.push(InlineObject::ControlEnd);
let mut doc = Document::new();
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
// Stored positions are UTF-8 byte offsets; each OBJ marker occupies three bytes.
let cursor = Pos::body(0, 9);
let del = doc.ime_delete_surrounding(&cursor, &cursor, 3, 1, DeleteUnit::Bytes).expect("a deletion");
assert_eq!(del.before, Some(6..9), "the control-start marker and `pre` stay outside");
assert_eq!(del.after, Some(12..13), "the control-end marker stays outside; only `X` goes");
assert_eq!((del.anchor, del.focus), (9, 9));
}
}
+6
View File
@@ -26,6 +26,7 @@ pub mod fields;
pub mod fill;
pub mod freeform;
pub mod graphic;
pub mod ime_text;
pub mod legacy_form;
pub mod line_dash;
pub mod link;
@@ -639,6 +640,10 @@ pub struct Settings {
/// after plus the second's space before, not the larger of the two.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub sum_paragraph_spacing: bool,
/// `w:doNotExpandShiftReturn`: a justified line that ends in a manual line break keeps its
/// natural spacing; by default Word stretches it to the margin like a wrapped line.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub do_not_expand_shift_return: bool,
/// Keep contextual paragraph spacing inside legacy table cells (`allowSpaceOfSameStyleInTable`).
/// Word ignores the layout override in compatibility mode 15, but the flag still round-trips.
pub allow_space_of_same_style_in_table: bool,
@@ -805,6 +810,7 @@ impl Default for Settings {
compat_mode: COMPAT_MODE_CURRENT,
roman_fonts: Vec::new(),
sum_paragraph_spacing: false,
do_not_expand_shift_return: false,
allow_space_of_same_style_in_table: false,
grid_h: DEFAULT_GRID,
grid_v: DEFAULT_GRID,
+68
View File
@@ -324,6 +324,21 @@ impl Lp {
let den = self.arg();
one(MNode::Frac { kind: FracKind::Bar, num, den })
}
"genfrac" => {
let beg = delim_arg(&self.arg());
let end = delim_arg(&self.arg());
let thickness = self.arg();
let _style = self.arg();
let num = self.arg();
let den = self.arg();
let kind = if is_zero_length(&thickness) { FracKind::NoBar } else { FracKind::Bar };
let frac = MNode::Frac { kind, num, den };
if beg.is_none() && end.is_none() {
one(frac)
} else {
one(MNode::Delim { beg, end, sep: None, grow: true, shp_match: false, elems: vec![vec![frac]] })
}
}
"binom" | "dbinom" | "tbinom" => {
let n = self.arg();
let k = self.arg();
@@ -801,6 +816,31 @@ fn text_chars(text: &str, s: &mut String) {
}
}
/// The delimiter of a `\genfrac` argument: one character, or none for `{}` and anything else.
fn delim_arg(a: &[MNode]) -> Option<char> {
let [MNode::Run(r)] = a else { return None };
let mut cs = r.text.chars();
match (cs.next(), cs.next()) {
(Some(c), None) if c != '.' => Some(c),
_ => None,
}
}
/// Whether a `\genfrac` thickness is a zero length such as `0pt`, `0.0pt` or `0 em`. An empty
/// thickness means the default rule and anything that is not a plain number and unit is not zero.
fn is_zero_length(a: &[MNode]) -> bool {
let mut text = String::new();
for n in a {
let MNode::Run(r) = n else { return false };
text.extend(r.text.chars().filter(|c| !c.is_whitespace() && !matches!(c, '\u{2009}' | '\u{205F}' | '\u{2005}')));
}
let number = text.trim_end_matches(|c: char| c.is_ascii_alphabetic());
let unit = &text[number.len()..];
!number.is_empty()
&& matches!(unit, "pt" | "em" | "ex" | "mm" | "cm" | "in" | "bp" | "pc" | "sp" | "mu")
&& number.parse::<f64>().is_ok_and(|v| v == 0.0)
}
fn delim_tex(c: Option<char>) -> String {
match c {
None => ".".into(),
@@ -1076,6 +1116,34 @@ mod tests {
let _ = (LimLoc::UndOvr, MathJc::Center);
}
#[test]
fn latex_binom_round_trip() {
let a = parse_latex(r"\binom{n}{k}");
let l = to_latex(&a);
assert_eq!(parse_latex(&l), a, "exported as {l}");
let nobar = |n: &str, k: &str| MNode::Frac { kind: FracKind::NoBar, num: parse_latex(n), den: parse_latex(k) };
let b = parse_latex(r"\genfrac{}{}{0pt}{}{n}{k}");
assert_eq!(b, vec![nobar("n", "k")]);
assert_eq!(parse_latex(&to_latex(&b)), b);
// Other spellings of a zero thickness.
for z in ["0 pt", "0.0pt", r"0\,pt", "0em"] {
let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{z}}}{{}}{{n}}{{k}}"));
assert_eq!(c, vec![nobar("n", "k")], "thickness {z}");
}
// A non-zero or default thickness keeps the bar.
for t in ["1pt", "0.4pt", ""] {
let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{t}}}{{}}{{n}}{{k}}"));
let bar = MNode::Frac { kind: FracKind::Bar, num: parse_latex("n"), den: parse_latex("k") };
assert_eq!(c, vec![bar], "thickness {t:?}");
}
// Delimiters are kept.
let d = parse_latex(r"\genfrac(){0pt}{}{n}{k}");
assert_eq!(d, a);
let e = parse_latex(r"\genfrac[]{0pt}{}{n}{k}");
assert!(matches!(&e[..], [MNode::Delim { beg: Some('['), end: Some(']'), .. }]), "{e:#?}");
assert_eq!(parse_latex(&to_latex(&e)), e);
}
#[test]
fn latex_round_trip() {
for lin in ["x=(-b±√(b^2-4ac))/2a", "∑_(i=1)^n▒i^2", "√(3&x)", "a⃗+x̂", "■(a&b@c&d)", "sin⁡x+cos⁡y", "e^(-x^2)"] {
+24 -1
View File
@@ -357,7 +357,8 @@ impl Counters {
return Some((def.text.clone(), def));
}
let mut label = String::new();
let mut chars = def.text.chars().peekable();
// 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))
@@ -375,6 +376,9 @@ impl Counters {
f => f,
};
label.push_str(&fmt.format(val));
if label.len() > MAX_LABEL_BYTES {
break;
}
} else {
label.push(c);
}
@@ -383,10 +387,29 @@ impl Counters {
}
}
/// Longest level text (in characters) expanded into a list label.
const MAX_LEVEL_TEXT: usize = 64;
/// Labels stop growing once they pass this many bytes.
const MAX_LABEL_BYTES: usize = 256;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_huge_level_text_gives_a_bounded_label() {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
let huge = Level { format: NumFormat::UpperRoman, text: "%1".repeat(30_000), start: u32::MAX, ..Level::default() };
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.level_overrides = vec![(0, huge)];
}
let mut c = Counters::default();
let label = c.next_label(&n, id, 0).unwrap().0;
assert!(label.len() <= 512, "label is {} bytes", label.len());
assert!(label.starts_with("MMMM"));
}
#[test]
fn a_list_s_own_level_definitions_win() {
// A specification's article numbers: level 1 counts parts with no label of its own, level 2
+37 -3
View File
@@ -255,7 +255,7 @@ impl NumFormat {
pub fn from_ooxml(s: &str) -> NumFormat {
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
}
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Arabic, Hindi, Thai,
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai,
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
/// (the 21st item of ①…⑳).
pub fn format(self, n: u32) -> String {
@@ -268,6 +268,8 @@ impl NumFormat {
NumFormat::LowerLetter => letters(n),
NumFormat::RussianUpper => russian_letters(n).to_uppercase(),
NumFormat::RussianLower => russian_letters(n),
NumFormat::ArabicAlpha => repeated_letter(&ARABIC_ALPHA, n),
NumFormat::ArabicAbjad => repeated_letter(&ARABIC_ABJAD, n),
NumFormat::Ordinal => format!("{n}{}", ordinal_suffix(n)),
NumFormat::CardinalText => cardinal_text(n),
NumFormat::OrdinalText => ordinal_text(n),
@@ -308,8 +310,6 @@ impl NumFormat {
| NumFormat::VietnameseCounting
| NumFormat::Hebrew1
| NumFormat::Hebrew2
| NumFormat::ArabicAlpha
| NumFormat::ArabicAbjad
| NumFormat::HindiVowels
| NumFormat::HindiConsonants
| NumFormat::HindiNumbers
@@ -399,6 +399,27 @@ pub fn russian_letters(n: u32) -> String {
std::iter::repeat_n(c, k as usize).collect()
}
/// `arabicAlpha`: the 28 letters in alphabetical (hijā'ī) order.
const ARABIC_ALPHA: [char; 28] =
['أ', 'ب', 'ت', 'ث', 'ج', 'ح', 'خ', 'د', 'ذ', 'ر', 'ز', 'س', 'ش', 'ص', 'ض', 'ط', 'ظ', 'ع', 'غ', 'ف', 'ق', 'ك', 'ل', 'م', 'ن', 'ه', 'و', 'ي'];
/// `arabicAbjad`: the 28 letters in abjad order (أبجد هوز حطي كلمن سعفص قرشت ثخذ ضظغ).
const ARABIC_ABJAD: [char; 28] =
['أ', 'ب', 'ج', 'د', 'ه', 'و', 'ز', 'ح', 'ط', 'ي', 'ك', 'ل', 'م', 'ن', 'س', 'ع', 'ف', 'ص', 'ق', 'ر', 'ش', 'ت', 'ث', 'خ', 'ذ', 'ض', 'ظ', 'غ'];
/// One letter of `alphabet`, repeated once per pass through it (as [`letters`]: a..z, aa..zz).
fn repeated_letter(alphabet: &[char], n: u32) -> String {
let Some(&first) = alphabet.first() else { return n.to_string() };
if n == 0 {
return String::new();
}
let len = alphabet.len() as u32;
let n = n.min(len * 30);
let k = (n - 1) / len + 1;
let c = alphabet.get(((n - 1) % len) as usize).copied().unwrap_or(first);
std::iter::repeat_n(c, k as usize).collect()
}
const ONES: [&str; 20] = [
"zero",
"one",
@@ -920,6 +941,19 @@ mod tests {
assert_eq!(NumFormat::RussianUpper.format(36), "ЖЖ");
}
#[test]
fn arabic_letters_count_in_both_orders() {
let alpha: Vec<String> = (1..=5).map(|n| NumFormat::ArabicAlpha.format(n)).collect();
assert_eq!(alpha.join(" "), "أ ب ت ث ج");
let abjad: Vec<String> = (1..=5).map(|n| NumFormat::ArabicAbjad.format(n)).collect();
assert_eq!(abjad.join(" "), "أ ب ج د ه");
assert_eq!(NumFormat::ArabicAlpha.format(28), "ي");
assert_eq!(NumFormat::ArabicAbjad.format(28), "غ");
// Past the 28th letter they double, as Latin and Russian letters do.
assert_eq!(NumFormat::ArabicAlpha.format(29), "أأ");
assert_eq!(NumFormat::ArabicAbjad.format(30), "بب");
}
#[test]
fn landscape_swaps() {
let mut s = SectionProps { margin_left: 50.0, ..Default::default() };
+6 -1
View File
@@ -295,8 +295,13 @@ impl Cipher {
if hflags & H_EXTERNAL != 0 {
return Err(unsupported("the document uses an external encryption provider"));
}
// [MS-OFFCRYPTO] defines AES only for the encrypted packages of Word 2007 and later
// (which open through the .docx reader whatever their extension); a Word 97-2003 file
// may only use XOR, RC4 or RC4 CryptoAPI, so there is no public scheme to follow here.
if hflags & H_AES != 0 {
return Err(unsupported("AES-encrypted .doc files are not supported"));
return Err(unsupported(
"this Word 97-2003 file claims AES encryption, which is only defined for the newer encrypted-package format; it can't be opened",
));
}
if hflags & H_CRYPTO_API == 0 {
return Err(unsupported("unknown encryption header"));
+1 -1
View File
@@ -113,7 +113,7 @@ impl PieceTable {
}
PrmRef::Sprm0 { isprm, val: (p.prm >> 8) as u8 }
} else {
PrmRef::Grpprl(self.prc_grpprls.get((p.prm >> 1) as usize).map(|v| v.as_slice()))
PrmRef::Grpprl(self.prc_grpprls.get((p.prm >> 1) as usize).map(Vec::as_slice))
}
}
+3
View File
@@ -199,6 +199,9 @@ fn unsupported_variants_say_so() {
let (h, k) = capi_header("pw", 128, hflags);
let e = read_with_password(&rc4_file_with(h, &*k), Some("pw")).unwrap_err();
assert!(matches!(e, DocbinError::Encryption(_)), "{e:?}");
if hflags & 0x20 != 0 {
assert!(e.to_string().contains("only defined for the newer encrypted-package format"), "{e}");
}
}
let (mut h, k) = rc4_header("pw");
h[0] = 9;
+367 -1
View File
@@ -9,6 +9,10 @@
//! a verifier that tells a wrong password apart; an HMAC over the encrypted stream checks that it
//! hasn't been damaged or tampered with.
//!
//! Older packages (Word 2007) use the *Standard* method (§2.3.4.5–§2.3.4.9): a binary
//! `EncryptionInfo`, one AES key derived from the password with SHA-1 and 50,000 spins, and the
//! whole package AES-ECB encrypted. [`decrypt`] reads both; [`encrypt`] writes agile only.
//!
//! Files are hostile: every stream read is capped, every length is checked before slicing, and the
//! spin count is bounded by the specification's maximum. Passwords and keys are wiped from memory
//! when dropped ([`Zeroizing`]) and never logged.
@@ -47,6 +51,20 @@ const BK_KEY_VALUE: [u8; 8] = [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6];
const BK_HMAC_KEY: [u8; 8] = [0x5f, 0xb2, 0xad, 0x01, 0x0c, 0xb9, 0xe1, 0xf6];
const BK_HMAC_VALUE: [u8; 8] = [0xa0, 0x67, 0x7f, 0x02, 0xb2, 0x2c, 0x84, 0x33];
/// Standard encryption (§2.3.2, §2.3.4.5–§2.3.4.7): `EncryptionHeader.Flags` bits, the AES and
/// SHA-1 algorithm ids, the provider type, the fixed spin count and the SHA-1 size.
const F_CRYPTO_API: u32 = 0x04;
const F_EXTERNAL: u32 = 0x10;
const F_AES: u32 = 0x20;
const STANDARD_FLAGS: u32 = F_CRYPTO_API | F_AES;
const ALG_AES_128: u32 = 0x660E;
const ALG_AES_192: u32 = 0x660F;
const ALG_AES_256: u32 = 0x6610;
const ALG_SHA1: u32 = 0x8004;
const PROV_RSA_AES: u32 = 0x18;
const STANDARD_SPIN_COUNT: u32 = 50_000;
const SHA1_SIZE: usize = 20;
/// An OLE compound file starts with these bytes ([MS-CFB] §2.2).
const CFB_MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
@@ -72,6 +90,9 @@ pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxErro
let mut comp = cfb::CompoundFile::open(Cursor::new(bytes)).map_err(|e| bad(format!("not a readable compound file ({e})")))?;
let info = stream(&mut comp, "/EncryptionInfo", MAX_INFO)?;
let package = stream(&mut comp, "/EncryptedPackage", bytes.len() as u64)?;
if matches!(info.get(..4), Some([2..=4, 0, 2, 0])) {
return decrypt_standard(&info, &package, password);
}
let info = parse_info(&info)?;
let Some(password) = password else { return Err(DocxError::PasswordRequired) };
let pe = &info.password;
@@ -125,6 +146,92 @@ pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxErro
Ok(out)
}
/// [`decrypt`] for Standard encryption (§2.3.4.5–§2.3.4.9): one AES key from the password,
/// checked with the verifier, and the whole package AES-ECB encrypted after its 8-byte size.
fn decrypt_standard(info: &[u8], package: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxError> {
let s = parse_standard(info)?;
let Some(password) = password else { return Err(DocxError::PasswordRequired) };
let key = standard_key(s.salt, password, s.key_bytes);
// The verifier (§2.3.4.9): a random value and its SHA-1, both encrypted with the key.
let verifier = Zeroizing::new(ecb(&key, s.verifier, false)?);
let hash = Zeroizing::new(ecb(&key, s.verifier_hash, false)?);
let hash = hash.get(..SHA1_SIZE).ok_or_else(|| bad("the password verifier hash is too short"))?;
if !same(&Hash::Sha1.hash(&[&verifier]), hash) {
return Err(DocxError::WrongPassword);
}
// The package (§2.3.4.4): StreamSize, then the data; bytes past the last block are ignored.
let size = package
.get(..8)
.and_then(|b| <[u8; 8]>::try_from(b).ok())
.map(u64::from_le_bytes)
.ok_or_else(|| bad("the encrypted package is truncated"))?;
let data = package.get(8..).unwrap_or(&[]);
let size = usize::try_from(size).ok().filter(|s| *s <= data.len()).ok_or_else(|| bad("the encrypted package is truncated"))?;
let needed = size.div_ceil(BLOCK).saturating_mul(BLOCK);
let data = data.get(..needed).ok_or_else(|| bad("the encrypted package is truncated"))?;
let mut out = ecb(&key, data, false)?;
out.truncate(size);
Ok(out)
}
/// What a Standard `EncryptionInfo` stream says (§2.3.4.5): the key size, and the verifier's salt
/// and encrypted values.
struct Standard<'a> {
key_bytes: usize,
salt: &'a [u8],
verifier: &'a [u8],
verifier_hash: &'a [u8],
}
fn parse_standard(info: &[u8]) -> Result<Standard<'_>, DocxError> {
let short = || bad("EncryptionInfo is truncated");
let u32_at = |at: usize| -> Result<u32, DocxError> {
let b = at.checked_add(4).and_then(|end| info.get(at..end)).ok_or_else(short)?;
<[u8; 4]>::try_from(b).map(u32::from_le_bytes).map_err(|_| short())
};
let flags = u32_at(4)?;
if flags & F_EXTERNAL != 0 {
return Err(bad("this file uses extensible encryption, which WordCraft can't open"));
}
if flags & STANDARD_FLAGS != STANDARD_FLAGS {
return Err(bad("this file's encryption isn't AES, which WordCraft can't open"));
}
// EncryptionHeader (§2.3.2): Flags, SizeExtra, AlgID, AlgIDHash, KeySize, ProviderType, two
// reserved words, then the CSP name, which isn't needed.
let header_size = usize::try_from(u32_at(8)?).map_err(|_| short())?;
if header_size < 32 {
return Err(bad("the encryption header is too short"));
}
let verifier_at = header_size.checked_add(12).filter(|&v| v <= info.len()).ok_or_else(short)?;
// AlgID 0 and AlgIDHash 0 mean "set by the flags": AES-128 and SHA-1.
let bits = match u32_at(20)? {
0 | ALG_AES_128 => 128,
ALG_AES_192 => 192,
ALG_AES_256 => 256,
id => return Err(bad(format!("encryption algorithm 0x{id:X} isn't supported"))),
};
let hash_id = u32_at(24)?;
if !matches!(hash_id, 0 | ALG_SHA1) {
return Err(bad(format!("hash algorithm 0x{hash_id:X} isn't supported")));
}
if u32_at(28)? != bits {
return Err(bad("the key size doesn't match the encryption algorithm"));
}
// EncryptionVerifier (§2.3.3): SaltSize (16), Salt, EncryptedVerifier, VerifierHashSize (20
// for SHA-1), EncryptedVerifierHash (32 bytes for AES).
if u32_at(verifier_at)? != SALT as u32 {
return Err(bad("the salt isn't 16 bytes"));
}
let field = |at: usize, len: usize| verifier_at.checked_add(at).and_then(|a| info.get(a..a.checked_add(len)?)).ok_or_else(short);
let salt = field(4, SALT)?;
let verifier = field(4 + SALT, BLOCK)?;
if u32_at(verifier_at.saturating_add(4 + SALT + BLOCK))? != SHA1_SIZE as u32 {
return Err(bad("the password verifier hash isn't SHA-1"));
}
let verifier_hash = field(8 + SALT + BLOCK, 2 * BLOCK)?;
Ok(Standard { key_bytes: bits as usize / 8, salt, verifier, verifier_hash })
}
/// Encrypt a zip package with `password` (agile encryption: AES-256-CBC, SHA-512, Word's spin
/// count), as Word's "Encrypt with Password" does.
pub fn encrypt(package: &[u8], password: &str) -> Result<Vec<u8>, DocxError> {
@@ -213,6 +320,47 @@ pub fn encrypt_with_spin_count(package: &[u8], password: &str, spin_count: u32)
Ok(comp.into_inner().into_inner())
}
/// A package encrypted with the *Standard* method (§2.3.4.5–§2.3.4.9: AES-ECB, SHA-1, 50,000
/// spins), as Word 2007 wrote it, for tests only: WordCraft saves with the agile method
/// ([`encrypt`]). `key_bits` is 128, 192 or 256. The compound file holds just the two streams
/// [`decrypt`] reads.
#[doc(hidden)]
pub fn encrypt_standard_for_tests(package: &[u8], password: &str, key_bits: u32) -> Result<Vec<u8>, DocxError> {
check_password(password)?;
let alg_id = match key_bits {
128 => ALG_AES_128,
192 => ALG_AES_192,
256 => ALG_AES_256,
_ => return Err(bad(format!("unsupported AES key size {key_bits}"))),
};
let salt = random(SALT)?;
let verifier = random(SALT)?;
let key = standard_key(&salt, password, key_bits as usize / 8);
let mut header = Vec::new();
for v in [STANDARD_FLAGS, 0, alg_id, ALG_SHA1, key_bits, PROV_RSA_AES, 0, 0] {
header.extend_from_slice(&v.to_le_bytes());
}
// CSPName: an empty null-terminated UTF-16 string.
header.extend_from_slice(&[0, 0]);
let mut info = vec![3, 0, 2, 0];
info.extend_from_slice(&STANDARD_FLAGS.to_le_bytes());
info.extend_from_slice(&(header.len() as u32).to_le_bytes());
info.extend_from_slice(&header);
info.extend_from_slice(&(SALT as u32).to_le_bytes());
info.extend_from_slice(&salt);
info.extend_from_slice(&ecb(&key, &verifier, true)?);
info.extend_from_slice(&(SHA1_SIZE as u32).to_le_bytes());
info.extend_from_slice(&ecb(&key, &padded(&Hash::Sha1.hash(&[&verifier])), true)?);
let mut stream = (package.len() as u64).to_le_bytes().to_vec();
stream.extend_from_slice(&ecb(&key, &padded(package), true)?);
let io = |e: std::io::Error| bad(format!("couldn't write the compound file ({e})"));
let mut comp = cfb::CompoundFile::create_with_version(cfb::Version::V3, Cursor::new(Vec::new())).map_err(io)?;
comp.create_stream("/EncryptionInfo").map_err(io)?.write_all(&info).map_err(io)?;
comp.create_stream("/EncryptedPackage").map_err(io)?.write_all(&stream).map_err(io)?;
comp.flush().map_err(io)?;
Ok(comp.into_inner().into_inner())
}
/// Refuse a password Word wouldn't accept: empty, or longer than [`MAX_PASSWORD_CHARS`].
pub fn check_password(password: &str) -> Result<(), DocxError> {
if password.is_empty() {
@@ -497,6 +645,45 @@ fn derive_key(hash: Hash, hn: &[u8], block_key: &[u8], len: usize) -> Zeroizing<
k
}
/// Standard encryption's key (§2.3.4.7): the SHA-1 password hash after 50,000 iterations, hashed
/// with block number 0; then X1 = SHA-1 of that hash XOR-ed into 64 bytes of 0x36, X2 the same
/// with 0x5C, and the key is the first `len` bytes of X1 + X2.
fn standard_key(salt: &[u8], password: &str, len: usize) -> Zeroizing<Vec<u8>> {
let hn = iterate(Hash::Sha1, salt, password, STANDARD_SPIN_COUNT);
let hfinal = Zeroizing::new(Hash::Sha1.hash(&[&hn, &0u32.to_le_bytes()]));
let x = |fill: u8| {
let mut buf = Zeroizing::new([fill; 64]);
for (b, h) in buf.iter_mut().zip(hfinal.iter()) {
*b ^= h;
}
Zeroizing::new(Hash::Sha1.hash(&[&buf[..]]))
};
let mut key = Zeroizing::new(x(0x36).to_vec());
key.extend_from_slice(&x(0x5C));
key.truncate(len);
key
}
/// AES-ECB without padding (Standard encryption, §2.3.4.7–§2.3.4.9): `data` must be whole blocks.
fn ecb(key: &[u8], data: &[u8], encrypt: bool) -> Result<Vec<u8>, DocxError> {
let aes = Aes::new(key)?;
let (blocks, rest) = data.as_chunks::<BLOCK>();
if !rest.is_empty() {
return Err(bad("encrypted data isn't a whole number of blocks"));
}
let mut out = Vec::with_capacity(data.len());
for b in blocks {
let mut x = aes::Block::from(*b);
if encrypt {
aes.encrypt(&mut x);
} else {
aes.decrypt(&mut x);
}
out.extend_from_slice(&x);
}
Ok(out)
}
/// An initialization vector (§2.3.4.12): H(salt + blockKey), or the salt itself, truncated to
/// the block size or padded with 0x36.
fn make_iv(hash: Hash, salt: &[u8], block_key: Option<&[u8]>, block_size: usize) -> Vec<u8> {
@@ -643,7 +830,6 @@ fn parse_info(info: &[u8]) -> Result<Info, DocxError> {
};
match (major, minor) {
(4, 4) => {}
(2..=4, 2) => return Err(bad("this file uses Standard encryption (Office 2007), which WordCraft can't open yet")),
(3 | 4, 3) => return Err(bad("this file uses extensible encryption, which WordCraft can't open")),
_ => return Err(bad(format!("unknown encryption version {major}.{minor}"))),
}
@@ -992,4 +1178,184 @@ mod tests {
assert!(!is_encrypted(&fake));
assert!(decrypt(&fake, Some("pw")).is_err());
}
/// Standard encryption's key derivation (§2.3.4.7) for the password "Password1234_" and the
/// salt 00 01 … 0F. The expected keys were computed outside WordCraft (Python's hashlib)
/// following the specification's steps, so the encryptor and decryptor can't share a mistake.
#[test]
fn standard_key_matches_an_independent_derivation() {
let salt: Vec<u8> = (0..16).collect();
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
assert_eq!(hex(&standard_key(&salt, "Password1234_", 16)), "340fc93a8e2154db8304d055df81cebc");
assert_eq!(hex(&standard_key(&salt, "Password1234_", 32)), "340fc93a8e2154db8304d055df81cebcef5e89fb8e5eb520efdca76f3330e032");
assert_eq!(standard_key(&salt, "Password1234_", 24).len(), 24);
// AES-ECB against FIPS-197 Appendix C.1 and C.3.
let pt: Vec<u8> = (0..16u8).map(|i| i * 0x11).collect();
let k128: Vec<u8> = (0..16).collect();
let k256: Vec<u8> = (0..32).collect();
assert_eq!(hex(&ecb(&k128, &pt, true).unwrap()), "69c4e0d86a7b0430d8cdb78070b4c55a");
assert_eq!(hex(&ecb(&k256, &pt, true).unwrap()), "8ea2b7ca516745bfeafc49904b496089");
assert_eq!(ecb(&k256, &ecb(&k256, &pt, true).unwrap(), false).unwrap(), pt);
assert!(ecb(&k128, &pt[..15], false).is_err());
}
/// Standard encryption (Word 2007's AES-ECB method) opens with the right password, for each
/// AES key size, and says when the password is missing or wrong — as agile files do.
#[test]
fn standard_encryption_round_trips() {
let pkg = zip_package();
for bits in [128, 192, 256] {
let enc = encrypt_standard_for_tests(&pkg, "pässwörd 🔑", bits).unwrap();
assert!(is_encrypted(&enc));
assert_eq!(decrypt(&enc, Some("pässwörd 🔑")), Ok(pkg.clone()), "AES-{bits}");
assert_eq!(decrypt(&enc, None), Err(DocxError::PasswordRequired));
assert_eq!(decrypt(&enc, Some("Pässwörd 🔑")), Err(DocxError::WrongPassword));
assert_eq!(crate::read(&enc), Err(DocxError::PasswordRequired));
}
let enc = encrypt_standard_for_tests(&pkg, "pw", 128).unwrap();
let doc = crate::read_with_password(&enc, Some("pw")).unwrap();
assert!(doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Top secret plans"));
// Sizes on and off the block boundary, and empty.
for n in [0usize, 1, 15, 16, 17, SEGMENT + 3] {
let data: Vec<u8> = (0..n).map(|i| (i * 7) as u8).collect();
assert_eq!(decrypt(&encrypt_standard_for_tests(&data, "x", 256).unwrap(), Some("x")).unwrap(), data);
}
assert!(encrypt_standard_for_tests(&pkg, "pw", 64).is_err());
assert!(encrypt_standard_for_tests(&pkg, "", 128).is_err());
}
/// A Standard-encrypted file assembled byte by byte from [MS-OFFCRYPTO] §2.3.4.5, §2.3.2, §2.3.3
/// and §2.3.4.4 with literal field values, without the test encryptor, so a layout mistake
/// shared by the encryptor and the reader can't pass. Only the key derivation and AES-ECB
/// (pinned by `standard_key_matches_an_independent_derivation`) are reused.
fn hand_built_standard(payload: &[u8], password: &str) -> Vec<u8> {
let salt: [u8; 16] = [0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF];
let verifier: [u8; 16] = *b"verifier-16bytes";
let key = standard_key(&salt, password, 16);
let mut hash = sha1::Sha1::digest(verifier).to_vec();
hash.resize(32, 0); // the hash, zero-padded to two AES blocks
let csp: Vec<u8> = "WordCraft test provider".encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect();
let mut header = Vec::new();
header.extend_from_slice(&0x24u32.to_le_bytes()); // Flags: fCryptoAPI | fAES
header.extend_from_slice(&0u32.to_le_bytes()); // SizeExtra
header.extend_from_slice(&0x660Eu32.to_le_bytes()); // AlgID: AES-128
header.extend_from_slice(&0x8004u32.to_le_bytes()); // AlgIDHash: SHA-1
header.extend_from_slice(&128u32.to_le_bytes()); // KeySize, in bits
header.extend_from_slice(&0x18u32.to_le_bytes()); // ProviderType: AES
header.extend_from_slice(&0u32.to_le_bytes()); // Reserved1
header.extend_from_slice(&0u32.to_le_bytes()); // Reserved2
header.extend_from_slice(&csp); // CSPName: UTF-16LE, NUL-terminated
let mut info = Vec::new();
info.extend_from_slice(&3u16.to_le_bytes()); // Version major
info.extend_from_slice(&2u16.to_le_bytes()); // Version minor
info.extend_from_slice(&0x24u32.to_le_bytes()); // Flags: fCryptoAPI | fAES
info.extend_from_slice(&(header.len() as u32).to_le_bytes()); // HeaderSize
info.extend_from_slice(&header);
info.extend_from_slice(&16u32.to_le_bytes()); // SaltSize
info.extend_from_slice(&salt); // Salt
info.extend_from_slice(&ecb(&key, &verifier, true).unwrap()); // EncryptedVerifier
info.extend_from_slice(&20u32.to_le_bytes()); // VerifierHashSize (SHA-1)
info.extend_from_slice(&ecb(&key, &hash, true).unwrap()); // EncryptedVerifierHash
let mut padded = payload.to_vec();
padded.resize(payload.len().next_multiple_of(16), 0);
let mut package = (payload.len() as u64).to_le_bytes().to_vec(); // StreamSize
package.extend_from_slice(&ecb(&key, &padded, true).unwrap());
package.extend_from_slice(&[0xEE, 0xEE, 0xEE]); // trailing padding the spec allows
cfb_with(&info, &package)
}
#[test]
fn standard_package_built_by_hand_from_the_spec_opens() {
let payload: Vec<u8> = (0..37u8).map(|i| i.wrapping_mul(5)).collect();
let file = hand_built_standard(&payload, "pw");
assert!(is_encrypted(&file));
assert_eq!(decrypt(&file, Some("pw")), Ok(payload.clone()));
assert_eq!(decrypt(&file, Some("PW")), Err(DocxError::WrongPassword));
assert_eq!(decrypt(&file, None), Err(DocxError::PasswordRequired));
// The test encryptor writes the same fixed fields: version and flags (bytes 0..8) and the
// header's eight words (12..44); HeaderSize differs with the CSP name, the rest is random.
let (hand, _) = streams(&file);
let (made, _) = streams(&encrypt_standard_for_tests(&payload, "pw", 128).unwrap());
assert_eq!(hand[..8], made[..8]);
assert_eq!(hand[12..44], made[12..44]);
}
/// The two streams of an encrypted package.
fn streams(file: &[u8]) -> (Vec<u8>, Vec<u8>) {
let mut comp = cfb::CompoundFile::open(Cursor::new(file)).unwrap();
let mut info = Vec::new();
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
let mut package = Vec::new();
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut package).unwrap();
(info, package)
}
#[test]
fn hostile_standard_encryption_info_errors_without_panicking() {
let data = b"a small package".to_vec();
let (info, package) = streams(&encrypt_standard_for_tests(&data, "pw", 128).unwrap());
// Layout: version 0, flags 4, header size 8, header 12 (flags, sizeExtra, algId 20,
// algIdHash 24, keySize 28, provider, 2 reserved, an empty CSP name), then the verifier at
// 46: saltSize, salt 50, encrypted verifier 66, verifierHashSize 82, its hash 86..118.
assert_eq!(info.len(), 118);
let put = |at: usize, v: u32| {
let mut i = info.clone();
i[at..at + 4].copy_from_slice(&v.to_le_bytes());
i
};
let mut cases: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for n in 0..info.len() {
cases.push((info[..n].to_vec(), package.clone()));
}
for v in [0, 4, 31, 0x1000, u32::MAX] {
cases.push((put(8, v), package.clone()));
}
// No AES, no CryptoAPI, an external (extensible) provider.
for v in [F_CRYPTO_API, F_AES, STANDARD_FLAGS | F_EXTERNAL, 0] {
cases.push((put(4, v), package.clone()));
}
// RC4, MD5, key sizes that don't fit the algorithm.
cases.push((put(20, 0x6801), package.clone()));
cases.push((put(24, 0x8003), package.clone()));
for v in [0, 64, 192, 256, u32::MAX] {
cases.push((put(28, v), package.clone()));
}
for v in [0, 15, 17, u32::MAX] {
cases.push((put(46, v), package.clone()));
}
for v in [0, 16, 32, u32::MAX] {
cases.push((put(82, v), package.clone()));
}
// The package: truncated, an absurd StreamSize, a StreamSize past the data, data that
// isn't whole blocks.
for n in [0, 4, 8, 9, package.len() - 1] {
cases.push((info.clone(), package[..n].to_vec()));
}
for size in [u64::MAX, 1 << 40, package.len() as u64] {
let mut p = package.clone();
p[..8].copy_from_slice(&size.to_le_bytes());
cases.push((info.clone(), p));
}
for (i, p) in cases {
let file = cfb_with(&i, &p);
assert!(decrypt(&file, Some("pw")).is_err());
let _ = crate::read_with_password(&file, Some("pw"));
assert!(crate::read(&file).is_err());
}
// Version 2.2 and 4.2 are Standard too; trailing bytes after the last block are ignored.
for v in [2u8, 4] {
let mut i = info.clone();
i[0] = v;
assert_eq!(decrypt(&cfb_with(&i, &package), Some("pw")).unwrap(), data);
}
let mut p = package.clone();
p.extend_from_slice(&[1, 2, 3]);
assert_eq!(decrypt(&cfb_with(&info, &p), Some("pw")).unwrap(), data);
// AlgID and AlgIDHash 0 mean "from the flags": AES-128 and SHA-1.
assert_eq!(decrypt(&cfb_with(&put(20, 0), &package), Some("pw")).unwrap(), data);
assert_eq!(decrypt(&cfb_with(&put(24, 0), &package), Some("pw")).unwrap(), data);
}
}
+2 -2
View File
@@ -25,8 +25,8 @@ mod xml;
pub use bibliography::{MAX_SOURCES, Sources, read_sources};
pub use chart_spec::{chart_items, chart_xml};
pub use crypt::{
DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_with_spin_count,
is_encrypted, protection_hash,
DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_standard_for_tests,
encrypt_with_spin_count, is_encrypted, protection_hash,
};
pub use flat::{XmlKind, sniff_xml, write as write_flat};
pub use fonts::{EmbeddedFont, embedded_fonts};
+1
View File
@@ -1212,6 +1212,7 @@ impl Reader<'_> {
s.compat_mode = m.clamp(11, 99);
}
s.sum_paragraph_spacing = k.children("w:doNotUseHTMLParagraphAutoSpacing").any(on_off);
s.do_not_expand_shift_return = k.children("w:doNotExpandShiftReturn").any(on_off);
}
"w:trackRevisions" => s.track_changes = on_off(k),
"w:doNotTrackMoves" => s.track_moves = !on_off(k),
+12 -5
View File
@@ -724,7 +724,8 @@ impl Reader<'_> {
if sc.fields.iter().any(|f| f.phase == Phase::Instr) {
return;
}
let c = match k.attr("w:type") {
let ty = k.attr("w:type").map(|t| t.trim().to_ascii_lowercase());
let c = match ty.as_deref() {
Some("page") => "\u{000C}",
Some("column") => "\u{000E}",
_ => "\n",
@@ -739,7 +740,13 @@ impl Reader<'_> {
"w:noBreakHyphen" => self.emit_text(sc, pb, "\u{2011}", props),
"w:softHyphen" => self.emit_text(sc, pb, "\u{00AD}", props),
"w:sym" => {
let ch = k.attr("w:char").and_then(|h| u32::from_str_radix(h.trim(), 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20);
let h_str = k.attr("w:char").map(|h| {
let s = h.trim();
let s = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s);
let s = s.strip_prefix("U+").or_else(|| s.strip_prefix("u+")).unwrap_or(s);
s.strip_prefix("#x").or_else(|| s.strip_prefix("#X")).unwrap_or(s)
});
let ch = h_str.and_then(|h| u32::from_str_radix(h, 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20);
if let Some(ch) = ch {
let mut p = props.clone();
// The symbol's own font, kept apart from the run's (ECMA-376 §17.3.3.30).
@@ -783,7 +790,7 @@ impl Reader<'_> {
}
}
"w:footnoteReference" | "w:endnoteReference" => {
let foot = k.name == "w:footnoteReference";
let foot = k.local() == "footnoteReference";
let fid = k.attr("w:id").and_then(int).unwrap_or(i64::MIN);
let map = if foot { &self.footnotes } else { &self.endnotes };
if let Some(id) = map.get(&fid).copied() {
@@ -794,7 +801,7 @@ impl Reader<'_> {
}
// The note's own number at the start of its text: a reference to the note being read.
"w:footnoteRef" | "w:endnoteRef" => {
let kind = if k.name == "w:footnoteRef" { NoteKind::Footnote } else { NoteKind::Endnote };
let kind = if k.local() == "footnoteRef" { NoteKind::Footnote } else { NoteKind::Endnote };
if let Some((nk, id)) = self.current_note
&& nk == kind
{
@@ -2291,7 +2298,7 @@ fn css_string(v: &str) -> String {
out.push(d);
}
// One whitespace after the escape is its terminator, not part of the value.
if chars.clone().next().is_some_and(|w| w.is_whitespace()) {
if chars.clone().next().is_some_and(char::is_whitespace) {
chars.next();
}
}
+1 -1
View File
@@ -68,7 +68,7 @@ impl<'d> EmbedWriter<'d> {
"[content_types].xml",
]
.iter()
.map(|s| s.to_string())
.map(ToString::to_string)
.collect();
taken.insert(VBA_PROJECT_PART.to_ascii_lowercase());
// The bibliography writer uses these names when sources are present (#532).
+3
View File
@@ -1083,6 +1083,9 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O
}
w.open("w:compat", &[]);
// CT_Compat: the older switches come before the `w:compatSetting`s.
if doc.settings.do_not_expand_shift_return {
w.empty("w:doNotExpandShiftReturn", &[]);
}
if doc.settings.sum_paragraph_spacing {
w.empty("w:doNotUseHTMLParagraphAutoSpacing", &[]);
}
+38
View File
@@ -15,6 +15,8 @@ use crate::DocxError;
pub const MAX_DEPTH: usize = 256;
/// Maximum number of elements in one part.
pub const MAX_ELEMENTS: usize = 8_000_000;
/// Maximum number of attributes on one element (#3702).
pub const MAX_ATTRS: usize = 1024;
/// Known namespaces and the canonical prefix we use for them (read and write).
pub const NAMESPACES: &[(&str, &str)] = &[
@@ -360,6 +362,9 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
};
let mut el = El { name, attrs: Vec::new(), kids: Vec::new() };
for a in e.attributes().with_checks(false) {
if el.attrs.len() >= MAX_ATTRS {
return Err(DocxError::Limit("too many attributes on one element".into()));
}
let Ok(a) = a else { continue };
let key = a.key;
let raw_key = key.as_ref();
@@ -669,6 +674,39 @@ mod tests {
assert!(parse(s.as_bytes()).is_err());
}
/// One start tag carrying 200,000 attributes (distinct or duplicate names) is refused or
/// truncated instead of allocated: a 59 KB `.docx` used to cost 611 MB (#3702, WC-06).
#[test]
fn too_many_attributes_on_one_element_is_capped() {
// The cap the issue suggests; a fix may pick a lower one, never a higher one.
const CAP: usize = 1024;
let distinct: String = (0..200_000).map(|i| format!(" a{i}=\"\"")).collect();
let duplicate = " a=\"\"".repeat(200_000);
for (label, attrs) in [("distinct", distinct), ("duplicate", duplicate)] {
let x = format!("<w:p xmlns:w=\"{}\"{attrs}/>", NAMESPACES[0].1);
match parse(x.as_bytes()) {
Err(DocxError::Limit(_)) => {}
Ok(p) => assert!(p.attrs.len() <= CAP, "{label}: kept {} attributes", p.attrs.len()),
Err(e) => panic!("{label}: expected a Limit error or a capped element, got {e}"),
}
}
// Real-world attribute counts are untouched.
let x = format!(
r#"<w:p xmlns:w="{}" w:rsidR="00A1" w:rsidRDefault="00A1" w:rsidP="00A1" w:rsidRPr="00A1"><w:rPr><w:rFonts w:ascii="A" w:hAnsi="B" w:eastAsia="C" w:cs="D" w:asciiTheme="minorHAnsi" w:hAnsiTheme="minorHAnsi" w:eastAsiaTheme="minorEastAsia" w:cstheme="minorBidi" w:hint="eastAsia"/></w:rPr></w:p>"#,
NAMESPACES[0].1
);
let p = parse(x.as_bytes()).unwrap();
assert_eq!(p.attrs.len(), 4);
let fonts = p.child("w:rPr").and_then(|r| r.child("w:rFonts")).unwrap();
assert_eq!(fonts.attrs.len(), 9);
assert_eq!(fonts.attr("w:cstheme"), Some("minorBidi"));
// Far more than any real producer writes, still well under any sane cap.
let many: String = (0..256).map(|i| format!(" a{i}=\"{i}\"")).collect();
let p = parse(format!("<p{many}/>").as_bytes()).unwrap();
assert_eq!(p.attrs.len(), 256);
assert_eq!(p.attr("a255"), Some("255"));
}
#[test]
fn garbage_is_error_not_panic() {
assert!(parse(b"").is_err());
+62
View File
@@ -48,6 +48,40 @@ fn read_body(body: &str) -> Document {
wordcraft_docx::read(&docx(body, &[], &[])).unwrap()
}
#[test]
fn repeated_style_ids_use_last() {
let styles = format!(
r#"<w:styles {W_NS}>
<w:style w:type="paragraph" w:styleId="Repeated"><w:name w:val="First"/>
<w:pPr><w:spacing w:after="240"/></w:pPr><w:rPr><w:b/><w:smallCaps/><w:sz w:val="28"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Repeated"><w:name w:val="Last"/>
<w:pPr><w:spacing w:before="120"/></w:pPr><w:rPr><w:i/><w:caps/><w:sz w:val="20"/></w:rPr>
</w:style></w:styles>"#
);
let bytes = docx(
r#"<w:p><w:pPr><w:pStyle w:val="Repeated"/></w:pPr><w:r><w:t>Original style probe</w:t></w:r></w:p>"#,
&[("styles", "styles", "styles.xml")],
&[("word/styles.xml", &styles)],
);
let mut document = wordcraft_docx::read(&bytes).unwrap();
for round in 0..=2 {
let style = document.styles.get("Repeated").unwrap();
assert_eq!(style.name, "Last");
assert_eq!(style.chr.size, Some(10.0));
assert_eq!(style.chr.italic, Some(true));
assert_eq!(style.chr.bold, None);
assert_eq!(style.chr.caps, Some(true));
assert_eq!(style.chr.small_caps, None);
assert_eq!(style.para.space_before, Some(6.0));
assert_eq!(style.para.space_after, None);
assert_eq!(document.styles.styles.iter().filter(|style| style.id == "Repeated").count(), 1);
if round < 2 {
document = wordcraft_docx::read(&wordcraft_docx::write(&document).unwrap()).unwrap();
}
}
}
#[test]
fn paragraph_default_presence_survives_repeated_saves() {
// Omission leaves paragraph defaults application-defined (ISO 29500 pPrDefault).
@@ -339,6 +373,18 @@ fn pcl6_symbol_roundtrip() {
}
}
#[test]
fn sym_hex_prefixes_and_break_type_casing() {
let body = r##"
<w:p>
<w:r><w:sym w:font="Wingdings" w:char="0xF0FC"/><w:sym w:font="Symbol" w:char="U+F061"/><w:sym w:font="Webdings" w:char="#xF061"/></w:r>
<w:r><w:br w:type="Page"/><w:br w:type="Column"/><w:br w:type="textWrapping"/></w:r>
</w:p>"##;
let d = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
let p = paras(&d);
assert_eq!(p[0].plain_text(), "\u{F0FC}\u{F061}\u{F061}\u{C}\u{E}\n");
}
#[test]
fn alternate_content_choice_and_fallback() {
let body = r#"
@@ -3572,6 +3618,22 @@ fn html_auto_spacing_off_is_read_and_kept() {
assert_eq!(settings(""), (false, false));
}
/// `w:doNotExpandShiftReturn` in the settings' `w:compat`: justified lines ending in a manual line
/// break keep their spacing (#3924).
#[test]
fn do_not_expand_shift_return_is_read_and_kept() {
let settings = |compat: &str| {
let s = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings {W_NS}><w:compat>{compat}</w:compat></w:settings>"#);
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings", "settings.xml")];
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/settings.xml", &s)])).unwrap();
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
(d.settings.do_not_expand_shift_return, again.settings.do_not_expand_shift_return)
};
assert_eq!(settings("<w:doNotExpandShiftReturn/>"), (true, true));
assert_eq!(settings(r#"<w:doNotExpandShiftReturn w:val="0"/>"#), (false, false));
assert_eq!(settings(""), (false, false));
}
/// A right triangle (`rtTriangle`) has its right angle at the bottom left: a cover page's white
/// corner cut-outs drawn as isosceles triangles pointed the wrong way.
#[test]
+58
View File
@@ -150,6 +150,21 @@ fn many_fields_and_huge_attribute_values() {
assert!(d.plain_text(wordcraft_doc::StoryRef::Body).contains("later"));
}
/// A paragraph with 2,000,000 attributes deflates to ~15 KB. Opening it is refused with a
/// limit error, or the extras are dropped and the text survives (#3702, WC-06).
#[test]
fn millions_of_attributes_on_one_paragraph_are_capped() {
let attrs: String = (0..2_000_000).map(|i| format!(" a{i}=\"\"")).collect();
let x = format!("<w:document {W_NS}><w:body><w:p{attrs}><w:r><w:t>kept</w:t></w:r></w:p></w:body></w:document>");
let start = std::time::Instant::now();
match read(&doc_zip(x.as_bytes())) {
Err(DocxError::Limit(_)) => {}
Ok(d) => assert!(d.plain_text(wordcraft_doc::StoryRef::Body).contains("kept")),
Err(e) => panic!("expected a Limit error or a document, got {e}"),
}
assert!(start.elapsed() < std::time::Duration::from_secs(10), "took {:?}", start.elapsed());
}
/// A small valid document.xml to mutate.
fn base_xml() -> String {
format!(
@@ -258,6 +273,49 @@ proptest! {
}
}
/// The streams of a Standard-encrypted package (gap 9), and a compound file holding them.
fn standard_streams() -> (Vec<u8>, Vec<u8>) {
use std::io::Read;
let file = wordcraft_docx::encrypt_standard_for_tests(&sample(), "pw", 128).unwrap();
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(file)).unwrap();
let mut info = Vec::new();
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
let mut package = Vec::new();
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut package).unwrap();
(info, package)
}
fn compound(info: &[u8], package: &[u8]) -> Vec<u8> {
let mut comp = cfb::CompoundFile::create(std::io::Cursor::new(Vec::new())).unwrap();
comp.create_stream("/EncryptionInfo").unwrap().write_all(info).unwrap();
comp.create_stream("/EncryptedPackage").unwrap().write_all(package).unwrap();
comp.flush().unwrap();
comp.into_inner().into_inner()
}
proptest! {
// Each case that parses runs the 50,000-spin key derivation, so fewer cases.
#![proptest_config(ProptestConfig { cases: 48, ..ProptestConfig::default() })]
#[test]
fn mutated_standard_encryption_never_panics(ops in proptest::collection::vec((0u8..3, any::<prop::sample::Index>(), any::<u8>(), any::<bool>()), 1..8)) {
let (mut info, mut package) = standard_streams();
for (k, idx, byte, in_package) in ops {
let x = if in_package { &mut package } else { &mut info };
let at = idx.index(x.len() + 1);
match k {
0 => { if at < x.len() { x[at] = byte; } }
1 => { x.truncate(at); }
_ => { x.splice(at..at, std::iter::repeat_n(byte, usize::from(byte % 9))); }
}
}
let file = compound(&info, &package);
let _ = wordcraft_docx::decrypt(&file, Some("pw"));
let _ = wordcraft_docx::read_with_password(&file, Some("pw"));
prop_assert!(read(&file).is_err());
}
}
#[test]
fn long_header_reference_chain_is_cut_off() {
const HDR_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
+10
View File
@@ -8,6 +8,12 @@ description = "WordCraft engine: session, selection, undo, the command registry
[lints]
workspace = true
[features]
# Front-end producer tests need to simulate a globally exhausted IME revision allocator. It only
# ever makes receipt classification fail closed. Never enable this for a production build; the
# UI's dev-dependencies enable it for its tests.
test-support = []
[dependencies]
wordcraft-geom = { workspace = true, features = ["image"] }
wordcraft-doc = { workspace = true }
@@ -36,6 +42,10 @@ js-sys = "0.3"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
getrandom = { workspace = true }
[target.'cfg(unix)'.dependencies]
# O_NONBLOCK, so a path swapped for a named pipe can't block `open` (#3666).
libc = { workspace = true }
[dev-dependencies]
encoding_rs = { workspace = true }
zip = { workspace = true }
+55 -6
View File
@@ -1,7 +1,7 @@
//! Design tab: themes, style sets, colours, fonts, paragraph spacing, watermark, page colour, borders.
use serde_json::{Value, json};
use wordcraft_doc::props::{Border, BorderStyle, Borders, LineSpacing, Rgb, TextColor};
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, LineSpacing, Rgb, TextColor};
use wordcraft_doc::{Watermark, styles};
use super::sel_result;
@@ -67,6 +67,8 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"name": "default|basic|lines|shaded|casual|centered|minimalist|title"}"#),
CommandSpec::new("design.paragraphSpacing", "Paragraph Spacing", "Design › Document Formatting", para_spacing)
.params(r#"{"value": "default|none|compact|tight|open|relaxed|double"}"#),
CommandSpec::new("design.scaleFontSizes", "Scale Font Sizes", "Design › Document Formatting", scale_font_sizes)
.params(r#"{"delta": pt (-1638..1638, not 0) | "percent": number (-99..1000, not 0; 10 = +10%)} (exactly one)"#),
CommandSpec::new("design.watermark", "Watermark", "Design › Page Background", watermark)
.params(r#"{"text"?: string, "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB"}"#),
CommandSpec::new("design.pageColor", "Page Color", "Design › Page Background", |s, v| {
@@ -169,11 +171,19 @@ fn style_set(s: &mut Session, v: &Value) -> CmdResult {
_ => {}
}
}
// Keep custom styles and fonts.
let customs: Vec<_> = s.doc.styles.styles.iter().filter(|x| !x.builtin).cloned().collect();
fresh.styles.extend(customs);
fresh.default_chr.font = s.doc.styles.default_chr.font.clone();
s.doc.styles = fresh;
// Overlay the set onto the document's sheet: every other style, the latent styles and the
// document defaults stay as they are (no set defines its own defaults).
for mut st in fresh.styles {
match s.doc.styles.styles.iter_mut().find(|x| x.id == st.id) {
Some(old) => {
st.default = old.default;
st.locked = old.locked;
st.builtin = old.builtin;
*old = st;
}
None => s.doc.styles.styles.push(st),
}
}
let (h, b) = (s.doc.settings.major_font.clone(), s.doc.settings.minor_font.clone());
set_fonts(s, &h, &b);
set_accent(s, accent);
@@ -196,6 +206,45 @@ fn para_spacing(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// Grow or shrink every style's font size at once. Only explicit style sizes change (including
/// table style parts); styles that inherit one (via `based_on` or the defaults) follow, and runs
/// keep their own direct formatting.
fn scale_font_sizes(s: &mut Session, v: &Value) -> CmdResult {
let (delta, percent) = (p::f32(v, "delta"), p::f32(v, "percent"));
// A field that is present but not a number is an error, not "absent".
if (v.get("delta").is_some() && delta.is_none()) || (v.get("percent").is_some() && percent.is_none()) {
return Err(CmdError::Params("`delta` and `percent` must be numbers".into()));
}
if delta.is_some() == percent.is_some() {
return Err(CmdError::Params("give exactly one of `delta` (points) or `percent`".into()));
}
if delta.is_some_and(|d| !(-1638.0..=1638.0).contains(&d) || d == 0.0) || percent.is_some_and(|x| !(-99.0..=1000.0).contains(&x) || x == 0.0) {
return Err(CmdError::Params("`delta` must be non-zero within ±1638 pt and `percent` non-zero within -99..1000".into()));
}
let factor = 1.0 + percent.unwrap_or(0.0) / 100.0;
let scale = |size: f32| ((size * factor + delta.unwrap_or(0.0)).clamp(1.0, 1638.0) * 2.0).round() / 2.0;
let scale_chr = |c: &mut CharProps| {
c.size = c.size.map(scale);
c.size_cs = c.size_cs.map(scale);
};
let st = &mut s.doc.styles;
// An unset document default resolves to the fallback size; make it explicit so inheriting styles follow.
st.default_chr.size = Some(scale(st.default_chr.size.unwrap_or(wordcraft_doc::props::DEFAULT_FONT_SIZE)));
st.default_chr.size_cs = st.default_chr.size_cs.map(scale);
for style in &mut st.styles {
scale_chr(&mut style.chr);
if let Some(t) = style.table.as_mut() {
for c in [&mut t.header_chr, &mut t.band_chr, &mut t.first_col_chr, &mut t.last_col_chr, &mut t.total_chr, &mut t.col_band_chr] {
scale_chr(c);
}
for part in t.regions.values_mut() {
scale_chr(&mut part.chr);
}
}
}
sel_result(s)
}
fn watermark(s: &mut Session, v: &Value) -> CmdResult {
if p::bool(v, "remove").unwrap_or(false) {
s.doc.settings.watermark = None;
+5 -2
View File
@@ -142,7 +142,7 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"tab"?: "find|replace|goto" (default replace)}"#)
.pure(),
CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all)
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards), …options of edit.find}"#),
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards; with regex also $1 ${n} ${name} $0 $$ \\1 \\n (paragraph mark) \\t), …options of edit.find}"#),
CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto)
.key("Mod+Alt+G / F5")
.params(r#"{"page"?: n, "pageBy"?: int (pages forward, negative back, from the caret's page), "bookmark"?: string (selects its text), "paragraph"?: n}; none opens Find and Replace on its Go To tab"#)
@@ -459,7 +459,10 @@ pub(crate) fn search_full(s: &Session, story: StoryRef, with_rep: bool) -> Resul
.configure(meta::Config::new().nfa_size_limit(Some(1 << 20)))
.build(&c.pattern)
.map_err(|e| CmdError::Params(format!("bad pattern: {e}")))?;
let pieces = with_rep.then(|| find_pattern::parse_replace(&f.replace, &opts));
let pieces = with_rep.then(|| {
let index = |name: &str| re.group_info().to_index(regex_automata::PatternID::ZERO, name);
find_pattern::resolve_names(find_pattern::parse_replace(&f.replace, &opts), &index)
});
let paths = s.doc.para_paths(story);
// Paragraphs read as one text: consecutive siblings when the pattern spans paragraphs.
let mut chunks: Vec<std::ops::Range<usize>> = Vec::new();
+481 -49
View File
@@ -10,7 +10,7 @@ use wordcraft_doc::para::OBJ;
use wordcraft_doc::{InlineObject, Pos};
use super::{delete_selection, sel_result, split_para};
use crate::{CmdError, CmdResult, CommandSpec, MathEdit, Selection, Session, p};
use crate::{CmdError, CmdResult, CommandSpec, ImeEditOutcome, MathEdit, Selection, Session, p};
fn editing(s: &Session) -> Option<&'static str> {
if s.math.is_none() { Some("not editing an equation") } else { None }
@@ -20,6 +20,9 @@ fn near_equation(s: &Session) -> Option<&'static str> {
if s.math.is_some() || equation_at_caret(s).is_some() { None } else { Some("no equation at the caret") }
}
/// Builds the equation command table: insert, edit and exit, typing, structures, symbols, caret
/// movement, deletion (including bounded surrounding deletion), enter, conversion, input format,
/// normal text, display, justification, numbering, set and get, gallery and structure actions.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("insert.equation", "Equation", "Insert › Symbols", insert)
@@ -40,6 +43,9 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"dir": "left|right|up|down|home|end|next|prev"}"#),
CommandSpec::new("equation.backspace", "Delete Back in Equation", "Equation", |s, _| delete(s, true)).when(editing),
CommandSpec::new("equation.delete", "Delete in Equation", "Equation", |s, _| delete(s, false)).when(editing),
CommandSpec::new("equation.deleteSurrounding", "Delete Around Caret in Equation", "Equation", delete_surrounding)
.when(editing)
.params(r#"{"before": n, "after": n}"#),
CommandSpec::new("equation.enter", "Enter in Equation", "Equation", enter).when(editing),
CommandSpec::new("equation.convert", "Convert", "Equation › Conversions", convert)
.when(near_equation_or_all)
@@ -100,21 +106,73 @@ fn equation_at_caret(s: &Session) -> Option<Pos> {
None
}
/// What one equation edit changed. `content_changed` gates an IME receipt; `state_changed`
/// covers style, representation and lazy-normalization changes that must poison a mixed frame.
#[derive(Clone, Copy, Debug, Default)]
struct EquationEffect {
content_changed: bool,
state_changed: bool,
}
impl EquationEffect {
/// Merges another edit's effect flags, so a change on either side marks the merged effect.
fn merge(&mut self, other: Self) {
self.content_changed |= other.content_changed;
self.state_changed |= other.state_changed;
}
}
/// Records the edit as a classified IME outcome; any content change also counts as a state
/// change, and `transformed` marks automatic rewrites.
fn apply_effect(s: &mut Session, effect: EquationEffect, transformed: bool) {
s.set_ime_edit_outcome(ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed || effect.content_changed,
transformed,
selection_changed: false,
});
}
/// Records a classified IME outcome with no content or state change, distinguishing a real
/// no-op from a command that never classified its edit.
fn classified_noop(s: &mut Session) {
s.set_ime_edit_outcome(ImeEditOutcome { classified: true, ..ImeEditOutcome::default() });
}
/// Run `f` on the equation at `at`; keeps its linear text in step and drops the source OMML
/// (it no longer matches).
fn with_eq<R>(s: &mut Session, at: &Pos, f: impl FnOnce(&mut Math, &mut bool) -> R) -> Result<R, CmdError> {
///
/// The logical before-state is captured *after* lazy `nodes` materialization: an imported
/// equation that only gets normalized is a state change, never an IME content change.
fn with_eq<R>(s: &mut Session, at: &Pos, f: impl FnOnce(&mut Math, &mut bool) -> R) -> Result<(R, EquationEffect), CmdError> {
let para = s.doc.para_mut(at.story, &at.path)?;
let Some(InlineObject::Equation { linear, display, math }) = para.object_at_mut(at.off) else {
return Err(CmdError::Failed("no equation there".into()));
};
if math.nodes.is_empty() && !linear.is_empty() && !math.linear {
let normalized = math.nodes.is_empty() && !linear.is_empty() && !math.linear;
if normalized {
math.nodes = parse_linear(linear);
}
let nodes_before = math.nodes.clone();
let linear_mode_before = math.linear;
let jc_before = math.jc;
let display_before = *display;
let cached_linear_before = linear.clone();
let omml_before_nonempty = !math.omml.is_empty();
let r = f(math, display);
*linear = if math.linear { plain(&math.nodes) } else { to_linear(&math.nodes) };
let new_linear = if math.linear { plain(&math.nodes) } else { to_linear(&math.nodes) };
let content_changed = math.nodes != nodes_before || math.linear != linear_mode_before;
let state_changed = normalized
|| content_changed
|| math.jc != jc_before
|| *display != display_before
|| omml_before_nonempty
|| new_linear != cached_linear_before;
*linear = new_linear;
math.omml.clear();
para.touch();
Ok(r)
Ok((r, EquationEffect { content_changed, state_changed }))
}
/// All the text of an equation's runs (its linear form while shown linear).
@@ -163,6 +221,9 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
insert_nodes_as_equation(s, nodes, v)
}
/// Clears any editing session, deletes the selection, creates an equation object (display on an
/// empty line unless the request overrides it), optionally opens editing at the first
/// placeholder, and reports a content change.
fn insert_nodes_as_equation(s: &mut Session, nodes: Arg, v: &Value) -> CmdResult {
s.math = None;
let props = s.typing_props();
@@ -178,14 +239,27 @@ fn insert_nodes_as_equation(s: &mut Session, nodes: Arg, v: &Value) -> CmdResult
let obj = InlineObject::Equation { linear, display, math: Math { nodes, ..Default::default() } };
let end = s.doc.insert_object(&at, obj, &props)?;
if p::bool(v, "edit").unwrap_or(true) {
let Some(session) = crate::next_math_session() else {
return Err(CmdError::Failed("no equation editing session available".into()));
};
s.sel = Selection::caret(at.clone());
s.math = Some(MathEdit { at, pos: caret });
s.math = Some(MathEdit { at, pos: caret, session });
} else {
s.sel = Selection::caret(end);
}
// Creating an equation inserts visible content, even when its tree is initially empty.
s.set_ime_edit_outcome(ImeEditOutcome {
classified: true,
content_changed: true,
state_changed: true,
transformed: false,
selection_changed: false,
});
result(s)
}
/// Starts editing the equation at the caret, materializing lazy nodes and clamping the requested
/// position, and returns the equation and caret.
fn edit(s: &mut Session, v: &Value) -> CmdResult {
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let nodes = match s.doc.para_at(&at).and_then(|p| p.object_at(at.off)) {
@@ -200,8 +274,11 @@ fn edit(s: &mut Session, v: &Value) -> CmdResult {
};
let pos =
v.get("pos").and_then(|p| serde_json::from_value::<MathPos>(p.clone()).ok()).unwrap_or_else(|| MathPos::new(Vec::new(), me::units(&nodes)));
let Some(session) = crate::next_math_session() else {
return Err(CmdError::Failed("no equation editing session available".into()));
};
s.sel = Selection::caret(at.clone());
s.math = Some(MathEdit { pos: me::clamp(&nodes, &pos), at });
s.math = Some(MathEdit { pos: me::clamp(&nodes, &pos), at, session });
result(s)
}
@@ -218,20 +295,30 @@ fn current(s: &Session) -> Result<MathEdit, CmdError> {
s.math.clone().ok_or_else(|| CmdError::Disabled("not editing an equation".into()))
}
/// Caps typing at `MAX_EQUATION_TYPE_CHARS`; empty text is a classified no-op. Linear form
/// inserts literal runs, while built-up form applies AutoCorrect and build-up before spaces and
/// relational characters.
fn type_text(s: &mut Session, v: &Value) -> CmdResult {
let text = p::req_str(v, "text")?.to_string();
let m = current(s)?;
if text.is_empty() {
// No normalization, no linear-cache rewrite, no revision: an empty commit is a no-op.
classified_noop(s);
return result(s);
}
let latex = s.math_latex;
let style = TypeStyle { nor: s.math_normal_text };
let mut pos = m.pos.clone();
with_eq(s, &m.at, |math, _| {
let mut transformed = false;
let (_, effect) = with_eq(s, &m.at, |math, _| {
if math.linear {
// Linear form is plain text: no build-up.
let text: String = text.chars().take(crate::MAX_EQUATION_TYPE_CHARS).collect();
pos = me::insert_text(&mut math.nodes, &pos, &text, TypeStyle::default());
set_literal(&mut math.nodes);
return;
}
for c in text.chars().take(10_000) {
for c in text.chars().take(crate::MAX_EQUATION_TYPE_CHARS) {
let rel = matches!(wordcraft_doc::math::math_class(c), wordcraft_doc::math::MClass::Rel);
if !style.nor && (c == ' ' || c == '\t' || rel) {
let mut changed = false;
@@ -243,6 +330,9 @@ fn type_text(s: &mut Session, v: &Value) -> CmdResult {
pos = p;
changed = true;
}
if changed {
transformed = true;
}
if c == ' ' && changed {
continue;
}
@@ -253,9 +343,9 @@ fn type_text(s: &mut Session, v: &Value) -> CmdResult {
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, transformed);
result(s)
}
/// Runs of a linear-form equation are literal text.
fn set_literal(nodes: &mut Arg) {
for n in nodes.iter_mut() {
@@ -265,6 +355,8 @@ fn set_literal(nodes: &mut Arg) {
}
}
/// Inserts a gallery, linear or LaTeX structure: a new equation when none is being edited,
/// literal text in linear form, or nodes at the caret in built-up form.
fn insert_structure(s: &mut Session, v: &Value) -> CmdResult {
let nodes = nodes_from(v).ok_or_else(|| CmdError::Params("give an id, linear or latex".into()))?;
let Some(m) = s.math.clone() else {
@@ -272,7 +364,7 @@ fn insert_structure(s: &mut Session, v: &Value) -> CmdResult {
return insert_nodes_as_equation(s, nodes, &json!({}));
};
let mut pos = m.pos.clone();
with_eq(s, &m.at, |math, _| {
let (_, effect) = with_eq(s, &m.at, |math, _| {
if math.linear {
pos = me::insert_text(&mut math.nodes, &pos, &to_linear(&nodes), TypeStyle::default());
set_literal(&mut math.nodes);
@@ -283,9 +375,12 @@ fn insert_structure(s: &mut Session, v: &Value) -> CmdResult {
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
result(s)
}
/// Rejects an empty character; outside editing creates an equation, otherwise inserts the
/// character at the math caret, marked literal in linear form.
fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult {
let c = p::req_str(v, "char")?.to_string();
if c.is_empty() {
@@ -296,7 +391,7 @@ fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult {
};
let mut pos = m.pos.clone();
let style = TypeStyle { nor: s.math_normal_text };
with_eq(s, &m.at, |math, _| {
let (_, effect) = with_eq(s, &m.at, |math, _| {
pos = me::insert_text(&mut math.nodes, &pos, &c, style);
if math.linear {
set_literal(&mut math.nodes);
@@ -305,6 +400,7 @@ fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult {
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
result(s)
}
@@ -317,6 +413,8 @@ fn nodes_of(s: &Session, at: &Pos) -> Option<Arg> {
}
}
/// Validates the direction, commits an AutoCorrect or build-up triggered by leaving a slot as one
/// undoable edit, moves or clamps the math caret, and exits past either end.
fn move_caret(s: &mut Session, v: &Value) -> CmdResult {
let m = current(s)?;
let dir = p::str(v, "dir").unwrap_or("right");
@@ -324,48 +422,49 @@ fn move_caret(s: &mut Session, v: &Value) -> CmdResult {
return Err(CmdError::Params(format!("unknown direction {dir}")));
}
// Leaving a slot finishes what was typed there (AutoCorrect names, build-up). That is worked
// out on a copy: plain navigation must leave the equation (and its source OMML) untouched,
// and a real change is a document edit — undoable and marking the document dirty (#446, #447).
// out on a local copy: plain navigation must leave the equation (and its source OMML)
// untouched, and a real change is a document edit — undoable and marking the document dirty
// (#446, #447). Any failure happens before the single commit, so no rollback is needed.
let shown_linear = matches!(
s.doc.para_at(&m.at).and_then(|p| p.object_at(m.at.off)),
Some(InlineObject::Equation { math, .. }) if math.linear
);
if !s.math_normal_text
&& !shown_linear
&& let Some(before) = nodes_of(s, &m.at)
{
let mut nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
let mut pos = m.pos.clone();
if !s.math_normal_text && !shown_linear {
let latex = s.math_latex;
let mut nodes = before.clone();
let mut pos = m.pos.clone();
let before = nodes.clone();
let mut candidate = nodes.clone();
let mut candidate_pos = pos.clone();
let mut changed = false;
if let Some(p) = me::autocorrect(&mut nodes, &pos) {
pos = p;
if let Some(p) = me::autocorrect(&mut candidate, &candidate_pos) {
candidate_pos = p;
changed = true;
}
if let Some(p) = me::build_up(&mut nodes, &pos, latex) {
pos = p;
if let Some(p) = me::build_up(&mut candidate, &candidate_pos, latex) {
candidate_pos = p;
changed = true;
}
if changed && nodes != before {
pos = candidate_pos;
if changed && candidate != before {
// `equation.move` is a pure command, so record the edit here.
s.checkpoint("Build Up");
with_eq(s, &m.at, |math, _| math.nodes = nodes)?;
let (_, effect) = with_eq(s, &m.at, |math, _| math.nodes = candidate.clone())?;
apply_effect(s, effect, true);
// `equation.move` is pure for ordinary navigation, so `Session::run` does not touch
// the document for us. A build-up is a real edit: mark it dirty and refresh layout.
s.touch();
if let Some(mm) = s.math.as_mut() {
mm.pos = pos;
}
nodes = candidate;
}
}
let m = current(s)?;
let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
let next = match dir {
"left" => me::move_left(&nodes, &m.pos),
"right" => me::move_right(&nodes, &m.pos),
"up" => Some(me::move_vertical(&nodes, &m.pos, true).unwrap_or(m.pos.clone())),
"down" => Some(me::move_vertical(&nodes, &m.pos, false).unwrap_or(m.pos.clone())),
"home" => Some(me::home_end(&nodes, &m.pos, false)),
"end" => Some(me::home_end(&nodes, &m.pos, true)),
"next" | "prev" => Some(me::next_placeholder(&nodes, &m.pos, dir == "prev").unwrap_or(m.pos.clone())),
"left" => me::move_left(&nodes, &pos),
"right" => me::move_right(&nodes, &pos),
"up" => Some(me::move_vertical(&nodes, &pos, true).unwrap_or(pos.clone())),
"down" => Some(me::move_vertical(&nodes, &pos, false).unwrap_or(pos.clone())),
"home" => Some(me::home_end(&nodes, &pos, false)),
"end" => Some(me::home_end(&nodes, &pos, true)),
"next" | "prev" => Some(me::next_placeholder(&nodes, &pos, dir == "prev").unwrap_or(pos.clone())),
other => return Err(CmdError::Params(format!("unknown direction {other}"))),
};
match next {
@@ -379,34 +478,96 @@ fn move_caret(s: &mut Session, v: &Value) -> CmdResult {
None => exit(s, &json!({"before": dir == "left"})),
}
}
/// With the caret in an empty equation, removes the whole equation as a content change;
/// otherwise deletes backward or forward, updates the caret and records the effect.
fn delete(s: &mut Session, back: bool) -> CmdResult {
let m = current(s)?;
let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
// An empty equation goes away entirely.
// An empty equation goes away entirely: removing the object is a content change.
if nodes.is_empty() {
s.math = None;
s.sel = Selection { anchor: m.at.clone(), focus: Pos { off: m.at.off + OBJ.len_utf8(), ..m.at.clone() } };
let at = delete_selection(s)?;
s.sel = Selection::caret(at);
s.set_ime_edit_outcome(ImeEditOutcome {
classified: true,
content_changed: true,
state_changed: true,
transformed: false,
selection_changed: false,
});
return sel_result(s);
}
let mut pos = m.pos.clone();
with_eq(s, &m.at, |math, _| {
let (_, effect) = with_eq(s, &m.at, |math, _| {
pos = if back { me::delete_back(&mut math.nodes, &pos) } else { me::delete_forward(&mut math.nodes, &pos) };
})?;
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
result(s)
}
/// Delete a bounded number of equation units on each side of the caret in one transaction.
///
/// The requested steps are simulated on a local tree and caret. Any step that cannot make
/// progress refuses the whole request before the document is touched; the commit (if any) is a
/// single command and a single undo step.
fn delete_surrounding(s: &mut Session, v: &Value) -> CmdResult {
let m = current(s)?;
let before = p::u64(v, "before").and_then(|n| usize::try_from(n).ok()).unwrap_or(usize::MAX);
let after = p::u64(v, "after").and_then(|n| usize::try_from(n).ok()).unwrap_or(usize::MAX);
if before.saturating_add(after) == 0 {
return Err(CmdError::Params("nothing to delete".into()));
}
if before > crate::MAX_EQUATION_DELETE_UNITS || after > crate::MAX_EQUATION_DELETE_UNITS {
return Err(CmdError::Params(format!("at most {} units on each side", crate::MAX_EQUATION_DELETE_UNITS)));
}
let mut nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
let mut pos = m.pos.clone();
for _ in 0..before {
let prev_nodes = nodes.clone();
let prev_pos = pos.clone();
pos = me::delete_back(&mut nodes, &pos);
if nodes == prev_nodes {
// Walking into the next argument is not a deletion. A requested unit that only
// moves the caret must refuse the whole request, or a later step can apply against
// a tree the platform's surrounding snapshot did not describe.
return Err(CmdError::Failed(if pos == prev_pos {
"the equation cannot delete that many units".into()
} else {
"the equation delete made no progress".into()
}));
}
}
for _ in 0..after {
let prev_nodes = nodes.clone();
let prev_pos = pos.clone();
pos = me::delete_forward(&mut nodes, &pos);
if nodes == prev_nodes {
return Err(CmdError::Failed(if pos == prev_pos {
"the equation cannot delete that many units".into()
} else {
"the equation delete made no progress".into()
}));
}
}
let (_, effect) = with_eq(s, &m.at, |math, _| math.nodes = nodes)?;
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
Ok(json!({"before": before, "after": after, "changed": true}))
}
/// In built-up form runs AutoCorrect, build-up and equation numbering; when nothing changes,
/// leaves the equation and splits a new paragraph.
fn enter(s: &mut Session, _: &Value) -> CmdResult {
let m = current(s)?;
let latex = s.math_latex;
let mut pos = m.pos.clone();
let mut changed = false;
with_eq(s, &m.at, |math, _| {
let (_, effect) = with_eq(s, &m.at, |math, _| {
if math.linear {
return;
}
@@ -427,6 +588,7 @@ fn enter(s: &mut Session, _: &Value) -> CmdResult {
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, true);
return result(s);
}
// Nothing to build: Enter ends the equation and starts a new paragraph.
@@ -434,6 +596,13 @@ fn enter(s: &mut Session, _: &Value) -> CmdResult {
let after = Pos { off: m.at.off + OBJ.len_utf8(), ..m.at };
let new = split_para(s, &after)?;
s.sel = Selection::caret(new);
s.set_ime_edit_outcome(ImeEditOutcome {
classified: true,
content_changed: true,
state_changed: true,
transformed: false,
selection_changed: false,
});
sel_result(s)
}
@@ -452,11 +621,14 @@ fn convert_math(math: &mut Math, professional: bool, latex: bool) {
}
}
/// Converts one equation or all equations between built-up and linear form, honoring LaTeX mode,
/// merging every effect and resetting the math caret.
fn convert(s: &mut Session, v: &Value) -> CmdResult {
let professional = p::str(v, "to").map(|t| !t.eq_ignore_ascii_case("linear")).unwrap_or(true);
let latex = s.math_latex;
if p::bool(v, "all").unwrap_or(false) {
let mut n = 0;
let mut effect = EquationEffect::default();
let stories: Vec<wordcraft_doc::StoryRef> =
std::iter::once(wordcraft_doc::StoryRef::Body).chain(s.doc.parts.keys().map(|k| wordcraft_doc::StoryRef::Part(*k))).collect();
for story in stories {
@@ -468,7 +640,8 @@ fn convert(s: &mut Session, v: &Value) -> CmdResult {
.unwrap_or_default();
for off in offs {
let at = Pos { story, path: path.clone(), off };
with_eq(s, &at, |math, _| convert_math(math, professional, latex))?;
let (_, one) = with_eq(s, &at, |math, _| convert_math(math, professional, latex))?;
effect.merge(one);
n += 1;
}
}
@@ -477,26 +650,32 @@ fn convert(s: &mut Session, v: &Value) -> CmdResult {
m.pos = MathPos::default();
}
s.clamp_selection();
apply_effect(s, effect, false);
return Ok(json!({"converted": n}));
}
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let mut end = 0;
with_eq(s, &at, |math, _| {
let (_, effect) = with_eq(s, &at, |math, _| {
convert_math(math, professional, latex);
end = me::units(&math.nodes);
})?;
if let Some(m) = s.math.as_mut() {
m.pos = MathPos::new(Vec::new(), end);
}
apply_effect(s, effect, false);
result(s)
}
/// Toggles or sets the equation's inline or display mode.
fn set_display(s: &mut Session, v: &Value) -> CmdResult {
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
with_eq(s, &at, |_, display| *display = p::bool(v, "value").unwrap_or(!*display))?;
let (_, effect) = with_eq(s, &at, |_, display| *display = p::bool(v, "value").unwrap_or(!*display))?;
apply_effect(s, effect, false);
result(s)
}
/// Maps `jc` to the math justification, with unknown values falling back to center-group, and
/// forces display mode.
fn justify(s: &mut Session, v: &Value) -> CmdResult {
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let jc = match p::str(v, "jc").unwrap_or("centerGroup") {
@@ -505,10 +684,11 @@ fn justify(s: &mut Session, v: &Value) -> CmdResult {
"center" => MathJc::Center,
_ => MathJc::CenterGroup,
};
with_eq(s, &at, |math, display| {
let (_, effect) = with_eq(s, &at, |math, display| {
math.jc = jc;
*display = true;
})?;
apply_effect(s, effect, false);
result(s)
}
@@ -516,7 +696,7 @@ fn justify(s: &mut Session, v: &Value) -> CmdResult {
fn number(s: &mut Session, v: &Value) -> CmdResult {
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let mut pos_reset = false;
with_eq(s, &at, |math, display| {
let (_, effect) = with_eq(s, &at, |math, display| {
let numbered = matches!(math.nodes.as_slice(), [MNode::EqArr { rows }] if rows.iter().any(|r| wordcraft_doc::math::has_number_mark(r)));
let want = p::bool(v, "value").unwrap_or(!numbered);
if want && !numbered {
@@ -539,6 +719,7 @@ fn number(s: &mut Session, v: &Value) -> CmdResult {
m.pos = MathPos::default();
}
s.clamp_selection();
apply_effect(s, effect, false);
result(s)
}
@@ -562,17 +743,20 @@ fn without_number(row: Arg) -> Arg {
out
}
/// Replaces the equation's nodes from linear or LaTeX input, forces built-up form, and puts the
/// caret at the new end.
fn set(s: &mut Session, v: &Value) -> CmdResult {
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
let nodes = nodes_from(v).ok_or_else(|| CmdError::Params("give linear or latex".into()))?;
let end = me::units(&nodes);
with_eq(s, &at, |math, _| {
let (_, effect) = with_eq(s, &at, |math, _| {
math.nodes = nodes;
math.linear = false;
})?;
if let Some(m) = s.math.as_mut() {
m.pos = MathPos::new(Vec::new(), end);
}
apply_effect(s, effect, false);
result(s)
}
@@ -601,6 +785,8 @@ fn structure_actions(s: &mut Session, _: &Value) -> CmdResult {
Ok(json!({"actions": acts}))
}
/// Applies a named structure action at an explicit or inferred level, refusing an action that
/// does not apply at the caret.
fn structure(s: &mut Session, v: &Value) -> CmdResult {
let m = current(s)?;
let action = p::req_str(v, "action")?.to_string();
@@ -614,11 +800,12 @@ fn structure(s: &mut Session, v: &Value) -> CmdResult {
.ok_or_else(|| CmdError::Params(format!("{action} doesn't apply here")))?,
};
let mut pos = None;
with_eq(s, &m.at, |math, _| pos = me::apply_structure(&mut math.nodes, &m.pos, level, &action))?;
let (_, effect) = with_eq(s, &m.at, |math, _| pos = me::apply_structure(&mut math.nodes, &m.pos, level, &action))?;
let pos = pos.ok_or_else(|| CmdError::Params(format!("{action} doesn't apply here")))?;
if let Some(m) = s.math.as_mut() {
m.pos = pos;
}
apply_effect(s, effect, false);
result(s)
}
@@ -858,14 +1045,24 @@ mod tests {
assert!(document_xml(&s).contains(">bx<"));
}
/// A Left move that builds up `a/b` makes the document dirty, advances layout and IME revisions,
/// reports a classified transformed change, and Undo restores the literal run; plain navigation
/// leaves no undo step.
#[test]
fn navigation_build_up_is_an_undoable_edit() {
// #447: building `a/b` up on a Left move left the document clean and Undo did nothing.
let mut s = imported("<m:r><m:t>a/b</m:t></m:r>");
assert!(!s.dirty);
let layout_rev = s.rev();
let ime_rev = revision(&mut s);
s.run("equation.move", &json!({"dir": "left"})).unwrap();
// Every command replaces the last outcome; sample the edit before the read-only query.
let outcome = s.ime_edit_outcome();
assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], "\\frac{a}{b}");
assert!(s.dirty, "build-up marks the document dirty");
assert!(s.rev() > layout_rev, "build-up refreshes the layout revision");
assert!(outcome.classified && outcome.content_changed && outcome.state_changed && outcome.transformed, "{outcome:?}");
assert!(revision(&mut s) > ime_rev, "build-up advances the unavailable IME revision");
s.run("edit.undo", &json!({})).unwrap();
s.run("equation.edit", &json!({})).unwrap();
assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], "a/b", "Undo restores the plain run");
@@ -941,4 +1138,239 @@ mod tests {
assert_eq!(subs, ["i", "j"], "{nodes:#?}");
assert!(nodes.iter().all(|n| matches!(n, MNode::Script { .. }) || matches!(n, MNode::Run(r) if r.text.trim().is_empty())), "{nodes:#?}");
}
/// Returns the session's current IME state revision, allocating a new one when the caret state changed.
fn revision(s: &mut Session) -> u64 {
s.ime_state_revision().expect("an IME state revision")
}
/// Clears the stored node tree of the equation currently being edited, leaving its linear text
/// and lazy state in place.
fn clear_nodes(s: &mut Session) {
let at = s.math.as_ref().expect("editing").at.clone();
if let Some(InlineObject::Equation { math, .. }) = s.doc.para_mut(at.story, &at.path).expect("paragraph").object_at_mut(at.off) {
math.nodes.clear();
}
}
/// Backspace at the start of an equation is classified with no content or state change,
/// allocates no revision and leaves the linear text intact.
#[test]
fn equation_backspace_at_start_is_a_classified_noop() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "x+y"})).unwrap();
s.run("equation.move", &json!({"dir": "home"})).unwrap();
let before = revision(&mut s);
s.run("equation.backspace", &json!({})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified);
assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}");
assert_eq!(revision(&mut s), before, "a no-op allocates no revision");
assert_eq!(linear(&s), "x+y");
}
/// Materializing an imported equation's lazy nodes is a state change only: the outcome is
/// classified, content is unchanged, and the revision advances.
#[test]
fn lazy_normalization_is_state_not_content() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "x+1"})).unwrap();
clear_nodes(&mut s);
// Editing parses the local tree but leaves the stored equation lazy.
s.run("equation.edit", &json!({})).unwrap();
s.run("equation.move", &json!({"dir": "home"})).unwrap();
let before = revision(&mut s);
s.run("equation.backspace", &json!({})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified);
assert!(!outcome.content_changed, "the normalized tree was already logical content");
assert!(outcome.state_changed, "materializing the cached nodes is a state change");
assert!(revision(&mut s) > before, "state changes advance the revision");
}
/// Empty typing is a classified no-op in built-up and linear form, without a new revision.
#[test]
fn empty_equation_type_is_a_classified_noop_in_both_modes() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "x"})).unwrap();
let before = revision(&mut s);
s.run("equation.type", &json!({"text": ""})).unwrap();
assert!(s.ime_edit_outcome().classified);
assert!(!s.ime_edit_outcome().content_changed && !s.ime_edit_outcome().state_changed);
assert_eq!(revision(&mut s), before, "built-up empty type is a no-op");
s.run("equation.convert", &json!({"to": "linear"})).unwrap();
let before = revision(&mut s);
s.run("equation.type", &json!({"text": ""})).unwrap();
assert!(!s.ime_edit_outcome().content_changed && !s.ime_edit_outcome().state_changed);
assert_eq!(revision(&mut s), before, "linear empty type is a no-op");
}
/// Typing `a/b ` or `\alpha ` reports both a content change and a transformed automatic rewrite.
#[test]
fn automatic_equation_rewrites_are_transformed() {
for text in ["a/b ", "\\alpha "] {
let mut s = session();
s.run("insert.equation", &json!({})).unwrap();
s.run("equation.type", &json!({"text": text})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.content_changed, "{text}: {outcome:?}");
assert!(outcome.transformed, "{text}: {outcome:?}");
}
}
/// Justification and display changes advance the revision and report a state, not content,
/// change.
#[test]
fn equation_style_changes_are_state_changes_without_content() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "x"})).unwrap();
let before = revision(&mut s);
s.run("equation.justify", &json!({"jc": "left"})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified && outcome.state_changed && !outcome.content_changed, "{outcome:?}");
assert!(revision(&mut s) > before);
let before = revision(&mut s);
s.run("equation.display", &json!({"value": false})).unwrap();
assert!(s.ime_edit_outcome().state_changed && !s.ime_edit_outcome().content_changed);
assert!(revision(&mut s) > before);
}
/// Converting every equation reports one classified effect that merges their content and state
/// changes.
#[test]
fn convert_all_merges_every_equations_effect() {
let mut s = session();
s.run("document.setText", &json!({"text": "\r"})).unwrap();
s.run("insert.equation", &json!({"linear": "a/b"})).unwrap();
s.run("equation.convert", &json!({"to": "linear"})).unwrap();
s.run("equation.exit", &json!({})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.equation", &json!({"linear": "c/d"})).unwrap();
s.run("equation.exit", &json!({})).unwrap();
s.run("equation.convert", &json!({"to": "professional", "all": true})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}");
}
/// A bounded deletion around the caret removes the requested units in one undo step and reports
/// a content change.
#[test]
fn equation_delete_surrounding_is_one_transaction_and_one_undo_step() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "abcd"})).unwrap();
let depth = s.undo_depth();
s.run("equation.deleteSurrounding", &json!({"before": 2, "after": 0})).unwrap();
assert_eq!(linear(&s), "ab");
assert_eq!(s.undo_depth(), depth + 1, "one undo step, not one per unit");
assert!(s.ime_edit_outcome().classified && s.ime_edit_outcome().content_changed);
}
/// An impossible bounded deletion applies no prefix, adds no undo step, keeps the session and
/// caret, and leaves no classified outcome.
#[test]
fn equation_delete_surrounding_refuses_partial_progress() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "ab"})).unwrap();
let session_token = s.math.as_ref().unwrap().session;
let at = s.math.as_ref().unwrap().at.clone();
let depth = s.undo_depth();
let err = s.run("equation.deleteSurrounding", &json!({"before": 3, "after": 0})).unwrap_err();
assert!(err.to_string().contains("cannot delete"), "{err}");
assert_eq!(linear(&s), "ab", "the whole request is refused, no prefix applied");
assert_eq!(s.undo_depth(), depth, "no undo step for a refused transaction");
let m = s.math.as_ref().expect("still editing");
assert_eq!(m.session, session_token);
assert_eq!(m.at, at);
assert!(!s.ime_edit_outcome().classified, "a refusal has no outcome");
assert_eq!(
s.last_command.as_ref().map(|(id, _)| id.as_str()),
Some("equation.deleteSurrounding"),
"only the pre-dispatch Repeat record remains"
);
}
/// Zero or oversized counts and deletions from an empty equation are refused without mutation;
/// exactly deleting all text leaves the empty equation object.
#[test]
fn equation_delete_surrounding_bounds_and_empty_equations() {
let mut s = session();
s.run("insert.equation", &json!({})).unwrap();
assert!(s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 0})).is_err(), "zero counts are refused");
assert!(s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 0})).is_err(), "an empty equation refuses");
assert!(s.math.is_some(), "the empty equation object stays");
s.run("equation.type", &json!({"text": "a"})).unwrap();
let depth = s.undo_depth();
assert!(s.run("equation.deleteSurrounding", &json!({"before": 65, "after": 0})).is_err());
assert!(s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 65})).is_err());
assert_eq!(linear(&s), "a");
assert_eq!(s.undo_depth(), depth);
s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 0})).unwrap();
assert_eq!(linear(&s), "", "exactly deleting the last unit leaves the equation object");
assert!(s.math.is_some());
}
/// A forward deletion that would only step into another argument is refused whole, leaving the
/// text and undo depth unchanged (the caret is not asserted here).
#[test]
fn equation_delete_surrounding_refuses_a_caret_only_step() {
let mut s = session();
s.run("insert.equation", &json!({"latex": "\\frac{a}{b}"})).unwrap();
// At the end of the numerator a forward delete only steps into the denominator: the
// tree does not change, so a request that would report progress is refused whole.
s.math.as_mut().expect("editing").pos = MathPos { path: vec![(0, 0)], off: 1 };
let before = linear(&s);
let depth = s.undo_depth();
let err = s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 1})).unwrap_err();
assert!(err.to_string().contains("no progress"), "{err}");
assert_eq!(linear(&s), before);
assert_eq!(s.undo_depth(), depth);
}
/// A deletion that really erases on one side cannot buy a caret-only step on the other:
/// the mixed request is refused whole, so a following IME commit cannot land in the
/// argument the platform never asked about.
#[test]
fn equation_delete_surrounding_refuses_a_mixed_caret_only_step() {
let mut s = session();
s.run("insert.equation", &json!({"latex": "\\frac{a}{b}"})).unwrap();
// At the end of the numerator. `before: 1` erases `a`, but `after: 1` would only step
// into the denominator (the report's mixed-direction reproducer).
s.math.as_mut().expect("editing").pos = MathPos { path: vec![(0, 0)], off: 1 };
let before = linear(&s);
let pos = s.math.as_ref().expect("editing").pos.clone();
let depth = s.undo_depth();
let err = s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 1})).unwrap_err();
assert!(err.to_string().contains("no progress"), "{err}");
assert_eq!(linear(&s), before, "the successful-looking prefix is not applied");
assert_eq!(s.undo_depth(), depth, "no undo step for a refused mixed request");
assert_eq!(s.math.as_ref().expect("editing").pos, pos, "the caret stays where the request found it");
}
/// Typing more than the cap inserts exactly `MAX_EQUATION_TYPE_CHARS` characters in both linear
/// and built-up form.
#[test]
fn equation_type_caps_linear_and_built_up_input() {
for linear_mode in [false, true] {
let mut s = session();
s.run("insert.equation", &json!({})).unwrap();
if linear_mode {
s.run("equation.convert", &json!({"to": "linear"})).unwrap();
}
let text = "a".repeat(crate::MAX_EQUATION_TYPE_CHARS + 5);
s.run("equation.type", &json!({"text": text})).unwrap();
assert_eq!(linear(&s).chars().count(), crate::MAX_EQUATION_TYPE_CHARS, "linear={linear_mode}");
}
}
/// Deleting one character forward removes it and moves the math caret to the start of the
/// remaining text.
#[test]
fn equation_delete_surrounding_moves_the_caret_for_a_one_step_delete() {
let mut s = session();
s.run("insert.equation", &json!({"linear": "abc"})).unwrap();
s.run("equation.move", &json!({"dir": "home"})).unwrap();
s.run("equation.deleteSurrounding", &json!({"after": 1, "before": 0})).unwrap();
assert_eq!(linear(&s), "bc");
assert_eq!(s.math.as_ref().unwrap().pos, MathPos::new(Vec::new(), 0));
}
}
+29 -9
View File
@@ -79,6 +79,10 @@ fn rename(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"name": name, "path": s.path}))
}
/// Builds the file and document command table: rename, new, open, save, save as and encryption,
/// PDF/PNG/print output and print settings, close, properties and info, author and privacy
/// settings, and document inspection and text access. Recovery, compatibility, sharing, autosave,
/// `file.newFromTemplate` and `file.saveTemplate` are registered in [`super::tools::specs`].
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("file.rename", "Rename Document", "File › Info", rename).params(r#"{"name": string (document name; its extension is kept, so the name may be given with or without it)}"#).pure(),
@@ -188,6 +192,10 @@ pub fn specs() -> Vec<CommandSpec> {
let text = p::req_str(v, "text")?;
let d = Document::from_text(text);
s.doc.body = d.body;
// The body is new even though the document id stays: a live IME composition (or an
// equation editing session at the same offset) belongs to the old content.
s.note_body_replaced();
s.math = None;
s.sel = crate::Selection::caret(s.doc.start_of(StoryRef::Body));
sel_result(s)
})
@@ -416,15 +424,25 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
let Some(m) = custom.as_object() else {
return Err(CmdError::Params("`custom` must be an object of name → string or null".into()));
};
// Removals first: a case-only rename sends the old spelling as null and the new one
// with its value, and `set_custom_prop` matches names case-insensitively, so setting
// first would keep the old spelling (object keys are sorted, either spelling may lead).
for (name, val) in m {
if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME {
return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters")));
if val.is_null() {
s.doc.remove_custom_prop(name);
}
}
for (name, val) in m {
match val {
Value::Null => {
s.doc.remove_custom_prop(name);
// Removals took the name as stored above: a file opened from an older Word may
// hold one longer than today's limit, and it must still be deletable (#2982).
Value::Null => {}
Value::String(x) if x.len() <= MAX_CUSTOM_VALUE => {
if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME {
return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters")));
}
s.doc.set_custom_prop(name, x);
}
Value::String(x) if x.len() <= MAX_CUSTOM_VALUE => s.doc.set_custom_prop(name, x),
_ => return Err(CmdError::Params(format!("custom property `{name}` must be a string (≤ {MAX_CUSTOM_VALUE} bytes) or null"))),
}
}
@@ -436,10 +454,12 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
Ok(out)
}
/// Longest custom property name (Word's limit).
const MAX_CUSTOM_NAME: usize = 255;
/// Largest custom property value we accept from a command.
const MAX_CUSTOM_VALUE: usize = 1024 * 1024;
/// Longest custom property name (Word's limit). The Custom Properties dialog validates with it
/// too (#2982).
pub const MAX_CUSTOM_NAME: usize = 255;
/// Largest custom property value we accept from a command. The Custom Properties dialog
/// validates with it too (#2982).
pub const MAX_CUSTOM_VALUE: usize = 1024 * 1024;
fn info(s: &mut Session, _: &Value) -> CmdResult {
let l = s.layout();
+87 -3
View File
@@ -345,15 +345,20 @@ pub enum Piece {
Lit(String),
/// `^&`: the text found.
Found,
/// `\n` with wildcards: group `n`.
/// `\n` with wildcards, or `$n` / `${n}` / `\n` with a regex: group `n` (0 is the match).
Group(usize),
/// `${name}` / `$name` with a regex: a named group, resolved to a [`Piece::Group`] once the
/// pattern is built ([`resolve_names`]); one left unresolved expands to nothing.
Named(String),
/// `^c`: the clipboard.
Clip,
}
/// Read Replace with text: `^` codes (unless `literal`), and `\1`–`\9` with wildcards.
/// Read Replace with text: `^` codes (unless `literal`), `\1`–`\9` with wildcards, and with a
/// regex also `$n`, `${n}`, `${name}`, `$name`, `$$`, `\n` (paragraph mark), `\t` and `\\`.
pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
if o.literal || o.regex {
// With a regex, `special: false` turns off only the `^` codes; group references still expand.
if o.literal && !o.regex {
return vec![Piece::Lit(with.to_string())];
}
let chars: Vec<char> = with.chars().collect();
@@ -367,6 +372,22 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
let mut i = 0;
while let Some(&c) = chars.get(i) {
i += 1;
if o.regex && (c == '$' || c == '\\') {
match regex_ref(&chars, i, c) {
Some((Ref::Char(ch), used)) => {
cur.push(ch);
i += used;
}
Some((Ref::Group(pc), used)) => {
flush(&mut cur, &mut out);
out.extend(pc);
i += used;
}
// Not a reference or an escape: the character as written.
None => cur.push(c),
}
continue;
}
if c == '\\' && o.wildcards {
match chars.get(i) {
Some(d) if d.is_ascii_digit() && *d != '0' => {
@@ -384,6 +405,7 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
}
}
if c == '^'
&& !o.literal
&& let Some(&n) = chars.get(i)
{
if n == '&' || n == 'c' {
@@ -409,6 +431,67 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
out
}
/// What a `$` or `\` starts in regex Replace with text.
enum Ref {
/// An escape: `$$`, `\n`, `\t`, `\\`.
Char(char),
/// A group reference; `None` for a number too large to be a group (expands to nothing).
Group(Option<Piece>),
}
/// A group named by its number or name, as the `regex` crate reads it.
fn group_ref(name: &str) -> Option<Piece> {
if name.bytes().all(|b| b.is_ascii_digit()) { name.parse().ok().map(Piece::Group) } else { Some(Piece::Named(name.to_string())) }
}
/// Read a regex reference or escape after `c` (`$` or `\`) at `chars[i..]`: what it is and how
/// many characters it uses after `c`, or `None` when `c` is a plain character.
fn regex_ref(chars: &[char], i: usize, c: char) -> Option<(Ref, usize)> {
let next = *chars.get(i)?;
if c == '\\' {
return match next {
'n' => Some((Ref::Char(PARA_MARK), 1)),
't' => Some((Ref::Char('\t'), 1)),
// `\$` and `\^` keep a `$` or `^` as typed (no group, no `^` code).
'\\' | '$' | '^' => Some((Ref::Char(next), 1)),
'1'..='9' => Some((Ref::Group(next.to_digit(10).map(|d| Piece::Group(d as usize))), 1)),
_ => None,
};
}
if next == '$' {
return Some((Ref::Char('$'), 1));
}
let rest = chars.get(i..).unwrap_or_default();
if next == '{' {
// `${…}`: everything up to the closing brace; unclosed or empty, the `$` is literal.
let close = rest.iter().position(|&ch| ch == '}')?;
let name: String = rest.get(1..close)?.iter().collect();
if name.is_empty() {
return None;
}
return Some((Ref::Group(group_ref(&name)), close + 1));
}
// `$name`: the longest run of letters, digits and `_`.
let len = rest.iter().take_while(|ch| ch.is_ascii_alphanumeric() || **ch == '_').count();
if len == 0 {
return None;
}
let name: String = rest.get(..len)?.iter().collect();
Some((Ref::Group(group_ref(&name)), len))
}
/// Turn named group references into numbered ones with `index` (a name's group number);
/// an unknown name is dropped, so it expands to nothing.
pub fn resolve_names(pieces: Vec<Piece>, index: &dyn Fn(&str) -> Option<usize>) -> Vec<Piece> {
pieces
.into_iter()
.filter_map(|p| match p {
Piece::Named(n) => index(&n).map(Piece::Group),
p => Some(p),
})
.collect()
}
/// Searched text as it is inserted again: marks become paragraph breaks.
fn as_inserted(s: &str) -> String {
s.replace(SECT_MARK, "\r")
@@ -423,6 +506,7 @@ pub fn expand(pieces: &[Piece], found: &str, group: &dyn Fn(usize) -> Option<Str
Piece::Found => out.push_str(&as_inserted(found)),
Piece::Group(n) => out.push_str(&as_inserted(&group(*n).unwrap_or_default())),
Piece::Clip => out.push_str(&clip.replace("\r\n", "\r").replace('\n', "\r")),
Piece::Named(_) => {}
}
}
out
+28 -4
View File
@@ -2,7 +2,7 @@
use serde_json::{Value, json};
use wordcraft_doc::para::{Float, InlineObject, ShapeKind, Wrap};
use wordcraft_doc::props::{Align, CharProps, Rgb, TabAlign, TabStop, TextColor};
use wordcraft_doc::props::{Align, Border, Borders, CharProps, Rgb, TabAlign, TabStop, TextColor};
use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, Table, para_block};
use super::{delete_selection, sel_result, split_para, type_text};
@@ -586,18 +586,42 @@ fn hf_part(s: &mut Session, header: bool) -> u32 {
id
}
/// Three independently editable cells spanning the section's text width.
fn three_column_hf(s: &Session, style: &str) -> wordcraft_doc::Blocks {
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
// The section `section_mut` would pick, read without making anything unique.
let sections = s.doc.sections();
let own = sections.iter().position(|(end, _)| block <= *end).filter(|i| i + 1 < sections.len()).and_then(|i| sections.get(i));
let width = own.map_or_else(|| s.doc.last_section.text_width(), |(_, sec)| sec.text_width());
let mut table = Table::new(1, 3, width);
table.props.style = Some("TableNormal".into());
table.props.width = Some(width);
table.props.fixed = true;
// Explicit nil borders also override borders inherited from the document's table style.
table.props.borders = Some(Borders::all(Border::default()));
for row in &mut table.rows {
for (cell, align) in row.cells.iter_mut().zip([Align::Left, Align::Center, Align::Right]) {
let mut para = Paragraph::new().styled(style);
para.props.align = Some(align);
cell.blocks = vec![para_block(para)];
}
}
// Keep a trailing paragraph so the story can also be edited after the table.
vec![std::sync::Arc::new(Block::Table(table)), para_block(Paragraph::new().styled(style))]
}
fn header_footer(s: &mut Session, v: &Value, header: bool) -> CmdResult {
let id = hf_part(s, header);
let style = if header { "Header" } else { "Footer" };
if let Some(text) = p::str(v, "text") {
let blocks = text.split('\n').map(|l| para_block(Paragraph::with_text(l, CharProps::default()).styled(style))).collect();
s.doc.set_story(StoryRef::Part(id), blocks)?;
} else if p::str(v, "preset") == Some("blankThree") {
let blocks = three_column_hf(s, style);
s.doc.set_story(StoryRef::Part(id), blocks)?;
} else if let Some(preset) = p::str(v, "preset") {
let mut para = Paragraph::new().styled(style);
match preset {
"blankThree" => {
para.insert_text(0, "[Type here]\t[Type here]\t[Type here]", &CharProps::default())?;
}
"title" => {
para.insert_text(0, if s.doc.core.title.is_empty() { "[Document title]" } else { &s.doc.core.title }, &CharProps::default())?;
para.props.align = Some(Align::Center);
+50 -5
View File
@@ -785,7 +785,13 @@ fn merge_fields(doc: &Document) -> Vec<String> {
}
fn mergefield_name(instr: &str) -> Option<String> {
let rest = instr.trim().strip_prefix("MERGEFIELD")?.trim();
// The field type ignores case and ends at a space or a quote, so `MERGEFIELDX` is another field.
let t = instr.trim();
let (kind, rest) = t.split_at_checked(t.find(|c: char| c.is_whitespace() || c == '"').unwrap_or(t.len()))?;
if !kind.eq_ignore_ascii_case("MERGEFIELD") {
return None;
}
let rest = rest.trim();
let name = if let Some(r) = rest.strip_prefix('"') { r.split('"').next().unwrap_or("") } else { rest.split_whitespace().next().unwrap_or("") };
Some(name.to_string())
}
@@ -840,11 +846,15 @@ enum Act {
Skip,
}
/// The merge field a rule tests: the name in its first nested field, if that is a MERGEFIELD.
fn rule_field(t: &str) -> Option<String> {
mergefield_name(t.split_once('{').and_then(|(_, r)| r.split('}').next())?)
}
/// Comparison of a rule condition `{ MERGEFIELD X } op "value" …` (IF, SKIPIF, NEXTIF) for
/// `row`: whether it holds, and the quoted texts after the value (IF's true / false text).
fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) {
let inner = t.split_once('{').and_then(|(_, r)| r.split('}').next()).unwrap_or("");
let field = mergefield_name(inner).unwrap_or_default();
let field = rule_field(t).unwrap_or_default();
let rest = t.split_once('}').map(|(_, r)| r).unwrap_or("");
let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '<'..='>')).collect();
let mut quoted = rest.split('"').skip(1).step_by(2);
@@ -887,7 +897,7 @@ fn act(s: &Session, instr: &str, row: usize) -> Option<Act> {
"SKIPIF" if condition(s, t, row).0 => Act::Skip,
"NEXTIF" | "SKIPIF" => Act::Text(String::new()),
// IF { MERGEFIELD X } = "v" "then" "else" (an IF on other fields isn't ours to touch).
"IF" if t.contains("MERGEFIELD") => {
"IF" if rule_field(t).is_some() => {
let (holds, texts) = condition(s, t, row);
Act::Text(texts.get(if holds { 0 } else { 1 }).copied().unwrap_or("").to_string())
}
@@ -904,7 +914,7 @@ fn placeholder(instr: &str) -> Option<String> {
"ADDRESSBLOCK" => Some("«AddressBlock»".into()),
"GREETINGLINE" => Some("«GreetingLine»".into()),
"NEXT" | "NEXTIF" | "SKIPIF" | "MERGEREC" => Some(String::new()),
"IF" if instr.contains("MERGEFIELD") => Some(String::new()),
"IF" if rule_field(instr).is_some() => Some(String::new()),
_ => None,
}
}
@@ -1546,6 +1556,41 @@ mod tests {
assert_eq!(r, vec![vec!["Name, first, last", "City"], vec!["Ann", "Paris"]]);
}
/// Field types ignore case (ECMA-376 Part 1, 17.16.1), and `MERGEFIELDX` is another field.
#[test]
fn merge_field_types_ignore_case() {
assert_eq!(mergefield_name(" mergefield City ").as_deref(), Some("City"));
assert_eq!(mergefield_name(r#"MergeField "First Name""#).as_deref(), Some("First Name"));
assert_eq!(mergefield_name(r#"MERGEFIELD"City""#).as_deref(), Some("City"));
assert_eq!(mergefield_name("MERGEFIELDX City"), None);
assert_eq!(placeholder("mergefield City").as_deref(), Some("«City»"));
assert_eq!(placeholder(r#"if { mergefield City } = "x" "y" "z""#).as_deref(), Some(""));
let mut s = Session::new(Document::new());
s.run("mailings.recipients", &json!({"rows": [{"Name": "Alpha"}, {"Name": "Beta"}]})).unwrap();
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
s.run("insert.field", &json!({"instr": "mergefield Name", "result": ""})).unwrap();
s.run("insert.field", &json!({"instr": r#"if { mergefield Name } = "Beta" " (b)" """#, "result": ""})).unwrap();
s.run("mailings.finish", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "Alpha\nBeta (b)");
}
/// An IF is a merge rule only when the field it tests is a MERGEFIELD: the word in a quoted
/// value doesn't make `IF { PAGE } = "mergefield" …` one.
#[test]
fn an_if_on_another_field_is_not_a_merge_rule() {
let other = r#"IF { PAGE } = "mergefield" "yes" "no""#;
assert_eq!(placeholder(other), None);
assert_eq!(placeholder(r#"IF { PAGE } = { MERGEFIELD City } "yes" "no""#), None);
assert_eq!(placeholder(r#"IF { MERGEFIELD City } = "mergefield" "yes" "no""#).as_deref(), Some(""));
let mut s = Session::new(Document::new());
s.run("mailings.recipients", &json!({"rows": [{"Name": "Alpha"}]})).unwrap();
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
s.run("insert.field", &json!({"instr": "mergefield Name", "result": ""})).unwrap();
s.run("insert.field", &json!({"instr": other, "result": " kept"})).unwrap();
s.run("mailings.finish", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), "Alpha kept");
}
#[test]
fn merge_end_to_end() {
let mut s = Session::new(Document::new());
+100 -15
View File
@@ -150,16 +150,43 @@ pub fn selected_paths(s: &Session) -> Vec<wordcraft_doc::Path> {
s.doc.para_paths_in(&a, &b)
}
/// Delete the selection (respecting track changes); returns the collapsed position.
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
/// What one selection deletion actually did, for IME outcome classification.
#[derive(Clone, Debug, Default)]
pub(crate) struct SelectionDelete {
pub at: Pos,
pub content_changed: bool,
pub state_changed: bool,
}
/// Delete the selection (respecting track changes), reporting whether committed content changed.
///
/// Under Track Changes an all-already-deleted range is a real no-op: no revision record is
/// created and the effect says so.
pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete, CmdError> {
let (a, b) = s.sel.ordered();
if a == b {
return Ok(a);
return Ok(SelectionDelete { at: a, content_changed: false, state_changed: false });
}
controls::check_delete(s, &a, &b)?;
let at = if s.doc.settings.track_changes { track_delete(s, &a, &b)? } else { s.doc.delete_range(&a, &b)? };
s.sel = crate::Selection::caret(at.clone());
Ok(at)
let effect = if s.doc.settings.track_changes {
if !track_delete_would_change(s, &a, &b) {
SelectionDelete { at: a.clone(), content_changed: false, state_changed: false }
} else {
let rid = new_revision(s, RevisionKind::Delete);
let deleted = track_delete_as(s, &a, &b, rid)?;
SelectionDelete { at: deleted.at, content_changed: deleted.changed, state_changed: deleted.changed }
}
} else {
let at = s.doc.delete_range(&a, &b)?;
SelectionDelete { at, content_changed: true, state_changed: true }
};
s.sel = crate::Selection::caret(effect.at.clone());
Ok(effect)
}
/// Delete the selection (respecting track changes); returns the collapsed position.
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
delete_selection_effect(s).map(|e| e.at)
}
/// A revision record for the current author.
@@ -217,15 +244,47 @@ pub fn remove_empty_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc:
Ok(true)
}
/// Whether a tracked deletion of `a..b` would change anything: any non-deleted run intersects
/// the range, or a removable/markable paragraph mark lies inside it.
pub(crate) fn track_delete_would_change(s: &Session, a: &Pos, b: &Pos) -> bool {
let paths = s.doc.paths_between(a, b);
for path in &paths {
let Some(p) = s.doc.para(a.story, path) else { continue };
let from = if *path == a.path { a.off } else { 0 };
let to = if *path == b.path { b.off } else { p.len() };
if p.run_ranges().any(|(r, c)| r.end > from && r.start < to && c.del.is_none()) {
return true;
}
if *path != b.path && has_next_para(s, a.story, path) && p.mark.del.is_none() {
return true;
}
}
false
}
/// What one tracked deletion did.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct TrackDeleteEffect {
pub at: Pos,
/// At least one run was removed/marked deleted, or a paragraph mark was removed/marked.
pub changed: bool,
}
/// Tracked deletion: own insertions are removed, other text is marked deleted.
///
/// Preflights first: an all-already-deleted range does nothing and creates no revision record.
pub(crate) fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<Pos, CmdError> {
if !track_delete_would_change(s, a, b) {
return Ok(a.clone());
}
let rid = new_revision(s, RevisionKind::Delete);
track_delete_as(s, a, b, rid)
track_delete_as(s, a, b, rid).map(|e| e.at)
}
/// [`track_delete`] marking the deleted text with revision `rid`.
pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Result<Pos, CmdError> {
pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Result<TrackDeleteEffect, CmdError> {
let author = s.author.clone();
let mut changed = false;
// Remove text this author inserted (it never existed for the reader); mark the rest.
let paths = s.doc.paths_between(a, b);
for path in paths.iter().rev() {
@@ -248,6 +307,7 @@ pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Re
} else {
para.format(x, y, &|c| c.del = Some(rid))?;
}
changed = true;
}
// The paragraph mark is inside the range unless this is its last paragraph. Like text,
// a mark this author inserted is removed (the paragraphs join again), any other is
@@ -264,14 +324,27 @@ pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Re
p.mark.del = Some(rid);
p.touch();
}
changed = true;
}
}
}
Ok(a.clone())
Ok(TrackDeleteEffect { at: a.clone(), changed })
}
/// What one text insertion/deletion commit changed, for IME outcome classification.
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct InsertEffect {
pub content_changed: bool,
pub state_changed: bool,
}
/// Insert typed text at the caret (replacing the selection), with pending formatting and track changes.
pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
insert_text(s, text, true).map(|_| ())
}
/// [`type_text`] with the effect the insertion had.
pub(crate) fn type_text_effect(s: &mut Session, text: &str) -> Result<InsertEffect, CmdError> {
insert_text(s, text, true)
}
@@ -279,30 +352,38 @@ pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
/// being transferred, not Enter key presses: an empty list item between them stays (Enter on
/// one would end the list).
pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
insert_text(s, text, false)
insert_text(s, text, false).map(|_| ())
}
fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> {
/// Inserts typed or pasted text at the caret: resolves field ends and content-control placeholders,
/// deletes the selection, applies tracked-change insertions, and splits paragraphs at every typed
/// line break or pasted paragraph separator. A pasted separator keeps the list semantics of pasted
/// text; a one-line control suppresses typed breaks only. It gives an otherwise undirected paragraph
/// right-to-left direction when its first strong letter is RTL, and returns whether content or state
/// changed.
fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect, CmdError> {
let mut props = s.typing_props();
if s.sel.is_collapsed() {
s.sel = crate::Selection::caret(s.doc.past_field_ends(&s.sel.focus));
}
// Content controls: a placeholder is replaced, locked ones refuse.
if let Some(p) = controls::prepare_edit(s)? {
let prepared = controls::prepare_edit(s)?;
let placeholder_changed = prepared.is_some();
if let Some(p) = prepared {
props = p;
}
// Pasted text is text: pending properties left by AutoCorrect don't give it a symbol's font (#2036).
if !enter {
props = props.without_symbol();
}
delete_selection(s)?;
let deletion = delete_selection_effect(s)?;
// Typed text is new: never deleted, and not a formatting change of the text around it.
props.fmt_change = None;
// Its direction comes from its own letters, as on screen, not from the run it's typed into:
// Latin typed into reopened Hebrew must not be saved as right-to-left (#1543). The DOCX
// writer marks the right-to-left pieces itself.
props.rtl = None;
if s.doc.settings.track_changes {
if s.doc.settings.track_changes && !text.is_empty() {
props.ins = Some(new_revision(s, RevisionKind::Insert));
props.del = None;
} else {
@@ -311,6 +392,7 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
}
let mut at = s.sel.focus.clone();
let mut first = true;
let mut inserted = false;
// Typed line breaks are the Enter key between the lines: in a one-line control it does
// nothing, so the lines run together and the control stays one paragraph (pasted text keeps
// its breaks, as before).
@@ -318,10 +400,12 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
for part in text.split(['\r', '\u{2029}']) {
if !first && !no_breaks {
at = split_para_as(s, &at, enter)?;
inserted = true;
}
first = false;
let part = part.replace('\u{000B}', "\n");
if !part.is_empty() {
inserted = true;
let starts_para = direction_unset(s, &at);
at = s.doc.insert_text(&at, &part, &props)?;
// Typing Arabic, Hebrew… as the first letters of a paragraph whose direction nothing
@@ -338,7 +422,8 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
}
s.sel = crate::Selection::caret(at);
s.goal_x = None;
Ok(())
let content_changed = deletion.content_changed || inserted;
Ok(InsertEffect { content_changed, state_changed: deletion.state_changed || content_changed || placeholder_changed })
}
/// Is the paragraph at `at` still without letters (empty, or only spaces, digits and
+1 -1
View File
@@ -58,7 +58,7 @@ pub fn cut_tracked(s: &mut Session) -> Result<bool, CmdError> {
}
s.doc.revisions.push(Revision::new(RevisionKind::Delete, s.author.clone(), now_iso()));
let rid = (s.doc.revisions.len() - 1) as u32;
let at = track_delete_as(s, &a, &b, rid)?;
let at = track_delete_as(s, &a, &b, rid)?.at;
s.sel = Selection::caret(at);
// Only the author's own insertions were cut: they are gone, not moved.
if in_use(s, rid) {
+103 -14
View File
@@ -5,7 +5,7 @@ use wordcraft_doc::para::{COLUMN_BREAK, NB_HYPHEN, NBSP, OBJ, PAGE_BREAK, SOFT_H
use wordcraft_doc::props::{Border, BorderStyle, NumRef};
use wordcraft_doc::{Block, ListKind, Path, Pos, StoryRef, Table};
use super::{delete_selection, non_mac, sel_result, split_para, type_text};
use super::{delete_selection, delete_selection_effect, non_mac, sel_result, split_para, type_text, type_text_effect};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
pub fn specs() -> Vec<CommandSpec> {
@@ -96,14 +96,26 @@ fn typing_options(s: &mut Session, text: &str) {
}
}
/// Inserts text with the typing options and smart quotes, records the IME outcome, and for a
/// single trigger character runs list, AutoCorrect, dash and double-space AutoFormat as a separate undo step
/// when they rewrite the paragraph.
fn insert(s: &mut Session, v: &Value) -> CmdResult {
let text = p::req_str(v, "text")?;
let raw = p::bool(v, "raw").unwrap_or(false);
typing_options(s, text);
let t = if raw { None } else { autoformat(s, text) };
type_text(s, t.as_deref().unwrap_or(text))?;
let smart_quote = if raw { None } else { autoformat(s, text) };
let was_collapsed = s.sel.is_collapsed();
let effect = type_text_effect(s, smart_quote.as_deref().unwrap_or(text))?;
let mut outcome = crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: smart_quote.is_some(),
selection_changed: false,
};
let trigger = text.chars().count() == 1 && text.chars().all(|c| c == ' ' || ",.;:!?".contains(c));
if raw || !trigger {
s.set_ime_edit_outcome(outcome);
return sel_result(s);
}
// The paragraph as typed, so an automatic change can be undone on its own.
@@ -114,8 +126,22 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
if !listed {
super::tools::autocorrect(s).inspect_err(|_| s.autocap = None)?;
}
if text == " " && !listed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.dashes {
dash_autoformat(s).inspect_err(|_| s.autocap = None)?;
let dashed = text == " "
&& !listed
&& s.prefs.autocorrect.enabled
&& s.prefs.autocorrect.dashes
&& dash_autoformat(s).inspect_err(|_| s.autocap = None)?;
if text == " " && !listed && was_collapsed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.double_space_period {
double_space_period(s)?;
}
// Compare the paragraph with the one exactly as typed. This is the actual effect of
// AutoCorrect (including formatting-only superscripts and links), not an undo label that
// may have been left by an earlier step. It also covers the double-space period.
let rewritten = typed.as_ref().is_some_and(|(para, _)| s.doc.para_at(&f) != Some(para));
if listed || dashed || rewritten {
outcome.content_changed = true;
outcome.state_changed = true;
outcome.transformed = true;
}
if let Some((para, sel)) = typed
&& s.doc.para_at(&f) != Some(&para)
@@ -133,6 +159,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
}
}
s.autocap.take_if(|c| c.depth == u64::MAX);
s.set_ime_edit_outcome(outcome);
sel_result(s)
}
@@ -187,11 +214,32 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
Ok(true)
}
/// "word -- word" → en dash, "word--word" → em dash (on the space after).
fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
/// "word" + two spaces → "word. ": the second Space turns the first into a period, after a
/// letter, a digit or a closing bracket or quote; not after other punctuation, at the start of a
/// paragraph, after a tab, or for a third space (the text before them ends in a period by then).
fn double_space_period(s: &mut Session) -> Result<(), CmdError> {
let f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return Ok(()) };
let Some(before) = para.text.get(..f.off) else { return Ok(()) };
let Some(word) = before.strip_suffix(" ") else { return Ok(()) };
if !word.chars().next_back().is_some_and(|c| c.is_alphanumeric() || ")]}\"'\u{201D}\u{2019}\u{00BB}\u{203A}".contains(c)) {
return Ok(());
}
// The period takes the formatting of the space it replaces.
let at = word.len();
let p = s.doc.para_mut(f.story, &f.path)?;
let props = p.props_at(at + 1).clone().without_symbol();
p.delete(at, at + 1)?;
p.insert_text(at, ".", &props)?;
s.sel = Selection::caret(f);
Ok(())
}
/// "word -- word" → en dash, "word--word" → em dash (on the space after).
fn dash_autoformat(s: &mut Session) -> Result<bool, CmdError> {
let f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return Ok(false) };
let Some(before) = para.text.get(..f.off) else { return Ok(false) };
if before.ends_with(' ')
&& let Some(i) = before.trim_end().rfind(" -- ")
&& before.trim_end().get(i + 4..).is_some_and(|w| !w.is_empty() && !w.contains(' '))
@@ -202,6 +250,7 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
p.delete(i + 1, i + 3)?;
p.insert_text(i + 1, "\u{2013}", &props)?;
s.sel = Selection::caret(Pos { off: f.off + 1, ..f });
return Ok(true);
} else if before.ends_with(' ')
&& let Some(i) = before.trim_end().rfind(" - ")
&& !before.trim_end()[i + 3..].contains(' ')
@@ -211,6 +260,7 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
p.delete(i + 1, i + 2)?;
p.insert_text(i + 1, "\u{2013}", &props)?;
s.sel = Selection::caret(Pos { off: f.off + 2, ..f });
return Ok(true);
} else if before.ends_with(' ')
&& let Some(i) = before.trim_end().rfind("--")
&& i > 0
@@ -221,8 +271,9 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
p.delete(i, i + 2)?;
p.insert_text(i, "\u{2014}", &props)?;
s.sel = Selection::caret(Pos { off: f.off + 1, ..f });
return Ok(true);
}
Ok(())
Ok(false)
}
/// An AutoFormat that Enter applies to the paragraph just finished.
@@ -773,8 +824,12 @@ fn tab(s: &mut Session, _: &Value) -> CmdResult {
if s.sel.is_collapsed() && tab_indent_on(s) && tab_indents(s, &f) {
return indent_by_tab(s, &f);
}
if !s.sel.is_collapsed() && s.sel.anchor.path != s.sel.focus.path {
// Multiple paragraphs selected: increase indent.
let whole_para = !s.sel.is_collapsed() && s.sel.anchor.path == s.sel.focus.path && {
let (a, b) = s.sel.ordered();
a.off == 0 && s.doc.para_at(&a).is_some_and(|p| b.off == p.len())
};
if !s.sel.is_collapsed() && (s.sel.anchor.path != s.sel.focus.path || whole_para) {
// Several paragraphs or a whole paragraph selected: increase indent.
return super::para::indent(s, &Value::Null);
}
ins_char(s, '\t')
@@ -857,11 +912,22 @@ fn backspace(s: &mut Session, v: &Value) -> CmdResult {
Ok(r)
}
/// Deletes the selection or the character before the caret. At a paragraph start it removes list
/// numbering while keeping the text position, takes a List Paragraph out of the list, outdents,
/// joins the previous paragraph or moves into a preceding table; the command wrapper restores a
/// content-control placeholder.
fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
s.goal_x = None;
s.pending = None;
if !s.sel.is_collapsed() {
delete_selection(s)?;
let effect = delete_selection_effect(s)?;
s.set_ime_edit_outcome(crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: false,
selection_changed: false,
});
return sel_result(s);
}
let f = s.sel.focus.clone();
@@ -872,7 +938,14 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
c.note_backspace(&a, &f);
}
s.sel = Selection { anchor: a, focus: f };
delete_selection(s)?;
let effect = delete_selection_effect(s)?;
s.set_ime_edit_outcome(crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: false,
selection_changed: false,
});
return sel_result(s);
}
// At the start of a paragraph.
@@ -952,11 +1025,20 @@ fn delete(s: &mut Session, v: &Value) -> CmdResult {
Ok(r)
}
/// Deletes the selection or the character after the caret, joins the next paragraph, handles a
/// following table and removes a trailing section break; tracked changes keep their marks.
fn delete_inner(s: &mut Session, _: &Value) -> CmdResult {
s.goal_x = None;
s.pending = None;
if !s.sel.is_collapsed() {
delete_selection(s)?;
let effect = delete_selection_effect(s)?;
s.set_ime_edit_outcome(crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: false,
selection_changed: false,
});
return sel_result(s);
}
let f = s.sel.focus.clone();
@@ -964,7 +1046,14 @@ fn delete_inner(s: &mut Session, _: &Value) -> CmdResult {
if f.off < para.len() {
let b = Pos { off: para.next_boundary(f.off), ..f.clone() };
s.sel = Selection { anchor: f, focus: b };
delete_selection(s)?;
let effect = delete_selection_effect(s)?;
s.set_ime_edit_outcome(crate::ImeEditOutcome {
classified: true,
content_changed: effect.content_changed,
state_changed: effect.state_changed,
transformed: false,
selection_changed: false,
});
return sel_result(s);
}
// At the end: join with the next paragraph in the same container.
+74 -10
View File
@@ -114,6 +114,9 @@ pub struct AutoCorrectPrefs {
/// letter or digit makes it Heading 1 (Heading 2 or 3 after one or two leading tabs).
/// Off by default.
pub heading_styles: bool,
/// A second Space after a word, number or closing bracket or quote turns the first into a
/// period (macOS's "Add period with double-space"); off by default.
pub double_space_period: bool,
/// The user's entries: added, or replacing a built-in one.
pub entries: Vec<(String, String)>,
/// Built-in entries the user deleted.
@@ -152,6 +155,7 @@ impl Default for AutoCorrectPrefs {
border_lines: true,
tab_indent: false,
heading_styles: false,
double_space_period: false,
entries: Vec::new(),
removed: Vec::new(),
exceptions: Vec::new(),
@@ -344,6 +348,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
("borderLines", &mut ac.border_lines),
("tabIndent", &mut ac.tab_indent),
("headingStyles", &mut ac.heading_styles),
("doubleSpacePeriod", &mut ac.double_space_period),
("autoAddFirstLetter", &mut ac.auto_add_first_letter),
("autoAddInitialCaps", &mut ac.auto_add_initial_caps),
] {
@@ -420,6 +425,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
"borderLines": ac.border_lines,
"tabIndent": ac.tab_indent,
"headingStyles": ac.heading_styles,
"doubleSpacePeriod": ac.double_space_period,
"autoAddFirstLetter": ac.auto_add_first_letter,
"autoAddInitialCaps": ac.auto_add_initial_caps,
"entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::<Vec<_>>(),
@@ -429,6 +435,11 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
}))
}
/// Builds the tools, review, file and insertion command table: Repeat and macros; AutoCorrect,
/// keyboard and ribbon customization and search; Compare, accessibility, inspection, protection,
/// versions, recovery, templates and autosave; compatibility, sharing, Quick Parts, fields,
/// signatures, objects and spreadsheets; header/footer, table and numbering settings; language,
/// markup, editor and selection commands; and design and view options.
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("edit.repeat", "Repeat", "Quick Access Toolbar", |s, _| {
@@ -457,7 +468,7 @@ pub fn specs() -> Vec<CommandSpec> {
.pure(),
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options)
.params(
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "doubleSpacePeriod"?: bool (Add a period with double-space; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
)
.pure(),
CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard)
@@ -521,6 +532,10 @@ pub fn specs() -> Vec<CommandSpec> {
s.doc.settings.protection = protection;
s.doc.settings.protect_hash = protect_hash;
s.doc.settings.protect_formatting = protect_formatting;
// The restored document reuses the open document's id; the body and any equation
// editing session are replaced.
s.note_body_replaced();
s.math = None;
s.touch();
s.clamp_selection();
}
@@ -1332,6 +1347,8 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
s.doc = doc;
s.doc.ensure_nonempty();
s.note_body_replaced();
s.math = None;
}
let level = p::str(v, "level").unwrap_or("word");
let opts = CompareOptions {
@@ -1355,7 +1372,8 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
// Tables match as a whole, by their text.
Block::Table(_) => format!("\u{0}table\u{0}{}", opts.key(&block_text(b, 0))),
};
let ops = lcs_ops(&old.iter().map(key).collect::<Vec<_>>(), &new.iter().map(key).collect::<Vec<_>>());
let mut budget = MAX_COMPARE_CELLS;
let ops = lcs_ops(&old.iter().map(key).collect::<Vec<_>>(), &new.iter().map(key).collect::<Vec<_>>(), &mut budget);
let mut out: Vec<Arc<Block>> = Vec::new();
let (mut i, mut j) = (0usize, 0usize);
let mut changes = 0usize;
@@ -1391,7 +1409,7 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
for x in 0..dels.max(inss) {
let (a, b) = (if x < dels { old.get(i + x) } else { None }, if x < inss { new.get(j + x) } else { None });
match (a.map(|x| &**x), b.map(|x| &**x)) {
(Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(pa, pb, ins, del, &opts))),
(Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(pa, pb, ins, del, &opts, &mut budget))),
_ => {
if let Some(x) = a {
out.push(Arc::new(mark_block(x, &|c| c.del = Some(del), 0)));
@@ -1412,6 +1430,8 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
}
s.doc.body = out;
s.doc.ensure_nonempty();
s.note_body_replaced();
s.math = None;
if show_in != "original" || p::str(v, "original").is_some() {
// The result is a new, untitled document: saving it never overwrites either file.
s.path = None;
@@ -1467,15 +1487,26 @@ enum Op {
/// compared as deleted and inserted, never dropped.
const MAX_LCS: usize = 4000;
/// Comparison table cells one compare may fill over all its tables (blocks and every changed
/// paragraph pair); once spent, the remaining middles are compared as deleted and inserted.
const MAX_COMPARE_CELLS: u64 = 50_000_000;
/// LCS edit script between two sequences: the common start and end are matched first, then the
/// middle (bounded size).
fn lcs_ops<T: PartialEq>(a: &[T], b: &[T]) -> Vec<Op> {
/// middle (bounded size). The middle's table is charged to `budget`; when it doesn't fit, the
/// middle is deleted and inserted whole.
fn lcs_ops<T: PartialEq>(a: &[T], b: &[T], budget: &mut u64) -> Vec<Op> {
let pre = a.iter().zip(b).take_while(|(x, y)| x == y).count();
let (ra, rb) = (a.get(pre..).unwrap_or_default(), b.get(pre..).unwrap_or_default());
let suf = ra.iter().rev().zip(rb.iter().rev()).take_while(|(x, y)| x == y).count();
let (a, b) = (ra.get(..ra.len() - suf).unwrap_or_default(), rb.get(..rb.len() - suf).unwrap_or_default());
let mut ops = vec![Op::Same; pre];
let (n, m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS));
let (mut n, mut m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS));
let cells = (n as u64).saturating_mul(m as u64);
if cells > *budget {
(n, m) = (0, 0);
} else {
*budget -= cells;
}
let mut t = vec![vec![0u32; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
@@ -1589,11 +1620,11 @@ fn tokens(s: &str, characters: bool) -> Vec<String> {
v
}
fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions) -> Paragraph {
fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions, budget: &mut u64) -> Paragraph {
let (at, bt) = (a.plain_text(), b.plain_text());
let chars = opts.characters && at.chars().count().max(bt.chars().count()) <= MAX_CHAR_DIFF;
let (ta, tb) = (tokens(&at, chars), tokens(&bt, chars));
let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::<Vec<_>>(), &tb.iter().map(|t| opts.key(t)).collect::<Vec<_>>());
let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::<Vec<_>>(), &tb.iter().map(|t| opts.key(t)).collect::<Vec<_>>(), budget);
// Only at character granularity: a matched whole word almost never happens by coincidence,
// but a matched single character very often does (digits, punctuation), so only there does
// a short interior match need folding back into its surrounding edit.
@@ -2242,12 +2273,12 @@ mod tests {
fn collapse_noise_matches_merges_short_interior_runs_but_keeps_prefix_suffix() {
// "01.09" vs "19.10": a pure LCS finds a scrambled chain of single-character matches
// ('1', '.', '0') threaded through what a reader expects to be one clean replacement.
let ops = lcs_ops(&['0', '1', '.', '0', '9'], &['1', '9', '.', '1', '0']);
let ops = lcs_ops(&['0', '1', '.', '0', '9'], &['1', '9', '.', '1', '0'], &mut { u64::MAX });
assert!(ops.contains(&Op::Same), "the raw LCS does find spurious matches here, or this test proves nothing");
assert_eq!(collapse_noise_matches(&ops), vec![Op::Del, Op::Del, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Ins, Op::Ins, Op::Ins]);
// A plain single-character replacement flanked by a real common prefix and suffix has no
// interior match to begin with (the DP's middle is pure delete/insert), so it's untouched.
let ops = lcs_ops(&['a', 'b', 'c', 'x', 'd', 'e', 'f'], &['a', 'b', 'c', 'y', 'd', 'e', 'f']);
let ops = lcs_ops(&['a', 'b', 'c', 'x', 'd', 'e', 'f'], &['a', 'b', 'c', 'y', 'd', 'e', 'f'], &mut { u64::MAX });
assert_eq!(collapse_noise_matches(&ops), ops);
}
@@ -2265,6 +2296,39 @@ mod tests {
);
}
#[test]
fn compare_bounds_total_diff_work() {
// Many long, slightly different paragraphs: each pair alone fits one comparison table,
// but the work over all pairs must stay bounded. Pairs past the budget are compared
// whole (deleted and inserted), and every pair still counts as a change.
let words = 3000;
let paras = 12;
let doc = |rev: bool| -> String {
(0..paras)
.map(|p| (0..words).map(|w| if w % 10 == 0 && rev { format!("x{p}w{w}") } else { format!("p{p}w{w}") }).collect::<Vec<_>>().join(" "))
.collect::<Vec<_>>()
.join("\n")
};
let (original, revised) = (doc(false), doc(true));
let mut s = Session::new(Document::from_text(&original));
let r = s.run("review.compare", &json!({"text": revised})).unwrap();
assert_eq!(r["changes"], paras);
// A pair diffed word by word keeps most of its text unmarked; one replaced whole keeps
// only its common start and end.
let word_diffed = |b: &Arc<Block>| {
let p = b.as_para().unwrap();
let same: usize = p.run_ranges().filter(|(_, c)| c.ins.is_none() && c.del.is_none()).map(|(r, _)| r.len()).sum();
same * 2 > p.text.len()
};
assert!(word_diffed(&s.doc.body[0]));
let diffed = s.doc.body.iter().filter(|b| word_diffed(b)).count() as u64;
let cells = (words as u64) * (words as u64);
assert!(diffed * cells <= 50_000_000 + cells, "{diffed} pairs diffed word by word");
assert!(!word_diffed(s.doc.body.last().unwrap()));
s.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), revised);
}
#[test]
fn macros_record_and_run() {
let mut s = Session::new(Document::new());
+71 -20
View File
@@ -146,7 +146,8 @@ fn open_binary_doc(bytes: &[u8], password: Option<&str>) -> Result<(Document, bo
}
/// [`open_bytes`] for a file that may be password-protected: a Word document encrypted with a
/// password ([MS-OFFCRYPTO] agile encryption, or the Word 97-2003 schemes) is decrypted with
/// password ([MS-OFFCRYPTO] agile or Standard encryption, under any Word extension, or the
/// Word 97-2003 schemes) is decrypted with
/// `password` first. Also says
/// whether the file was encrypted. Without the password needed the error satisfies
/// [`needs_password`]; with a wrong one, [`wrong_password`].
@@ -527,21 +528,67 @@ impl LocalImages {
/// Up to `limit + 1` bytes of the regular file at `path` (more than `limit` means too large), or
/// `None` for anything else. A named pipe blocks when opened and a device reports a length of 0
/// and may never end, so the path is checked before opening, the opened handle is checked again,
/// and the read is bounded whatever length the file reports.
/// and the read is bounded whatever length the file reports. Public for the app (a copied picture
/// file, Paste Special).
#[cfg(not(target_arch = "wasm32"))]
fn read_regular_file(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
use std::io::Read;
if !std::fs::metadata(path).ok()?.is_file() {
pub fn read_regular_file(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
let (file, meta) = open_regular_file(path).ok()?;
read_bounded(file, &meta, limit).ok()
}
#[cfg(target_arch = "wasm32")]
pub fn read_regular_file(_path: &std::path::Path, _limit: u64) -> Option<Vec<u8>> {
None
}
/// The bytes of the regular file at `path` when it holds at most `limit` of them; `None` for
/// anything else. A file whose opened handle reports more than `limit` bytes is refused before
/// anything is read; the read stays bounded for a file that grows after the check. Public for the
/// app (a copied picture file, Paste Special).
#[cfg(not(target_arch = "wasm32"))]
pub fn read_regular_file_within(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
let (file, meta) = open_regular_file(path).ok()?;
if meta.len() > limit {
return None;
}
let file = std::fs::File::open(path).ok()?;
let meta = file.metadata().ok()?;
read_bounded(file, &meta, limit).ok().filter(|b| b.len() as u64 <= limit)
}
#[cfg(target_arch = "wasm32")]
pub fn read_regular_file_within(_path: &std::path::Path, _limit: u64) -> Option<Vec<u8>> {
None
}
/// The regular file at `path`, opened once, with the opened handle's metadata. The path is
/// checked before opening (a named pipe blocks in `open` until a writer appears) and the handle
/// after (the path may have been swapped in between); anything but a regular file is "not a file".
#[cfg(not(target_arch = "wasm32"))]
fn open_regular_file(path: &std::path::Path) -> Result<(std::fs::File, std::fs::Metadata), String> {
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
if !meta.is_file() {
return None;
return Err(format!("{}: not a file", path.display()));
}
let mut open = std::fs::OpenOptions::new();
open.read(true);
// A path swapped for a named pipe since the check above would block `open`; non-blocking
// opens it at once (no effect on a regular file) and the handle check below refuses it.
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::custom_flags(&mut open, libc::O_NONBLOCK);
let file = open.open(path).map_err(|e| format!("{}: {e}", path.display()))?;
let meta = file.metadata().map_err(|e| format!("{}: {e}", path.display()))?;
if !meta.is_file() {
return Err(format!("{}: not a file", path.display()));
}
Ok((file, meta))
}
/// Up to `limit + 1` bytes of `file` (more than `limit` means too large), however long it says it is.
#[cfg(not(target_arch = "wasm32"))]
fn read_bounded(file: std::fs::File, meta: &std::fs::Metadata, limit: u64) -> std::io::Result<Vec<u8>> {
use std::io::Read;
let mut out = Vec::with_capacity(usize::try_from(meta.len().min(limit)).unwrap_or(0));
file.take(limit.saturating_add(1)).read_to_end(&mut out).ok()?;
Some(out)
file.take(limit.saturating_add(1)).read_to_end(&mut out)?;
Ok(out)
}
/// Largest file [`read_file`] reads whole — the same 2 GB ceiling [`open_path_with`] gives a
@@ -563,29 +610,33 @@ pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
}
/// [`read_file`] for a file that must be a regular file of at most `max` bytes, checked before
/// anything is read (a font named by the user, which could be any size).
/// anything is read (a font named by the user, which could be any size). The file is opened once
/// and the read stops past `max`, so a file swapped or grown after the check is refused too (#3666).
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result<Vec<u8>, String> {
crate::file_access::check_read(path)?;
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
if !meta.is_file() {
return Err(format!("{path}: not a file"));
}
let too_large = || format!("{path}: the {what} is larger than {} MB", max >> 20);
let (file, meta) = open_regular_file(std::path::Path::new(path))?;
if meta.len() > max {
return Err(format!("{path}: the {what} is larger than {} MB", max >> 20));
return Err(too_large());
}
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
let bytes = read_bounded(file, &meta, max).map_err(|e| format!("{path}: {e}"))?;
if bytes.len() as u64 > max {
return Err(too_large());
}
Ok(bytes)
}
/// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation
/// read root when there is one: enough to tell a file's format by its start.
/// read root when there is one: enough to tell a file's format by its start. Only a regular file
/// is opened: a named pipe would block the open until a writer appeared (#3666).
#[cfg(not(target_arch = "wasm32"))]
pub fn read_file_head(path: &str, max: u64) -> Result<Vec<u8>, String> {
use std::io::Read;
crate::file_access::check_read(path)?;
let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
let (file, _) = open_regular_file(std::path::Path::new(path))?;
let mut head = Vec::new();
f.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?;
file.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?;
Ok(head)
}
+33 -1
View File
@@ -28,8 +28,38 @@ pub use io::Password;
pub use keymap::KeyMap;
pub use ribbon::RibbonLayout;
pub use session::{
CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, blank_document, set_system_locale,
CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, blank_document,
next_math_session, set_system_locale,
};
/// The outcome a command reports for IME receipt classification.
///
/// A command that can be called by an input method must classify its committed effects. Only a
/// classified content change without an automatic rewrite may mint an IME receipt; any other
/// committed state change still advances the IME state revision and therefore makes a mixed
/// frame `Other` rather than certifying it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ImeEditOutcome {
/// The handler explicitly classified the committed effects.
pub classified: bool,
/// IME-visible content changed. Only this gates an IME receipt.
pub content_changed: bool,
/// Any committed document state changed, including style or representation changes.
pub state_changed: bool,
/// An automatic engine rewrite was applied (AutoCorrect, build-up, AutoFormat).
pub transformed: bool,
/// The caret or selection changed. Filled in by [`Session::run`].
pub selection_changed: bool,
}
/// The largest text one `equation.type` call inserts, in characters. The engine truncates to this
/// in both linear and built-up modes; the UI refuses an IME commit above it instead of inserting a
/// prefix.
pub const MAX_EQUATION_TYPE_CHARS: usize = 10_000;
/// The largest number of unit steps one `equation.deleteSurrounding` request may take on either
/// side of the caret. A hostile IME count must not become an unbounded command.
pub const MAX_EQUATION_DELETE_UNITS: usize = 64;
pub use wordcraft_doc as doc;
pub use wordcraft_geom::Region;
pub use wordcraft_layout as layout;
@@ -172,6 +202,8 @@ mod tests_bidi;
#[cfg(test)]
mod tests_fonts;
#[cfg(test)]
mod tests_ime_noop;
#[cfg(test)]
mod tests_revision_checks;
#[cfg(test)]
mod tests_typing;
+431 -8
View File
@@ -23,7 +23,7 @@ use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, PartKind, Path, Pos, StoryRef, Table};
use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, ViewMode};
use crate::{CmdError, Registry};
use crate::{CmdError, ImeEditOutcome, Registry};
/// A selection: anchor (where it started) and focus (where the caret is).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -327,8 +327,10 @@ struct Undo {
/// What [`Session::restore`] needs to take commands back exactly: the document, the selection,
/// the undo and redo stacks (also the oldest undo steps the undo limit pushes out meanwhile), the
/// open typing group, a pending [`Session::join_next_undo`] (and whether its group has a step)
/// and the dirty flag. See [`Session::edit_snapshot`].
/// open typing group, the pending
/// [`Session::join_next_undo`]/[`Session::join_next_equation_edit`]/body IME replacement promises,
/// whether the group has a step of its own, and the dirty flag. See
/// [`Session::edit_snapshot`].
pub struct EditSnapshot {
doc: Document,
sel: Selection,
@@ -341,8 +343,14 @@ pub struct EditSnapshot {
redo: Vec<Arc<Undo>>,
typing_open: bool,
join_next: bool,
equation_join_next: bool,
/// The body `DeleteSurrounding` replacement promise then, with the exact caret it expects.
ime_text_join: Option<Selection>,
join_anchor: bool,
dirty: bool,
/// The body's replacement epoch then: a previewed replacement that is rolled back is not a
/// replacement after all.
ime_body_epoch: u64,
}
/// Undo steps that the commands run between [`Session::edit_snapshot`] and [`Session::restore`]
@@ -423,6 +431,14 @@ pub struct Session {
typing_open: bool,
/// The next mutating command joins the previous undo step (later frames of a drag).
join_next: bool,
/// The next equation typing command joins a successful equation `deleteSurrounding`'s undo
/// step. Unlike [`Self::join_next`], any other command consumes and discards it, so an
/// unrelated Bold or document replacement can never be merged into the deletion.
equation_join_next: bool,
/// The next `text.insert` at this exact selection joins a successful body
/// `DeleteSurrounding`'s undo step. Any other command consumes and discards it, so an
/// intervening edit owns its own checkpoint instead of being merged into the deletion.
ime_text_join: Option<Selection>,
/// Whether the undo group has a step of its own for the next joined command to merge
/// into. A command that changed nothing leaves the group without one, so the next
/// command in the group starts the step the group needs.
@@ -437,6 +453,31 @@ pub struct Session {
/// Which document this is: changes when [`Session::set_document`] replaces it (open, new,
/// mail merge, recover), never on an edit.
document_id: u64,
/// How many times the document's body was replaced under the same [`Self::document_id`]
/// (`document.setText`, version restore, compare, Undo/Redo). It is the body's IME
/// replacement epoch: a composition must not survive the swap, even when the new body has
/// the same text or the same bounded surrounding window.
ime_body_epoch: u64,
/// Engine-owned, checked, process-monotonic revision for IME receipts whose target cannot
/// provide surrounding text. `None` means the allocator is exhausted (fail closed).
ime_state_revision: Option<u64>,
/// The caret/equation-session token the revision was last allocated for. It covers selection
/// and caret changes without walking the document.
ime_state_seen: Option<ImeCaretState>,
/// The current command's explicit IME effect classification.
ime_edit_outcome: ImeEditOutcome,
/// Simulation of a globally exhausted IME revision allocator for front-end producer tests.
/// Only [`Self::exhaust_ime_state_revision`] sets it; production builds do not compile it,
/// and it only makes receipt classification fail closed.
#[cfg(any(test, feature = "test-support"))]
ime_state_exhausted: bool,
/// A successful non-IME command changed document state since the last IME observation. It is
/// drained before publishing a receipt and at observation time; it is never cleared by a later
/// command. It covers mixed formatting/IME frames when available stamps ignore the revision.
pending_ime_application_change: bool,
/// Whether the current command was entered through [`Session::run_ime`]. Nested commands
/// inherit the origin.
ime_origin: bool,
cache: LayoutCache,
/// (revision, width, read height, view, read line spacing, layout, proofing, outline,
/// (deletions left out, deletions marked Hidden, body deletions in balloons), numeral display, the word being typed).
@@ -528,13 +569,45 @@ pub struct Session {
/// The number of edit locations Go Back cycles through (Word: four).
const EDIT_MARKS: usize = 4;
/// The caret identity used for the process-monotonic IME state revision.
#[derive(Clone, Debug, PartialEq, Eq)]
enum ImeCaretState {
Body(Selection),
Equation { session: u64, pos: wordcraft_doc::math_edit::MathPos },
}
/// One checked allocation from a process-monotonic IME revision counter.
fn allocate_ime_state_revision(counter: &std::sync::atomic::AtomicU64) -> Option<u64> {
counter.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok()
}
static IME_STATE_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// Allocates the next process-monotonic IME state revision, returning `None` when the counter is
/// exhausted.
fn next_ime_state_revision() -> Option<u64> {
allocate_ime_state_revision(&IME_STATE_REVISION)
}
/// The equation being edited.
#[derive(Clone, Debug, PartialEq)]
pub struct MathEdit {
/// The equation object (its U+FFFC).
/// The equation object (its U+FFFC). This moves as the document is edited, so it is not an
/// IME owner; [`Self::session`] is.
pub at: Pos,
/// The caret inside it.
pub pos: wordcraft_doc::math_edit::MathPos,
/// A stable token for this editing session, unique across documents and re-entry.
pub session: u64,
}
/// Next stable token for an equation editing session, or `None` when the counter is exhausted.
///
/// Exhaustion fails closed: a repeated token could let a cached IME context outlive the
/// equation it described, so the caller refuses to start editing instead.
pub fn next_math_session() -> Option<u64> {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
NEXT.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok()
}
/// Editing preferences that persist between runs (the front end saves and restores them).
@@ -725,11 +798,21 @@ impl Session {
redo: Vec::new(),
typing_open: false,
join_next: false,
equation_join_next: false,
ime_text_join: None,
join_anchor: false,
autocap: None,
rev: 1,
protect_facts: std::sync::Mutex::new(None),
document_id: 1,
ime_body_epoch: 0,
ime_state_revision: None,
ime_state_seen: None,
ime_edit_outcome: ImeEditOutcome::default(),
#[cfg(any(test, feature = "test-support"))]
ime_state_exhausted: false,
pending_ime_application_change: false,
ime_origin: false,
cache: LayoutCache::new(),
layout: None,
typing: None,
@@ -837,6 +920,29 @@ impl Session {
pub fn document_id(&self) -> u64 {
self.document_id
}
/// How many times the body was replaced under this [`Self::document_id`].
///
/// Front ends use it as the body's IME replacement epoch and to tell a body swap from an
/// ordinary edit; it advances for `document.setText`, a version restore, a compare and
/// Undo/Redo, never for typing.
pub fn ime_body_epoch(&self) -> u64 {
self.ime_body_epoch
}
/// Note that a command replaced the document or its whole body under this document id.
///
/// The counter fails closed: if it were ever exhausted (unreachable in practice), the
/// document owner id advances too, so a cached IME context still cannot be reused.
pub fn note_body_replaced(&mut self) {
self.ime_body_epoch = match self.ime_body_epoch.checked_add(1) {
Some(epoch) => epoch,
None => {
self.document_id = self.document_id.saturating_add(1);
u64::MAX
}
};
}
pub fn touch(&mut self) {
self.rev = self.rev.wrapping_add(1);
self.dirty = true;
@@ -1120,6 +1226,36 @@ impl Session {
pub fn join_next_undo(&mut self) {
self.join_next = true;
}
/// Make the next equation typing command (`equation.type` or `equation.backspace`) join the
/// previous equation deletion's undo step. Unlike [`Self::join_next_undo`], the promise is
/// not generic: any other command consumes and discards it, so unrelated edits get their own
/// checkpoint. A deletion-only IME request therefore cannot leak a join into the next edit.
pub fn join_next_equation_edit(&mut self) {
self.equation_join_next = true;
}
/// Discard an equation replacement join that received no replacement (for example an empty
/// commit), so it cannot join a later composition's typing.
pub fn clear_equation_join(&mut self) {
self.equation_join_next = false;
}
/// Make the next `text.insert` at the current selection join a successful body
/// `DeleteSurrounding`'s undo step. Unlike [`Self::join_next_undo`], the promise is scoped to
/// that exact replacement: any other command consumes and discards it, so an intervening
/// ordinary edit gets its own checkpoint.
pub fn join_next_ime_text_replacement(&mut self) {
self.ime_text_join = Some(self.sel.clone());
}
/// Discard a body replacement join that received no replacement (an empty or refused
/// commit, a new request or a refusal), so it cannot join a later composition.
pub fn clear_ime_text_join(&mut self) {
self.ime_text_join = None;
}
/// Discard every pending IME replacement join. A refused surrounding request supersedes the
/// composition it interrupts, including any promise from an earlier accepted request.
pub fn clear_ime_joins(&mut self) {
self.equation_join_next = false;
self.ime_text_join = None;
}
/// Stamp the document's save metadata (modified time, last modified by, revision; created
/// when it has none) the way Word does on every save. `file.save` calls it, and so does any
/// front end that writes the bytes itself (the web build's download, #262). Like Word, the
@@ -1197,6 +1333,9 @@ impl Session {
}
done
}
/// Undoes the newest step: closes typing, records an AutoCorrect exception when the undone step
/// was its automatic capital, swaps the document, selection, pending formatting and column with
/// the redo snapshot, replaces the body identity, refreshes equation editing and touches layout.
pub fn undo(&mut self) -> bool {
self.typing_open = false;
// Undoing the automatic capitalisation's own step makes its word an exception; any
@@ -1214,6 +1353,9 @@ impl Session {
column: std::mem::replace(&mut self.column, u.column),
};
self.redo.push(Arc::new(cur));
// Undo swaps the whole body back: a composition belongs to the text it showed.
self.note_body_replaced();
self.refresh_math_session();
self.touch();
true
}
@@ -1222,6 +1364,8 @@ impl Session {
let next = if redo { self.redo.last() } else { self.history.last() };
next.is_some_and(|u| crate::cmd::protect::protection_lifted(&self.doc, &u.doc))
}
/// Redoes the newest undone step by swapping the snapshots back, replacing the body identity,
/// refreshing equation editing and touching layout.
pub fn redo(&mut self) -> bool {
self.typing_open = false;
let Some(u) = self.redo.pop().map(Arc::unwrap_or_clone) else { return false };
@@ -1233,9 +1377,28 @@ impl Session {
column: std::mem::replace(&mut self.column, u.column),
};
self.history.push(Arc::new(cur));
self.note_body_replaced();
self.refresh_math_session();
self.touch();
true
}
/// An equation's editing session cannot ride across Undo/Redo: the equation tree, or the
/// object at that offset, may be a different one now. A new session token starts a new IME
/// context; if tokens are exhausted, the equation is left (fail closed).
fn refresh_math_session(&mut self) {
if self.math.is_none() {
return;
}
match next_math_session() {
Some(session) => {
if let Some(m) = self.math.as_mut() {
m.session = session;
}
}
None => self.math = None,
}
}
/// Drop history (after open / new).
pub fn reset_history(&mut self) {
self.autocap = None;
@@ -1302,12 +1465,16 @@ impl Session {
/// Replace the document (open/new).
pub fn set_document(&mut self, doc: Document) {
self.autocap = None;
self.document_id = self.document_id.wrapping_add(1);
// Never reuse an id after wrapping: the IME context is keyed by it.
self.document_id = self.document_id.saturating_add(1);
self.note_body_replaced();
self.bib_style = doc_bib_style(&doc);
self.doc = doc;
self.doc.ensure_nonempty();
self.password = None;
self.file_name = None;
// The old document's equation is gone: its editing session cannot carry over.
self.math = None;
self.sel = Selection::caret(self.doc.start_of(StoryRef::Body));
self.pending = None;
self.typing = None;
@@ -1331,8 +1498,9 @@ impl Session {
/// may want to take it back without a trace (cheap: the document's blocks are shared).
///
/// Covered: `doc`, `sel`, the undo stack (its length, and its oldest steps when it is near
/// the limit), `redo`, the open typing group, a pending `join_next_undo` (and whether its
/// group has a step to merge into) and `dirty`. `rev`,
/// the limit), `redo`, the open typing group, the pending `join_next_undo`,
/// `join_next_equation_edit` and body replacement promises, whether the typing group has a
/// step of its own, and `dirty`. `rev`,
/// `cache` and `layout` are derived (`restore` bumps `rev`). The other fields are not
/// covered: a caller that lets the command change them (the view, the find state, the
/// clipboard, macros, the file path…) saves and puts them back itself.
@@ -1378,6 +1546,8 @@ impl Session {
redo,
typing_open,
join_next,
equation_join_next,
ime_text_join,
join_anchor,
autocap: _,
rev: _,
@@ -1398,6 +1568,16 @@ impl Session {
merge: _,
ui_requests: _,
document_id: _,
ime_body_epoch,
// The IME revision and its caret token are process-global evidence, not document
// state: a rollback must not reuse a revision.
ime_state_revision: _,
ime_state_seen: _,
ime_edit_outcome: _,
#[cfg(any(test, feature = "test-support"))]
ime_state_exhausted: _,
pending_ime_application_change: _,
ime_origin: _,
read_aloud: _,
// The other windows, like `view`: not part of the document or its history.
windows: _,
@@ -1435,8 +1615,11 @@ impl Session {
redo: redo.clone(),
typing_open: *typing_open,
join_next: *join_next,
equation_join_next: *equation_join_next,
ime_text_join: ime_text_join.clone(),
join_anchor: *join_anchor,
dirty: *dirty,
ime_body_epoch: *ime_body_epoch,
}
}
@@ -1444,7 +1627,21 @@ impl Session {
/// selection or undo/redo (undo steps the limit pushed out meanwhile come back). The layout
/// is recomputed.
pub fn restore(&mut self, snap: EditSnapshot) {
let EditSnapshot { doc, sel, history_len, evicted, head, redo, typing_open, join_next, join_anchor, dirty } = snap;
let EditSnapshot {
doc,
sel,
history_len,
evicted,
head,
redo,
typing_open,
join_next,
equation_join_next,
ime_text_join,
join_anchor,
dirty,
ime_body_epoch,
} = snap;
let gone = usize::try_from(self.undo_evicted.saturating_sub(evicted)).unwrap_or(usize::MAX);
if gone > 0 {
let back = gone.min(head.len());
@@ -1459,6 +1656,9 @@ impl Session {
self.redo = redo;
self.typing_open = typing_open;
self.join_next = join_next;
self.equation_join_next = equation_join_next;
self.ime_text_join = ime_text_join;
self.ime_body_epoch = ime_body_epoch;
self.join_anchor = join_anchor;
self.touch();
self.dirty = dirty;
@@ -1526,6 +1726,87 @@ impl Session {
f
}
/// The current command's explicit IME effect classification.
pub fn ime_edit_outcome(&self) -> ImeEditOutcome {
self.ime_edit_outcome
}
/// Set the effect classification from a command handler. [`Session::run`] fills
/// `selection_changed` and enforces the content/state invariant after the command.
pub(crate) fn set_ime_edit_outcome(&mut self, outcome: ImeEditOutcome) {
self.ime_edit_outcome = outcome;
}
/// Take the pending application-origin change signal. The UI owns one session, so a simple
/// bool is enough; it deliberately survives later commands until drained.
pub fn take_pending_ime_application_change(&mut self) -> bool {
std::mem::take(&mut self.pending_ime_application_change)
}
/// Simulate a globally exhausted IME revision allocator in this session (test support).
///
/// This only ever makes receipt classification fail closed. It exists so front-end producer
/// tests can exercise the real `ime_stamp` path instead of hand-building an `Unavailable`
/// stamp; production builds do not compile it.
#[cfg(any(test, feature = "test-support"))]
#[doc(hidden)]
pub fn exhaust_ime_state_revision(&mut self) {
self.ime_state_exhausted = true;
self.ime_state_revision = None;
}
/// The checked, process-monotonic revision for IME receipts with unavailable surrounding
/// text. Selection/caret motion allocates on the next call; `None` means exhausted.
pub fn ime_state_revision(&mut self) -> Option<u64> {
self.sync_ime_caret_state();
self.ime_state_revision
}
/// Returns the caret identity used for IME state revisions: the equation session and position
/// while editing, otherwise the body selection.
fn current_ime_caret_state(&self) -> ImeCaretState {
match &self.math {
Some(m) => ImeCaretState::Equation { session: m.session, pos: m.pos.clone() },
None => ImeCaretState::Body(self.sel.clone()),
}
}
/// Allocate the next IME state revision, or store `None` when the allocator is exhausted.
/// Test support can pin exhaustion for this session so a front-end producer test sees the
/// real `ime_stamp`/receipt path fail closed.
fn reset_ime_state_revision(&mut self) {
#[cfg(any(test, feature = "test-support"))]
if self.ime_state_exhausted {
self.ime_state_revision = None;
return;
}
// v5: allocation failure must store `None`, never retain a stale revision.
self.ime_state_revision = next_ime_state_revision();
}
/// Allocates a new revision only when the body selection or the equation session and position
/// differs from the last observed state.
fn sync_ime_caret_state(&mut self) {
let state = self.current_ime_caret_state();
if self.ime_state_seen.as_ref() != Some(&state) {
self.ime_state_seen = Some(state);
self.reset_ime_state_revision();
}
}
/// Run one command as the input method's own transaction.
///
/// Commands run inside this scope are IME-origin: a successful command run *outside* this
/// scope sets the pending application-change signal, but nothing inside it does. Nested commands inherit
/// the origin. The previous origin is restored on every return, including unknown, disabled or
/// refused commands.
pub fn run_ime(&mut self, id: &str, params: &Value) -> Result<Value, CmdError> {
let previous = std::mem::replace(&mut self.ime_origin, true);
let result = self.run(id, params);
self.ime_origin = previous;
result
}
/// Whether the command changed nothing: the document, the selection, the formatting
/// waiting at the caret and the column selection are as they were before it ran.
///
@@ -1539,9 +1820,24 @@ impl Session {
/// control channel). Mutating commands are undoable; a failed command leaves the document as
/// it was; a panic inside a command becomes an error.
pub fn run(&mut self, id: &str, params: &Value) -> Result<Value, CmdError> {
// Every dispatch starts from an unclassified outcome. Early returns (unknown, disabled,
// protected) leave it at the default.
self.ime_edit_outcome = ImeEditOutcome::default();
// A rolled-back transaction must not leave a successful application-change signal from a
// nested command that ran before the failure.
let pending_application_change_before = self.pending_ime_application_change;
let join = std::mem::take(&mut self.join_next);
// The equation replacement promise is not a generic undo join: only the command that
// actually types into the equation may consume it. Any other command discards it.
let equation_join = std::mem::take(&mut self.equation_join_next);
// The body replacement promise is likewise scoped: only the `text.insert` at the
// selection the deletion left may consume it.
let ime_text_join = self.ime_text_join.take();
let reg = self.registry.clone();
let Some(spec) = reg.get(id) else { return Err(CmdError::Unknown(id.to_string())) };
let join = join
|| (equation_join && matches!(id, "equation.type" | "equation.backspace"))
|| (id == "text.insert" && ime_text_join.as_ref().is_some_and(|sel| sel == &self.sel));
if let Some(why) = (spec.enabled)(self) {
return Err(CmdError::Disabled(format!("{id}: {why}")));
}
@@ -1579,6 +1875,10 @@ impl Session {
}
// The undo stacks are snapshotted whole, not as a length: commands run nested commands,
// whose checkpoints, undos and redos (and evictions at the limit) must be put back too.
let ime_epoch_before = spec.mutates.then_some(self.ime_body_epoch);
// Commands may leave an equation (a whole-body replacement) even after they fail; put
// the editing session back with the document.
let math_before = spec.mutates.then(|| self.math.clone());
let before_doc = if spec.mutates {
Some((
self.doc.clone(),
@@ -1653,6 +1953,7 @@ impl Session {
let default_language_before = self.prefs.default_language.clone();
let run = spec.run;
let mutates = spec.mutates;
let ime_caret_before = self.current_ime_caret_state();
let rev_before = self.rev;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
let mut result = match result {
@@ -1732,6 +2033,28 @@ impl Session {
}
}
self.clamp_selection();
let mut ime_outcome = self.ime_edit_outcome;
if ime_outcome.content_changed {
ime_outcome.state_changed = true;
}
// An unclassified mutating command is conservatively a state change. It can make
// a frame Other, but it can never mint a receipt because `classified` is false.
// A command that changed nothing is not an edit (#23), so it is not an IME
// state change either: otherwise Backspace at the document start would still
// signal an application change and move the settled IME revision.
if !ime_outcome.classified && mutates && !no_op {
ime_outcome.state_changed = true;
}
ime_outcome.selection_changed = self.current_ime_caret_state() != ime_caret_before;
self.ime_edit_outcome = ime_outcome;
if ime_outcome.state_changed {
let caret = self.current_ime_caret_state();
self.ime_state_seen = Some(caret);
self.reset_ime_state_revision();
}
if !self.ime_origin && !no_op && (ime_outcome.content_changed || ime_outcome.state_changed || (!ime_outcome.classified && mutates)) {
self.pending_ime_application_change = true;
}
if sel_before.focus.story == StoryRef::Body && self.in_header_footer() {
self.hf_return = Some(sel_before.clone());
}
@@ -1754,8 +2077,16 @@ impl Session {
self.note_typing(id, before_doc.as_ref().map(|b| &b.0), &sel_before);
}
Err(e) => {
self.ime_edit_outcome = ImeEditOutcome::default();
self.pending_ime_application_change = pending_application_change_before;
self.autocap = None;
self.prefs.default_language = default_language_before;
if let Some(epoch) = ime_epoch_before {
self.ime_body_epoch = epoch;
}
if let Some(math) = math_before {
self.math = math;
}
if let Some(versions) = versions_before {
self.versions = versions;
}
@@ -1877,3 +2208,95 @@ pub fn blank_document(region: &wordcraft_geom::Region) -> Document {
d.last_section.page_h = h;
d
}
#[cfg(test)]
mod ime_revision_tests {
use super::*;
use serde_json::json;
/// Checked allocations stop at `u64::MAX` and report exhaustion instead of wrapping.
#[test]
fn checked_revision_allocation_refuses_to_wrap() {
let counter = std::sync::atomic::AtomicU64::new(u64::MAX - 1);
assert_eq!(allocate_ime_state_revision(&counter), Some(u64::MAX - 1));
assert_eq!(allocate_ime_state_revision(&counter), None, "exhaustion fails closed");
assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), u64::MAX);
}
/// An unchanged caret reuses its revision, while a direct selection assignment is observed and
/// advances the revision.
#[test]
fn selection_and_caret_motion_advance_the_unavailable_revision() {
let mut s = Session::new(Document::from_text("hello"));
let first = s.ime_state_revision().expect("a revision");
assert_eq!(s.ime_state_revision(), Some(first), "an unchanged caret reuses its revision");
s.sel = Selection::caret(Pos::body(0, 2));
let moved = s.ime_state_revision().expect("a revision");
assert!(moved > first, "a direct selection assignment is caught");
}
/// A selection-only command advances the revision on the next read, while a committed state
/// change advances it immediately.
#[test]
fn successful_state_changes_allocate_but_selection_only_commands_do_not() {
let mut s = Session::new(Document::from_text("hello"));
let first = s.ime_state_revision().expect("a revision");
s.run("select.all", &json!({})).unwrap();
// `run` does not allocate for selection-only work; the next stamp sees the new caret.
assert!(s.ime_state_revision() > Some(first));
let selected = s.ime_state_revision().expect("a revision");
s.run("para.alignCenter", &json!({})).unwrap();
assert!(s.ime_state_revision() > Some(selected), "a committed state change advances");
}
/// After the revision allocator is exhausted, a real equation edit still reports its classified
/// change but cannot mint a revision.
#[test]
fn exhausted_allocator_keeps_a_real_equation_commit_uncertifiable() {
let mut s = Session::new(Document::new());
s.run("insert.equation", &json!({})).unwrap();
s.exhaust_ime_state_revision();
assert_eq!(s.ime_state_revision(), None, "exhaustion fails closed");
s.run_ime("equation.type", &json!({"text": "x"})).unwrap();
let outcome = s.ime_edit_outcome();
assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}");
assert_eq!(s.ime_state_revision(), None, "a real producer command cannot mint a revision");
}
/// The application-change signal is consumed once; commands run through `run_ime` do not set it,
/// while a normal command does.
#[test]
fn application_origin_signal_is_taken_once_and_ime_commands_do_not_set_it() {
let mut s = Session::new(Document::from_text("hello"));
s.run("para.alignCenter", &json!({})).unwrap();
assert!(s.take_pending_ime_application_change(), "a normal command signals");
assert!(!s.take_pending_ime_application_change(), "taking clears it");
s.run_ime("text.insert", &json!({"text": "x"})).unwrap();
assert!(!s.take_pending_ime_application_change(), "an IME transaction is not an application change");
s.run("text.insert", &json!({"text": "y"})).unwrap();
assert!(s.take_pending_ime_application_change());
}
/// Unknown and refused IME commands restore the normal origin without signalling an application
/// change, and a nested command inside a macro inherits the IME origin.
#[test]
fn ime_origin_is_restored_and_nested_commands_inherit_it() {
let mut s = Session::new(Document::from_text("hello"));
s.run("tools.macros", &json!({"define": {"name": "centre", "steps": [{"command": "para.alignCenter", "params": {}}]}})).unwrap();
s.take_pending_ime_application_change();
// Unknown and refused commands restore the normal application origin; the failed run
// itself leaves no application-change signal.
assert!(s.run_ime("no.such.command", &json!({})).is_err());
assert!(!s.take_pending_ime_application_change(), "an unknown IME command is not an application change");
assert!(s.run_ime("equation.backspace", &json!({})).is_err());
assert!(!s.take_pending_ime_application_change(), "a refused IME command is not an application change");
// A nested `run` inherits the IME origin from `run_ime`; the same macro run normally is
// an application change.
s.run_ime("tools.macros", &json!({"run": "centre"})).unwrap();
assert!(!s.take_pending_ime_application_change(), "the nested command inherits the IME origin");
s.run("tools.macros", &json!({"run": "centre"})).unwrap();
assert!(s.take_pending_ime_application_change(), "a normal macro run is application-origin");
}
}
+108 -11
View File
@@ -25,6 +25,9 @@ use wordcraft_doc::{Document, InlineObject, Pos};
/// Longest sentence handed to the speech program (longer ones are split at a space).
const MAX_SENTENCE: usize = 600;
/// Longest utterance, even with no space to split at (a long URL, a run of letters): it is cut
/// there, mid-word.
const MAX_UTTERANCE: usize = 2 * MAX_SENTENCE;
/// Most sentences in one queue.
const MAX_SENTENCES: usize = 20_000;
/// Speed limits (1.0 = the normal speed).
@@ -72,7 +75,8 @@ impl Utterance {
}
/// The sentences from `from` (to `to`, if given) in `from`'s story, in reading order.
/// With `skip_citations`, citation and bibliography fields are left out.
/// With `skip_citations`, citation and bibliography fields are left out. At most
/// [`MAX_SENTENCES`]; when that cuts the text short a warning is logged, so the stop is not silent.
pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: bool) -> Vec<Utterance> {
let story = from.story;
// Spans to leave out: whole range fields, both markers included.
@@ -128,12 +132,16 @@ pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: b
}
}
}
// Sliced per sentence; skipping from the start each time was quadratic in the paragraph.
let text_chars: Vec<char> = text.chars().collect();
for (a, b) in split_sentences(&text) {
if out.len() >= MAX_SENTENCES {
log::warn!(
"Read Aloud: the queue holds at most {MAX_SENTENCES} sentences; the rest of the text is not read (start again from where it stops)"
);
return out;
}
let chars: Vec<char> = text.chars().skip(a).take(b - a).collect();
let spoken = tidy(&chars.iter().collect::<String>());
let spoken = tidy(&text_chars.get(a..b).unwrap_or(&[]).iter().collect::<String>());
if !spoken.chars().any(char::is_alphanumeric) {
continue;
}
@@ -426,6 +434,14 @@ fn push_trimmed(chars: &[char], mut a: usize, mut b: usize, out: &mut Vec<(usize
while b > a && chars.get(b - 1).is_some_and(|c| c.is_whitespace()) {
b -= 1;
}
// No space to split at (a long URL, a run of letters or full stops): cut the span hard.
while b - a > MAX_UTTERANCE {
out.push((a, a + MAX_UTTERANCE));
a += MAX_UTTERANCE;
while a < b && chars.get(a).is_some_and(|c| c.is_whitespace()) {
a += 1;
}
}
if a < b {
out.push((a, b));
}
@@ -450,18 +466,39 @@ fn is_abbreviation(chars: &[char], start: usize, dot: usize) -> bool {
}
/// Collapse whitespace and tidy what removed citations leave behind: "( )", "[]", " ." → ".".
/// One pass, linear in `s` (#3680): a closing bracket right after its opening one (or after it
/// and a space) removes the pair, so nested empty brackets "( [ ] )" go too.
fn tidy(s: &str) -> String {
let mut t: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
loop {
let before = t.len();
for (from, to) in [("()", ""), ("[]", ""), ("( )", ""), ("[ ]", ""), (" .", "."), (" ,", ","), (" ;", ";"), (" :", ":"), (" ", " ")] {
t = t.replace(from, to);
let mut t = String::with_capacity(s.len());
for c in s.chars() {
if c.is_whitespace() {
if !t.is_empty() && !t.ends_with(' ') {
t.push(' ');
}
continue;
}
if t.len() == before {
break;
let open = match c {
')' => Some('('),
']' => Some('['),
_ => None,
};
if let Some(open) = open {
let body = t.strip_suffix(' ').unwrap_or(&t);
if let Some(rest) = body.strip_suffix(open) {
let keep = rest.len();
t.truncate(keep);
continue;
}
}
if matches!(c, '.' | ',' | ';' | ':') && t.ends_with(' ') {
t.pop();
}
t.push(c);
}
t.trim().to_string()
if t.ends_with(' ') {
t.pop();
}
t
}
/// Reading state.
@@ -926,10 +963,70 @@ mod tests {
assert!(spans.iter().all(|(a, b)| b - a <= MAX_SENTENCE + 5));
}
/// A word with no space to split at, or a run of punctuation, is still cut into utterances of
/// at most `MAX_UTTERANCE` characters, covering all of it (#3680).
#[test]
fn sentences_without_spaces_are_split() {
for t in ["x".repeat(10_000), "!".repeat(10_000), format!("Go {} now.", "a.".repeat(5_000))] {
let spans = split_sentences(&t);
assert!(spans.len() >= 8, "{} spans", spans.len());
assert!(spans.iter().all(|(a, b)| a < b && b - a <= MAX_UTTERANCE));
assert_eq!(spans.first().map(|s| s.0), Some(0));
assert_eq!(spans.last().map(|s| s.1), Some(t.chars().count()));
assert!(spans.windows(2).all(|w| w[0].1 <= w[1].0));
}
}
#[test]
fn tidy_removes_citation_leftovers() {
assert_eq!(tidy("as shown ( ) ."), "as shown.");
assert_eq!(tidy("as shown [] , and"), "as shown, and");
assert_eq!(tidy(" a ( ( [ ] ) ) b ( x ) [ ) ] ; "), "a b ( x ) [ ) ];");
}
/// The tidy that ran replacements until nothing changed, kept to check the one-pass tidy gives
/// the same text.
fn tidy_by_replacing(s: &str) -> String {
let mut t: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
loop {
let before = t.len();
for (from, to) in [("()", ""), ("[]", ""), ("( )", ""), ("[ ]", ""), (" .", "."), (" ,", ","), (" ;", ";"), (" :", ":"), (" ", " ")] {
t = t.replace(from, to);
}
if t.len() == before {
break;
}
}
t.trim().to_string()
}
#[test]
fn tidy_matches_replacing_until_nothing_changes() {
let alphabet = ['(', ')', '[', ']', ' ', ' ', '\t', '\u{a0}', '.', ',', ';', ':', 'a', 'é'];
let mut seed: u64 = 0x3680;
for _ in 0..20_000 {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
let len = (seed >> 59) as usize + 1;
let s: String = (0..len)
.map(|i| {
let r = seed.rotate_left(5 * i as u32 + 1) ^ (i as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15);
alphabet[(r % alphabet.len() as u64) as usize]
})
.collect();
assert_eq!(tidy(&s), tidy_by_replacing(&s), "{s:?}");
}
}
/// Nested empty brackets are removed in one pass: 40,000 pairs took seconds when each pass
/// removed one level (#3680).
#[test]
fn tidy_is_linear_on_nested_brackets() {
let n = 40_000;
let started = std::time::Instant::now();
assert_eq!(tidy(&format!("a {}{} b", "( [".repeat(n), "] )".repeat(n))), "a b");
assert_eq!(tidy(&format!("{}x{}", "(".repeat(n), ")".repeat(n))).len(), 2 * n + 1);
// Milliseconds here; `tidy_by_replacing` (the old tidy) takes about 5 minutes on this input.
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
}
#[test]
+445 -3
View File
@@ -56,6 +56,30 @@ fn encrypted_doc_asks_for_a_password_and_rejects_a_wrong_one() {
assert!(crate::io::needs_password(&e), "{e}");
}
/// Gap 9: AES-encrypted Word files — Standard encryption (Word 2007) and agile packages — open
/// with the right password whatever Word extension they carry, and ask for one or refuse a wrong
/// one the same way.
#[test]
fn aes_encrypted_packages_open_under_any_word_extension() {
let mut doc = wordcraft_doc::Document::from_text("Old secret minutes");
doc.core.title = "Minutes".into();
let pkg = wordcraft_docx::write(&doc).unwrap();
let standard = wordcraft_docx::encrypt_standard_for_tests(&pkg, "letmein", 128).unwrap();
let agile = wordcraft_docx::encrypt_with_spin_count(&pkg, "letmein", 8).unwrap();
for (kind, bytes) in [("standard", &standard), ("agile", &agile)] {
for name in ["old.doc", "old.docx", "old.dot", "noext"] {
let (d, encrypted) = crate::io::open_bytes_with(name, bytes, Some("letmein")).unwrap_or_else(|e| panic!("{kind} {name}: {e}"));
assert!(encrypted, "{kind} {name}");
assert!(d.plain_text(StoryRef::Body).contains("Old secret minutes"), "{kind} {name}");
assert_eq!(d.core.title, "Minutes");
let e = crate::io::open_bytes_with(name, bytes, None).unwrap_err();
assert!(crate::io::needs_password(&e), "{kind} {name}: {e}");
let e = crate::io::open_bytes_with(name, bytes, Some("letmeout")).unwrap_err();
assert!(crate::io::wrong_password(&e), "{kind} {name}: {e}");
}
}
}
#[test]
fn txt_open_decodes_windows_1252_and_unmarked_utf16() {
// #433: the TXT open path sniffs encodings like the formats importer.
@@ -892,6 +916,72 @@ fn find_replace_wildcards() {
assert_eq!(run(&mut s, "edit.find", json!({"text": "eg", "matchSuffix": false, "ignorePunct": true}))["count"], 2);
}
#[test]
fn regex_replace_expands_groups() {
// #4177: with Regular expressions on, Replace with expands capture groups.
let rep = |text: &str, q: &str, with: &str| rep_mode(text, q, with, json!({"regex": true}));
let names = "Smith, John and Doe, Jane";
// The issue's cases: `\n` and `$n` swap words; `${name}` names a group.
assert_eq!(rep(names, r"(\w+), (\w+)", r"\2 \1"), "John Smith and Jane Doe");
assert_eq!(rep(names, r"(\w+), (\w+)", "$2 $1"), "John Smith and Jane Doe");
assert_eq!(rep("2024-05", r"(?<y>\d{4})-", "${y}/"), "2024/05");
// Braced numbers, `$0` / `${0}` (whole match), `$$` (a dollar sign).
assert_eq!(rep("ab", "(a)(b)", "${2}${1}"), "ba");
assert_eq!(rep("ab", "a", "[$0|${0}]"), "[a|a]b");
assert_eq!(rep("5", r"(\d)", "$$$1"), "$5");
// `$name` takes the longest run of letters, digits and `_` (the regex crate's rule).
assert_eq!(rep("ab", "(?<x>a)", "$x_"), "b");
assert_eq!(rep("ab", "(?<x>a)", "${x}_"), "a_b");
assert_eq!(rep("ab", "(a)", "$1a"), "b");
// Missing, unknown and out-of-range groups expand to nothing.
assert_eq!(rep("ab", "(a)", "<$2>"), "<>b");
assert_eq!(rep("ab", "(a)", r"<\9>"), "<>b");
assert_eq!(rep("ab", "(a)", "<${nope}>"), "<>b");
assert_eq!(rep("ab", "(a)", "<$99999999999999999999999>"), "<>b");
assert_eq!(rep("ab", "(a)|(z)", "<$2>"), "<>b");
// A `$` that starts no group, and an unclosed brace, stay as written.
assert_eq!(rep("ab", "a", "$ ${x"), "$ ${xb");
// `\t` a tab, `\\` a backslash; other escapes stay as written.
assert_eq!(rep("ab", "a", r"\t\\\q"), "\t\\\\qb");
// Word's `^` codes still work.
assert_eq!(rep("ab", "a", "^&^t^&"), "a\tab");
// `\$` and `\^` keep the character as typed (CodeRabbit on #4177).
assert_eq!(rep("ab", "(a)", r"\$1\^p"), "$1^pb");
// `special: false` turns off the `^` codes only; groups still expand (CodeRabbit on #4177).
assert_eq!(rep_mode("ab", "(a)", "$1^t", json!({"regex": true, "special": false})), "a^tb");
// `\n` is a paragraph mark: it splits the paragraph, as `^p` does, in one undo step.
let mut s = s();
run(&mut s, "document.setText", json!({"text": "one, two"}));
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": r"(\w+), (\w+)", "with": r"$1\n$2", "regex": true}))["replaced"], 1);
assert_eq!(text(&s), "one\ntwo");
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2);
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "one, two");
run(&mut s, "document.setText", json!({"text": "a-b"}));
run(&mut s, "edit.replaceAll", json!({"text": "-", "with": "^p", "regex": true}));
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2);
// Plain and wildcard Replace are unchanged: `$1` is literal there.
assert_eq!(rep_mode("ab", "a", "$1", json!({})), "$1b");
assert_eq!(rep_mode("ab", "(a)", "$1\\1", json!({"wildcards": true})), "$1ab");
// Hostile Replace text never panics.
run(&mut s, "document.setText", json!({"text": "ab ab"}));
for with in ["$", "${", "${}", "${0", "$\u{e9}", "\\", "${\u{1F600}}", "$18446744073709551616", "${-1}", "$$$", "\\0\\n\\", "^"] {
let _ = s.run("edit.replaceAll", &json!({"text": "(a)(?<n>b)?", "with": with, "regex": true}));
}
}
/// The body text after Replace All of `q` with `with` in `text`, matching case, with `extra` options.
fn rep_mode(text: &str, q: &str, with: &str, extra: serde_json::Value) -> String {
let mut s = s();
run(&mut s, "document.setText", json!({"text": text}));
let mut v = json!({"text": q, "with": with, "matchCase": true});
if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) {
o.extend(e.clone());
}
run(&mut s, "edit.replaceAll", v);
self::text(&s)
}
#[test]
fn hostile_find_patterns_are_bounded() {
let mut s = s();
@@ -4421,6 +4511,21 @@ fn word_count_with_an_object_selected_counts_the_document() {
assert_eq!(run(&mut s, "review.wordCount", json!({}))["words"].as_u64(), Some(1));
}
/// #2982: a property imported from a file made by an older Word may have a name longer than
/// today's limit. Such a name is still deletable (the dialog sends `null`), while setting or
/// renaming a property to it stays refused.
#[test]
fn custom_properties_delete_legacy_names_but_new_names_keep_the_limit() {
let mut s = s();
let long = "x".repeat(crate::cmd::file::MAX_CUSTOM_NAME + 1);
s.doc.custom_props.push(wordcraft_doc::CustomProp { name: long.clone(), kind: "raw".into(), value: "<data/>".into() });
let mut custom = serde_json::Map::new();
custom.insert(long.clone(), serde_json::Value::Null);
run(&mut s, "file.properties", json!({"custom": custom}));
assert!(s.doc.custom_props.is_empty(), "the legacy name could be deleted");
assert!(s.run("file.properties", &json!({"custom": { (long): "x" }})).is_err(), "a new or updated name keeps the limit");
}
#[test]
fn custom_properties_set_read_remove_and_undo() {
let mut s = s();
@@ -4438,6 +4543,37 @@ fn custom_properties_set_read_remove_and_undo() {
assert_eq!(s.doc.custom_prop("Status"), Some("draft"));
}
/// #2982: a case-only rename arrives as the old spelling with null and the new one with its
/// value. The removal must run first: object keys are sorted, so `Status` sorts before
/// `status`, and setting before removing would let the removal take the new spelling too.
#[test]
fn custom_properties_case_only_rename_keeps_the_new_spelling() {
let mut s = s();
run(&mut s, "file.properties", json!({"custom": {"status": "draft"}}));
run(&mut s, "file.properties", json!({"custom": {"Status": "draft", "status": null}}));
let props: Vec<(&str, &str)> = s.doc.custom_props.iter().map(|p| (p.name.as_str(), p.value.as_str())).collect();
assert_eq!(props, [("Status", "draft")], "the property keeps the new spelling and its value");
}
/// #2982: what the Custom Properties dialog saves (through `file.properties`) survives a .docx
/// save and reopen.
#[test]
fn custom_properties_round_trip_through_docx() {
let mut s = s();
run(&mut s, "file.properties", json!({"custom": {"Status": "draft", "Reviewer": "Ada L."}}));
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
assert_eq!(back.custom_prop("Status"), Some("draft"));
assert_eq!(back.custom_prop("Reviewer"), Some("Ada L."));
assert_eq!(back.custom_props.len(), 2);
// One call removes one and adds another; the saved file follows the document.
run(&mut s, "file.properties", json!({"custom": {"Status": null, "Owner": "Ada L."}}));
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
assert_eq!(back.custom_prop("Status"), None);
assert_eq!(back.custom_prop("Reviewer"), Some("Ada L."));
assert_eq!(back.custom_prop("Owner"), Some("Ada L."));
assert_eq!(back.custom_props.len(), 2);
}
/// `document_id` tells an edit from a replacement (the UI drops a "Save changes?" prompt
/// about a document that has been replaced).
#[test]
@@ -4660,7 +4796,7 @@ fn opening_a_named_pipe_or_device_is_refused_without_blocking() {
let got = rx.recv_timeout(std::time::Duration::from_secs(10));
if got.is_err() {
// Unblock the stuck reader so the thread ends, then fail.
let _ = std::fs::OpenOptions::new().write(true).open(&fifo);
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
}
let _ = std::fs::remove_dir_all(&dir);
let [pipe, device] = got.expect("opening a named pipe blocked");
@@ -4689,7 +4825,7 @@ fn html_picture_that_is_a_named_pipe_is_skipped_without_blocking() {
let got = rx.recv_timeout(std::time::Duration::from_secs(10));
if got.is_err() {
// Unblock the stuck reader so the thread ends, then fail.
let _ = std::fs::OpenOptions::new().write(true).open(&fifo);
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
}
let _ = std::fs::remove_dir_all(&dir);
let doc = got.expect("opening the HTML file blocked on a named pipe").unwrap();
@@ -4719,7 +4855,7 @@ fn insert_picture_from_a_named_pipe_is_refused_without_blocking() {
let got = rx.recv_timeout(std::time::Duration::from_secs(10));
if got.is_err() {
// Unblock the stuck reader so the thread ends, then fail.
let _ = std::fs::OpenOptions::new().write(true).open(&fifo);
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
}
let _ = std::fs::remove_dir_all(&dir);
assert!(got.expect("reading a picture path blocked on a named pipe").is_err(), "a pipe is not a file to read");
@@ -4728,6 +4864,92 @@ fn insert_picture_from_a_named_pipe_is_refused_without_blocking() {
}
}
/// #3666: `read_file_capped` (fonts, citation sources, `read_file`) and `read_file_head` (the
/// Word 2003 check on saving to an `.xml` path) read only a regular file. A named pipe must be
/// refused, not opened: opening one blocks until a writer appears.
#[cfg(unix)]
#[test]
fn file_head_and_capped_reads_refuse_a_named_pipe_without_blocking() {
let dir = scratch_dir("read-helpers-fifo");
let fifo = dir.join("pipe.xml");
let made = std::process::Command::new("mkfifo").arg(&fifo).status();
if !made.is_ok_and(|s| s.success()) {
let _ = std::fs::remove_dir_all(&dir);
eprintln!("mkfifo unavailable; skipping");
return;
}
let (tx, rx) = std::sync::mpsc::channel();
let path = fifo.to_string_lossy().into_owned();
std::thread::spawn(move || {
let head = crate::io::read_file_head(&path, 64 * 1024).map(|_| ());
let capped = crate::io::read_file_capped(&path, 1 << 20, "font file").map(|_| ());
let _ = tx.send([head, capped]);
});
let got = rx.recv_timeout(std::time::Duration::from_secs(10));
if got.is_err() {
// Unblock the stuck reader (twice: each helper may be stuck in turn) so the thread ends, then
// fail. Non-blocking: with no reader left the open fails at once instead of hanging the test.
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
}
let _ = std::fs::remove_dir_all(&dir);
let [head, capped] = got.expect("reading a named pipe blocked");
assert!(head.unwrap_err().contains("not a file"));
assert!(capped.unwrap_err().contains("not a file"));
}
/// #3666: a folder is not a file to read, whichever helper reads it.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn file_head_and_capped_reads_refuse_a_folder() {
let dir = scratch_dir("read-helpers-dir");
let path = dir.to_string_lossy().into_owned();
let head = crate::io::read_file_head(&path, 64);
let capped = crate::io::read_file_capped(&path, 1 << 20, "font file");
let _ = std::fs::remove_dir_all(&dir);
assert!(head.unwrap_err().contains("not a file"));
assert!(capped.unwrap_err().contains("not a file"));
}
/// #3666: `read_file_capped` reads a file of up to `max` bytes whole and refuses a larger one;
/// `read_file_head` returns at most `max` bytes.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn file_capped_read_refuses_content_over_the_cap() {
let dir = scratch_dir("read-helpers-cap");
let file = dir.join("data.bin");
std::fs::write(&file, vec![7u8; 100]).unwrap();
let path = file.to_string_lossy().into_owned();
let exact = crate::io::read_file_capped(&path, 100, "file");
let over = crate::io::read_file_capped(&path, 99, "file");
let head = crate::io::read_file_head(&path, 10);
let whole = crate::io::read_file_head(&path, 1000);
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(exact.unwrap().len(), 100);
assert!(over.unwrap_err().contains("larger than"));
assert_eq!(head.unwrap(), vec![7u8; 10]);
assert_eq!(whole.unwrap().len(), 100);
}
/// #3666: `read_regular_file_within` (Paste Special's copied picture file) reads a file of up to
/// `limit` bytes whole and refuses one whose handle reports more, before reading any of it.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn regular_file_read_within_a_limit_refuses_a_larger_file() {
let dir = scratch_dir("read-within");
let file = dir.join("data.bin");
std::fs::write(&file, vec![7u8; 100]).unwrap();
let big = dir.join("big.bin");
std::fs::File::create(&big).unwrap().set_len(64 << 20).unwrap();
let exact = crate::io::read_regular_file_within(&file, 100);
let over = crate::io::read_regular_file_within(&file, 99);
let large = crate::io::read_regular_file_within(&big, 1 << 20);
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(exact.map(|b| b.len()), Some(100));
assert_eq!(over, None);
assert_eq!(large, None);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn html_picture_size_limit_holds_whatever_length_the_file_reports() {
@@ -10548,3 +10770,223 @@ fn export_png_refuses_a_path_that_is_not_png() {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// The body's IME replacement epoch advances when the whole body is swapped (setText, Undo/Redo)
/// and stays put for ordinary edits; an equation editing session cannot ride across a replacement.
#[test]
fn ime_body_epoch_marks_body_replacements_not_edits() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "one"}));
let first = s.ime_body_epoch();
run(&mut s, "text.insert", json!({"text": " two"}));
assert_eq!(s.ime_body_epoch(), first, "typing is not a replacement");
run(&mut s, "document.setText", json!({"text": "same text"}));
let replaced = s.ime_body_epoch();
assert!(replaced > first, "setText replaces the body under the same document id");
assert_eq!(s.document_id(), 1, "the document id itself is unchanged");
// The same text again still replaces the body: a composition must not survive on content
// equality.
run(&mut s, "document.setText", json!({"text": "same text"}));
assert!(s.ime_body_epoch() > replaced);
// Undo swaps the body back.
let before_undo = s.ime_body_epoch();
assert!(s.undo());
assert!(s.ime_body_epoch() > before_undo, "Undo replaces the body");
}
/// Replacing the document body while an equation is being edited ends that equation editing
/// session.
#[test]
fn a_body_replacement_ends_equation_editing() {
let mut s = s();
run(&mut s, "insert.equation", json!({"linear": "x+y"}));
assert!(s.math.is_some());
run(&mut s, "document.setText", json!({"text": "x+y"}));
assert!(s.math.is_none(), "the old equation's editing session is gone");
}
/// A failed command that cleared the equation session puts it back with the document.
#[test]
fn a_failed_replacement_keeps_equation_editing() {
let mut s = s();
run(&mut s, "insert.equation", json!({"linear": "x+y"}));
let session = s.math.as_ref().map(|m| m.session);
assert!(session.is_some());
// Missing `text` is rejected before any mutation.
assert!(s.run("document.setText", &json!({})).is_err());
assert_eq!(s.math.as_ref().map(|m| m.session), session);
}
/// Track Changes: deleting a range whose text is already marked deleted is a real no-op. It
/// creates no revision record, no undo-visible change and no IME revision.
#[test]
fn deleting_an_already_deleted_tracked_range_is_a_classified_noop() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "abc"}));
run(&mut s, "review.trackChanges", json!({"value": true}));
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) };
run(&mut s, "text.backspace", json!({}));
let revisions = s.doc.revisions.len();
let revision = s.ime_state_revision().expect("an IME revision");
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) };
run(&mut s, "text.backspace", json!({}));
let outcome = s.ime_edit_outcome();
assert!(outcome.classified, "{outcome:?}");
assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}");
assert_eq!(s.doc.revisions.len(), revisions, "no unused Delete revision is appended");
assert_eq!(s.ime_state_revision(), Some(revision), "the no-op did not move the settled caret");
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) };
assert!(s.ime_state_revision() > Some(revision), "re-selecting moves the caret, not the document");
}
/// `text.insert` classifies the automatic rewrites its own handler performs: smart quotes,
/// list autoformat and AutoCorrect are `transformed`, not the IME's literal insert.
#[test]
fn text_insert_marks_automatic_rewrites_as_transformed() {
let mut session = s();
run(&mut session, "text.insert", json!({"text": "\""}));
assert!(session.ime_edit_outcome().transformed, "smart quote: {:?}", session.ime_edit_outcome());
assert_eq!(text(&session), "\u{201C}");
let mut session = s();
for c in ["W", "o", "r", "d", " ", "(", "c", ")", " "] {
run(&mut session, "text.insert", json!({"text": c}));
}
assert!(session.ime_edit_outcome().transformed, "AutoCorrect: {:?}", session.ime_edit_outcome());
let mut session = s();
run(&mut session, "text.insert", json!({"text": "*"}));
run(&mut session, "text.insert", json!({"text": " "}));
assert!(session.ime_edit_outcome().transformed, "list AutoFormat: {:?}", session.ime_edit_outcome());
let mut session = s();
run(&mut session, "text.insert", json!({"text": "A--b"}));
run(&mut session, "text.insert", json!({"text": " "}));
assert!(session.ime_edit_outcome().transformed, "dash AutoFormat: {:?}", session.ime_edit_outcome());
assert_eq!(text(&session), "A\u{2014}b ");
let mut session = s();
run(&mut session, "tools.autocorrect", json!({"doubleSpacePeriod": true}));
for c in "Hello ".chars() {
run(&mut session, "text.insert", json!({"text": c.to_string()}));
}
let outcome = session.ime_edit_outcome();
assert!(outcome.transformed && outcome.content_changed && outcome.state_changed, "double-space period: {outcome:?}");
assert_eq!(text(&session), "Hello. ");
run(&mut session, "edit.undo", json!({}));
assert_eq!(text(&session), "Hello ", "undo restores the spaces in one step");
}
#[test]
fn style_set_keeps_imported_styles_latent_styles_and_defaults() {
use wordcraft_doc::props::{Rgb, TextColor};
use wordcraft_doc::styles::{Style, StyleKind};
for name in ["basic", "lines", "shaded", "casual", "centered", "minimalist", "title"] {
let mut s = s();
let mut body = s.doc.styles.get("Normal").unwrap().clone();
body.id = "BodyText".into();
body.name = "Body Text".into();
body.builtin = true;
body.based_on = Some("Normal".into());
body.chr.size = Some(20.0);
body.chr.color = Some(TextColor::Rgb(Rgb(0xFF, 0, 0)));
let grid = Style { id: "GridTable4".into(), name: "Grid Table 4".into(), kind: StyleKind::Table, builtin: true, ..Default::default() };
s.doc.styles.styles.push(body.clone());
s.doc.styles.styles.push(grid.clone());
s.doc.styles.latent_styles = Some("<w:latentStyles/>".into());
s.doc.styles.default_chr.lang = Some("de-DE".into());
s.doc.styles.default_chr.size = Some(11.0);
run(&mut s, "design.styleSet", json!({ "name": name }));
assert_eq!(s.doc.styles.get("BodyText"), Some(&body), "{name}: BodyText");
assert_eq!(s.doc.styles.get("GridTable4"), Some(&grid), "{name}: GridTable4");
assert_eq!(s.doc.styles.latent_styles.as_deref(), Some("<w:latentStyles/>"), "{name}");
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("de-DE"), "{name}");
assert_eq!(s.doc.styles.default_chr.size, Some(11.0), "{name}");
assert_eq!(s.doc.styles.styles.iter().filter(|x| x.id == "Heading1").count(), 1, "{name}");
let saved = crate::io::save_bytes("x.docx", &s.doc).unwrap();
let back = crate::io::open_bytes("x.docx", &saved).unwrap();
assert!(back.styles.get("BodyText").is_some(), "{name}: BodyText after round trip");
}
let mut s = s();
run(&mut s, "design.styleSet", json!({ "name": "title" }));
assert_eq!(s.doc.styles.get("Title").unwrap().chr.size, Some(36.0));
}
fn style_size(s: &Session, id: &str) -> f32 {
s.doc.styles.resolve_char(Some(id), &Default::default()).size
}
#[test]
fn scale_font_sizes_adds_points_to_every_style() {
let mut s = Session::new(crate::sample::sample_document());
let ids: Vec<String> = s.doc.styles.styles.iter().map(|x| x.id.clone()).collect();
let before: Vec<f32> = ids.iter().map(|i| style_size(&s, i)).collect();
let base = s.doc.styles.resolve_char(None, &Default::default()).size;
let runs = serde_json::to_string(&s.doc.body).unwrap();
run(&mut s, "design.scaleFontSizes", json!({"delta": 2}));
// Every style (explicit or inherited) grows by exactly 2, once.
for (i, b) in ids.iter().zip(&before) {
assert_eq!(style_size(&s, i), b + 2.0, "{i}");
}
assert_eq!(s.doc.styles.resolve_char(None, &Default::default()).size, base + 2.0);
assert_eq!(serde_json::to_string(&s.doc.body).unwrap(), runs, "runs untouched");
run(&mut s, "edit.undo", json!({}));
for (i, b) in ids.iter().zip(&before) {
assert_eq!(style_size(&s, i), *b, "undo {i}");
}
}
#[test]
fn scale_font_sizes_percent_and_clamp_and_errors() {
let mut s = Session::new(crate::sample::sample_document());
let n = style_size(&s, "Normal");
run(&mut s, "design.scaleFontSizes", json!({"percent": 10}));
assert_eq!(style_size(&s, "Normal"), (n * 1.1 * 2.0).round() / 2.0);
run(&mut s, "design.scaleFontSizes", json!({"delta": -500}));
assert_eq!(style_size(&s, "Normal"), 1.0);
for bad in [
json!({}),
json!({"delta": 1e9}),
json!({"percent": -100}),
json!({"percent": 1e9}),
json!({"delta": "x"}),
json!({"delta": 0}),
json!({"percent": 0}),
json!({"delta": 1, "percent": 10}),
json!({"delta": 2, "percent": "x"}),
json!({"percent": 10, "delta": null}),
] {
assert!(s.run("design.scaleFontSizes", &bad).is_err(), "{bad}");
}
}
#[test]
fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() {
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Style, StyleKind, styles::TablePart};
let mut s = s();
s.doc.styles.default_chr.size = None;
s.doc.styles.default_chr.size_cs = Some(10.0);
let mut ch = Style { id: "Big".into(), name: "Big".into(), kind: StyleKind::Character, ..Default::default() };
ch.chr.size = Some(20.0);
ch.chr.size_cs = Some(22.0);
let mut tb = Style { id: "Grid".into(), name: "Grid".into(), kind: StyleKind::Table, ..Default::default() };
let mut parts = wordcraft_doc::styles::TableStyleParts::default();
parts.header_chr.size = Some(14.0);
parts.regions.entry(TablePart::Band2Horz).or_default().chr.size = Some(8.0);
tb.table = Some(parts);
s.doc.styles.styles.extend([ch, tb]);
run(&mut s, "design.scaleFontSizes", json!({"delta": 2}));
let st = &s.doc.styles;
assert_eq!(st.default_chr.size, Some(13.0), "unset default is 11 pt, made explicit");
assert_eq!(st.default_chr.size_cs, Some(12.0));
let big = CharProps { style: Some("Big".into()), ..Default::default() };
assert_eq!(st.resolve_char(None, &big).size, 22.0);
assert_eq!(st.resolve_char(None, &big).size_cs, 24.0);
let grid = st.styles.iter().find(|x| x.id == "Grid").and_then(|x| x.table.as_ref()).unwrap();
assert_eq!(grid.header_chr.size, Some(16.0));
assert_eq!(grid.regions.get(&TablePart::Band2Horz).unwrap().chr.size, Some(10.0));
}
+122
View File
@@ -0,0 +1,122 @@
//! Regressions where main's "a command that changes nothing is not an edit" (#23) meets the
//! migration's input-method application-change and state-revision tracking.
//!
//! Kept in its own module so the merged `tests.rs` is not touched.
use serde_json::json;
use crate::Session;
/// Creates an empty-document session for the IME no-op regressions.
fn s() -> Session {
Session::new(wordcraft_doc::Document::new())
}
/// Runs a command in the test session, panicking with the command id on error.
fn run(s: &mut Session, id: &str, v: serde_json::Value) -> serde_json::Value {
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"))
}
/// An *unclassified* no-op (`text.backspace` at the very start of the body) is not an edit and
/// must not look like one to the input method: no effect flags, no application-change signal, no
/// settled-revision move, and no history/dirty/revision side effects.
#[test]
fn a_no_op_command_is_not_an_ime_state_change() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "x"}));
run(&mut s, "caret.docStart", json!({}));
assert!(s.pending.is_none(), "caret movement dropped any pending formatting");
s.take_pending_ime_application_change();
let revision = s.ime_state_revision().expect("an IME revision");
let rev = s.rev();
let depth = s.undo_depth();
let dirty = s.dirty;
run(&mut s, "text.backspace", json!({}));
let outcome = s.ime_edit_outcome();
assert!(!outcome.classified, "the handler leaves this no-op unclassified: {outcome:?}");
assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}");
assert!(!s.take_pending_ime_application_change(), "a no-op is not an application change");
assert_eq!(s.ime_state_revision(), Some(revision), "the settled IME revision did not move");
assert_eq!((s.undo_depth(), s.dirty, s.rev()), (depth, dirty, rev));
}
/// `document.setText` with identical text replaces the body under the same document id, so the
/// IME replacement epoch must advance — but because nothing observable changed it is not an edit
/// and must not signal an application-origin change (main's #23 no-op rule).
#[test]
fn a_same_text_set_text_advances_the_body_epoch_without_an_application_change() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Same body"}));
s.take_pending_ime_application_change();
let epoch = s.ime_body_epoch();
let document_id = s.document_id();
let rev = s.rev();
let depth = s.undo_depth();
run(&mut s, "document.setText", json!({"text": "Same body"}));
assert!(s.ime_body_epoch() > epoch, "the body is replaced again under the same document id");
assert_eq!(s.document_id(), document_id, "the document id itself is unchanged");
assert!(!s.take_pending_ime_application_change(), "an identical setText is not an application change");
assert_eq!((s.undo_depth(), s.rev()), (depth, rev));
}
/// The IME body replacement promise (`DeleteSurrounding`, then the commit that replaces the
/// erased text) joins only a `text.insert` at the selection the deletion left. Tab, an ordinary
/// command, consumes it: it is no IME replacement receipt itself, and the commit after it owns a
/// new Undo step instead of joining the deletion.
#[test]
fn tab_between_an_ime_replacement_and_its_commit_keeps_the_steps_apart() {
let text = |s: &Session| s.doc.plain_text(wordcraft_doc::StoryRef::Body);
// The promise itself joins the commit at the unchanged selection.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "ab"}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(text(&s), "a", "DeleteSurrounding erased the b");
s.join_next_ime_text_replacement();
let depth = s.undo_depth();
run(&mut s, "text.insert", json!({"text": "c"}));
assert_eq!(text(&s), "ac");
assert_eq!(s.undo_depth(), depth, "the commit joins the deletion's step");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "ab", "one Undo restores the deletion and its commit");
// Tab consumes the promise: the commit no longer joins the deletion.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "ab"}));
run(&mut s, "text.backspace", json!({}));
s.join_next_ime_text_replacement();
let depth = s.undo_depth();
run(&mut s, "text.tab", json!({}));
let outcome = s.ime_edit_outcome();
assert!(!outcome.classified, "Tab is not an IME replacement receipt: {outcome:?}");
assert_eq!(s.undo_depth(), depth + 1, "Tab owns its Undo step");
run(&mut s, "text.insert", json!({"text": "c"}));
assert_eq!(text(&s), "a\tc");
assert_eq!(s.undo_depth(), depth + 2, "the commit after Tab starts its own Undo step");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "a\t", "Undo takes back only the commit");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "a", "then the Tab");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "ab", "and the IME deletion on its own");
// Returning directly to the promised selection must not resurrect a promise consumed by
// Tab. Do not use a caret command here: that command would itself consume a leaked promise.
let mut s = self::s();
run(&mut s, "text.insert", json!({"text": "ab"}));
run(&mut s, "text.backspace", json!({}));
s.join_next_ime_text_replacement();
let promised_selection = s.sel.clone();
run(&mut s, "text.tab", json!({}));
let depth = s.undo_depth();
s.sel = promised_selection;
run(&mut s, "text.insert", json!({"text": "c"}));
assert_eq!(text(&s), "ac\t");
assert_eq!(s.undo_depth(), depth + 1, "returning to the old selection cannot reuse the consumed promise");
run(&mut s, "edit.undo", json!({}));
assert_eq!(text(&s), "a\t", "only the new insert is undone");
}
+83
View File
@@ -3303,3 +3303,86 @@ fn highlight_partial_column_ignores_unselected_marks() {
assert_eq!(p.mark.highlight, Some(Highlight::Yellow));
}
}
#[test]
fn tab_with_a_whole_paragraph_selected_indents_it() {
let mut s = typed("Hello world");
run(&mut s, "select.paragraph", json!({}));
run(&mut s, "text.tab", json!({}));
assert_eq!(paras(&s), vec![para("Hello world", None)]);
assert_eq!(indents(&s).0, Some(36.0));
}
#[test]
fn tab_with_a_partial_selection_still_inserts_a_tab() {
let mut s = typed("Hello world");
run(&mut s, "select.text", json!({"text": "ello"}));
run(&mut s, "text.tab", json!({}));
assert_eq!(paras(&s), vec![para("H\t world", None)]);
}
/// Tab with a whole paragraph selected indents it as one Undo step: the selection stays while
/// the indent is applied, and Undo restores both the indentation and that same selection. A
/// partial selection still types a tab character (the test above), and Tab in a table cell still
/// navigates (the table tests above).
#[test]
fn tab_with_a_whole_paragraph_selected_undoes_indent_and_selection_together() {
let mut s = typed("Hello world");
run(&mut s, "select.paragraph", json!({}));
let selected = s.sel.clone();
let depth = s.undo_depth();
run(&mut s, "text.tab", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello world", "Tab indents, it types no tab");
assert_eq!(s.sel, selected, "the whole paragraph stays selected");
assert_eq!(indents(&s), (Some(36.0), None));
assert_eq!(s.undo_depth(), depth + 1, "one paragraph, one Undo step");
run(&mut s, "edit.undo", json!({}));
assert_eq!(indents(&s), (None, None), "Undo takes the indent back");
assert_eq!(s.sel, selected, "and restores the selection the indent was applied to");
assert_eq!(paras(&s), vec![para("Hello world", None)]);
}
/// #2848: "Add a period with double-space" (an AutoFormat As You Type switch, off by default):
/// a second Space after a word, a number or a closing bracket or quote turns the first into a
/// period. Undo puts the two spaces back; other punctuation, a lone space and a run of three
/// spaces are left as typed.
#[test]
fn double_space_adds_a_period_when_asked() {
let with_option = |keys: &str| {
let mut s = s();
run(&mut s, "tools.autocorrect", json!({"doubleSpacePeriod": true}));
typ(&mut s, keys);
s
};
let text = |s: &Session| s.doc.plain_text(StoryRef::Body);
// Off unless asked for.
assert_eq!(text(&typed("Hello world")), "Hello world");
assert!(!s().prefs.autocorrect.double_space_period);
// On: the second space makes the period, and the next sentence starts with a capital.
assert_eq!(text(&with_option("Hello world")), "Hello. world");
assert_eq!(text(&with_option("Hello world ")), "Hello. World ");
assert_eq!(text(&with_option("See page 5 Then")), "See page 5. Then");
assert_eq!(text(&with_option("(it is done) Next")), "(it is done). Next");
assert_eq!(text(&with_option("He said \"stop\" Next")), "He said “stop”. Next");
// Undo brings the two spaces back, in one step.
let mut t = with_option("Hello ");
assert_eq!(text(&t), "Hello. ");
run(&mut t, "edit.undo", json!({}));
assert_eq!(text(&t), "Hello ");
// Left alone: other punctuation before the spaces, a lone space, spaces at the start of a
// paragraph or after a tab, and a third space.
for keys in ["Hello. x", "Hello, x", "Hello? x", "Hello; x", "Hello: x", "Hello x", " x", "\n x", "a\t x"] {
let want = typed(keys);
assert_eq!(text(&with_option(keys)), text(&want), "{keys:?}");
}
assert_eq!(text(&with_option("Hello x")), "Hello. x");
// A selection typed over, and the master switch.
let mut t = with_option("Hello world");
run(&mut t, "select.all", json!({}));
run(&mut t, "text.insert", json!({"text": " "}));
assert_eq!(text(&t), " ");
let mut t = s();
run(&mut t, "tools.autocorrect", json!({"doubleSpacePeriod": true, "enabled": false}));
typ(&mut t, "Hello world");
assert_eq!(text(&t), "Hello world");
}
+1
View File
@@ -8,3 +8,4 @@ mod recovery_format;
mod rtf_sections;
#[cfg(not(target_arch = "wasm32"))]
mod safe_save;
mod three_column_headers;
@@ -0,0 +1,101 @@
//! #2197: three-column header/footer presets are editable, borderless tables.
use serde_json::json;
use wordcraft_doc::props::Align;
use wordcraft_doc::{Block, Document, Path, Pos, StoryRef, Table};
use wordcraft_engine::Session;
fn table(doc: &Document, story: StoryRef) -> &Table {
let Block::Table(table) = &*doc.story(story).unwrap()[0] else { panic!("three columns must be a table") };
table
}
#[test]
fn three_columns_are_borderless_cells_with_the_story_style_and_section_width() {
for (command, header, style, existing) in [
("insert.header", true, "Header", false),
("insert.header", true, "Header", true),
("insert.footer", false, "Footer", false),
("insert.footer", false, "Footer", true),
] {
let mut doc = Document::from_text("Body text");
doc.last_section.page_w = 540.0;
doc.last_section.margin_left = 54.0;
doc.last_section.margin_right = 54.0;
let width = doc.last_section.text_width();
let mut s = Session::new(doc);
if existing {
s.run(command, &json!({"text": "Original story content"})).unwrap();
s.run("insert.closeHeader", &json!({})).unwrap();
}
let before = s.doc.clone();
let depth = s.undo_depth();
let id = s.run(command, &json!({"preset": "blankThree"})).unwrap()["story"].as_u64().unwrap() as u32;
let story = StoryRef::Part(id);
let t = table(&s.doc, story);
assert_eq!(t.rows.len(), 1);
assert_eq!(t.rows[0].cells.len(), 3);
assert_eq!(t.grid.len(), 3);
assert!((t.grid.iter().sum::<f32>() - width).abs() < 0.01);
assert!(t.grid.iter().all(|w| (*w - width / 3.0).abs() < 0.01));
assert!(t.props.fixed);
assert!(!t.props.borders.as_ref().unwrap().any_visible());
assert_ne!(t.props.style.as_deref(), Some("TableGrid"));
for (cell, align) in t.rows[0].cells.iter().zip([Align::Left, Align::Center, Align::Right]) {
let Block::Para(p) = &*cell.blocks[0] else { panic!("cell paragraph missing") };
assert!(p.text.is_empty());
assert_eq!(p.props.style.as_deref(), Some(style));
assert_eq!(p.props.align, Some(align));
}
assert_eq!(s.sel.focus, Pos { story, path: Path(vec![0, 0, 0, 0]), off: 0 });
let after = s.doc.clone();
assert_eq!(s.undo_depth(), depth + 1);
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc, after);
for (cell, text) in ["Left text", "Middle text", "Right text"].into_iter().enumerate() {
if cell > 0 {
s.run("text.tab", &json!({})).unwrap();
}
assert_eq!(s.sel.focus, Pos { story, path: Path(vec![0, 0, cell as u32, 0]), off: 0 });
s.run("text.insert", &json!({"text": text})).unwrap();
}
let filled = s.doc.clone();
s.run("edit.undo", &json!({})).unwrap();
assert!(!s.doc.plain_text(story).contains("Right text"));
assert!(s.doc.plain_text(story).contains("Middle text"));
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc, filled);
s.run("select.text", &json!({"text": "Middle text"})).unwrap();
s.run("format.bold", &json!({"value": true})).unwrap();
let mut back = s.doc.clone();
for _ in 0..2 {
back = wordcraft_docx::read(&wordcraft_docx::write(&back).unwrap()).unwrap();
let id = if header { back.last_section.headers.default } else { back.last_section.footers.default }.unwrap();
let t = table(&back, StoryRef::Part(id));
assert_eq!(t.rows.len(), 1);
assert_eq!(t.rows[0].cells.len(), 3);
assert!((t.grid.iter().sum::<f32>() - width).abs() < 0.1);
assert!(t.props.fixed);
assert!(!t.props.borders.as_ref().unwrap().any_visible());
for ((cell, text), align) in
t.rows[0].cells.iter().zip(["Left text", "Middle text", "Right text"]).zip([Align::Left, Align::Center, Align::Right])
{
let Block::Para(p) = &*cell.blocks[0] else { panic!("cell lost") };
assert_eq!(p.text, text);
assert_eq!(p.props.style.as_deref(), Some(style));
assert_eq!(p.props.align, Some(align));
assert_eq!(p.props_at(0).bold == Some(true), text == "Middle text");
}
assert_eq!(back.plain_text(StoryRef::Body), "Body text");
}
}
}
#[test]
fn explicit_text_keeps_precedence_over_the_preset() {
let mut s = Session::new(Document::new());
let id = s.run("insert.footer", &json!({"text": "Keep explicit text", "preset": "blankThree"})).unwrap()["story"].as_u64().unwrap() as u32;
assert_eq!(s.doc.plain_text(StoryRef::Part(id)), "Keep explicit text");
assert!(matches!(&*s.doc.story(StoryRef::Part(id)).unwrap()[0], Block::Para(_)));
}
+18 -1
View File
@@ -1243,7 +1243,7 @@ impl FontDb {
stack.push(p);
continue;
}
let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(|e| e.to_ascii_lowercase());
let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
continue;
}
@@ -2010,6 +2010,23 @@ mod tests {
assert_eq!(bundled.fallback_for_run(&chars, latin.id(), false, false).is_some(), thai_bundled);
}
/// With only bundled Latin faces and no system fallback, a Thai run has no single covering face,
/// so run-level fallback declines. This test asserts that `fallback_for_run` returns `None`; the
/// caller's per-character path is not exercised here.
#[test]
fn a_run_without_a_covering_face_keeps_the_per_character_path() {
// Use only this repository's Latin faces. Optional craft-fonts inputs can contain a
// complete Thai face even when system font loading is disabled.
let bundled = FontDb::with_font_dirs(Vec::new());
bundled.faces.write().unwrap().retain(|face| BUNDLED.iter().any(|bytes| std::ptr::eq(face.data(), *bytes)));
bundled.set_system_fallback(false);
let latin = bundled.face(FALLBACK_FAMILY, "Regular");
let chars: Vec<char> = THAI_RUN.chars().filter(|c| !c.is_whitespace()).collect();
assert!(!bundled.faces.read().unwrap().is_empty(), "the negative fixture retains real Latin faces");
assert!(bundled.faces.read().unwrap().iter().all(|face| !face.covers('น')));
assert!(bundled.fallback_for_run(&chars, latin.id(), false, false).is_none());
}
#[test]
fn a_family_named_for_a_heavy_weight_is_that_heavy() {
let face = |bytes: &'static [u8], family: &str| {
+1 -1
View File
@@ -182,7 +182,7 @@ pub(crate) fn find_ui_font(dirs: &[std::path::PathBuf], names: &[&str], needed:
if !tried.insert(path.clone()) {
continue;
}
let Some(size) = std::fs::metadata(&path).ok().filter(|m| m.is_file()).map(|m| m.len()) else { continue };
let Some(size) = std::fs::metadata(&path).ok().filter(std::fs::Metadata::is_file).map(|m| m.len()) else { continue };
if size > MAX_FILE {
continue;
}
+102 -2
View File
@@ -5,7 +5,7 @@
//! bottom-border paragraph), bullet and ordered lists (nested by indentation), GFM pipe tables,
//! and inlines: emphasis/strong (the CommonMark delimiter algorithm), `~~strike~~`, code spans,
//! inline links, autolinks, images (`data:` URIs become pictures, others their alt text),
//! backslash escapes, entities, `<br>` and `<a id>` anchors.
//! backslash escapes, entities, HTML comments (`<!-- ... -->`), `<br>` and `<a id>` anchors.
//!
//! Export writes the same constructs; pictures become `![alt](media-name)` placeholders.
@@ -351,6 +351,7 @@ fn interrupts(l: &str) -> bool {
|| thematic_break(t)
|| fence(t).is_some()
|| t.starts_with('>')
|| t.starts_with("<!--")
|| list_marker(t).is_some_and(|m| (!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or("")))
}
@@ -360,7 +361,11 @@ fn flush_para(lines: &mut Vec<String>, out: &mut Vec<FBlock>, depth: usize, refs
}
let text = join_lines(lines);
lines.clear();
let p = Para { inlines: parse_inlines(&text, &Fmt::default(), depth, refs), ..Default::default() };
let inlines = parse_inlines(&text, &Fmt::default(), depth, refs);
if inlines.is_empty() {
return;
}
let p = Para { inlines, ..Default::default() };
out.push(FBlock::Para(p));
}
@@ -470,6 +475,16 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs) -> Vec<F
}
continue;
}
if t.starts_with("<!--") {
flush_para(&mut para, &mut out, depth, refs);
while let Some(l) = lines.get(i) {
i += 1;
if l.contains("-->") {
break;
}
}
continue;
}
if t.starts_with('>') {
flush_para(&mut para, &mut out, depth, refs);
let mut inner: Vec<String> = Vec::new();
@@ -1016,6 +1031,12 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
}
}
b'<' => {
if let Some(pos) = s.get(i..).and_then(|r| r.strip_prefix("<!--")).and_then(|r| r.find("-->")) {
flush!();
i += 4 + pos + 3;
text_start = i;
continue;
}
let rest = s.get(i + 1..).unwrap_or("");
let end = within(rest, MAX_TAG).find('>');
let inner = end.and_then(|e| rest.get(..e)).unwrap_or("");
@@ -1281,6 +1302,7 @@ fn escape_lines(body: String) -> String {
}
fn code_span(t: &str) -> String {
let t = t.replace("\r\n", " ").replace(['\r', '\n'], " ");
let mut longest = 0;
let mut cur = 0;
for c in t.chars() {
@@ -1884,6 +1906,75 @@ mod tests {
assert_eq!(exported, "`cmd | grep`");
}
#[test]
fn code_span_line_endings_stay_literal_on_export() {
let cases = [
("a\n# b", "a # b"),
("a\n<div>b</div>", "a <div>b</div>"),
("a\n- b", "a - b"),
("a\n```", "a ```"),
("a\r# b", "a # b"),
("a\r\n# b", "a # b"),
("a\n\n> b", "a > b"),
("\na\n", " a "),
("`a\n# b`", "`a # b`"),
("cmd | grep", "cmd | grep"),
];
let shapes = [
(Kind::Normal, None),
(Kind::Normal, Some(ListInfo { ordered: false, level: 0, start: 1 })),
(Kind::Code, Some(ListInfo { ordered: true, level: 0, start: 1 })),
];
for (source, expected) in cases {
for (kind, list) in shapes {
let mut p = Para::new(kind);
p.list = list;
p.push_text(source, &Fmt { code: true, ..Default::default() });
let mut doc = model::to_doc(&model::Flow { blocks: vec![FBlock::Para(p)], ..Default::default() });
// The flow builder removes CR controls; keep the native document's raw input.
let p = doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
p.text = source.to_string();
p.runs[0].len = source.len();
let bytes = crate::export("md", &doc).unwrap().unwrap();
let md = String::from_utf8(bytes).unwrap();
assert_eq!(md.trim_end().lines().count(), 1, "{source:?} {kind:?} {list:?} -> {md:?}");
assert!(!md.contains('\r'), "{md:?}");
let back = crate::import("md", md.as_bytes()).unwrap().unwrap();
let flow = model::from_doc(&back);
let [FBlock::Para(q)] = flow.blocks.as_slice() else { panic!("{md:?}") };
assert_eq!(q.text(), expected, "{source:?} {kind:?} {list:?} -> {md:?}");
assert_eq!(q.list, list, "{md:?}");
assert!(q.inlines.iter().all(|i| matches!(i, Inline::Text(_, f) if f.code)), "{md:?}");
}
}
let fenced = "```\na\n# b\n```";
assert_eq!(export(&import(fenced)).trim_end(), fenced);
}
#[test]
fn code_span_line_endings_in_table_cells_stay_literal() {
for ending in ["\n", "\r", "\r\n"] {
let source = format!("a{ending}# b|c");
let mut p = Para::default();
p.push_text(&source, &Fmt { code: true, ..Default::default() });
let cell = Cell { blocks: vec![FBlock::Para(p)], ..Default::default() };
let table = FTable { rows: vec![vec![cell, Cell::default()], vec![Cell::default(), Cell::default()]], ..Default::default() };
let mut doc = model::to_doc(&model::Flow { blocks: vec![FBlock::Table(table)], ..Default::default() });
let p = doc.para_mut(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path(vec![0, 0, 0, 0])).unwrap();
p.text = source.clone();
p.runs[0].len = source.len();
let md = export(&doc);
let back = model::from_doc(&import(&md));
let [FBlock::Table(table), ..] = back.blocks.as_slice() else { panic!("{md:?}") };
assert!(back.blocks.iter().skip(1).all(|b| matches!(b, FBlock::Para(p) if p.text().is_empty())), "{md:?}");
assert_eq!(table.rows.len(), 2, "{md:?}");
assert_eq!(table.rows[0].len(), 2, "{md:?}");
let [FBlock::Para(p)] = table.rows[0][0].blocks.as_slice() else { panic!("{md:?}") };
assert_eq!(p.text(), "a # b|c", "{md:?}");
assert!(p.inlines.iter().all(|i| matches!(i, Inline::Text(_, f) if f.code)), "{md:?}");
}
}
#[test]
fn table_code_pipe_export_import_round_trip() {
let mut table = FTable::default();
@@ -2052,4 +2143,13 @@ mod tests {
assert_eq!(p3.text(), "one item");
assert!(p3.list.unwrap().ordered);
}
#[test]
fn html_comments_are_ignored_in_blocks_and_inlines() {
let md = "<!-- block comment -->\n\nHello <!-- inline comment -->world!\n\n<!--\nmultiline\ncomment\n-->\n\nAfter comment.";
let flow = parse(md);
assert_eq!(flow.blocks.len(), 2);
let texts: Vec<String> = flow.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect();
assert_eq!(texts, ["Hello world!", "After comment."]);
}
}
+58 -5
View File
@@ -440,7 +440,7 @@ impl Writer {
/// Paragraph `p`, or with `chain` the paragraphs joined at deleted paragraph marks (`p`
/// is the first).
fn para_in(&mut self, p: &Para, chain: Option<&Vec<Para>>, out: &mut String) {
let paras: &[Para] = chain.map(|c| c.as_slice()).unwrap_or(std::slice::from_ref(p));
let paras: &[Para] = chain.map(Vec::as_slice).unwrap_or(std::slice::from_ref(p));
let start = self.master.take();
self.chain = chain.map(|c| c.iter().map(|q| self.para_style(q)).collect()).unwrap_or_default();
self.part = 0;
@@ -2530,6 +2530,8 @@ fn read_meta(xml: &[u8]) -> Meta {
let mut m = Meta::default();
let mut cur: Option<String> = None;
let mut keywords: Vec<String> = Vec::new();
let mut creator = String::new();
let mut mod_date = String::new();
events(xml, |ev| match ev {
Ev::Start(q, _) => cur = Some(q),
Ev::End(_) => cur = None,
@@ -2538,16 +2540,32 @@ fn read_meta(xml: &[u8]) -> Meta {
Some("dc:subject") => m.subject.push_str(&t),
Some("dc:description") => m.description.push_str(&t),
Some("meta:initial-creator") => m.author.push_str(&t),
Some("dc:creator") if m.author.is_empty() => m.author.push_str(&t),
Some("dc:creator") => creator.push_str(&t),
Some("meta:keyword") => keywords.push(t),
Some("meta:creation-date") => m.created.push_str(&t),
Some("dc:date") => mod_date.push_str(&t),
_ => {}
},
});
if m.author.is_empty() {
m.author = creator;
}
if m.created.is_empty() {
m.created = mod_date;
}
m.keywords = keywords.join(", ");
if !m.created.is_empty() && !m.created.ends_with('Z') && !m.created.contains('+') {
let base = m.created.split('.').next().unwrap_or("").to_string();
m.created = format!("{base}Z");
let created = m.created.trim();
if !created.is_empty() {
let has_tz =
created.ends_with(['Z', 'z']) || created.contains('+') || created.split_once('T').map(|(_, time)| time.contains('-')).unwrap_or(false);
if !has_tz {
let base = created.split('.').next().unwrap_or("");
m.created = format!("{base}Z");
} else {
m.created = created.to_string();
}
} else {
m.created.clear();
}
m
}
@@ -3830,4 +3848,39 @@ mod tests {
assert!(!fmt(ps[1]).underline, "a line type does not override a line style of none");
assert!(!fmt(ps[1]).strike);
}
#[test]
fn meta_creation_date_timezones_and_fallbacks() {
let xml_neg = br#"<office:document-meta><office:meta>
<meta:creation-date>2024-03-05T13:45:00-05:00</meta:creation-date>
<dc:creator>Modifier</dc:creator>
<meta:initial-creator>Original Author</meta:initial-creator>
</office:meta></office:document-meta>"#;
let m = read_meta(xml_neg);
assert_eq!(m.created, "2024-03-05T13:45:00-05:00");
assert_eq!(m.author, "Original Author");
let xml_neg_frac = br#"<office:document-meta><office:meta>
<meta:creation-date>2024-03-05T13:45:00.500-05:00</meta:creation-date>
</office:meta></office:document-meta>"#;
assert_eq!(read_meta(xml_neg_frac).created, "2024-03-05T13:45:00.500-05:00");
let xml_local_frac = br#"<office:document-meta><office:meta>
<meta:creation-date>2024-03-05T13:45:00.123</meta:creation-date>
</office:meta></office:document-meta>"#;
assert_eq!(read_meta(xml_local_frac).created, "2024-03-05T13:45:00Z");
let xml_z_lower = br#"<office:document-meta><office:meta>
<meta:creation-date>2024-03-05T13:45:00z</meta:creation-date>
</office:meta></office:document-meta>"#;
assert_eq!(read_meta(xml_z_lower).created, "2024-03-05T13:45:00z");
let xml_dc_date_fallback = br#"<office:document-meta><office:meta>
<dc:date>2024-03-05T13:45:00Z</dc:date>
<dc:creator>Solo Creator</dc:creator>
</office:meta></office:document-meta>"#;
let m2 = read_meta(xml_dc_date_fallback);
assert_eq!(m2.created, "2024-03-05T13:45:00Z");
assert_eq!(m2.author, "Solo Creator");
}
}
+13 -3
View File
@@ -2128,9 +2128,9 @@ impl Reader {
"cell" => self.end_cell(),
"nestcell" => self.end_para(true),
"row" => self.end_row(),
"line" => self.ch('\n'),
"line" | "softline" => self.ch('\n'),
"tab" => self.ch('\t'),
"page" => self.ch('\u{000C}'),
"page" | "softpage" => self.ch('\u{000C}'),
"column" => self.ch('\u{000E}'),
"emdash" => self.ch('—'),
"endash" => self.ch('–'),
@@ -2144,6 +2144,8 @@ impl Reader {
"qmspace" => self.ch('\u{2005}'),
"zwj" => self.ch('\u{200D}'),
"zwnj" => self.ch('\u{200C}'),
"zwbo" => self.ch('\u{200B}'),
"zwnbo" => self.ch('\u{2060}'),
"u" => {
let u = (pv as i64).rem_euclid(65_536) as u16;
if (0xD800..0xDC00).contains(&u) {
@@ -2727,7 +2729,7 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
i += 2;
continue;
}
b'~' => {
b'~' | b' ' => {
r.ch('\u{00A0}');
i += 2;
}
@@ -4397,4 +4399,12 @@ mod tests {
assert!(exported.contains("\\clvertalc"));
assert!(exported.contains("\\clvertalb"));
}
#[test]
fn rtf_backslash_space_and_special_breaks() {
let rtf = br"{\rtf1\ansi A\ B\~C\zwbo D\softline E\softpage F\par}";
let doc = import(rtf).unwrap();
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");
}
}
+10 -14
View File
@@ -58,9 +58,9 @@ pub enum ViewMode {
/// How digits look on screen (Word › File › Options › Advanced › Numeral).
///
/// Display only: the document text keeps the typed codepoints, so find, spelling, copying and
/// the saved file all see the original digits. Extended Arabic-Indic digits (U+06F0–U+06F9,
/// Persian/Urdu ۰۱۲۳…) always show as typed; only ASCII and Arabic-Indic (U+0660–U+0669) digits
/// are substituted.
/// the saved file all see the original digits. Only ASCII digits are substituted: Arabic-Indic
/// (U+0660–U+0669) and Extended Arabic-Indic digits (U+06F0–U+06F9, Persian/Urdu ۰۱۲۳…) always
/// show as typed, so fonts that ligate them (e.g. the end-of-ayah mark U+06DD) keep working.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum NumeralMode {
@@ -121,13 +121,7 @@ impl NumeralMode {
NumeralMode::Context => rtl,
NumeralMode::System => false,
};
if hindi {
if c.is_ascii_digit() { char::from_u32(0x660 + (c as u32 - '0' as u32)).unwrap_or(c) } else { c }
} else if ('\u{660}'..='\u{669}').contains(&c) {
(b'0' + (c as u32 - 0x660) as u8) as char
} else {
c
}
if hindi { if c.is_ascii_digit() { char::from_u32(0x660 + (c as u32 - '0' as u32)).unwrap_or(c) } else { c } } else { c }
}
}
@@ -1304,7 +1298,7 @@ fn layout_box(
let float_y = if float.v_rel == Anchor::Paragraph { para_top } else { y };
let r = float_rect(frame, (0.0, width), float_y, w, h, float);
let outline = ctx.wrap_outline(o);
excl.extend(wrap_area(r, float, outline.as_deref().map(|v| v.as_slice())));
excl.extend(wrap_area(r, float, outline.as_deref().map(Vec::as_slice)));
floats.insert(oi, r);
}
let rel = rel_exclusions(&excl, 0.0, y);
@@ -3045,8 +3039,8 @@ impl PageFrame<'_> {
/// Alignment `a` across the page: inside and outside become right and left on an even page.
fn h_align(frame: Option<&Self>, a: Option<FloatAlign>) -> Option<FloatAlign> {
match a {
Some(FloatAlign::Inside) if frame.is_some_and(|f| f.even()) => Some(FloatAlign::End),
Some(FloatAlign::Outside) if frame.is_some_and(|f| f.even()) => Some(FloatAlign::Start),
Some(FloatAlign::Inside) if frame.is_some_and(Self::even) => Some(FloatAlign::End),
Some(FloatAlign::Outside) if frame.is_some_and(Self::even) => Some(FloatAlign::Start),
a => a,
}
}
@@ -3365,7 +3359,7 @@ fn anchor_floats(
// Positions relative to the paragraph start above its space before (Word for Windows).
let mut r = float_rect(Some(frame), (col_x, width), y0 - before, w, h, float);
let outline = ctx.wrap_outline(o);
let outline = outline.as_deref().map(|v| v.as_slice());
let outline = outline.as_deref().map(Vec::as_slice);
let mut area = wrap_area(r, float, outline);
if pb.settle && text_before {
let seed = early.and_then(|v| v.iter().find(|(b, k, _)| (*b, *k) == (block, oi))).map(|(_, _, a)| a);
@@ -4939,6 +4933,8 @@ mod tests_grid;
#[cfg(test)]
mod tests_ligature;
#[cfg(test)]
mod tests_nbsp;
#[cfg(test)]
mod tests_numeral;
#[cfg(test)]
mod tests_pages;
+2 -2
View File
@@ -1518,7 +1518,7 @@ impl Ctx {
}
segs_rows.push(segs.into_iter().map(hcat).collect());
}
let ncols = segs_rows.iter().map(|r| r.len()).max().unwrap_or(1);
let ncols = segs_rows.iter().map(Vec::len).max().unwrap_or(1);
// Rows without `&` are centred below, so they don't widen the aligned columns.
let mut col_w = vec![0.0f32; ncols];
let mut lone_w = 0.0f32;
@@ -1829,7 +1829,7 @@ impl Ctx {
}
cells.push(row);
}
let ncols = cells.iter().map(|r| r.len()).max().unwrap_or(0);
let ncols = cells.iter().map(Vec::len).max().unwrap_or(0);
let mut col_w = vec![0.0f32; ncols];
for r in &cells {
for (i, b) in r.iter().enumerate() {
+99 -19
View File
@@ -129,7 +129,8 @@ pub enum ClKind {
Marker,
}
/// The smallest unit of layout: a grapheme cluster (or object, tab, break).
/// One drawing segment of a grapheme cluster (or object, tab, break).
/// Font/style changes may split its drawing; paragraph edit/wrap boundaries keep it atomic.
#[derive(Clone, Debug)]
pub struct Cluster {
pub start: usize,
@@ -357,6 +358,9 @@ pub struct ParaLayout {
/// Sum of line heights (without space before/after).
pub height: f32,
pub text_len: usize,
/// Drawing segments that start inside a whole-paragraph grapheme/complex-script cluster.
/// Font or run formatting can split the drawing, but cannot introduce an edit/wrap stop.
joined_cluster_starts: Vec<usize>,
/// Paragraph contains fields whose text depends on the page.
pub has_page_fields: bool,
/// Note references (part id) in this paragraph, by cluster index.
@@ -1066,6 +1070,10 @@ impl<'a> Builder<'a> {
let d = show(c);
if d != c {
d
} else if c == '\u{a0}' {
// Word sets a no-break space as wide as a space; some fonts draw their own
// U+00A0 glyph wider. Only the shaping changes: line breaking reads the text.
' '
} else if upper {
upper_char(c)
} else {
@@ -1517,6 +1525,8 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
let math_props = doc.settings.math.clone().unwrap_or_default();
// The byte ranges of runs left out of the layout, in order, adjacent runs merged.
let mut left: Vec<std::ops::Range<usize>> = Vec::new();
// The byte ranges of objects with nothing to show (bookmarks, comment anchors), in order.
let mut bare: Vec<std::ops::Range<usize>> = Vec::new();
// Each run's proofing language and whether it is checked (spelling, grammar, hyphenation).
let mut runs: Vec<ProofRun> = Vec::new();
for (range, props) in p.run_ranges() {
@@ -1752,7 +1762,10 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
push(&mut b, ClKind::Tab, 0.0, 0.0);
ptabs.push((b.clusters.len() - 1, *align, *relative_to, *leader));
}
_ => push(&mut b, ClKind::Marker, 0.0, 0.0),
_ => {
bare.push(start..end);
push(&mut b, ClKind::Marker, 0.0, 0.0)
}
}
}
}
@@ -1769,15 +1782,19 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
if rp.snap_to_grid && env.grid.chars.is_some() { Vec::new() } else { auto_space(&mut b, &p.text, rp.auto_space_de, rp.auto_space_dn) };
// Break opportunities and hyphenation points come from the text as laid out: hidden text that is
// not shown and, without markup, tracked deletions are left out, so a space or soft hyphen there
// is no place to break, and words are hyphenated as they read.
// is no place to break, and words are hyphenated as they read. An object with nothing to show
// is left out too: its U+FFFC has the line-break class CB, but a bookmark never moves a break.
let mut skip = left.clone();
skip.extend(bare);
skip.sort_unstable_by_key(|r| r.start);
// The text and, for each char of it, its end there and in `p.text`.
let (text, ends): (std::borrow::Cow<str>, Vec<(usize, usize)>) = if left.is_empty() {
let (text, ends): (std::borrow::Cow<str>, Vec<(usize, usize)>) = if skip.is_empty() {
(p.text.as_str().into(), Vec::new())
} else {
let (mut text, mut ends) = (String::new(), Vec::new());
let mut next = left.iter().peekable();
let mut next = skip.iter().peekable();
for (i, c) in p.text.char_indices() {
// `left` is in order: skip the ranges that end here, then is `i` in the next one?
// `skip` is in order: skip the ranges that end here, then is `i` in the next one?
while next.next_if(|r| r.end <= i).is_some() {}
if next.peek().is_some_and(|r| r.start <= i) {
continue;
@@ -1789,7 +1806,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
};
// The end of a char in `text` → the same place in `p.text`.
let to_para = |i: usize| -> Option<usize> {
if left.is_empty() { Some(i) } else { ends.binary_search_by_key(&i, |e| e.0).ok().and_then(|k| ends.get(k)).map(|e| e.1) }
if skip.is_empty() { Some(i) } else { ends.binary_search_by_key(&i, |e| e.0).ok().and_then(|k| ends.get(k)).map(|e| e.1) }
};
let mut found = Vec::new();
for (i, o) in unicode_linebreak::linebreaks(&text) {
@@ -1903,6 +1920,19 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
Label { text: shown(text), ascent, style, glyphs, runs, x: 0.0, width: x }
});
// Shaping happens separately per font/style segment. Segment-local grapheme starts can
// therefore land inside a paragraph grapheme (e.g. an emoji whose components use different
// fallback faces). Retain the drawing segments and record only those illegal edit stops.
let joined_cluster_starts = if p.text.is_ascii() {
Vec::new()
} else {
let boundaries = wordcraft_doc::cluster::cluster_starts(&p.text);
let mut joined: Vec<usize> =
b.clusters.iter().filter_map(|c| (c.start < p.text.len() && boundaries.binary_search(&c.start).is_err()).then_some(c.start)).collect();
joined.sort_unstable();
joined.dedup();
joined
};
let mut pl = ParaLayout {
rp,
styles: b.styles,
@@ -1913,6 +1943,7 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
label,
height: 0.0,
text_len: p.text.len(),
joined_cluster_starts,
has_page_fields,
notes,
issues: env.proofing.map(|o| proof_issues(p, &runs, &o, env.typing_word, is_list_item(doc, p))).unwrap_or_default(),
@@ -1937,6 +1968,11 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
let mark_left_out = env.hide_deleted.hides(doc, mark_rc.del) || (mark_rc.hidden && !env.show_hidden);
let gone = mark_left_out && (p.text.is_empty() || left.first() == Some(&(0..p.text.len())));
pl.left_out = left;
// Neither ordinary nor hyphenated wrapping may split a drawing group inside a grapheme.
for c in &mut pl.clusters {
c.break_after &= pl.joined_cluster_starts.binary_search(&c.end).is_err();
}
pl.hyph_after.retain(|&k| pl.clusters.get(k as usize).is_some_and(|c| pl.joined_cluster_starts.binary_search(&c.end).is_err()));
for k in pl.hyph_after.clone() {
if let Some(c) = pl.clusters.get_mut(k as usize) {
c.break_after = true;
@@ -2049,6 +2085,8 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
// Word 2013+ (compatibility mode 15) fits more text on a justified line by shrinking its
// spaces (`space_room`); never on lines with tabs.
let shrink_spaces = rp.align == Align::Justify && env.doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT;
// Word stretches a justified line ending in a manual line break unless the document says not to.
let expand_break = !env.doc.settings.do_not_expand_shift_return;
let hanging_at = if rp.indent_first < 0.0 { Some(rp.indent_left) } else { None };
// Hyphenation Options: the zone, and how many lines in a row may end with a hyphen.
let zone = Some(env.doc.settings.hyphenation_zone).filter(|z| z.is_finite() && *z >= 0.0).unwrap_or(HYPHENATION_ZONE).min(1584.0);
@@ -2266,24 +2304,27 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
end = LineEnd::Wrap;
break;
}
// A grapheme can have several drawing segments; fit it as one unit while keeping
// each segment's font, glyphs and position for painting.
let advance = pl.atomic_advance(j);
// Text after a right/centre tab grows leftwards into the tab's space first.
let absorbs = pending_tab.is_some_and(|(tj, stop, _)| {
let room = pl.clusters.get(tj).map(|t| t.adv).unwrap_or(0.0);
match stop.align {
TabAlign::Right | TabAlign::Decimal => room >= c.adv,
TabAlign::Center => room >= c.adv / 2.0 && x + c.adv / 2.0 <= right_edge + 0.01,
TabAlign::Right | TabAlign::Decimal => room >= advance,
TabAlign::Center => room >= advance / 2.0 && x + advance / 2.0 <= right_edge + 0.01,
_ => false,
}
});
let shrink = if shrink_spaces && !has_tab { space_room(space_w, x + c.adv - pl.auto_space_after(j) - word_from) } else { 0.0 };
let shrink = if shrink_spaces && !has_tab { space_room(space_w, x + advance - pl.auto_space_after(j) - word_from) } else { 0.0 };
let fits =
absorbs || x + c.adv - pl.auto_space_after(j) <= right_edge + shrink + 0.01 || c.kind == ClKind::Space || c.kind == ClKind::Marker;
absorbs || x + advance - pl.auto_space_after(j) <= right_edge + shrink + 0.01 || c.kind == ClKind::Space || c.kind == ClKind::Marker;
// A line's first item stays on it even when it doesn't fit; markers before it
// (a content control's start, a bookmark) and floating objects' anchors take no room
// and don't count (Word: a picture a little wider than the line, after an anchor,
// stays on the first line rather than leaving the anchor a line of its own).
let takes_room = |c: &Cluster| c.kind != ClKind::Marker && !(matches!(c.kind, ClKind::Object(_)) && c.adv == 0.0 && c.obj_h == 0.0);
if !fits && (c0..j).any(|k| pl.clusters.get(k).is_some_and(takes_room)) {
if !fits && pl.is_edit_boundary(c.start) && (c0..j).any(|k| pl.clusters.get(k).is_some_and(takes_room)) {
// Wrap: back up to the last break opportunity on this line.
end = LineEnd::Wrap;
// Hyphenate only when the plain break leaves more than the hyphenation zone empty.
@@ -2318,11 +2359,14 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
// hyphenation point.
let full = base_right - row_lo;
skip = narrowed && skipped < 50 && widths.get(c0).is_some_and(|w| *w <= full + 0.01);
// Never split a ligature's letters across lines: the glyph sits on the first
// letter, so back up to it (unless it starts the line).
// Never split a ligature's letters or a grapheme's drawing segments across
// lines: back up to their first segment (unless it starts the line).
let glyphless = |k: usize| pl.clusters.get(k).is_some_and(|c| c.kind == ClKind::Text && c.g1 <= c.g0);
let mut b = j;
while b > c0 + 1 && glyphless(b) {
while b > c0 + 1 && (glyphless(b) || pl.clusters.get(b).is_some_and(|c| !pl.is_edit_boundary(c.start))) {
b -= 1;
}
while b > c0 && pl.clusters.get(b).is_some_and(|c| !pl.is_edit_boundary(c.start)) {
b -= 1;
}
if b < j
@@ -2570,7 +2614,9 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u32, level: Optio
*label_shift = shift;
}
}
let justify = display.is_none() && ((rp.align == Align::Justify && end == LineEnd::Wrap) || rp.align == Align::Distribute);
// A line ending in a manual line break is stretched too, unless `doNotExpandShiftReturn` says not to.
let stretch_end = end == LineEnd::Wrap || (end == LineEnd::LineBreak && expand_break);
let justify = display.is_none() && ((rp.align == Align::Justify && stretch_end) || rp.align == Align::Distribute);
// A line that only fits with shrunk spaces is shrunk, the last line of the paragraph too.
let shrink = display.is_none() && shrink_spaces && slack < 0.0 && last_tab.is_none();
if shrink {
@@ -3014,6 +3060,29 @@ fn resolve_tab(pl: &mut ParaLayout, pending: &mut Option<(usize, TabStop, f32)>,
}
impl ParaLayout {
/// Returns whether `off` is a legal caret and edit boundary, that is, not the interior start of a
/// joined grapheme cluster.
fn is_edit_boundary(&self, off: usize) -> bool {
self.joined_cluster_starts.binary_search(&off).is_err()
}
/// At a legal group start, include every following drawing segment of that grapheme.
/// Interior segments keep their own advance; they are never independent wrap candidates.
fn atomic_advance(&self, k: usize) -> f32 {
let Some(c) = self.clusters.get(k) else { return 0.0 };
let mut advance = c.adv;
if self.joined_cluster_starts.is_empty() || !self.is_edit_boundary(c.start) {
return advance;
}
for next in self.clusters.get(k + 1..).into_iter().flatten() {
if self.is_edit_boundary(next.start) {
break;
}
advance += next.adv;
}
advance
}
/// The automatic space in cluster `k`'s advance ([`auto_space`]), 0 without one.
fn auto_space_after(&self, k: usize) -> f32 {
self.auto_space.binary_search_by_key(&k, |a| a.0).ok().and_then(|i| self.auto_space.get(i)).map_or(0.0, |a| a.1)
@@ -3031,6 +3100,13 @@ impl ParaLayout {
/// x of byte offset `off` within line `li` (clamped to the line).
pub fn x_of(&self, li: usize, off: usize) -> Option<f32> {
let l = self.lines.get(li)?;
let off = if self.is_edit_boundary(off) {
off
} else {
// Explicit offsets at a font boundary inside a grapheme use its leading edge too.
let k = self.clusters.partition_point(|c| c.start < off);
self.clusters.get(..k).into_iter().flatten().rev().find(|c| self.is_edit_boundary(c.start)).map_or(0, |c| c.start)
};
if !l.vis.is_empty() {
return self.x_of_bidi(l, off);
}
@@ -3108,11 +3184,12 @@ impl ParaLayout {
}
}
/// Caret offsets a click or arrow key can reach on line `li`: each cluster start and the line
/// end, except after a line-ending break or past a wrapped line's trailing space.
/// Caret offsets a click or arrow key can reach on line `li`: whole-grapheme starts and the
/// line end, except after a line-ending break or past a wrapped line's trailing space.
pub fn line_offsets(&self, li: usize) -> Vec<usize> {
let Some(l) = self.lines.get(li) else { return Vec::new() };
let mut v: Vec<usize> = (l.c0..l.c1).filter_map(|k| self.clusters.get(k).map(|c| c.start)).collect();
let mut v: Vec<usize> =
(l.c0..l.c1).filter_map(|k| self.clusters.get(k).filter(|c| self.is_edit_boundary(c.start)).map(|c| c.start)).collect();
let last_break =
l.c1 > l.c0 && self.clusters.get(l.c1 - 1).is_some_and(|c| matches!(c.kind, ClKind::LineBreak | ClKind::PageBreak | ClKind::ColumnBreak));
let wrapped = l.c1 > l.c0 && l.end == LineEnd::Wrap && self.lines.get(li + 1).is_some();
@@ -3140,6 +3217,9 @@ impl ParaLayout {
for k in l.c0..=l.c1 {
let Some(cx) = l.xs.get(k - l.c0) else { break };
let off = if k < l.c1 { self.clusters.get(k).map(|c| c.start).unwrap_or(l.stop) } else { l.stop };
if !self.is_edit_boundary(off) {
continue;
}
// Don't place the caret after a line-ending break or wrap-trailing space on a wrapped line.
if k == l.c1 && k > l.c0 {
let lastc = self.clusters.get(k - 1);
+487 -37
View File
@@ -11,6 +11,202 @@ fn lay(doc: &Document) -> DocLayout {
layout(doc, &mut c, &LayoutOptions::default())
}
/// These pagination fixtures test physical boundaries, not the host's Aptos substitution.
/// Keep their explicit spacing local; ordinary document/layout defaults remain unchanged.
pub(super) fn pagination_fixture_document(text: &str) -> Document {
let mut doc = Document::from_text(text);
doc.styles.default_chr.font = Some(WRAP_TEST_FONT.into());
doc.styles.default_chr.size = Some(12.0);
for block in &mut doc.body {
if let Block::Para(para) = Arc::make_mut(block) {
para.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(14.0));
para.props.space_before = Some(0.0);
para.props.space_after = Some(8.0);
}
}
doc
}
/// Asserts that every laid-out fixture paragraph uses the bundled Source Sans 3 Regular bytes at
/// 12 pt and that at least one paragraph was actually inspected.
pub(super) fn assert_pagination_fixture_font(layout: &DocLayout) {
let mut paragraphs = 0;
for page in &layout.pages {
for item in &page.items {
if let Placed::Lines { para, .. } = item {
paragraphs += 1;
for style in &para.styles {
assert_eq!(style.rc.font, WRAP_TEST_FONT, "the fixture requests its bundled face");
assert_eq!(style.face.family, WRAP_TEST_FONT, "the fixture must not silently substitute another family");
assert_eq!(style.face.style, "Regular", "the fixture must resolve the intended regular face");
assert_eq!(style.size, 12.0, "the fixture keeps its explicit point size");
assert!(
style.face.data() == include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf").as_slice(),
"the selected face must be the exact already-bundled regular font bytes"
);
}
}
}
}
assert!(paragraphs > 0, "font assertions must inspect actual laid-out paragraphs");
}
/// Preserve the 22 short filler paragraphs while giving the following row a measured boundary.
fn pagination_filler_leaving(room: f32) -> Document {
let lines: Vec<String> = (1..=22).map(|i| format!("Filler {i}")).collect();
let mut doc = pagination_fixture_document(&lines.join("\n"));
let after = doc.last_section.text_height() - room - 22.0 * 14.0 - 21.0 * 8.0;
assert!(after >= 8.0, "the last gap may add room-taking space, not remove filler content");
doc.para_mut(StoryRef::Body, &Path::top(21)).unwrap().props.space_after = Some(after);
let probe = lay(&doc);
assert_pagination_fixture_font(&probe);
assert_eq!(probe.pages.len(), 1, "all filler must precede the row on page one");
let lines: Vec<_> = probe.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story: StoryRef::Body, path, para, y, l0, l1, .. } if path.0.len() == 1 => Some((para, *y, l1 - l0)),
_ => None,
})
.collect();
assert_eq!(lines.len(), 22, "every filler paragraph remains");
assert!(lines.iter().all(|(_, _, count)| *count == 1), "filler text must remain one line per paragraph");
let (last, y, _) = lines.last().unwrap();
let remaining = probe.pages[0].body.bottom() - y - last.height - last.rp.space_after;
assert!((remaining - room).abs() < 0.02, "requested {room}pt of actual row room, got {remaining}pt");
assert!(remaining > 0.0 && (remaining < 63.6) == (room < 63.6), "the measured room must be on the intended side of the row boundary");
doc
}
/// The top-level body text bottom and its trailing space-after, if the page has one.
fn pagination_body_bottom(page: &Page) -> Option<(f32, f32)> {
let mut body_bottom = None::<(f32, f32)>;
for item in &page.items {
if let Placed::Lines { story: StoryRef::Body, path, para, l0, l1, y, .. } = item
&& path.0.len() == 1
&& let (Some(first), Some(last)) = (para.lines.get(*l0), para.lines.get(l1.saturating_sub(1)))
{
let bottom = y + last.top + last.height - first.top;
if body_bottom.is_none_or(|(prior, _)| bottom > prior) {
body_bottom = Some((bottom, para.rp.space_after));
}
}
}
body_bottom
}
/// Report the page's top-level body text bottom, when there is one.
fn pagination_write_body_bottom(out: &mut String, page: &Page) {
use std::fmt::Write as _;
if let Some((bottom, after)) = pagination_body_bottom(page) {
let _ = writeln!(
out,
" top-level body text bottom={bottom:.3} trailing_space_after={after:.3} remaining_to_body_bottom={:.3} remaining_after_unadjusted_space={:.3}",
page.body.bottom() - bottom,
page.body.bottom() - bottom - after
);
}
}
/// The first and last line of the placed range, with their physical extents.
fn pagination_write_line_rows(out: &mut String, para: &ParaLayout, l0: usize, l1: usize) {
use std::fmt::Write as _;
for (li, line) in para.lines.iter().enumerate().filter(|(i, _)| *i >= l0 && *i < l1 && (*i == l0 || i.saturating_add(1) == l1)) {
let _ = writeln!(
out,
" line={li} bytes={}..{} top={:.3} height={:.3} baseline={:.3} text_asc={:.3} text_desc={:.3} end={:?}",
line.start, line.stop, line.top, line.height, line.baseline, line.text_asc, line.text_desc, line.end
);
}
}
/// The styles this paragraph actually uses (plus the first), with their resolved metrics.
fn pagination_write_styles(out: &mut String, para: &ParaLayout) {
use std::fmt::Write as _;
for (si, st) in para.styles.iter().enumerate().filter(|(i, _)| *i == 0 || para.clusters.iter().any(|c| c.style as usize == *i)).take(4) {
let (asc, desc) = wordcraft_fonts::word::line_metrics(&st.face);
let raw_height = (asc + desc) * f64::from(st.size) / st.face.upem;
let _ = writeln!(
out,
" style={si} requested={:?} actual={:?}/{:?} face_id={} upem={} size={} word_asc={} word_desc={} raw_height_pt={raw_height:.3} resolved_asc={:.3} resolved_desc={:.3}",
st.rc.font,
st.face.family,
st.face.style,
st.face.id(),
st.face.upem,
st.size,
asc,
desc,
st.ascent,
st.descent
);
}
}
/// One placed paragraph line range and its styles.
fn pagination_write_lines(out: &mut String, ii: usize, item: &Placed, doc: Option<&Document>) {
use std::fmt::Write as _;
let Placed::Lines { story, path, para, l0, l1, x, y, turn } = item else {
return;
};
let text = doc.and_then(|d| d.para(*story, path)).map(|p| p.text.chars().take(96).collect::<String>());
let _ = writeln!(
out,
" item={ii} Lines story={story:?} path={path:?} text={text:?} xy=({x:.3},{y:.3}) turn={turn:?} lines={l0}..{l1}/{} para_height={:.3} before={:.3} after={:.3} spacing={:?}",
para.lines.len(),
para.height,
para.rp.space_before,
para.rp.space_after,
para.rp.line_spacing
);
pagination_write_line_rows(out, para, *l0, *l1);
pagination_write_styles(out, para);
}
/// One retained placed item (the middle items are omitted by the caller).
fn pagination_write_item(out: &mut String, ii: usize, item: &Placed, doc: Option<&Document>) {
use std::fmt::Write as _;
match item {
Placed::Lines { .. } => pagination_write_lines(out, ii, item, doc),
Placed::Cell { rect, table, row, cell, story, clip, .. } => {
let _ = writeln!(out, " item={ii} Cell table={table:?} story={story:?} row={row} cell={cell} rect={rect:?} clip={clip}");
}
Placed::Object { rect, story, path, off, text_box, .. } => {
let _ = writeln!(out, " item={ii} Object story={story:?} path={path:?} off={off} textbox={text_box:?} rect={rect:?}");
}
_ => {}
}
}
/// Failure-only evidence for the selected pagination fixtures below. Inspect the already produced
/// styles and placements, not a second run.
pub(super) fn pagination_fixture_diagnostics(label: &str, doc: Option<&Document>, layout: &DocLayout) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "\npagination fixture {label}: {} pages", layout.pages.len());
for (pi, page) in layout.pages.iter().enumerate().take(8) {
let _ = writeln!(
out,
"page={pi} body={:?} first_block={} floats={:?} items={}",
page.body,
page.first_block,
page.float_tables,
page.items.len()
);
pagination_write_body_bottom(&mut out, page);
for (ii, item) in page.items.iter().enumerate().filter(|(i, _)| *i < 32 || i.saturating_add(16) >= page.items.len()) {
pagination_write_item(&mut out, ii, item, doc);
}
if page.items.len() > 48 {
let _ = writeln!(out, " omitted {} middle items (first32/last16 retained)", page.items.len() - 48);
}
}
if layout.pages.len() > 8 {
let _ = writeln!(out, "omitted {} later pages", layout.pages.len() - 8);
}
out
}
#[derive(Clone, Copy)]
enum SpacingLocation {
Cell,
@@ -1443,6 +1639,35 @@ fn a_bookmark_before_a_left_tab_past_the_right_indent_is_not_a_line() {
assert_eq!(lines(true), lines(false));
}
/// A bookmark is not text: where a line breaks is the same with it as without it. U+FFFC has the
/// line-break class CB, which on its own would allow a break before and after it.
#[test]
fn a_bookmark_is_no_line_break_opportunity() {
// (the bookmark's offset in "Oremus", or none) -> the lines' clusters, not counting the bookmarks'
let lines = |marks: &[usize]| {
let mut d = Document::from_text("Oremus");
// A column too narrow for the word, which then breaks inside it.
d.last_section.page_w = d.last_section.margin_left + d.last_section.margin_right + 30.0;
// From the end, so the offsets stay those of "Oremus".
for (n, &at) in marks.iter().enumerate().rev() {
let mark = InlineObject::BookmarkStart { name: format!("b{n}") };
d.insert_object(&Pos::body(0, at), mark, &Default::default()).unwrap();
}
let l = lay(&d);
let Some(para) = l.pages[0].items.iter().find_map(|i| if let Placed::Lines { para, .. } = i { Some(para.clone()) } else { None }) else {
panic!("no lines")
};
para.lines
.iter()
.map(|l| para.clusters.get(l.c0..l.c1).map_or(0, |cs| cs.iter().filter(|c| c.kind != para::ClKind::Marker).count()))
.collect::<Vec<_>>()
};
let plain = lines(&[]);
assert!(plain.len() > 1, "the word is meant to break inside: {plain:?}");
assert_eq!(lines(&[0, 0]), plain, "two bookmarks before the word leave no line of their own");
assert_eq!(lines(&[3]), plain, "a bookmark inside the word is no place to wrap");
}
#[test]
fn lists_get_labels_and_indent() {
let mut d = Document::from_text("one\ntwo\nthree");
@@ -4714,11 +4939,11 @@ fn exact_rows_add_only_the_bottom_margin() {
assert!((tops[0] - (72.0 + 0.5 + 4.0)).abs() < 0.01, "first cell text at {}", tops[0]);
}
/// A 63.6 pt exact row moves whole to page two with only 50 pt left and stays on page one with
/// 90 pt.
#[test]
fn exact_row_moves_whole() {
use wordcraft_doc::props::HeightRule;
let lines: Vec<String> = (1..=200).map(|i| format!("Filler {i}")).collect();
let one_page = lay(&Document::from_text(&lines.join("\n"))).pages[0].items.iter().filter(|i| matches!(i, Placed::Lines { .. })).count();
let mut t = Table::new(1, 1, 400.0);
t.rows[0].props.height = Some(63.6);
t.rows[0].props.height_rule = HeightRule::Exact;
@@ -4727,12 +4952,27 @@ fn exact_row_moves_whole() {
p.insert_object(0, picture(30.0, height, Default::default()), &Default::default()).unwrap();
t.rows[0].cells[0].blocks.push(wordcraft_doc::para_block(p));
}
let mut d = Document::from_text(&lines[..one_page - 2].join("\n"));
let end = d.body.len();
d.insert_block(StoryRef::Body, &Path::top(end), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let row_page = l.pages.iter().position(|p| p.items.iter().any(|i| matches!(i, Placed::Cell { row: 0, .. })));
assert_eq!(row_page, Some(1), "Word keeps an exact row together when it fits on the next page");
// The same intact row moves with only 50pt left, and stays when 90pt is available.
for (room, expected_page) in [(50.0, 1), (90.0, 0)] {
assert_eq!(room < 63.6, expected_page == 1);
let mut d = pagination_filler_leaving(room);
let end = d.body.len();
d.insert_block(StoryRef::Body, &Path::top(end), wordcraft_doc::Block::Table(t.clone())).unwrap();
let l = lay(&d);
assert_pagination_fixture_font(&l);
let row_pages: Vec<_> = l
.pages
.iter()
.enumerate()
.flat_map(|(page, p)| p.items.iter().filter_map(move |i| matches!(i, Placed::Cell { row: 0, .. }).then_some(page)))
.collect();
assert_eq!(
row_pages,
vec![expected_page],
"Word keeps an exact row whole; available={room} height=63.6{}",
pagination_fixture_diagnostics("exact_row_moves_whole", Some(&d), &l)
);
}
}
/// `cell_margins` is what the layout uses: a cell's own margins, else the table's, else Word's;
@@ -4858,6 +5098,41 @@ fn space_shrinking_is_only_for_justified_text() {
}
}
/// #3924: a justified line that ends in a manual line break is stretched to the margin like a
/// wrapped one; the last line of the paragraph stays short. `w:doNotExpandShiftReturn` keeps it short.
#[test]
fn justified_line_ending_in_line_break_is_stretched() {
let text = "We tie the spend\nto a pipe";
let ends = |no_expand: bool| {
let mut d = Document::from_text("");
d.settings.do_not_expand_shift_return = no_expand;
let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default());
p.props = ParaProps { align: Some(Align::Justify), ..Default::default() };
d.body = vec![wordcraft_doc::para_block(p)];
let l = lay(&d);
let mut out = Vec::new();
for pg in &l.pages {
for it in &pg.items {
if let Placed::Lines { para, l0, l1, x, .. } = it {
for li in *l0..*l1 {
let line = &para.lines[li];
let content = text[line.start..line.stop].trim_end().len();
out.push(para.x_of(li, line.start + content).unwrap() + x);
}
}
}
}
out
};
let stretched = ends(false);
assert_eq!(stretched.len(), 2, "{stretched:?}");
assert!((stretched[0] - 540.0).abs() < 0.05, "the line before the break ends at {}", stretched[0]);
assert!(stretched[1] < 400.0, "the last line stays short: {}", stretched[1]);
let kept = ends(true);
assert_eq!(kept.len(), 2, "{kept:?}");
assert!(kept[0] < 400.0, "doNotExpandShiftReturn keeps the line before the break short: {}", kept[0]);
}
#[test]
fn lines_with_tabs_never_shrink() {
let text = "Item\tWe tie the spend to a pipe and to the books, so it is in an ad hoc view of all of it. ".repeat(30);
@@ -5405,14 +5680,43 @@ fn floating_tables_take_no_room_and_text_wraps_beside_them() {
assert!(bx + bleft > nx + 200.0, "body text at {}, right of the narrow table", bx + bleft);
}
/// A margin-anchored floating table is placed from the anchor page's margin even when a body
/// paragraph keeps trailing empty lines: all 24 preceding paragraphs stay on page one and every
/// table row is drawn exactly once there.
#[test]
fn a_floating_table_ignores_a_paragraphs_empty_lines() {
use wordcraft_doc::para::Anchor;
use wordcraft_doc::props::TableFloat;
let mut doc = Document::from_text(&[""; 9].join("\n"));
let mut doc = pagination_fixture_document(&[""; 9].join("\n"));
// Visible's line breaks belong inside its paragraph, not separate body paragraphs.
doc.body.push(wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Visible\n\n\n", Default::default())));
doc.body.extend((0..14).map(|_| wordcraft_doc::para_block(wordcraft_doc::Paragraph::new())));
for block in &mut doc.body {
if let Block::Para(para) = Arc::make_mut(block) {
para.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(14.0));
para.props.space_before = Some(0.0);
para.props.space_after = Some(8.0);
}
}
let before_table = lay(&doc);
assert_pagination_fixture_font(&before_table);
assert_eq!(doc.body.len(), 24, "all original body paragraphs remain");
assert_eq!(before_table.pages.len(), 1, "the table's preceding body/anchor must be on page zero");
let preceding: Vec<_> = before_table.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story: StoryRef::Body, path, para, y, .. } if path.0.len() == 1 => Some((path.0[0], *y + para.height)),
_ => None,
})
.collect();
assert_eq!(preceding.iter().map(|(block, _)| *block).collect::<Vec<_>>(), (0..24).collect::<Vec<_>>());
assert!(preceding.last().unwrap().1 < before_table.pages[0].body.bottom(), "the final preceding paragraph actually fits the anchor page");
assert!(
before_table.pages[0].items.iter().any(|it| matches!(it, Placed::Lines { path, para, .. } if path.0 == [9] && para.lines.len() == 4)),
"the visible paragraph retains all three trailing empty lines"
);
let mut table = Table::new(12, 1, 300.0);
for (i, row) in table.rows.iter_mut().enumerate() {
row.cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("Approval {i}"), Default::default()))];
@@ -5422,6 +5726,18 @@ fn a_floating_table_ignores_a_paragraphs_empty_lines() {
doc.insert_block(StoryRef::Body, &Path::top(at), wordcraft_doc::Block::Table(table)).unwrap();
let layout = lay(&doc);
assert_pagination_fixture_font(&layout);
let cells: Vec<_> = layout
.pages
.iter()
.flat_map(|page| &page.items)
.filter_map(|it| match it {
Placed::Cell { row, rect, .. } => Some((*row, *rect)),
_ => None,
})
.collect();
assert_eq!(cells.iter().map(|(row, _)| *row).collect::<Vec<_>>(), (0..12).collect::<Vec<_>>(), "every floating row remains once");
assert!((cells[0].1.y - (before_table.pages[0].body.y + 50.0)).abs() < 0.01, "the table keeps its margin-relative 50pt anchor");
let table_pages: Vec<usize> = layout
.pages
.iter()
@@ -5433,7 +5749,11 @@ fn a_floating_table_ignores_a_paragraphs_empty_lines() {
})
})
.collect();
assert!(!table_pages.is_empty() && table_pages.iter().all(|page| *page == 0), "table rows on pages {table_pages:?}");
assert!(
!table_pages.is_empty() && table_pages.iter().all(|page| *page == 0),
"table rows on pages {table_pages:?}; table_block={at} margin_anchor_y=50{}",
pagination_fixture_diagnostics("a_floating_table_ignores_a_paragraphs_empty_lines", Some(&doc), &layout)
);
}
#[test]
@@ -6233,44 +6553,120 @@ fn an_inherited_footer_sizes_its_percentage_table_by_its_own_section() {
assert!((own - landscape_w).abs() < 1.0, "own footer: {own} wide, landscape text {landscape_w}");
}
/// With exactly 50 pt left, an eight-paragraph row without a minimum splits on page one, while
/// the same row with an at-least height of 63.6 pt moves to page two whole; all eight paragraphs
/// remain.
#[test]
fn a_row_with_less_room_than_its_minimum_height_moves_on_whole() {
use wordcraft_doc::props::HeightRule;
// Measured in Word: with 49 pt left on the page, a row of eight lines splits, but the same row
// with an at-least height of 63.6 pt goes to the next page whole.
// Exactly 50pt of measured room remains. The same eight paragraphs split without a minimum,
// but an at-least height of 63.6pt moves the row to the next page whole.
let first_row_line_page = |min: Option<f32>| {
let lines: Vec<String> = (1..=200).map(|i| format!("Filler {i}")).collect();
let one_page = lay(&Document::from_text(&lines.join("\n"))).pages[0].items.iter().filter(|i| matches!(i, Placed::Lines { .. })).count();
let mut d = pagination_filler_leaving(50.0);
let mut t = Table::new(1, 1, 400.0);
t.rows[0].props.height = min;
t.rows[0].props.height_rule = HeightRule::AtLeast;
t.rows[0].cells[0].blocks =
(1..=8).map(|k| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("Row line {k}"), Default::default()))).collect();
// Fill the page, leaving two filler paragraphs' room (less than 63.6 pt) for the row.
let mut d = Document::from_text(&lines[..one_page - 2].join("\n"));
let end = d.body.len();
d.insert_block(StoryRef::Body, &Path::top(end), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
l.pages.iter().position(|p| p.items.iter().any(|i| matches!(i, Placed::Lines { path, .. } if path.0.first() == Some(&(end as u32)))))
assert_pagination_fixture_font(&l);
let row_lines: Vec<_> = l
.pages
.iter()
.enumerate()
.flat_map(|(page, p)| {
p.items.iter().filter_map(move |i| match i {
Placed::Lines { path, l0, l1, .. } if path.0.first() == Some(&(end as u32)) => Some((page, l1 - l0)),
_ => None,
})
})
.collect();
assert_eq!(row_lines.iter().map(|(_, count)| count).sum::<usize>(), 8, "all eight row paragraphs remain");
(row_lines.first().map(|(page, _)| *page), d, l)
};
assert_eq!(first_row_line_page(None), Some(0), "a row without a minimum height splits");
assert_eq!(first_row_line_page(Some(63.6)), Some(1), "a row with a minimum height that doesn't fit moves on");
let (page, d, l) = first_row_line_page(None);
assert_eq!(page, Some(0), "a row without a minimum height splits{}", pagination_fixture_diagnostics("at_least_row/no_minimum", Some(&d), &l));
let (page, d, l) = first_row_line_page(Some(63.6));
assert_eq!(
page,
Some(1),
"a row with a minimum height that doesn't fit moves on; available=50 height=63.6{}",
pagination_fixture_diagnostics("at_least_row/minimum", Some(&d), &l)
);
}
/// #1742: a paragraph that leaves `room` points of the letter page's 648 pt body, then the table
/// `build` shapes (one 468 pt column, `rows` rows of one short line each); with `room` 648 the
/// table comes first, at the top of the page. Returns the layout and, per page, the table rows
/// that have a cell there.
fn at_least_rows(room: f32, rows: usize, build: impl Fn(&mut Table)) -> (DocLayout, Vec<Vec<usize>>) {
let mut d = Document::from_text("Text above the table");
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.space_after = Some(648.0 - room - 14.0);
let mut t = Table::new(rows, 1, 468.0);
build(&mut t);
/// Build the filler paragraph and insert-room setting for [`at_least_rows`].
fn at_least_rows_filler(room: f32) -> (Document, usize) {
let mut d = pagination_fixture_document("Text above the table");
let at = if room >= 648.0 { 0 } else { 1 };
d.insert_block(StoryRef::Body, &Path::top(at), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let pages = l
.pages
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.space_after = Some(if at == 0 { 0.0 } else { 648.0 - room - 14.0 });
let before_table = lay(&d);
assert_pagination_fixture_font(&before_table);
assert_eq!(before_table.pages.len(), 1);
if at == 1 {
let (para, y) = before_table.pages[0]
.items
.iter()
.find_map(|it| match it {
Placed::Lines { story: StoryRef::Body, path, para, y, .. } if path.0 == [0] => Some((para, *y)),
_ => None,
})
.unwrap();
let remaining = before_table.pages[0].body.bottom() - y - para.height - para.rp.space_after;
assert!((remaining - room).abs() < 0.02, "the measured filler leaves {remaining}pt, expected {room}pt");
}
(d, at)
}
/// Give every cell paragraph the fixture's exact one-line spacing.
fn set_at_least_row_spacing(t: &mut Table) {
for row in &mut t.rows {
for cell in &mut row.cells {
for block in &mut cell.blocks {
if let Block::Para(para) = Arc::make_mut(block) {
para.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(14.0));
para.props.space_before = Some(0.0);
para.props.space_after = Some(0.0);
}
}
}
}
}
/// The pages holding the real paragraph that remains before/after the table.
fn at_least_rows_trailing(l: &DocLayout, at: usize) -> Vec<usize> {
l.pages
.iter()
.enumerate()
.flat_map(|(page, p)| {
p.items.iter().filter_map(move |it| match it {
Placed::Lines { story: StoryRef::Body, path, .. } if path.0 == [if at == 0 { 1 } else { 0 }] => Some(page),
_ => None,
})
})
.collect()
}
/// Prove that the table begins at the page top and the retained paragraph follows it.
fn assert_at_least_table_at_page_top(l: &DocLayout, trailing: &[usize]) {
let first_cell = l.pages[0]
.items
.iter()
.find_map(|it| match it {
Placed::Cell { row: 0, rect, .. } => Some(rect),
_ => None,
})
.unwrap();
assert!((first_cell.y - l.pages[0].body.y).abs() < 0.01, "the tested table really begins at the page top");
let last_table_page = l.pages.iter().rposition(|p| p.items.iter().any(|it| matches!(it, Placed::Cell { .. }))).unwrap();
assert!(trailing[0] >= last_table_page, "the retained paragraph follows the table");
}
/// The table rows that have a cell on each page.
fn at_least_rows_pages(l: &DocLayout) -> Vec<Vec<usize>> {
l.pages
.iter()
.map(|p| {
let mut rows: Vec<usize> =
@@ -6278,7 +6674,28 @@ fn at_least_rows(room: f32, rows: usize, build: impl Fn(&mut Table)) -> (DocLayo
rows.dedup();
rows
})
.collect();
.collect()
}
/// #1742: a paragraph that leaves `room` points of the letter page's 648 pt body, then the table
/// `build` shapes (one 468 pt column, `rows` rows of one short line each); with `room` 648 the
/// table comes first, at the top of the page. Returns the layout and, per page, the table rows
/// that have a cell there.
fn at_least_rows(room: f32, rows: usize, build: impl Fn(&mut Table)) -> (DocLayout, Vec<Vec<usize>>) {
let (mut d, at) = at_least_rows_filler(room);
let mut t = Table::new(rows, 1, 468.0);
build(&mut t);
set_at_least_row_spacing(&mut t);
d.insert_block(StoryRef::Body, &Path::top(at), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
assert_pagination_fixture_font(&l);
let trailing = at_least_rows_trailing(&l, at);
assert_eq!(trailing.len(), 1, "the real paragraph before/after the table is retained exactly once");
assert_eq!(d.para(StoryRef::Body, &Path::top(if at == 0 { 1 } else { 0 })).unwrap().text, "Text above the table");
if at == 0 {
assert_at_least_table_at_page_top(&l, &trailing);
}
let pages = at_least_rows_pages(&l);
(l, pages)
}
@@ -6343,17 +6760,41 @@ fn an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page() {
assert!(pages.len() >= 3 && pages[1..].iter().all(|p| p == &vec![0]), "it splits from page 2 on: {pages:?}");
}
/// An at-least row taller than the page splits from the top of a fresh page instead of moving
/// on, keeps all 80 cell paragraphs, and pagination terminates.
#[test]
fn an_at_least_row_at_the_top_of_a_page_splits_instead_of_moving_on() {
use wordcraft_doc::props::HeightRule;
// At the top of a page (or under the header rows repeated there) moving on gains nothing: a
// row taller than the room splits, and pagination ends.
let (_, pages) = at_least_rows(648.0, 1, |t| {
let (l, pages) = at_least_rows(648.0, 1, |t| {
t.rows[0].props.height = Some(700.0);
t.rows[0].props.height_rule = HeightRule::AtLeast;
t.rows[0].cells[0].blocks = lines_in_cell(80, "Line");
});
assert!(pages.len() >= 2 && pages.iter().all(|p| p == &vec![0]), "{pages:?}");
assert!(
pages.len() >= 2 && pages.iter().all(|p| p == &vec![0]),
"{pages:?}; helper retains body block1 \"Text above the table\" after table block0{}",
pagination_fixture_diagnostics("at_least_row/top_of_page", None, &l)
);
let data_lines = |layout: &DocLayout, row: u32| {
let mut paragraphs = std::collections::BTreeSet::new();
let count = layout
.pages
.iter()
.flat_map(|p| &p.items)
.filter_map(|it| match it {
Placed::Lines { story: StoryRef::Body, path, l0, l1, .. } if path.0.len() == 4 && path.0[..3] == [0, row, 0] => {
paragraphs.insert(path.0[3]);
Some(l1 - l0)
}
_ => None,
})
.sum::<usize>();
assert_eq!(paragraphs.into_iter().collect::<Vec<_>>(), (0..80).collect::<Vec<_>>(), "all eighty original cell paragraphs remain");
assert_eq!(count, 80, "every short cell paragraph is drawn exactly once");
};
data_lines(&l, 0);
// A 100 pt header row, then a row at least 600 pt with 80 lines: under the header only 548 pt
// are left on each page, so the row splits there instead of running past the bottom margin.
let (l, pages) = at_least_rows(648.0, 2, |t| {
@@ -6364,10 +6805,19 @@ fn an_at_least_row_at_the_top_of_a_page_splits_instead_of_moving_on() {
t.rows[1].props.height_rule = HeightRule::AtLeast;
t.rows[1].cells[0].blocks = lines_in_cell(80, "Line");
});
assert!(pages.len() >= 3 && pages.iter().all(|p| p == &vec![0, 1]), "{pages:?}");
assert!(
pages.len() >= 3 && pages.iter().all(|p| p == &vec![0, 1]),
"{pages:?}; repeated100pt header plus minimum600pt row{}",
pagination_fixture_diagnostics("at_least_row/repeated_header", None, &l)
);
data_lines(&l, 1);
for p in &l.pages {
let bottom = p.items.iter().filter_map(|i| if let Placed::Cell { rect, .. } = i { Some(rect.y + rect.h) } else { None }).fold(0.0, f32::max);
assert!(bottom <= 720.0 + 0.5, "a row part runs past the bottom margin to {bottom}");
assert!(
bottom <= 720.0 + 0.5,
"a row part runs past the bottom margin to {bottom}{}",
pagination_fixture_diagnostics("at_least_row/repeated_header_bottom", None, &l)
);
}
}
+224
View File
@@ -171,6 +171,102 @@ fn an_emoji_zwj_sequence_stays_one_cluster() {
assert!(xs.windows(2).all(|w| w[0] < w[1]), "{xs:?}");
}
/// Font/style fragments still draw independently, but every user-facing stop and emergency
/// wrap must be at a whole paragraph grapheme boundary, in LTR and bidirectional lines alike.
fn assert_atomic_drawing_group(d: &Document, legal: &[usize], start: usize, inside: usize, end: usize) {
let l = lay(d);
let pl = para_layout(&l);
assert!(pl.clusters.iter().any(|c| c.start == inside), "fixture must actually split the drawing inside the grapheme");
let li = pl.line_of(start);
assert_eq!(pl.x_of(li, inside), pl.x_of(li, start), "an explicit internal offset uses the grapheme's leading edge");
let mut reachable: Vec<_> = (0..pl.lines.len()).flat_map(|i| pl.line_offsets(i)).collect();
reachable.sort_unstable();
reachable.dedup();
assert_eq!(reachable, legal, "every expected grapheme boundary remains reachable");
if d.last_section.text_width() <= 18.01 {
assert!(pl.lines.len() > 1, "the narrow fixture must actually exercise wrapping");
}
for (li, line) in pl.lines.iter().enumerate() {
assert!(legal.contains(&line.start) && legal.contains(&line.stop), "wrap cannot split a grapheme: {}..{}", line.start, line.stop);
assert!(pl.line_offsets(li).iter().all(|o| legal.contains(o)), "only legal keyboard stops");
let left = (line.c0..line.c1).filter_map(|k| line.cl_left(k)).fold(line.left, f32::min);
let right = (line.c0..line.c1).filter_map(|k| line.cl_right(k)).fold(line.right, f32::max);
for sample in 0..=128 {
let x = left + (right - left) * sample as f32 / 128.0;
let off = pl.off_at_x(li, x);
assert!(legal.contains(&off), "click at {x} reached internal offset {off}");
}
}
// The whole group's selection covers every differently drawn component without gaps.
let rects = l.selection_rects(d, &Pos::body(0, start), &Pos::body(0, end), 0);
let width: f32 = rects.iter().map(|(_, r)| r.w).sum();
let expected = (pl.x_of(li, end).unwrap() - pl.x_of(li, start).unwrap()).abs();
assert!(width > 0.0 && (width - expected).abs() < 0.05, "whole-grapheme selection: {width}, expected {expected}");
}
/// A mixed-size emoji ZWJ grapheme keeps both drawing sizes and gives caret hit testing,
/// selection and wrapping atomic boundaries in LTR and bidi at several widths.
#[test]
fn a_mixed_style_emoji_grapheme_has_atomic_caret_hit_and_wrap_boundaries() {
for bidi in [false, true] {
let mut d = Document::from_text("x\u{1F469}\u{200D}\u{1F4BB}y");
d.format_range(&Pos::body(0, 0), &Pos::body(0, 13), &|c| {
c.font = Some("Source Sans 3".into());
c.size = Some(24.0);
})
.unwrap();
d.format_range(&Pos::body(0, 8), &Pos::body(0, 12), &|c| c.size = Some(48.0)).unwrap();
d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.bidi = Some(bidi);
let wide = lay(&d);
let pl = para_layout(&wide);
if bidi {
assert!(pl.lines.iter().any(|line| !line.vis.is_empty()), "exercise the bidi hit/caret path");
}
let styles: Vec<_> = pl.clusters.iter().filter(|c| c.start >= 1 && c.end <= 12).map(|c| pl.styles[c.style as usize].size).collect();
assert!(styles.contains(&24.0) && styles.contains(&48.0), "both drawing sizes must survive: {styles:?}");
assert_atomic_drawing_group(&d, &[0, 1, 12, 13], 1, 8, 12);
for width in [18.0, 32.0, 48.0, 96.0] {
d.last_section.page_w = 144.0 + width;
assert_atomic_drawing_group(&d, &[0, 1, 12, 13], 1, 8, 12);
}
}
}
/// A base letter and combining mark using different bundled fonts keep their own faces and
/// glyphs, with atomic hit, selection and wrap boundaries across widths and bidi.
#[test]
fn a_mixed_bundled_font_combining_grapheme_preserves_drawing_and_atomic_boundaries() {
for bidi in [false, true] {
let mut d = Document::from_text("xa\u{301}b");
d.format_range(&Pos::body(0, 0), &Pos::body(0, 5), &|c| {
c.font = Some("Source Sans 3".into());
c.size = Some(24.0);
})
.unwrap();
d.format_range(&Pos::body(0, 2), &Pos::body(0, 4), &|c| {
c.font = Some("Source Serif 4".into());
c.size = Some(48.0);
})
.unwrap();
d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.bidi = Some(bidi);
let wide = lay(&d);
let pl = para_layout(&wide);
if bidi {
assert!(pl.lines.iter().any(|line| !line.vis.is_empty()), "exercise the bidi hit/caret path");
}
let base = pl.clusters.iter().find(|c| c.start == 1).unwrap();
let mark = pl.clusters.iter().find(|c| c.start == 2).unwrap();
assert_eq!(pl.styles[base.style as usize].face.family, "Source Sans 3");
assert_eq!(pl.styles[mark.style as usize].face.family, "Source Serif 4");
assert!(base.g1 > base.g0 && mark.g1 > mark.g0, "both independently shaped pieces keep their glyphs");
assert_atomic_drawing_group(&d, &[0, 1, 4, 5], 1, 2, 4);
for width in [18.0, 32.0, 48.0, 96.0] {
d.last_section.page_w = 144.0 + width;
assert_atomic_drawing_group(&d, &[0, 1, 4, 5], 1, 2, 4);
}
}
}
#[test]
fn an_indic_conjunct_stays_one_cluster() {
// क्ष: ka, virama, ssa — one conjunct; the caret never stops inside it.
@@ -219,3 +315,131 @@ fn a_hyphenation_point_never_splits_a_ligature() {
}
}
}
/// The paragraph's text without its inline-object markers (`U+FFFC`), and for every character
/// start of that text the byte offset it has in the paragraph (plus the end).
fn without_markers(p: &wordcraft_doc::Paragraph) -> (String, Vec<(usize, usize)>) {
let mut text = String::new();
let mut at = Vec::new();
for (i, c) in p.text.char_indices() {
if c == wordcraft_doc::para::OBJ {
continue;
}
at.push((text.len(), i));
text.push(c);
}
at.push((text.len(), p.text.len()));
(text, at)
}
/// "x👩‍💻y" with the emoji's components at two sizes, justified; optionally with a zero-width
/// marker at each end of the grapheme and of the paragraph. Every marker sits on a grapheme
/// boundary: a marker *inside* one is a `U+FFFC` in the text and splits it in the document model.
fn marked_grapheme_document(marks: bool, bidi: bool) -> Document {
let mut d = Document::from_text("x\u{1F469}\u{200D}\u{1F4BB}y");
d.format_range(&Pos::body(0, 0), &Pos::body(0, 13), &|c| {
c.font = Some("Source Sans 3".into());
c.size = Some(24.0);
})
.unwrap();
d.format_range(&Pos::body(0, 8), &Pos::body(0, 12), &|c| c.size = Some(48.0)).unwrap();
{
let p = d.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
p.props.bidi = Some(bidi);
p.props.align = Some(wordcraft_doc::props::Align::Justify);
}
if marks {
// From the paragraph's end, so the earlier insertions keep their offsets.
let marks = [
(13, wordcraft_doc::para::InlineObject::BookmarkStart { name: "g".into() }),
(12, wordcraft_doc::para::InlineObject::CommentStart { id: 7 }),
(1, wordcraft_doc::para::InlineObject::ControlStart { control: Box::default() }),
(0, wordcraft_doc::para::InlineObject::FieldStart { instr: "PAGE".into(), locked: false, dirty: false }),
];
for (at, mark) in marks {
d.insert_object(&Pos::body(0, at), mark, &Default::default()).unwrap();
}
}
d
}
/// Text clusters on each line, not counting zero-width markers: a marker may not move text
/// between lines.
fn text_cluster_counts(pl: &ParaLayout) -> Vec<usize> {
pl.lines.iter().map(|l| pl.clusters.get(l.c0..l.c1).map_or(0, |cs| cs.iter().filter(|c| c.kind != crate::para::ClKind::Marker).count())).collect()
}
/// A bookmark, comment, content-control and field marker at the edges of a mixed-style emoji
/// grapheme (and of the paragraph) add no line of their own and move no break into the grapheme,
/// at narrow and wide widths, LTR and bidi. Their line-break skip and the grapheme's atomic
/// caret stops must agree.
#[test]
fn markers_at_a_mixed_style_graphemes_edges_keep_it_atomic() {
const START: usize = 1; // the emoji's first byte in the text without markers
const INSIDE: usize = 8; // its size change: a drawing segment start inside the grapheme
for bidi in [false, true] {
for width in [18.0, 32.0, 48.0, 180.0] {
let mut plain_doc = marked_grapheme_document(false, bidi);
plain_doc.last_section.page_w = 144.0 + width;
let plain = lay(&plain_doc);
let plain_counts = text_cluster_counts(para_layout(&plain));
if width == 18.0 {
assert!(plain_counts.len() > 1, "the narrow fixture must exercise wrapping");
}
let mut d = marked_grapheme_document(true, bidi);
d.last_section.page_w = 144.0 + width;
let l = lay(&d);
let pl = para_layout(&l);
let p = d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
let (clean, at) = without_markers(p);
assert_eq!(clean, "x\u{1F469}\u{200D}\u{1F4BB}y", "{bidi}/{width}: markers are not text");
let para_off = |clean_off: usize| at.binary_search_by_key(&clean_off, |e| e.0).ok().and_then(|k| at.get(k)).map(|e| e.1);
let clean_starts = wordcraft_doc::cluster::cluster_starts(&clean);
let mut boundaries: Vec<usize> = clean_starts.iter().filter_map(|&o| para_off(o)).collect();
// The paragraph's end is a caret stop on the last line but not a grapheme start.
boundaries.extend(para_off(clean.len()));
let markers: Vec<usize> = p.text.char_indices().filter(|&(_, c)| c == wordcraft_doc::para::OBJ).map(|(i, _)| i).collect();
assert_eq!(markers.len(), 4, "{bidi}/{width}: the fixture carries all four zero-width markers");
for &marker in &markers {
let clean_offset = p.text[..marker].chars().filter(|&c| c != wordcraft_doc::para::OBJ).map(char::len_utf8).sum::<usize>();
assert!(clean_starts.contains(&clean_offset) || clean_offset == clean.len(), "marker must sit on a clean grapheme edge");
}
assert_eq!(pl.clusters.iter().filter(|c| c.kind == crate::para::ClKind::Marker).count(), 4);
let start = para_off(START).unwrap();
let inside = para_off(INSIDE).unwrap();
let interior: Vec<usize> = clean.char_indices().map(|(i, _)| i).filter(|o| !clean_starts.contains(o)).filter_map(&para_off).collect();
assert!(interior.contains(&inside), "{bidi}/{width}: {inside} must be inside the grapheme: {interior:?}");
let allowed = |off: usize| boundaries.contains(&off) || markers.contains(&off);
assert_eq!(text_cluster_counts(pl), plain_counts, "{bidi}/{width}: markers must not move text between lines");
assert!(pl.clusters.iter().any(|c| c.start == inside), "{bidi}/{width}: the size change must still split the drawing");
for (li, line) in pl.lines.iter().enumerate() {
assert!(
pl.clusters.get(line.c0..line.c1).is_some_and(|cs| cs.iter().any(|c| c.kind != crate::para::ClKind::Marker)),
"{bidi}/{width}: line {li} holds only markers: {line:?}"
);
for off in [line.start, line.stop] {
assert!(!interior.contains(&off), "{bidi}/{width}: line {li} ends or starts inside the grapheme at {off}: {line:?}");
assert!(allowed(off), "{bidi}/{width}: line {li} boundary {off} is not a grapheme boundary: {line:?}");
}
for off in pl.line_offsets(li) {
assert!(!interior.contains(&off), "{bidi}/{width}: caret stop {off} inside the grapheme");
assert!(allowed(off), "{bidi}/{width}: caret stop {off} is not a grapheme boundary");
}
let left = (line.c0..line.c1).filter_map(|k| line.cl_left(k)).fold(line.left, f32::min);
let right = (line.c0..line.c1).filter_map(|k| line.cl_right(k)).fold(line.right, f32::max);
for sample in 0..=64 {
let off = pl.off_at_x(li, left + (right - left) * sample as f32 / 64.0);
assert!(!interior.contains(&off), "{bidi}/{width}: a click on line {li} reached {off} inside the grapheme");
}
}
let li = pl.line_of(start);
assert_eq!(
pl.x_of(li, inside).expect("interior offset resolves"),
pl.x_of(li, start).expect("grapheme start resolves"),
"{bidi}/{width}: an explicit interior offset uses the grapheme's leading edge"
);
}
}
}
+65
View File
@@ -0,0 +1,65 @@
//! A no-break space is laid out as wide as an ordinary space, whatever the font's own U+00A0
//! glyph measures (Lucida Bright draws it twice as wide).
use super::*;
/// Inter with its U+00A0 glyph twice as wide as its space, under the family name "Wnbsp".
/// Built from the bundled font so the test needs no installed font.
fn wide_nbsp_family() -> &'static str {
const FAMILY: &str = "Wnbsp";
let inter = wordcraft_fonts::word::resolve("Inter", false, false).face;
let mut data = inter.data().to_vec();
let gid = inter.glyph_for('\u{a0}') as usize;
assert_ne!(gid, inter.glyph_for(' ') as usize, "Inter keeps a glyph of its own for U+00A0");
let u16_at = |d: &[u8], o: usize| u16::from_be_bytes([d[o], d[o + 1]]) as usize;
let u32_at = |d: &[u8], o: usize| u32::from_be_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]) as usize;
let table = |d: &[u8], tag: &[u8; 4]| {
(0..u16_at(d, 4)).map(|i| 12 + 16 * i).find(|&r| &d[r..r + 4] == tag).map(|r| (u32_at(d, r + 8), u32_at(d, r + 12))).unwrap()
};
let (hmtx, _) = table(&data, b"hmtx");
assert!(gid < u16_at(&data, table(&data, b"hhea").0 + 34), "the glyph has a full metrics record");
let advance = u16_at(&data, hmtx + 4 * gid);
data[hmtx + 4 * gid..hmtx + 4 * gid + 2].copy_from_slice(&((advance * 2) as u16).to_be_bytes());
// The family name, in the name table's UTF-16 and single-byte records alike.
let (name, len) = table(&data, b"name");
let wide = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
for (from, to) in [(wide("Inter"), wide(FAMILY)), (b"Inter".to_vec(), FAMILY.as_bytes().to_vec())] {
for i in name..name + len - from.len() {
if data[i..i + from.len()] == from[..] {
data[i..i + from.len()].copy_from_slice(&to);
}
}
}
wordcraft_fonts::FontDb::global().add_font(data);
FAMILY
}
/// The width of the first line of `text` set in the wide-nbsp font.
fn line_width(text: &str) -> f32 {
let chr = wordcraft_doc::CharProps { font: Some(wide_nbsp_family().into()), size: Some(12.0), ..Default::default() };
let d = Document { body: vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, chr))], ..Document::new() };
let mut c = LayoutCache::new();
let l = layout(&d, &mut c, &LayoutOptions::default());
for p in &l.pages {
for it in &p.items {
if let Placed::Lines { para, .. } = it {
let xs = &para.lines[0].xs;
return xs[xs.len() - 1] - xs[0];
}
}
}
panic!("paragraph not laid out");
}
#[test]
fn a_no_break_space_is_as_wide_as_a_space() {
let face = wordcraft_fonts::word::resolve(wide_nbsp_family(), false, false).face;
assert_eq!(face.family, wide_nbsp_family(), "the test font is installed under its own name");
assert!(
face.advance(face.glyph_for('\u{a0}')) > 1.9 * face.advance(face.glyph_for(' ')),
"the font's own U+00A0 glyph is twice as wide as its space"
);
let nbsp = line_width(&format!("|{}|", "\u{a0}".repeat(10)));
let space = line_width(&format!("|{}|", " ".repeat(10)));
assert!((nbsp - space).abs() < 0.01, "ten no-break spaces measure {nbsp} pt, ten spaces {space} pt");
}
+29 -18
View File
@@ -49,18 +49,18 @@ fn parse_accepts_the_four_modes_any_case() {
}
#[test]
fn map_substitutes_digits_both_ways() {
fn map_substitutes_ascii_digits_only() {
for d in '0'..='9' {
let hindi = char::from_u32(0x660 + (d as u32 - '0' as u32)).unwrap();
assert_eq!(NumeralMode::Hindi.map(d, false), hindi);
assert_eq!(NumeralMode::Hindi.map(d, true), hindi, "Hindi mode ignores direction");
assert_eq!(NumeralMode::Arabic.map(hindi, false), d);
assert_eq!(NumeralMode::Arabic.map(hindi, true), d, "Arabic mode converts back both ways");
assert_eq!(NumeralMode::Arabic.map(hindi, false), hindi);
assert_eq!(NumeralMode::Arabic.map(hindi, true), hindi, "Arabic mode keeps typed Hindi digits");
assert_eq!(NumeralMode::Arabic.map(d, false), d);
assert_eq!(NumeralMode::Context.map(d, true), hindi);
assert_eq!(NumeralMode::Context.map(d, false), d);
assert_eq!(NumeralMode::Context.map(hindi, true), hindi);
assert_eq!(NumeralMode::Context.map(hindi, false), d);
assert_eq!(NumeralMode::Context.map(hindi, false), hindi);
}
// Letters, punctuation and Persian digits are never touched.
for c in ['A', 'ب', '.', ' ', '(', '۰', '۹'] {
@@ -120,21 +120,11 @@ fn hindi_mode_reshapes_digits_but_keeps_offsets() {
}
#[test]
fn arabic_mode_converts_hindi_digits_back() {
fn arabic_mode_keeps_typed_hindi_digits() {
let text = "عام ٢٠٢٤";
let hindi = lay_opts(text, true, NumeralMode::Hindi);
let arabic = lay_opts(text, true, NumeralMode::Arabic);
let h = line_gids(&hindi, text);
let a = line_gids(&arabic, text);
assert_eq!(h.len(), a.len());
let mut changed = 0;
for (n, (gh, ga)) in h.iter().zip(a.iter()).enumerate() {
if text.chars().nth(n).unwrap() == '٢' {
assert_ne!(gh, ga);
changed += 1;
}
}
assert!(changed > 0, "typed Hindi digits display Western in Arabic mode");
let hindi = line_gids(&lay_opts(text, true, NumeralMode::Hindi), text);
let arabic = line_gids(&lay_opts(text, true, NumeralMode::Arabic), text);
assert_eq!(hindi, arabic, "typed Hindi digits display as typed in Arabic mode");
}
#[test]
@@ -176,3 +166,24 @@ fn remap_range_maps_display_bytes_to_original() {
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 99, 200), (4, 4));
assert_eq!(remap_range(&map_starts, &orig_of, orig.len(), 3, 1), (1, 2));
}
#[test]
fn arabic_mode_keeps_arabic_indic_digits_as_typed() {
// Issue #3259: the end-of-ayah mark (U+06DD) ligates with the real Arabic-Indic digits that
// follow it, so those codepoints must reach the shaper unchanged in the default mode.
for n in 0..10u32 {
let hindi = char::from_u32(0x660 + n).unwrap();
let ascii = char::from_u32('0' as u32 + n).unwrap();
for m in [NumeralMode::default(), NumeralMode::Arabic, NumeralMode::System] {
assert_eq!(m.map(hindi, false), hindi, "{m:?} keeps {hindi:?} (ltr)");
assert_eq!(m.map(hindi, true), hindi, "{m:?} keeps {hindi:?} (rtl)");
}
let typed = format!("\u{6dd}{hindi}");
let western = format!("\u{6dd}{ascii}");
let t = line_gids(&lay_opts(&typed, true, NumeralMode::default()), &typed);
let w = line_gids(&lay_opts(&western, true, NumeralMode::default()), &western);
let h = line_gids(&lay_opts(&typed, true, NumeralMode::Hindi), &typed);
assert_ne!(t, w, "U+{:04X} is not shaped as ASCII {ascii:?} in the default mode", 0x660 + n);
assert_eq!(t, h, "U+{:04X} shapes the same as in Hindi mode", 0x660 + n);
}
}
+98 -14
View File
@@ -179,6 +179,9 @@ fn own_margins_and_resize_to_fit_keep_the_text_in_view() {
assert!(shown >= 1);
}
/// An auto-fit inline box shrinks to its three lines and keeps the explicit page break on page
/// one; a fixed 10 cm box keeps its saved height and pushes the break and following text to
/// later pages.
#[test]
fn inline_box_that_resizes_to_fit_takes_its_texts_height() {
// #2791: an inline box with Resize shape to fit text, saved 10 cm tall around three short
@@ -186,25 +189,106 @@ fn inline_box_that_resizes_to_fit_takes_its_texts_height() {
let brk = wordcraft_doc::para::PAGE_BREAK;
let doc = |auto_fit: bool| {
let filler: Vec<String> = (1..=FILLER).map(|i| format!("Filler line {i}")).collect();
let mut d = Document::from_text(&format!("{}\n\n\n{brk}\nSecond page text.", filler.join("\n")));
let mut d = super::tests::pagination_fixture_document(&format!("{}\n\n\n{brk}\nSecond page text.", filler.join("\n")));
// Exact spacing is only for the filler/short text. The object paragraph must retain
// automatic spacing so its inline box really contributes its physical height.
d.para_mut(StoryRef::Body, &Path::top(FILLER)).unwrap().props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.15));
let probe = lay(&d);
super::tests::assert_pagination_fixture_font(&probe);
let body_para = |layout: &DocLayout, index: usize| {
layout
.pages
.iter()
.enumerate()
.find_map(|(page, p)| {
p.items.iter().find_map(|it| match it {
Placed::Lines { story: StoryRef::Body, path, para, y, .. } if path.0 == [index as u32] => {
Some((page, *y, para.height, para.rp.space_after))
}
_ => None,
})
})
.unwrap()
};
let (anchor_page, anchor_y, _, _) = body_para(&probe, FILLER);
assert_eq!(anchor_page, 0, "all twenty-two original filler paragraphs must precede the box on page one");
assert_eq!(d.body.len(), FILLER + 4, "all original blank, break and following paragraphs remain");
assert_eq!(d.para(StoryRef::Body, &Path::top(FILLER + 1)).unwrap().text, "");
assert_eq!(d.para(StoryRef::Body, &Path::top(FILLER + 2)).unwrap().text, brk.to_string());
let available = probe.pages[0].body.bottom() - anchor_y;
let (_, _, blank_h, blank_after) = body_para(&probe, FILLER + 1);
let (_, _, break_h, break_after) = body_para(&probe, FILLER + 2);
let id = text_box(&mut d, FILLER, "", 453.5, 283.46, TextBody { auto_fit, anchor: VAlign::Center, ..Default::default() });
d.parts.get_mut(&id).unwrap().blocks =
(1..=3).map(|i| para_block(Paragraph::with_text(&format!("Text box line {i}"), Default::default()))).collect();
d.parts.get_mut(&id).unwrap().blocks = (1..=3)
.map(|i| {
let mut p = Paragraph::with_text(&format!("Text box line {i}"), Default::default());
p.props.line_spacing = Some(wordcraft_doc::props::LineSpacing::Exactly(14.0));
p.props.space_before = Some(0.0);
p.props.space_after = Some(8.0);
para_block(p)
})
.collect();
let l = lay(&d);
let r = l.pages.iter().find_map(|p| {
p.items.iter().find_map(|it| match it {
Placed::Object { rect, text_box: Some(s), .. } if *s == id => Some(*rect),
super::tests::assert_pagination_fixture_font(&l);
let (object_page, r) = l
.pages
.iter()
.enumerate()
.find_map(|(page, p)| {
p.items.iter().find_map(|it| match it {
Placed::Object { rect, text_box: Some(s), .. } if *s == id => Some((page, *rect)),
_ => None,
})
})
.unwrap();
let (_, _, object_line_h, object_after) = body_para(&l, FILLER);
let needed = object_line_h + object_after + blank_h + blank_after + break_h + break_after;
assert!(available > 0.0, "the box anchor must have measured space available");
if auto_fit {
assert!(needed < available - 1.0, "auto-fit box plus blank and break need {needed}pt, only {available}pt available");
} else {
assert!(r.h > available + 1.0, "the fixed 10cm box itself must exceed the {available}pt remaining");
}
let shown: Vec<_> = l
.pages
.iter()
.flat_map(|p| &p.items)
.filter_map(|it| match it {
Placed::Lines { story: StoryRef::Part(s), path, l0, l1, .. } if *s == id => Some((path.0.clone(), l1 - l0)),
_ => None,
})
});
(l.pages.len(), r.unwrap())
.collect();
assert_eq!(shown.iter().map(|(_, count)| count).sum::<usize>(), 3, "all three original text-box paragraphs remain visible");
assert_eq!(shown.iter().map(|(path, _)| path.clone()).collect::<Vec<_>>(), vec![vec![0], vec![1], vec![2]]);
assert_eq!(object_page, usize::from(!auto_fit), "the real inline box moves only when its saved height cannot fit");
assert_eq!(body_para(&l, FILLER + 2).0, usize::from(!auto_fit), "the explicit page break follows the box on its page");
assert_eq!(body_para(&l, FILLER + 3).0, if auto_fit { 1 } else { 2 }, "the following text stays after the explicit break");
(l.pages.len(), r, d, l)
};
let (pages, r) = doc(true);
assert!(r.h > 3.0 * 12.0 && r.h < 80.0, "the box fits its three lines: {r:?}");
assert_eq!(pages, 2, "the page break stays on the first page");
let (pages, saved) = doc(false);
assert_eq!(pages, 3, "a box keeping its 10 cm pushes the page break over");
assert!((saved.h - 283.46).abs() < 1.0, "without auto-fit the box keeps its saved height: {saved:?}");
let (pages, r, d, l) = doc(true);
assert!(
r.h > 3.0 * 12.0 && r.h < 80.0,
"the box fits its three lines: {r:?}{}",
super::tests::pagination_fixture_diagnostics("inline_autofit/height", Some(&d), &l)
);
assert_eq!(
pages,
2,
"the page break stays on the first page; filler={FILLER} box={r:?}{}",
super::tests::pagination_fixture_diagnostics("inline_autofit/pages", Some(&d), &l)
);
let (pages, saved, d, l) = doc(false);
assert_eq!(
pages,
3,
"a box keeping its 10 cm pushes the page break over; filler={FILLER} box={saved:?}{}",
super::tests::pagination_fixture_diagnostics("inline_fixed/pages", Some(&d), &l)
);
assert!(
(saved.h - 283.46).abs() < 1.0,
"without auto-fit the box keeps its saved height: {saved:?}{}",
super::tests::pagination_fixture_diagnostics("inline_fixed/height", Some(&d), &l)
);
}
/// Filler paragraphs before the box in [`inline_box_that_resizes_to_fit_takes_its_texts_height`]:
+47 -5
View File
@@ -1,12 +1,17 @@
//! Where MCP tool calls end up: a control-channel method call.
use std::io::{BufRead, BufReader, Write};
use std::io::{BufReader, Write};
use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs};
use std::path::Path;
use std::time::Duration;
use serde_json::{Value, json};
use crate::server::{BoundedLine, read_bounded_line};
/// Longest reply line accepted from the app (screenshots and `document.inspect` are large).
const MAX_REPLY_LINE: usize = 256 << 20;
/// Something that answers control-channel methods (`engine.execute`, `document.inspect`,
/// `ui.pointer`, `ui.render`, …). See `wordcraft_ui_egui::control` for the full list.
pub trait Backend {
@@ -161,11 +166,11 @@ impl Remote {
writer.write_all(line.as_bytes())?;
writer.write_all(b"\n")?;
writer.flush()?;
let mut reply = String::new();
if reader.read_line(&mut reply)? == 0 {
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"));
match read_bounded_line(reader, MAX_REPLY_LINE)? {
BoundedLine::Line(reply) => Ok(reply),
BoundedLine::Eof => Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")),
BoundedLine::TooLong => Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "reply from app too long")),
}
Ok(reply)
}
/// One request and its reply, with one retry on a new connection (the app may have
@@ -189,6 +194,11 @@ impl Remote {
Ok(r) => break r,
Err(e) => {
self.conn = None;
// A reply that arrived but can't be read (too long, not UTF-8): the app has
// run the request, so sending it again could run a state-changing one twice.
if e.kind() == std::io::ErrorKind::InvalidData {
return Err(format!("bad reply from app: {e}"));
}
if !first {
return Err(unreachable(&self.addr, e, self.connected));
}
@@ -246,6 +256,7 @@ impl Backend for Remote {
#[cfg(test)]
mod tests {
use super::*;
use std::io::BufRead;
use std::net::{SocketAddr, TcpListener};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
@@ -335,6 +346,37 @@ mod tests {
(port, rx)
}
#[test]
fn an_unreadable_reply_is_not_retried() {
// The app answered something that can't be read (here not UTF-8; an over-long reply
// takes the same path): the request ran, so it must not be sent again.
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let port = listener.local_addr().unwrap().port();
let seen = Arc::new(AtomicUsize::new(0));
let n = seen.clone();
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let n = n.clone();
std::thread::spawn(move || {
let mut out = stream.try_clone().unwrap();
for line in BufReader::new(stream).lines() {
if line.is_err() {
return;
}
n.fetch_add(1, Ordering::SeqCst);
if out.write_all(b"\xff\xfe\n").is_err() {
return;
}
}
});
}
});
let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), fixed_key("k1")).unwrap();
let e = r.call("engine.execute", json!({"command": "text.insert", "params": {"text": "x"}})).unwrap_err();
assert!(e.contains("bad reply"), "{e}");
assert_eq!(seen.load(Ordering::SeqCst), 1, "the request was sent once");
}
fn fixed_key(key: &'static str) -> KeyFinder {
Box::new(move |_| Ok(key.to_string()))
}
+52 -5
View File
@@ -1,6 +1,6 @@
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods.
use std::io::{BufRead, Write};
use std::io::{BufRead, Read, Write};
use serde_json::{Value, json};
@@ -28,6 +28,49 @@ execute format.bold → inspect_document to verify → render_page to look at th
pub const DOC_URI: &str = "wordcraft://document";
pub const COMMANDS_URI: &str = "wordcraft://commands";
/// Longest request line accepted on stdin.
pub const MAX_REQUEST_LINE: usize = 1 << 20;
/// One bounded line read.
pub(crate) enum BoundedLine {
Eof,
Line(String),
/// The line exceeded the limit; the rest of it has been discarded.
TooLong,
}
/// Read one line of at most `max` bytes (excluding the newline) without buffering more than
/// `max + 1` bytes; an over-long line is skipped up to its newline.
pub(crate) fn read_bounded_line(reader: &mut impl BufRead, max: usize) -> std::io::Result<BoundedLine> {
let mut buf = Vec::new();
reader.by_ref().take(max as u64 + 1).read_until(b'\n', &mut buf)?;
if buf.is_empty() {
return Ok(BoundedLine::Eof);
}
if buf.last() != Some(&b'\n') && buf.len() > max {
loop {
let chunk = reader.fill_buf()?;
if chunk.is_empty() {
break;
}
let found = chunk.iter().position(|&b| b == b'\n');
let used = found.map_or(chunk.len(), |i| i + 1);
reader.consume(used);
if found.is_some() {
break;
}
}
return Ok(BoundedLine::TooLong);
}
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
}
String::from_utf8(buf).map(BoundedLine::Line).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
/// An MCP server bound to one backend.
pub struct Server {
backend: Box<dyn Backend>,
@@ -58,10 +101,14 @@ impl Server {
/// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is
/// the protocol stream).
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
for line in input.lines() {
let line = line?;
if let Some(reply) = self.handle_line(&line) {
pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
loop {
let reply = match read_bounded_line(&mut input, MAX_REQUEST_LINE)? {
BoundedLine::Eof => break,
BoundedLine::Line(line) => self.handle_line(&line),
BoundedLine::TooLong => Some(error(Value::Null, INVALID_REQUEST, "request line too long").to_string()),
};
if let Some(reply) = reply {
output.write_all(reply.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
+17
View File
@@ -228,3 +228,20 @@ fn type_text_paragraphs_reports_a_refusal_and_types_nothing_after_it() {
assert_eq!(calls[0].0, "text.insert");
assert_eq!(calls[0].1["text"], "one\rtwo", "the line break is a paragraph break");
}
/// #3685: an over-long request line gets an error reply and the server keeps serving.
#[test]
fn over_long_request_line_is_rejected_and_serving_continues() {
let mut s = Server::new(Box::new(Headless::default()));
let mut input = vec![b'a'; (1 << 20) + 10];
input.push(b'\n');
input.extend_from_slice(br#"{"jsonrpc":"2.0","id":5,"method":"ping"}"#);
input.push(b'\n');
let mut out = Vec::new();
s.serve(std::io::Cursor::new(input), &mut out).unwrap();
let text = String::from_utf8(out).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines[0].contains("too long"), "{}", lines[0]);
assert!(lines[1].contains(r#""id":5"#), "{}", lines[1]);
}
+1 -1
View File
@@ -103,7 +103,7 @@ pub(crate) fn subpaths(pts: &[P], counts: &[usize], close: bool) -> Vec<Op> {
let mut at = 0usize;
for &c in counts {
let end = at.saturating_add(c).min(pts.len());
let Some((first, rest)) = pts.get(at..end).and_then(|s| s.split_first()) else {
let Some((first, rest)) = pts.get(at..end).and_then(<[_]>::split_first) else {
at = end;
continue;
};
+1 -1
View File
@@ -419,7 +419,7 @@ impl AffixDict {
}
stems.push((word, flags));
}
AffixDict::build(rules, stems, comments.iter().map(|s| s.to_string()).collect())
AffixDict::build(rules, stems, comments.iter().map(ToString::to_string).collect())
}
fn build(rules: Rules, mut stems: Vec<(String, String)>, comments: Vec<String>) -> Result<AffixDict, String> {
-5
View File
@@ -217,11 +217,6 @@ impl Patterns {
self.exceptions.len()
}
#[allow(dead_code)]
pub(crate) fn from_parts(alpha: Alphabet, map: FxMap<u128, Box<[u8]>>, max_len: usize) -> Patterns {
Patterns { alpha, map, max_len, exceptions: HashMap::new() }
}
/// Make `letters` part of the alphabet even if no pattern mentions them.
pub fn add_letters(&mut self, letters: impl IntoIterator<Item = char>) {
for c in letters {
+10 -3
View File
@@ -9,7 +9,7 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi
workspace = true
[dependencies]
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
# Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n
# catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in
# `craft-i18n.rev`). Its own rev, ahead of craft-ui's: lazy per-locale parsing
@@ -17,16 +17,16 @@ craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c
craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
wordcraft-geom = { workspace = true, features = ["image"] }
wordcraft-doc = { workspace = true }
unicode-bidi = { workspace = true }
wordcraft-fonts = { workspace = true }
wordcraft-layout = { workspace = true }
wordcraft-render = { workspace = true }
wordcraft-engine = { workspace = true }
wordcraft-zotero = { workspace = true }
egui = { workspace = true }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
web-time = { workspace = true }
# Pictures pasted from the system clipboard arrive as pixels and are inserted as PNG (#45).
image = { workspace = true }
# The system's preferred interface languages (#8) and region (#838). Native: OS APIs; wasm:
@@ -42,6 +42,7 @@ zbus = "=5.19.0"
# Comment times in the user's own time zone (`local_time.rs`); the web app asks the browser.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
chrono = { version = "0.4.45", default-features = false, features = ["clock"] }
craft-update = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# The browser clock (`SystemTime::now()`/`Instant::now()` panic on wasm32).
@@ -49,6 +50,12 @@ js-sys = "0.3"
[dev-dependencies]
cfb = { workspace = true }
# Producer tests simulate a globally exhausted IME revision allocator through the engine's
# explicit test-support feature; production builds of this crate never enable it.
wordcraft-engine = { workspace = true, features = ["test-support"] }
# kittest's own dev-dependencies do not propagate their font features. Declare the fonts the
# interface tests lay out explicitly: default faces plus monochrome emoji fallbacks.
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
zip = { workspace = true }
+8 -2
View File
@@ -16,9 +16,12 @@ use wordcraft_ui_egui::{Services, WordApp};
static READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Applies the harness's pending raw input, runs one full app frame, and returns its wall-clock
/// duration in milliseconds.
fn step(h: &mut egui_kittest::Harness<'_, WordApp>) -> f64 {
let mut raw = std::mem::take(h.input_mut());
h.state_mut().raw_input_hook(&mut raw);
let ctx = h.ctx.clone();
h.state_mut().raw_input_hook(&ctx, &mut raw);
*h.input_mut() = raw;
let t = Instant::now();
h.step();
@@ -27,11 +30,13 @@ fn step(h: &mut egui_kittest::Harness<'_, WordApp>) -> f64 {
fn stats(v: &[f64]) -> serde_json::Value {
let mut s = v.to_vec();
s.sort_by(|a, b| a.total_cmp(b));
s.sort_by(f64::total_cmp);
let at = |q: f64| s.get(((s.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0);
json!({"n": s.len(), "med": (at(0.5) * 10.0).round() / 10.0, "p95": (at(0.95) * 10.0).round() / 10.0, "max": (at(1.0) * 10.0).round() / 10.0})
}
/// Opens the document named on the command line and prints JSON timings for start-up, typing,
/// scrolling, zooming, ribbon tabs and window resizes.
fn main() {
let path = std::env::args().nth(1).expect("usage: perf_probe doc.docx [block]");
let block: Option<usize> = std::env::args().nth(2).and_then(|b| b.parse().ok());
@@ -108,6 +113,7 @@ fn main() {
delta: egui::vec2(0.0, dy),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
source: egui::MouseWheelSource::Unknown,
});
f.push(step(h));
}
+3 -1
View File
@@ -84,9 +84,11 @@ fn main() {
}
}
/// Applies the harness's pending raw input and advances one UI frame.
fn step(harness: &mut egui_kittest::Harness<'_, WordApp>) {
let mut raw = std::mem::take(harness.input_mut());
harness.state_mut().raw_input_hook(&mut raw);
let ctx = harness.ctx.clone();
harness.state_mut().raw_input_hook(&ctx, &mut raw);
*harness.input_mut() = raw;
harness.step();
}
+93 -5
View File
@@ -44,14 +44,25 @@ pub struct A11yCache {
struct Key {
layout: Arc<DocLayout>,
doc: Id,
/// Whether the page shows Insert › Table's preview copy rather than the document: the two
/// can otherwise share a layout identity only across the preview's start and end, which must
/// rebuild the tree even when the page's rectangles and caret are unchanged.
previewing: bool,
pages: (usize, usize),
rects: Vec<Rect>,
sel: (Pos, Pos),
}
impl Key {
/// Compares two AccessKit cache keys: layout identity, document id, table-preview flag, page range,
/// page rectangles and selection.
fn same(&self, o: &Key) -> bool {
Arc::ptr_eq(&self.layout, &o.layout) && self.doc == o.doc && self.pages == o.pages && self.rects == o.rects && self.sel == o.sel
Arc::ptr_eq(&self.layout, &o.layout)
&& self.doc == o.doc
&& self.previewing == o.previewing
&& self.pages == o.pages
&& self.rects == o.rects
&& self.sel == o.sel
}
}
@@ -80,10 +91,24 @@ pub fn expose(app: &mut crate::WordApp, ctx: &egui::Context, doc_id: Id, layout:
return;
}
let pages = page_window(rects, view).unwrap_or((0, 0));
let sel = (app.session.sel.anchor.clone(), app.session.sel.focus.clone());
let key = Key { layout: layout.clone(), doc: doc_id, pages, rects: rects.get(pages.0..pages.1).unwrap_or_default().to_vec(), sel };
// The page may show Insert › Table's preview copy while the grid is hovered (#3931): describe
// the same document and selection the pages and caret are drawn from, not the real document
// that the preview's layout does not belong to.
let preview = crate::canvas::preview_sel(app);
let sel = preview.map_or_else(|| (app.session.sel.anchor.clone(), app.session.sel.focus.clone()), |sel| (sel.anchor.clone(), sel.focus.clone()));
let key = Key {
layout: layout.clone(),
doc: doc_id,
previewing: preview.is_some(),
pages,
rects: rects.get(pages.0..pages.1).unwrap_or_default().to_vec(),
sel,
};
if !app.canvas.a11y.key.as_ref().is_some_and(|k| k.same(&key)) {
app.canvas.a11y.tree = build(&app.session.doc, layout, doc_id, rects, pages, (&key.sel.0, &key.sel.1));
// Build before writing the cache: the preview document borrows `app`, and the tree is
// owned, so the borrow ends with this statement.
let tree = build(crate::canvas::page_doc(app), layout, doc_id, rects, pages, (&key.sel.0, &key.sel.1));
app.canvas.a11y.tree = tree;
app.canvas.a11y.key = Some(key);
}
let title = app.title_stem();
@@ -435,6 +460,7 @@ impl Builder<'_> {
Some(url) => {
let lid = lb.pid.with(("link", lb.li, lb.seg));
let mut l = Node::new(Role::Link);
l.set_label(n.value().filter(|text| !text.is_empty()).unwrap_or(url).to_owned());
l.set_url(url.clone());
l.set_bounds(bounds(r));
self.add(lid, Some(lb.pid), l);
@@ -546,6 +572,68 @@ mod tests {
update.expect("AccessKit output")
}
/// What AccessKit is told after a few frames in which Insert › Table's grid hovers `rows` ×
/// `cols`: the picker sets the request from the popup after the ribbon draws, and the canvas
/// lays out and describes the page from that preview.
fn described_with_table_preview(app: &mut crate::WordApp, ctx: &egui::Context, rows: usize, cols: usize) -> accesskit::TreeUpdate {
ctx.enable_accesskit();
let mut update = None;
for _ in 0..4 {
let input = egui::RawInput {
screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))),
time: Some(ctx.input(|i| i.time) + 0.1),
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| {
// Set it in every pass, as the grid does while its square is hovered.
app.canvas.table_preview = Some((rows, cols));
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
update = out.platform_output.accesskit_update.take();
}
update.expect("AccessKit output")
}
/// #3931 review: the page shows a previewed table while Insert › Table's grid is hovered, so
/// the AccessKit tree must describe that page's table, not the real document's block at the
/// preview's path. In the preview copy path `[0]` is a 2 × 2 table; in the real document it is
/// the paragraph "Lanterns". Describing the real document with the preview layout made the
/// table node lose its dimensions (and its cells their text) whenever a screen reader was on.
#[test]
fn the_preview_table_is_described_from_the_previewed_page() {
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Lanterns\nKites")), Default::default());
let update = described_with_table_preview(&mut app, &egui::Context::default(), 2, 2);
let nodes: HashMap<NodeId, &Node> = update.nodes.iter().map(|(id, n)| (*id, n)).collect();
let tables: Vec<_> = nodes.iter().filter(|(_, n)| n.role() == Role::Table).map(|(id, _)| *id).collect();
assert_eq!(tables.len(), 1, "the previewed page has one table");
let table = nodes[&tables[0]];
assert_eq!(table.row_count(), Some(2), "the 2 × 2 preview's rows, not the real paragraph at its path");
assert_eq!(table.column_count(), Some(2), "the 2 × 2 preview's columns");
// The preview's caret is in the table; the document's own caret names a paragraph that
// the preview layout no longer has at that path, so it must not reach the tree instead.
let (_, doc) = nodes.iter().find(|(_, n)| n.role() == Role::Document).expect("a document node");
let selection = doc.text_selection().expect("the preview's caret is described");
let mut parent = HashMap::new();
for (id, n) in &nodes {
for c in n.children() {
parent.insert(*c, *id);
}
}
let mut at = Some(selection.focus.node);
let mut in_table = false;
while let Some(id) = at {
if id == tables[0] {
in_table = true;
break;
}
at = parent.get(&id).copied();
}
assert!(in_table, "the described caret is the preview's, inside the preview table");
}
/// #488: with AccessKit on, the canvas describes the document: a heading with its level, a
/// paragraph, a list item in a list, a table's rows and cells and a link, with the text in
/// text runs and the caret at its paragraph and character.
@@ -702,7 +790,7 @@ mod tests {
let n = layout.pages.len();
let rects = vec![Rect::from_min_size(egui::pos2(f32::NAN, 0.0), egui::vec2(f32::INFINITY, 1.0)); n];
let p = Pos::body(0, 1_000_000);
let t = build(&doc, &layout, Id::new("doc"), &rects, (0, n + 5), (&p, &p));
let t = build(&doc, &layout, Id::unique("doc"), &rects, (0, n + 5), (&p, &p));
assert!(!t.nodes.is_empty() && t.nodes.len() <= MAX_NODES);
assert!(t.selection.is_some(), "an offset past the end lands at the end");
assert!(page_window(&rects, Rect::NOTHING).is_none());
+2 -2
View File
@@ -65,7 +65,7 @@ pub fn accessibility(app: &mut WordApp, ui: &mut Ui) {
/// The checker's findings, kept until the document changes.
fn issues(app: &mut WordApp, ui: &Ui) -> Vec<Value> {
let key = egui::Id::new("a11y_issues");
let key = egui::Id::unique("a11y_issues");
let stamp = (app.session.document_id(), app.session.rev());
if let Some((s, v)) = ui.data(|d| d.get_temp::<((u64, u64), Vec<Value>)>(key))
&& s == stamp
@@ -140,7 +140,7 @@ fn still_selected(app: &WordApp, pos: &Pos) -> bool {
/// A text box editing `current`; the draft lives in egui memory while it has focus (frame-local
/// buffers lose keystrokes). Returns the new text when editing ends with a change.
fn draft_box(ui: &mut Ui, key: impl std::hash::Hash + std::fmt::Debug, current: &str, enabled: bool, multiline: bool) -> Option<String> {
let key = egui::Id::new(key);
let key = egui::Id::unique(key);
let mut text = ui.data(|d| d.get_temp::<String>(key)).unwrap_or_else(|| current.to_string());
let edit = if multiline { egui::TextEdit::multiline(&mut text).desired_rows(5) } else { egui::TextEdit::singleline(&mut text) };
let r = ui.add_enabled(enabled, edit.desired_width(f32::INFINITY));
+36 -20
View File
@@ -29,6 +29,8 @@ fn back_chevron_c(band_min_x: f32, center_y: f32, fullscreen: bool, content_left
pos2(band_min_x + x, center_y)
}
/// Draws the Backstage side menu and the active page. Without a document only the Home, New, Open
/// and Options pages work, and it handles Back, Escape, Save, Save As and Close routing.
pub fn show(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// No document open (#1129): only the pages that open or create one, and Options.
@@ -53,7 +55,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
}
// The chevron's stroke starts at the content edge.
let c = back_chevron_c(band.min.x, band.center().y, app.session.view.full_screen, app.mac_titlebar.content_left);
let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, band.height())), ui.id().with("backstage_back"), Sense::click());
let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, band.height())), ui.scope_id().with("backstage_back"), Sense::click());
icons::paint(ui.painter(), Rect::from_center_size(c, vec2(18.0, 18.0)), "chevronLeft", egui::Color32::WHITE, egui::Color32::WHITE);
resp
} else {
@@ -62,7 +64,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
// not also close the File page, same as the macOS chevron fix (#2972) below.
let (r, _) = ui.allocate_exact_size(vec2(200.0, 40.0), Sense::hover());
let c = pos2(r.min.x + 26.0, r.center().y);
let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, r.height())), ui.id().with("backstage_back"), Sense::click());
let resp = ui.interact(Rect::from_center_size(c, vec2(22.0, r.height())), ui.scope_id().with("backstage_back"), Sense::click());
icons::paint(ui.painter(), Rect::from_center_size(c, vec2(18.0, 18.0)), "chevronLeft", egui::Color32::WHITE, egui::Color32::WHITE);
resp
};
@@ -76,7 +78,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
// No document open (#1129): Info, Save, Save As, Print and Export are greyed out.
let on = has_document || works_without_document(id);
let (r, resp) = ui.add_enabled_ui(on, |ui| ui.allocate_exact_size(vec2(200.0, 38.0), Sense::click())).inner;
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tl!(label)));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tl!(label)));
let active = app.ui.backstage_page == id;
if active {
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(46));
@@ -124,7 +126,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
for (label, id) in [("About", "about"), ("Discord community", "discord")] {
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 32.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(label)));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(label)));
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26));
}
@@ -222,6 +224,8 @@ fn heading(ui: &mut Ui, s: &str) {
ui.add_space(16.0);
}
/// Draws a template thumbnail and caption as one accessible button, lazily renders and caches a
/// region-aware page preview, and creates a document from the template on click.
fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
let t = Tokens::get(ui.ctx());
ui.vertical(|ui| {
@@ -231,7 +235,7 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
let caption = crate::i18n::tc("template", label);
let galley = ui.painter().layout_no_wrap(caption.to_string(), regular(12.5), ui.visuals().text_color());
let (tile, resp) = ui.allocate_exact_size(vec2(galley.size().x.max(150.0), 194.0 + galley.size().y), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), caption));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), caption));
let r = Rect::from_min_size(tile.min, vec2(150.0, 194.0));
ui.painter().rect(
r,
@@ -243,7 +247,7 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
let ppp = ui.ctx().pixels_per_point();
let key = format!("tpl:{template}:{ppp}");
let region = app.session.region();
let tex = ui.ctx().data(|d| d.get_temp::<egui::TextureHandle>(egui::Id::new(&key))).or_else(|| {
let tex = ui.ctx().data(|d| d.get_temp::<egui::TextureHandle>(egui::Id::unique(&key))).or_else(|| {
let mut s = wordcraft_engine::Session::new(wordcraft_doc::Document::new());
// The blank tile shows the region's paper (#838).
s.system_region = region;
@@ -257,7 +261,7 @@ fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px),
egui::TextureOptions::LINEAR,
);
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(&key), h.clone()));
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique(&key), h.clone()));
Some(h)
});
if let Some(h) = tex {
@@ -325,7 +329,7 @@ fn home_page(app: &mut WordApp, ui: &mut Ui) {
fn new_page(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
heading(ui, "New");
let id = egui::Id::new("backstage_template_search");
let id = egui::Id::unique("backstage_template_search");
let mut query = ui.ctx().data(|d| d.get_temp::<String>(id)).unwrap_or_default();
let search = ui.add(egui::TextEdit::singleline(&mut query).hint_text(tl!("Search for templates")).desired_width(360.0));
if search.changed() {
@@ -404,8 +408,9 @@ struct PersonalListing {
templates: Vec<PersonalTemplate>,
}
/// Stable storage id for the personal-template listing cache.
fn personal_listing_id() -> egui::Id {
egui::Id::new("backstage_personal_templates")
egui::Id::unique("backstage_personal_templates")
}
#[cfg(test)]
@@ -498,10 +503,13 @@ fn open_personal(app: &mut WordApp, path: &str) -> Result<(), String> {
Ok(())
}
/// Stable storage id for the personal-template listing error.
fn personal_error_id() -> egui::Id {
egui::Id::new("backstage_personal_error")
egui::Id::unique("backstage_personal_error")
}
/// Draws the Recent documents list, distinguishes duplicate names by path for screen readers, and
/// stores an open failure in this page's temporary state.
fn open_list(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(tl!("Recent")).font(semibold(16.0)));
@@ -521,15 +529,13 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(700.0), 40.0), Sense::click());
// Two documents with the same file name are told apart by their path, as on screen.
let spoken = if names.iter().filter(|n| *n == name).count() > 1 { format!("{name}, {p}") } else { name.clone() };
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &spoken));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &spoken));
if resp.hovered() {
ui.painter().rect_filled(r, 4.0, t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(22.0, 22.0)), "printLayout", t.icon, t.accent);
let display_name = crate::canvas::bidi_display(name);
let display_p = crate::canvas::bidi_display(p);
ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 13.0), Align2::LEFT_CENTER, display_name.as_ref(), medium(13.0), t.text);
ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 29.0), Align2::LEFT_CENTER, display_p.as_ref(), regular(11.0), t.text_dim);
ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 13.0), Align2::LEFT_CENTER, name, medium(13.0), t.text);
ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 29.0), Align2::LEFT_CENTER, p, regular(11.0), t.text_dim);
if resp.clicked() {
let failed = open_recent(app, p).err();
ui.ctx().data_mut(|d| d.insert_temp(open_error_id(), failed.unwrap_or_default()));
@@ -537,8 +543,9 @@ fn open_list(app: &mut WordApp, ui: &mut Ui) {
}
}
/// Stable storage id for the Recent list's open error.
fn open_error_id() -> egui::Id {
egui::Id::new("backstage_open_error")
egui::Id::unique("backstage_open_error")
}
/// Drop the Recent list's open error once File is closed, so it doesn't come back next time.
@@ -553,7 +560,7 @@ pub(crate) fn forget_open_error(ctx: &egui::Context) {
/// render loop skips the status bar, so a failed write (no permission, disk full, a bad path)
/// needs its own place to say why, or the page just sits there with a Browse… button and no clue.
pub(crate) fn save_as_error_id() -> egui::Id {
egui::Id::new("backstage_save_as_error")
egui::Id::unique("backstage_save_as_error")
}
/// Drop the Save As page's error once File is closed or a later save succeeds, so it doesn't
@@ -649,6 +656,15 @@ fn info_page(app: &mut WordApp, ui: &mut Ui) {
if focused != app.info_editing.map(|(f, _)| f) {
app.info_editing = None;
}
// Advanced Properties › Custom (#2982): view and edit the document's custom properties.
ui.add_space(6.0);
if ui
.button(egui::RichText::new(tl!("Advanced Properties…")).font(medium(13.5)))
.on_hover_text(tl!("View and edit the document's custom properties."))
.clicked()
{
let _ = app.run("ui.dialog", json!({"name": "customProperties"}));
}
// Protect Document › Encrypt with Password (#55).
ui.add_space(18.0);
ui.label(egui::RichText::new(tl!("Protect Document")).font(semibold(15.0)));
@@ -1106,14 +1122,14 @@ mod tests {
assert!(h.query_by_label("Report").is_some(), "the built-in templates stay");
// The search covers both.
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), "memo".to_string()));
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("backstage_template_search"), "memo".to_string()));
h.run_steps(3);
assert!(h.query_by_label("Team memo").is_some(), "search finds a personal template");
assert!(h.query_by_label("Contract").is_none() && h.query_by_label("Report").is_none());
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), "zzz".to_string()));
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("backstage_template_search"), "zzz".to_string()));
h.run_steps(3);
assert!(h.query_by_label("No templates match your search.").is_some());
h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), String::new()));
h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("backstage_template_search"), String::new()));
h.run_steps(3);
h.get_by_label("Team memo").click();
+1519 -205
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -321,8 +321,9 @@ fn parse_ms(value: Option<&str>) -> f64 {
}
}
/// Stable id for the caret glide's temporary animation state.
fn state_id() -> egui::Id {
egui::Id::new("caret_glide")
egui::Id::unique("caret_glide")
}
/// What puts the caret where it is: the document's revision and the caret's position in it.
+23 -15
View File
@@ -26,7 +26,7 @@ pub(crate) fn dictionary_problem(lang: &str, unreadable: &Option<(std::path::Pat
/// The Quick Access Toolbar's button for command `id`.
pub(crate) fn qat_button_id(id: &str) -> egui::Id {
egui::Id::new(("qat_button", id))
egui::Id::unique(("qat_button", id))
}
fn qat_button(ui: &mut Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, enabled: bool) -> egui::Response {
@@ -101,12 +101,12 @@ fn quick_access(ui: &mut Ui, app: &mut WordApp) {
/// The Undo button's drop-down arrow (#701), which opens [`undo_list`].
pub(crate) fn undo_list_button_id() -> egui::Id {
egui::Id::new("qat_undo_list")
egui::Id::unique("qat_undo_list")
}
/// The status bar's message, the one that says what went wrong (#2402).
pub(crate) fn status_message_id() -> egui::Id {
egui::Id::new("status_message")
egui::Id::unique("status_message")
}
/// Row `i` of the Undo list (0 = the newest step).
@@ -203,7 +203,7 @@ fn undo_list(ui: &mut Ui, app: &mut WordApp) {
/// The Quick Access Toolbar's menu button (…), which opens [`qat_menu`].
pub(crate) fn qat_menu_button_id() -> egui::Id {
egui::Id::new("qat_menu_button")
egui::Id::unique("qat_menu_button")
}
/// The Quick Access Toolbar's menu: common commands to show or hide, and More Commands…
@@ -275,6 +275,8 @@ impl MacTitleBar {
}
}
/// Draws the title bar with window dragging, AutoSave, quick access, account, community and search
/// controls, and the centred clickable document title.
pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// macOS: shorter, so the system's traffic lights are centred in it; the tallest controls
@@ -289,7 +291,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
.show(ui, |ui| {
let full = ui.max_rect();
// Drag the window by the title bar (integrated title bar on macOS).
let drag = ui.interact(full, ui.id().with("title_drag"), Sense::click_and_drag());
let drag = ui.interact(full, ui.scope_id().with("title_drag"), Sense::click_and_drag());
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
@@ -347,7 +349,7 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
// Account / community.
let (r, resp) = ui.allocate_exact_size(vec2(control_h, control_h), Sense::click());
ui.painter().circle_filled(r.center(), control_h / 2.0 - 1.0, t.accent);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Account")));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Account")));
let initials: String = app.session.user_initials().chars().take(2).collect();
ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), t.on_accent);
if resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author)).clicked() {
@@ -405,8 +407,8 @@ pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
let w = text_width(ui.painter(), &shown, &font, track, t.text);
if available > 24.0 {
let rect = Rect::from_center_size(pos2(cx, full.center().y), vec2(w + 8.0, control_h));
let response = ui.interact(rect, ui.id().with("document_name"), Sense::click()).on_hover_text(tl!("Rename Document"));
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &title));
let response = ui.interact(rect, ui.scope_id().with("document_name"), Sense::click()).on_hover_text(tl!("Rename Document"));
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &title));
if response.clicked() {
let _ = app.run("file.rename", json!({}));
}
@@ -430,7 +432,7 @@ fn search_box(app: &mut WordApp, ui: &mut Ui, control_h: f32) {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(260.0, control_h), Sense::click());
let ph = tl!("Search commands and help");
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, ph));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, ph));
ui.painter().rect(r, 6.0, if resp.hovered() { t.input } else { t.ribbon }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(16.0, 16.0)), "search", t.text_dim, t.accent);
let pfont = TypeRung::Control.regular();
@@ -506,7 +508,7 @@ pub fn screen_bar(app: &mut WordApp, ui: &mut Ui, screen: Screen) {
let shade = if resp.hovered() || app.focus_ribbon { egui::Color32::from_gray(70) } else { egui::Color32::from_gray(40) };
ui.painter().rect_filled(r, 4.0, shade);
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), "more", egui::Color32::from_gray(220), t.accent);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Show the ribbon")));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Show the ribbon")));
if resp.on_hover_text(tl!("Show the ribbon")).clicked() {
let _ = app.run("ui.focusRibbon", json!({}));
}
@@ -522,7 +524,7 @@ pub fn screen_bar(app: &mut WordApp, ui: &mut Ui, screen: Screen) {
/// click on the page puts it away; Focus stays on.
pub fn focus_ribbon(app: &mut WordApp, ctx: &egui::Context) {
let width = ctx.content_rect().width();
egui::Area::new(egui::Id::new("focus_ribbon")).fixed_pos(pos2(0.0, SCREEN_BAR_HEIGHT)).order(egui::Order::Middle).show(ctx, |ui| {
egui::Area::new(egui::Id::unique("focus_ribbon")).fixed_pos(pos2(0.0, SCREEN_BAR_HEIGHT)).order(egui::Order::Middle).show(ctx, |ui| {
ui.set_width(width);
ui.set_max_width(width);
crate::ribbon::show(app, ui);
@@ -536,6 +538,8 @@ pub fn focus_ribbon(app: &mut WordApp, ctx: &egui::Context) {
}
}
/// Draws the status bar's page, word count, zoom and status controls, fitting a status message
/// with a tooltip; without a document the bar is empty.
pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::bottom("status")
@@ -568,7 +572,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
let w = text_width(ui.painter(), s, &font, track, t.text_dim);
let (r, resp) = ui.allocate_exact_size(vec2(w + 1.0, TypeRung::Caption.line_height()), Sense::click());
paint_text(ui.painter(), pos2(r.min.x, r.min.y), Align2::LEFT_TOP, s, &font, track, t.text_dim);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, s));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, s));
resp
};
if st(ui, &crate::i18n::fmt(tl!("Page {page} of {pages}"), &[("page", &page.to_string()), ("pages", &l.pages.len().to_string())]))
@@ -652,7 +656,11 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
let mut v = if reading { app.session.view.read_zoom } else { app.canvas.scale / crate::canvas::PX_PER_PT };
let slider = egui::Slider::new(&mut v, if reading { 0.75..=1.5 } else { 0.1..=5.0 }).logarithmic(!reading).show_value(false);
ui.spacing_mut().slider_width = 110.0;
if ui.add(slider).changed() {
let response = ui.add(slider);
response.widget_info(|| {
egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), if reading { tl!("Text Size") } else { tl!("Zoom") })
});
if response.changed() {
let command = if reading { "view.readTextSize" } else { "view.zoom" };
let _ = app.run(command, json!({"value": (v * 100.0).round()}));
}
@@ -678,7 +686,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
ui.painter().rect_filled(r, 3.0, t.hover);
}
icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent);
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, on, name));
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, name));
if resp.on_hover_text(name).clicked() {
let _ = app.run(id, json!({}));
}
@@ -705,7 +713,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
ftrack,
t.text_dim,
);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, focus));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, focus));
if resp.clicked() {
let _ = app.run("view.focus", json!({}));
}
+11 -6
View File
@@ -247,16 +247,19 @@ pub enum Pick {
More,
}
/// Stable storage id for the Recent Colors list.
fn recent_id() -> Id {
Id::new("wordcraft.recentColors")
Id::unique("wordcraft.recentColors")
}
/// Stable storage id for the pending More Colors request.
fn request_id() -> Id {
Id::new("wordcraft.moreColorsRequest")
Id::unique("wordcraft.moreColorsRequest")
}
/// Stable storage id for the Colors dialog's open flag.
fn open_flag_id() -> Id {
Id::new("wordcraft.colorsDialogOpen")
Id::unique("wordcraft.colorsDialogOpen")
}
/// Removes and returns a temporary value.
@@ -449,7 +452,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
ctx.data_mut(|m| m.insert_temp(open_flag_id(), false));
return;
};
let resp = egui::Modal::new(Id::new("wordcraft.colorsDialog")).show(ctx, |ui| body(ui, &mut d));
let resp = egui::Modal::new(Id::unique("wordcraft.colorsDialog")).show(ctx, |ui| body(ui, &mut d));
let mut done = resp.inner;
if done.is_none() && resp.should_close() {
done = Some(false);
@@ -794,12 +797,14 @@ mod tests {
assert_eq!((g.first().copied(), g.last().copied()), (Some(Rgb::WHITE), Some(Rgb::BLACK)));
}
/// Assert that colour targets build the expected `format.color` or JSON-path command payload, and
/// that a field target has no command.
#[test]
fn targets_put_the_colour_where_the_command_wants_it() {
assert_eq!(Target::cmd("format.color").command("1A2B3C"), Some(("format.color".into(), json!({"color": "1A2B3C"}))));
let glow = Target::at("shape.effects", json!({"glow": {"color": null, "size": 8.0}}), "/glow/color");
assert_eq!(glow.command("00FF00"), Some(("shape.effects".into(), json!({"glow": {"color": "00FF00", "size": 8.0}}))));
assert_eq!(Target::Field(Id::new("x")).command("00FF00"), None);
assert_eq!(Target::Field(Id::unique("x")).command("00FF00"), None);
}
#[test]
@@ -837,7 +842,7 @@ mod tests {
assert_eq!(app.canvas.last_font_color, "7A3EC8");
assert_eq!(app.ui.recent_colors.first().map(String::as_str), Some("7A3EC8"));
// A dialog field gets the colour back instead.
let id = Id::new("field");
let id = Id::unique("field");
d.target = Target::Field(id);
finish(&mut app, &ctx, &d);
let mut value = String::new();
+1 -1
View File
@@ -163,7 +163,7 @@ pub fn popup(app: &mut WordApp, ctx: &egui::Context) {
let t = Tokens::get(ctx);
let at_json = serde_json::to_value(&at).unwrap_or_default();
let mut close = false;
let area = egui::Area::new(egui::Id::new("content_control_popup"))
let area = egui::Area::new(egui::Id::unique("content_control_popup"))
.order(egui::Order::Foreground)
.fixed_pos(pos2(anchor.max.x - 180.0, anchor.max.y + 2.0));
let shown = area.show(ctx, |ui| {
+748 -14
View File
@@ -15,6 +15,8 @@
use std::sync::mpsc::Sender;
use craft_ui::capture::CaptureSlot;
use serde_json::{Value, json};
use crate::WordApp;
@@ -124,6 +126,9 @@ pub(crate) fn close_refused(app: &WordApp, id: &str, params: &Value) -> Option<&
/// The reply to a request that needs a document while none is open (#1129).
const NO_DOCUMENT: &str = "no document is open; create one (file.new) or open one (file.open) first";
/// Dispatches one JSON-lines control request: commands and inspection, parity, synthetic click,
/// drag, key, wheel and text input, screenshots and renders, resize, focus and quit, and unknown
/// ids as commands. It refuses closing unsaved work and document operations without a document.
pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
let p = &req.params;
let s = |k: &str| p.get(k).and_then(Value::as_str);
@@ -245,7 +250,7 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O
};
let delta = egui::vec2(f("dx").unwrap_or(0.0), f("dy").unwrap_or(0.0));
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x, y)));
app.synthetic.push(egui::Event::MouseWheel { unit, delta, phase, modifiers: mods(p) });
app.synthetic.push(egui::Event::MouseWheel { unit, delta, phase, modifiers: mods(p), source: egui::MouseWheelSource::Unknown });
ok(json!({"queued": true}))
}
"ui.text" => {
@@ -313,28 +318,755 @@ fn render_png(app: &WordApp, page: u64, scale: f32) -> Result<Value, String> {
Ok(json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()}))
}
/// The bounded admission policy for `ui.screenshot` captures. This is the screenshot queue;
/// the engine's 64-entry UI-intent queue is unrelated. The per-image and aggregate limits are
/// working-set limits, not just raw RGBA: encoding a PNG and base64-encoding it for a reply can
/// briefly need several times the raw pixel buffer.
pub(crate) const SCREENSHOT_MAX_REQUESTS: usize = 4;
pub(crate) const SCREENSHOT_MAX_IMAGE_BYTES: usize = 128 * 1024 * 1024;
pub(crate) const SCREENSHOT_MAX_AGGREGATE_BYTES: usize = 256 * 1024 * 1024;
pub(crate) const SCREENSHOT_MAX_RETRIES: u32 = 2;
/// An incompressible PNG of `raw` RGBA bytes. Deflate's stored-block worst case adds five bytes
/// per 64 KiB block; zlib/PNG framing and the encoder's small internal buffers get 64 KiB more.
/// Checked, because every byte count here comes from a hostile window size.
fn png_upper_bound(raw: usize) -> Option<usize> {
let blocks = raw.div_ceil(65_535);
raw.checked_add(blocks.checked_mul(5)?)?.checked_add(64 * 1024)
}
/// The length of the base64 text for `bytes`, rounded up, as `base64_encode` produces it.
fn base64_upper_bound(bytes: usize) -> Option<usize> {
bytes.div_ceil(3).checked_mul(4)
}
/// Peak memory of one screenshot, in bytes: the completed `ColorImage`, the one straight-alpha
/// RGBA copy handed to the PNG encoder, the PNG buffer, and (for a reply without a path) the
/// base64 string. All of these can be live at once, so admission reserves their sum rather than
/// only the raw pixels or a PNG-only estimate. Checked throughout: a hostile surface size must
/// result in an error, never a wrapped budget.
fn working_set(raw: usize, inline_reply: bool) -> Option<usize> {
let png = png_upper_bound(raw)?;
let mut peak = raw.checked_add(raw)?.checked_add(png)?;
if inline_reply {
peak = peak.checked_add(base64_upper_bound(png)?)?;
}
Some(peak)
}
/// The surface reported by one `RawInput`, before it is tied to a document. Captured in the host
/// input hook because `InputState::viewport_rect` and the last painted screen rect are stale on a
/// hidden logic tick; RawInput is the host's answer for this frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct SurfaceGeometry {
viewport: egui::Vec2,
/// Native pixels per point times egui's zoom, so a zoom change is its own epoch.
pixels_per_point: f32,
max_texture_side: usize,
minimized: bool,
occluded: bool,
}
impl SurfaceGeometry {
/// Derives this frame's capturable surface from raw viewport data, rejecting a missing, non-finite
/// or zero-sized visible surface and recording minimized or occluded state.
pub(crate) fn from_raw(raw: &egui::RawInput, ctx: &egui::Context) -> Option<Self> {
let viewport = raw.viewports.get(&raw.viewport_id)?;
let minimized = viewport.minimized == Some(true);
let occluded = viewport.occluded == Some(true);
// A minimized/occluded surface may report no rectangle at all. Its stale size is harmless
// because such a surface is never capturable; a visible surface without a rectangle is not
// fresh enough to use.
let rect = match viewport.inner_rect.or(raw.screen_rect) {
Some(rect) => rect,
None if minimized || occluded => ctx.content_rect(),
None => return None,
};
let zoom = ctx.zoom_factor();
let pixels_per_point = viewport.native_pixels_per_point.map_or_else(|| ctx.pixels_per_point(), |native| native * zoom);
let max_texture_side = raw.max_texture_side.filter(|side| *side > 0).unwrap_or_else(|| ctx.input(|i| i.max_texture_side));
if !rect.width().is_finite() || !rect.height().is_finite() || rect.width() <= 0.0 || rect.height() <= 0.0 {
return None;
}
if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 || !zoom.is_finite() || zoom <= 0.0 {
return None;
}
Some(Self { viewport: rect.size(), pixels_per_point, max_texture_side, minimized, occluded })
}
/// Pairs the surface geometry with the document id a capture belongs to.
pub(crate) fn with_document(self, document: u64) -> CaptureGeometry {
CaptureGeometry { surface: self, document }
}
}
/// The viewport geometry and document identity a capture is admitted, issued and completed
/// against. `WordApp` keeps a monotonic epoch: any change, including a change back, makes an
/// older image stale.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct CaptureGeometry {
surface: SurfaceGeometry,
document: u64,
}
impl CaptureGeometry {
/// Returns the document id the capture was admitted for.
pub(crate) fn document(self) -> u64 {
self.document
}
/// Explains why the surface cannot be captured, when it is minimized or fully occluded, and
/// returns `None` when it can.
pub(crate) fn unavailable_reason(self) -> Option<&'static str> {
if self.surface.minimized {
Some("the window is minimized")
} else if self.surface.occluded {
Some("the window is fully occluded")
} else {
None
}
}
/// The screenshot's physical size, rounded to the nearest pixel.
///
/// The renderer's maximum texture side is deliberately not applied here. It is a texture
/// limit, not the size of the surface the callback reports; capping one axis would distort
/// the expected aspect and reject the real image. [`Self::oversize_reason`] rejects a window
/// that cannot have a surface texture at all, before admission.
pub(crate) fn expected_size(self) -> Option<[usize; 2]> {
if self.unavailable_reason().is_some() {
return None;
}
let axis = |points: f32| -> Option<usize> {
let px = (f64::from(points) * f64::from(self.surface.pixels_per_point)).round();
if !px.is_finite() || px <= 0.0 || px > usize::MAX as f64 {
return None;
}
Some(px as usize)
};
Some([axis(self.surface.viewport.x)?, axis(self.surface.viewport.y)?])
}
/// Why this surface is too large to capture at all: a window larger than the renderer's
/// maximum texture side has no surface texture to copy. Reject it explicitly rather than
/// silently capping the expected size and failing exact comparisons later.
pub(crate) fn oversize_reason(self) -> Option<String> {
let [width, height] = self.expected_size()?;
let max = self.surface.max_texture_side;
(max > 0 && (width > max || height > max))
.then(|| format!("screenshot failed: the window is {width}×{height} pixels, over the renderer's {max}-pixel texture limit"))
}
/// Checked `width * height * 4` bytes for the expected image.
pub(crate) fn expected_bytes(self) -> Option<usize> {
let [width, height] = self.expected_size()?;
width.checked_mul(height)?.checked_mul(4)
}
/// The admission reservation for the complete capture working set (image plus encoding).
/// `inline_reply` includes the base64 string a reply without a path produces.
pub(crate) fn reserved_bytes(self, inline_reply: bool) -> Option<usize> {
working_set(self.expected_bytes()?, inline_reply)
}
}
/// One admitted capture. The request owns its token, path, deadline, immutable document identity,
/// geometry and reservation; the shared slot owns the image and its weak callback.
pub(crate) struct Shot {
pub(crate) token: u64,
pub(crate) path: Option<String>,
pub(crate) reply: Sender<Value>,
/// One monotonic deadline for the whole request, including retries.
pub(crate) deadline: f64,
pub(crate) ready_at: f64,
pub(crate) frames: u32,
pub(crate) issued: bool,
pub(crate) retries: u32,
/// The document the request was admitted for. It never changes; a document switch cancels.
pub(crate) document: u64,
pub(crate) geometry: CaptureGeometry,
pub(crate) epoch: u64,
pub(crate) expected: [usize; 2],
/// Admission reservation, including the encoding working set.
pub(crate) bytes: usize,
pub(crate) slot: Option<CaptureSlot>,
pub(crate) callback: Option<egui::ScreenshotCallback>,
}
/// All outstanding `ui.screenshot` requests.
#[derive(Default)]
pub(crate) struct Shots {
pub(crate) token: u64,
pub(crate) requests: Vec<Shot>,
}
impl Shots {
/// Whether any screenshot request is outstanding.
pub(crate) fn in_flight(&self) -> bool {
!self.requests.is_empty()
}
/// Sums, saturating, the admission bytes reserved by outstanding screenshot requests.
fn reserved(&self) -> usize {
self.requests.iter().fold(0usize, |sum, shot| sum.saturating_add(shot.bytes))
}
}
/// Builds a screenshot error reply carrying the request token.
fn error_reply(token: u64, message: impl std::fmt::Display) -> Value {
json!({"ok": false, "error": message.to_string(), "request": token})
}
/// Sends one screenshot error reply, ignoring a disconnected receiver.
fn reject_shot(reply: Sender<Value>, token: u64, message: impl std::fmt::Display) {
let _ = reply.send(error_reply(token, message));
}
/// Advances the screenshot token, wrapping and skipping zero so every request has a non-zero
/// token.
fn next_token(app: &mut WordApp) -> u64 {
let mut token = app.shots.token.wrapping_add(1);
if token == 0 {
token = 1;
}
app.shots.token = token;
token
}
/// Admit one screenshot request against the count and byte budgets **before** a capture is
/// issued. A rejected request gets one error reply, with its request token, and never evicts an
/// earlier request.
pub(crate) fn admit_screenshot(app: &mut WordApp, ctx: &egui::Context, path: Option<String>, reply: Sender<Value>) {
let token = next_token(app);
let Some(geometry) = app.capture_geometry.map(|g| CaptureGeometry { document: app.session.document_id(), ..g }) else {
reject_shot(reply, token, "screenshot failed: the window geometry is not known yet");
return;
};
if let Some(reason) = geometry.unavailable_reason() {
reject_shot(reply, token, format!("screenshot failed: {reason}"));
return;
}
let Some(expected) = geometry.expected_size() else {
reject_shot(reply, token, "screenshot failed: the window has no capturable size");
return;
};
if let Some(reason) = geometry.oversize_reason() {
reject_shot(reply, token, reason);
return;
}
let inline_reply = path.is_none();
let Some(bytes) = geometry.reserved_bytes(inline_reply) else {
reject_shot(reply, token, "screenshot failed: the window size overflows the screenshot budget");
return;
};
if bytes > SCREENSHOT_MAX_IMAGE_BYTES {
reject_shot(
reply,
token,
format!(
"screenshot budget exceeded: {bytes} bytes reserved for one {}×{} image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit",
expected[0], expected[1]
),
);
return;
}
if app.shots.requests.len() >= SCREENSHOT_MAX_REQUESTS {
reject_shot(
reply,
token,
format!("screenshot budget exceeded: {} requests are already outstanding (limit {SCREENSHOT_MAX_REQUESTS})", app.shots.requests.len()),
);
return;
}
let reserved = app.shots.reserved().saturating_add(bytes);
if reserved > SCREENSHOT_MAX_AGGREGATE_BYTES {
reject_shot(
reply,
token,
format!("screenshot budget exceeded: {reserved} bytes reserved, over the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit"),
);
return;
}
let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), SCREENSHOT_MAX_IMAGE_BYTES);
let now = crate::now_ms();
app.shots.requests.push(Shot {
token,
path,
reply,
deadline: now + 8000.0,
ready_at: now + 120.0,
frames: 0,
issued: false,
retries: 0,
document: geometry.document(),
geometry,
epoch: app.capture_epoch,
expected,
bytes,
slot: Some(slot),
callback: Some(callback),
});
// The capture command is issued from `ui`; while hidden, logic keeps asking for a painted
// frame until it gets there.
ctx.request_repaint();
}
/// Issued only from `ui`, after the layout has settled. Counts one frame per host frame on the
/// first pass, revalidates geometry and the byte reservation, and sends one callback command.
/// `current_pass_index() == 0` is meaningful here because this function is already in a UI pass.
pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) {
let now = crate::now_ms();
let first_pass = ctx.current_pass_index() == 0;
let mut failures: Vec<(usize, String)> = Vec::new();
for i in 0..app.shots.requests.len() {
let (issued, ready_at, frames) = {
let shot = &app.shots.requests[i];
(shot.issued, shot.ready_at, shot.frames)
};
if issued {
continue;
}
let frames = if first_pass { frames.saturating_add(1) } else { frames };
app.shots.requests[i].frames = frames;
if now < ready_at || frames < 3 {
continue;
}
let Some(current) = app.capture_geometry.map(|g| CaptureGeometry { document: app.session.document_id(), ..g }) else {
failures.push((i, "screenshot failed: the window geometry is not known yet".into()));
continue;
};
if let Some(reason) = current.unavailable_reason() {
failures.push((i, format!("screenshot failed: {reason} while the capture was queued")));
continue;
}
if current.document() != app.shots.requests[i].document {
failures.push((i, "screenshot failed: the document changed while the capture was queued".into()));
continue;
}
let Some(expected) = current.expected_size() else {
failures.push((i, "screenshot failed: the window size overflows the screenshot budget".into()));
continue;
};
if let Some(reason) = current.oversize_reason() {
failures.push((i, reason));
continue;
}
let inline_reply = app.shots.requests[i].path.is_none();
let Some(bytes) = current.reserved_bytes(inline_reply) else {
failures.push((i, "screenshot failed: the window size overflows the screenshot budget".into()));
continue;
};
if bytes > SCREENSHOT_MAX_IMAGE_BYTES {
failures.push((
i,
format!(
"screenshot budget exceeded: {bytes} bytes reserved for one image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit"
),
));
continue;
}
let others = app.shots.requests.iter().enumerate().filter(|(j, _)| *j != i).fold(0usize, |sum, (_, shot)| sum.saturating_add(shot.bytes));
if others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES {
failures.push((i, format!("screenshot budget exceeded: outstanding requests would reserve over {SCREENSHOT_MAX_AGGREGATE_BYTES} bytes")));
continue;
}
let epoch = app.capture_epoch;
let shot = &mut app.shots.requests[i];
shot.geometry = current;
shot.expected = expected;
shot.bytes = bytes;
shot.epoch = epoch;
if let Some(callback) = shot.callback.take() {
ctx.send_viewport_cmd_to(shot.viewport(), egui::ViewportCommand::Screenshot(callback));
shot.issued = true;
}
}
for (i, message) in failures.into_iter().rev() {
let shot = app.shots.requests.remove(i);
if let Some(slot) = shot.slot {
slot.cancel();
}
reject_shot(shot.reply, shot.token, message);
}
if app.shots.in_flight() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
impl Shot {
/// The screenshot callback's viewport: the root window.
fn viewport(&self) -> egui::ViewportId {
egui::ViewportId::ROOT
}
}
/// A completed image is either stale because the surface changed (safe to retry once within the
/// deadline) or invalid for this request (report it at once; retrying cannot help).
enum ImageCheck {
Stale(String),
Invalid(String),
}
/// The callback's physical size is the host surface texture's extent, rounded independently of
/// the expected `round(logical points × pixels_per_point)`. One physical pixel on each axis is
/// therefore the same surface seen through a different rounding boundary, not a resize.
fn dimensions_are_a_rounded_variant(expected: [usize; 2], actual: [usize; 2]) -> bool {
// Both axes are derived from the same logical viewport and the same pixels-per-point; each
// may round at a boundary one pixel away, but a larger change on either axis is a resize.
expected[0].abs_diff(actual[0]) <= 1 && expected[1].abs_diff(actual[1]) <= 1
}
/// Rejects an empty, overflowing, underfilled or oversized image. A size one pixel away from the
/// expected surface is accepted as a rounding variant; a larger difference is stale.
fn validate_image(image: &egui::ColorImage, expected: [usize; 2]) -> Result<(), ImageCheck> {
let [width, height] = image.size;
if width == 0 || height == 0 {
return Err(ImageCheck::Invalid("the screenshot has no pixels".to_string()));
}
// A screenshot is issued for one exact logical viewport. Exact equality would reject a valid
// fractional-DPI capture whose callback rounds by one pixel; a larger difference, or a
// different aspect, is a different surface.
if !dimensions_are_a_rounded_variant(expected, [width, height]) {
return Err(ImageCheck::Stale(format!(
"screenshot is {width}×{height} pixels, expected {}×{}: the window changed size while it was captured",
expected[0], expected[1]
)));
}
let pixels = width.checked_mul(height).ok_or_else(|| ImageCheck::Invalid("screenshot dimensions overflow".to_string()))?;
if image.pixels.len() != pixels {
return Err(ImageCheck::Invalid(format!("screenshot has {} pixels for its {width}×{height} dimensions", image.pixels.len())));
}
// The largest image allowed by the one-pixel rounding allowance. The reservation was made for
// the expected size; a sibling one pixel wider or taller is still the same surface.
let largest = expected[0]
.checked_add(1)
.and_then(|w| expected[1].checked_add(1).and_then(|h| w.checked_mul(h)))
.ok_or_else(|| ImageCheck::Invalid("screenshot dimensions overflow".to_string()))?;
if pixels > largest {
return Err(ImageCheck::Invalid(format!("screenshot has {pixels} pixels, over the {largest} allowed by its reservation")));
}
Ok(())
}
/// What one pass over an outstanding screenshot request decides.
enum ShotAction {
Keep,
Remove(Value),
Restart,
}
/// Classify one pending request: deadline first, then admission state, then the completed slot.
fn take_screenshot_action(app: &mut WordApp, index: usize, now: f64, current: Option<CaptureGeometry>, epoch: u64) -> ShotAction {
let completed = {
let shot = &mut app.shots.requests[index];
if now >= shot.deadline {
if let Some(slot) = shot.slot.take() {
slot.cancel();
}
return ShotAction::Remove(error_reply(shot.token, "no frame was presented before the screenshot timeout"));
}
if !shot.issued {
return ShotAction::Keep;
}
if let Some(reason) = current.and_then(CaptureGeometry::unavailable_reason) {
if let Some(slot) = shot.slot.take() {
slot.cancel();
}
return ShotAction::Remove(error_reply(shot.token, format!("screenshot failed: {reason} while the capture was pending")));
}
if current.is_some_and(|geometry| geometry.document() != shot.document) {
if let Some(slot) = shot.slot.take() {
slot.cancel();
}
return ShotAction::Remove(error_reply(shot.token, "screenshot failed: the document changed while the capture was queued"));
}
shot.slot.as_ref().and_then(|slot| slot.take())
};
match completed {
None => ShotAction::Keep,
Some(Err(error)) => ShotAction::Remove(error_reply(app.shots.requests[index].token, error)),
Some(Ok(image)) => completed_screenshot_action(app, index, image, current, epoch),
}
}
/// Judge one taken image: its geometry and epoch identity first, then the image's own validity.
fn completed_screenshot_action(
app: &mut WordApp,
index: usize,
image: std::sync::Arc<egui::ColorImage>,
current: Option<CaptureGeometry>,
epoch: u64,
) -> ShotAction {
let shot = &app.shots.requests[index];
let stale = current != Some(shot.geometry) || epoch != shot.epoch;
if stale {
if shot.retries < SCREENSHOT_MAX_RETRIES {
return ShotAction::Restart;
}
return ShotAction::Remove(error_reply(shot.token, "screenshot geometry changed while it was captured; no image was written"));
}
match validate_image(&image, shot.expected) {
Err(ImageCheck::Stale(_)) if shot.retries < SCREENSHOT_MAX_RETRIES => ShotAction::Restart,
Err(ImageCheck::Stale(message) | ImageCheck::Invalid(message)) => ShotAction::Remove(error_reply(shot.token, message)),
Ok(()) => ShotAction::Remove(save_screenshot(image.as_ref(), shot.path.as_deref(), shot.token)),
}
}
/// Drop one request at `index` with its reply, cancelling any slot still held.
fn remove_shot(app: &mut WordApp, index: usize, value: Value) {
let shot = app.shots.requests.remove(index);
if let Some(slot) = shot.slot {
slot.cancel();
}
let _ = shot.reply.send(value);
}
/// Drop one request at `index` and send an error reply for it.
fn reject_removed_shot(app: &mut WordApp, index: usize, message: impl std::fmt::Display) {
let shot = app.shots.requests.remove(index);
if let Some(slot) = shot.slot {
slot.cancel();
}
reject_shot(shot.reply, shot.token, message);
}
/// Reissue the request at `index` against the current geometry. Returns whether it was reissued:
/// a request removed for a refusal keeps `index` for the request that slides into its place.
fn restart_shot(app: &mut WordApp, ctx: &egui::Context, index: usize, now: f64, current: Option<CaptureGeometry>, epoch: u64) -> bool {
let Some(current) = current else {
reject_removed_shot(app, index, "screenshot failed: the window geometry is not known");
return false;
};
let Some(expected) = current.expected_size() else {
reject_removed_shot(app, index, "screenshot failed: the window size overflows the screenshot budget");
return false;
};
if let Some(reason) = current.oversize_reason() {
reject_removed_shot(app, index, reason);
return false;
}
let inline_reply = app.shots.requests[index].path.is_none();
let Some(bytes) = current.reserved_bytes(inline_reply) else {
reject_removed_shot(app, index, "screenshot failed: the window size overflows the screenshot budget");
return false;
};
let others = app.shots.requests.iter().enumerate().filter(|(j, _)| *j != index).fold(0usize, |sum, (_, shot)| sum.saturating_add(shot.bytes));
if bytes > SCREENSHOT_MAX_IMAGE_BYTES || others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES {
reject_removed_shot(app, index, "screenshot budget exceeded while retrying after a resize");
return false;
}
let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), SCREENSHOT_MAX_IMAGE_BYTES);
let shot = &mut app.shots.requests[index];
shot.slot = Some(slot);
shot.callback = Some(callback);
shot.issued = false;
shot.ready_at = now + 120.0;
shot.frames = 0;
shot.retries = shot.retries.saturating_add(1);
shot.geometry = current;
shot.expected = expected;
shot.bytes = bytes;
shot.epoch = epoch;
true
}
/// Called from `logic`: take each completed slot once, check the deadline **before** writing,
/// validate request/document/geometry identity, and only then encode and reply. A late, stale or
/// oversized image is dropped without a file; a document switch cancels instead of rebinding.
pub(crate) fn drain_screenshots(app: &mut WordApp, ctx: &egui::Context) {
let now = crate::now_ms();
let current = app.capture_geometry.map(|g| CaptureGeometry { document: app.session.document_id(), ..g });
let epoch = app.capture_epoch;
let mut i = 0;
while i < app.shots.requests.len() {
match take_screenshot_action(app, i, now, current, epoch) {
ShotAction::Keep => i += 1,
ShotAction::Remove(value) => remove_shot(app, i, value),
ShotAction::Restart => {
if restart_shot(app, ctx, i, now, current, epoch) {
i += 1;
}
}
}
}
if app.shots.in_flight() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
/// The screenshot as a straight-alpha PNG, using one RGBA copy rather than the extra full-image
/// copy [`wordcraft_render::Rendered::to_png`] makes for its premultiplied input. The callback's
/// `ColorImage` is premultiplied; PNG stores straight alpha.
fn screenshot_png(image: &egui::ColorImage) -> Option<Vec<u8>> {
let [width, height] = image.size;
if width == 0 || height == 0 {
return None;
}
let pixels = width.checked_mul(height)?;
if image.pixels.len() != pixels {
return None;
}
let mut rgba = Vec::with_capacity(pixels.checked_mul(4)?);
for color in &image.pixels {
let [r, g, b, a] = color.to_array();
if a == 0 || a == 255 {
rgba.extend_from_slice(&[r, g, b, a]);
} else {
let straight = |c: u8| ((u32::from(c) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8;
rgba.extend_from_slice(&[straight(r), straight(g), straight(b), a]);
}
}
let img = image::RgbaImage::from_raw(u32::try_from(width).ok()?, u32::try_from(height).ok()?, rgba)?;
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
Some(png)
}
/// The window screenshot: written to `path` when one was given, else base64 PNG in the reply
/// (never a fixed name in the shared temporary folder).
pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value {
/// (never a fixed name in the shared temporary folder). Every reply carries the request token so
/// a client can correlate it with the admission, timeout and error replies.
pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, token: u64) -> Value {
let [w, h] = image.size;
let bytes: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
let rendered = wordcraft_render::Rendered { width: w as u32, height: h as u32, pixels: bytes };
let png = rendered.to_png();
let Some(raw) = w.checked_mul(h).and_then(|pixels| pixels.checked_mul(4)) else {
return error_reply(token, "screenshot dimensions overflow the byte count");
};
let inline_reply = path.is_none();
let Some(peak) = working_set(raw, inline_reply) else {
return error_reply(token, "screenshot working set overflows the byte count");
};
if peak > SCREENSHOT_MAX_IMAGE_BYTES {
return error_reply(
token,
format!("screenshot needs {peak} bytes of working set, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit"),
);
}
let Some(png) = screenshot_png(image) else {
return error_reply(token, "screenshot PNG encoding failed");
};
if png.len() > SCREENSHOT_MAX_IMAGE_BYTES {
return error_reply(token, format!("screenshot PNG uses {} bytes, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", png.len()));
}
let Some(path) = path else {
return json!({"ok": true, "result": {"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": w, "height": h}});
let encoded = wordcraft_engine::cmd::insert::base64_encode(&png);
if encoded.len() > SCREENSHOT_MAX_IMAGE_BYTES {
return error_reply(
token,
format!("screenshot base64 reply uses {} bytes, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", encoded.len()),
);
}
return json!({"ok": true, "request": token, "result": {"png": encoded, "width": w, "height": h}});
};
#[cfg(target_arch = "wasm32")]
let _ = (&png, path);
#[cfg(not(target_arch = "wasm32"))]
{
if !path.to_ascii_lowercase().ends_with(".png") {
return json!({"ok": false, "error": format!("{path}: a screenshot path must end in .png")});
return error_reply(token, format!("{path}: a screenshot path must end in .png"));
}
if let Err(e) = wordcraft_engine::io::write_file(path, &png) {
return json!({"ok": false, "error": e});
return error_reply(token, e);
}
}
json!({"ok": true, "result": {"path": path, "width": w, "height": h}})
json!({"ok": true, "request": token, "result": {"path": path, "width": w, "height": h}})
}
#[cfg(test)]
mod tests {
use super::*;
/// Builds a visible root-surface fixture with the given viewport, scale and texture limit for
/// screenshot tests.
fn surface(viewport: egui::Vec2, pixels_per_point: f32, max_texture_side: usize) -> SurfaceGeometry {
let ctx = egui::Context::default();
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, viewport);
let mut raw = egui::RawInput { screen_rect: Some(rect), max_texture_side: Some(max_texture_side), ..Default::default() };
raw.viewports.insert(
egui::ViewportId::ROOT,
egui::ViewportInfo {
inner_rect: Some(rect),
native_pixels_per_point: Some(pixels_per_point),
minimized: Some(false),
occluded: Some(false),
..Default::default()
},
);
SurfaceGeometry::from_raw(&raw, &ctx).expect("a visible surface")
}
/// Builds a capture geometry fixture for document id 7.
fn geometry(viewport: egui::Vec2, pixels_per_point: f32, max_texture_side: usize) -> CaptureGeometry {
CaptureGeometry { surface: surface(viewport, pixels_per_point, max_texture_side), document: 7 }
}
/// Creates a black test image of the given size.
fn image(width: usize, height: usize) -> egui::ColorImage {
egui::ColorImage::filled([width, height], egui::Color32::BLACK)
}
/// The reservation covers every live copy: the image, the RGBA copy the encoder consumes, the
/// PNG buffer and the base64 reply string. A path reply needs no base64.
#[test]
fn screenshot_working_set_counts_image_copy_png_and_base64() {
let raw = 1_000_000usize;
let png = png_upper_bound(raw).expect("the PNG bound");
let base64 = base64_upper_bound(png).expect("the base64 bound");
assert_eq!(working_set(raw, false), Some(raw + raw + png));
assert_eq!(working_set(raw, true), Some(raw + raw + png + base64));
assert!(working_set(raw, true).expect("bounded") > raw * 4, "raw pixels alone are not the peak");
assert_eq!(base64_upper_bound(0), Some(0));
assert_eq!(base64_upper_bound(1), Some(4));
assert_eq!(base64_upper_bound(4), Some(8), "rounded up, as the encoder pads");
assert_eq!(base64_upper_bound(usize::MAX), None, "overflow is refused, not wrapped");
assert_eq!(working_set(usize::MAX, true), None);
}
/// The 128 MiB per-image budget is a working-set budget, not a raw-pixel budget: a
/// 3000×3000 inline reply no longer fits even though its raw pixels are only 36 MB, while a
/// path reply for the same image (no base64) does.
#[test]
fn the_per_image_budget_reflects_the_encoding_peak() {
let fits = geometry(egui::vec2(2000.0, 2000.0), 1.0, 16_384);
assert!(fits.reserved_bytes(true).expect("bounded") <= SCREENSHOT_MAX_IMAGE_BYTES);
let too_big_inline = geometry(egui::vec2(3000.0, 3000.0), 1.0, 16_384);
assert!(too_big_inline.reserved_bytes(true).expect("bounded") > SCREENSHOT_MAX_IMAGE_BYTES);
assert!(too_big_inline.reserved_bytes(false).expect("bounded") <= SCREENSHOT_MAX_IMAGE_BYTES);
}
/// A texture limit must not cap the expected physical size: capping one axis changes the
/// expected aspect and makes the real callback look stale. Out-of-limit surfaces are rejected
/// explicitly instead.
#[test]
fn expected_size_is_not_capped_by_the_texture_limit() {
let wide = geometry(egui::vec2(10_000.0, 2_000.0), 1.0, 8_192);
assert_eq!(wide.expected_size(), Some([10_000, 2_000]));
let reason = wide.oversize_reason().expect("over the texture limit");
assert!(reason.contains("texture limit"), "{reason}");
let fits = geometry(egui::vec2(8_000.0, 2_000.0), 1.0, 8_192);
assert_eq!(fits.expected_size(), Some([8_000, 2_000]));
assert_eq!(fits.oversize_reason(), None);
}
/// Fractional DPI rounds the host surface and the expected size at different boundaries: a
/// one-pixel difference on each axis is the same capture, a two-pixel difference is stale.
#[test]
fn a_callback_one_pixel_off_the_rounded_size_is_accepted() {
let g = geometry(egui::vec2(100.4, 100.4), 1.5, 16_384);
assert_eq!(g.expected_size(), Some([151, 151]));
assert!(validate_image(&image(152, 150), [151, 151]).is_ok());
assert!(matches!(validate_image(&image(153, 149), [151, 151]), Err(ImageCheck::Stale(_))));
assert!(matches!(validate_image(&image(800, 600), [151, 151]), Err(ImageCheck::Stale(_))));
}
/// A real resize is stale even when one axis happens to be close: both axes must match within
/// the one-pixel rounding allowance.
#[test]
fn a_two_pixel_change_is_a_resize_not_rounding() {
assert!(dimensions_are_a_rounded_variant([1000, 1000], [1001, 999]));
assert!(!dimensions_are_a_rounded_variant([1000, 1000], [1200, 800]));
assert!(!dimensions_are_a_rounded_variant([1000, 1000], [1002, 999]));
assert!(!dimensions_are_a_rounded_variant([1000, 1000], [1000, 998]));
}
}
#[cfg(test)]
@@ -349,6 +1081,8 @@ mod screenshot_tests {
}
}
/// A screenshot path must end in `.png` and is refused otherwise; a PNG is written, while a
/// symlinked PNG target is not followed or overwritten.
#[test]
fn screenshot_path_must_be_png_and_does_not_follow_symlinks() {
let dir = std::env::temp_dir().join(format!("wc-shot-{}", std::process::id()));
@@ -357,12 +1091,12 @@ mod screenshot_tests {
let img = egui::ColorImage::filled([2, 2], egui::Color32::RED);
let txt = dir.join("shot.txt");
let r = save_screenshot(&img, txt.to_str());
let r = save_screenshot(&img, txt.to_str(), 101);
assert_eq!(r["ok"], false);
assert!(!txt.exists());
let png = dir.join("shot.png");
let r = save_screenshot(&img, png.to_str());
let r = save_screenshot(&img, png.to_str(), 102);
assert_eq!(r["ok"], true);
assert!(std::fs::read(&png).unwrap().starts_with(b"\x89PNG"));
@@ -372,7 +1106,7 @@ mod screenshot_tests {
std::fs::write(&victim, "keep").unwrap();
let link = dir.join("link.png");
std::os::unix::fs::symlink(&victim, &link).unwrap();
let _ = save_screenshot(&img, link.to_str());
let _ = save_screenshot(&img, link.to_str(), 103);
assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep");
}
@@ -380,7 +1114,7 @@ mod screenshot_tests {
std::fs::create_dir_all(&other).unwrap();
let roots = wordcraft_engine::file_access::AutomationRoots::new(None, Some(&other)).unwrap();
let outside = dir.join("outside.png");
let r = wordcraft_engine::file_access::confine(roots.as_ref(), || save_screenshot(&img, outside.to_str()));
let r = wordcraft_engine::file_access::confine(roots.as_ref(), || save_screenshot(&img, outside.to_str(), 104));
assert_eq!(r["ok"], false);
assert!(!outside.exists());
}
+1 -1
View File
@@ -212,7 +212,7 @@ impl Default for View {
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
pub fn contributors_ui(ui: &mut egui::Ui) {
let id = egui::Id::new("credits_view");
let id = egui::Id::unique("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.label(tl!("Show"));
+1 -1
View File
@@ -285,7 +285,7 @@ pub fn cursor(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at:
/// it can't be decoded (then the cropped-away area is just dimmed).
fn source(app: &WordApp, ctx: &egui::Context, media: &str) -> Option<egui::TextureHandle> {
let bytes = app.session.doc.media.get(media)?;
let id = egui::Id::new(("crop_source", media, bytes.len()));
let id = egui::Id::unique(("crop_source", media, bytes.len()));
if let Some(t) = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(id)) {
return t;
}
File diff suppressed because it is too large. Load diff
+32 -1
View File
@@ -288,7 +288,7 @@ pub fn data_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ChartDataForm) -> bool {
}
if let Some(row) = f.cells.get_mut(i) {
for cell in row.iter_mut() {
ui.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(cell).horizontal_align(egui::Align::Max));
ui.add_sized([CELL_W, ROW_H - 4.0], value_cell(cell));
ui.allocate_exact_size(vec2(X_W, ROW_H - 4.0), Sense::hover());
}
}
@@ -337,6 +337,13 @@ pub fn data_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ChartDataForm) -> bool {
}
}
/// A numeric chart-data cell. `RIGHT_TOP` preserves the old deprecated
/// `horizontal_align(Align::Max)` behaviour: numbers are right-aligned but stay on the cell's
/// first line, rather than becoming vertically centred.
fn value_cell(text: &mut String) -> egui::TextEdit<'_> {
egui::TextEdit::singleline(text).align(egui::Align2::RIGHT_TOP)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -358,6 +365,30 @@ mod tests {
assert!(f.params().is_err());
}
/// The chart-data grid kept Word's top line when the deprecated `horizontal_align(Align::Max)`
/// became `align(Align2)`: `RIGHT_CENTER` silently changed vertical alignment too.
#[test]
fn chart_value_cells_are_right_and_top_aligned() {
/// Finds the first text shape's position, descending into a vector of shapes.
fn text_pos(shape: &egui::Shape) -> Option<egui::Pos2> {
match shape {
egui::Shape::Text(text) => Some(text.pos),
egui::Shape::Vec(shapes) => shapes.iter().find_map(text_pos),
_ => None,
}
}
let ctx = egui::Context::default();
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(120.0, 120.0))), ..Default::default() };
let mut text = "7".to_owned();
let output = ctx.run_ui(raw, |ui| {
ui.add_sized([100.0, 100.0], value_cell(&mut text));
});
let pos = output.shapes.iter().find_map(|clipped| text_pos(&clipped.shape)).expect("the cell's text is painted");
output.drop_without_applying_deltas();
assert!(pos.y < 20.0, "RIGHT_TOP leaves the number on the first line, not centred (pos={pos:?})");
assert!(pos.x > 40.0, "the number is right-aligned (pos={pos:?})");
}
/// Default names are saved into the document, so an RTL interface must not add isolate marks.
#[test]
fn default_names_in_an_rtl_interface_have_no_isolates() {
+73 -3
View File
@@ -23,6 +23,9 @@ pub struct KeyboardForm {
pub category: String,
/// The selected command id.
pub command: String,
/// Filter-as-you-type query for the command list (#1845); UI-only dialog state, ignored
/// while empty. Never persisted and never a command parameter.
pub filter: String,
/// The key picked in "Keys for this command" (for Remove).
pub current: String,
/// The key combination pressed in "New shortcut", canonical (`Mod+Shift+K`).
@@ -72,6 +75,32 @@ pub(crate) fn commands(app: &WordApp, category: &str) -> Vec<(&'static str, Stri
out
}
/// The commands to list for `category`, narrowed by `query` (#1845): an empty query keeps the
/// category scope; any other text searches every registered command by its id and its English or
/// translated label, case-insensitive (the id lets "file.save" or "save" find Save). Sorted as
/// [`commands`].
pub(crate) fn matching_commands(app: &WordApp, category: &str, query: &str) -> Vec<(String, String)> {
let q = crate::i18n::lowercase(query.trim());
if q.is_empty() {
return commands(app, category).into_iter().map(|(id, l)| (id.to_string(), l)).collect();
}
let mut out: Vec<(String, String)> = app
.session
.registry
.all()
.iter()
.filter(|s| s.id.to_lowercase().contains(&q) || s.label.to_lowercase().contains(&q) || crate::i18n::lowercase(tl!(s.label)).contains(&q))
.map(|s| (s.id.to_string(), tl!(s.label).to_string()))
.collect();
out.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()).then(a.0.cmp(&b.0)));
out
}
/// Keep `wanted` when it is still listed, else fall back to the first row (#1845).
pub(crate) fn select_in(rows: &[(String, String)], wanted: &str) -> String {
if rows.iter().any(|(id, _)| id == wanted) { wanted.to_string() } else { rows.first().map(|(id, _)| id.clone()).unwrap_or_default() }
}
/// Only combinations with Mod, Ctrl or Alt, or function keys, can be assigned from the dialog
/// (plain letters and Shift+letter type text).
fn assignable(key: &str) -> bool {
@@ -111,7 +140,9 @@ pub(crate) fn list(ui: &mut Ui, id: &str, size: egui::Vec2, rows: &[(String, Str
ui.centered_and_justified(|ui| ui.label(egui::RichText::new(tl!("(none)")).weak()));
return;
}
let row_h = ui.text_style_height(&egui::TextStyle::Body) + 4.0;
// Virtual rows must include the button's padding and minimum interaction height;
// otherwise the last row can extend beyond the scroll area's declared content height.
let row_h = (ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y).max(ui.spacing().interact_size.y);
// Bring the selected row into view when the selection (or the list) changes, so the row
// an action targets is never off-screen; scrolling by hand doesn't change the selection.
let seen = ui.make_persistent_id(("scrolled_to", id));
@@ -161,15 +192,21 @@ pub fn customize_keyboard(app: &mut WordApp, ui: &mut Ui, f: &mut KeyboardForm)
let (left, right) = (170.0, 300.0);
heading(ui, "Choose a command");
ui.add(egui::TextEdit::singleline(&mut f.filter).hint_text(tl!("Search")).desired_width(left + right + 8.0));
let mut cats: Vec<(String, String)> = categories(app).into_iter().map(|c| (c.clone(), tl!(&c).to_string())).collect();
cats.push((ALL.to_string(), tl!("All Commands").to_string()));
let cmds: Vec<(String, String)> = commands(app, &f.category).into_iter().map(|(id, l)| (id.to_string(), l)).collect();
let cmds: Vec<(String, String)> = matching_commands(app, &f.category, &f.filter);
f.command = select_in(&cmds, &f.command);
if cmds.is_empty() {
ui.label(egui::RichText::new(tl!("No matching commands")).weak());
}
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.label(tl!("Categories:"));
if let Some(c) = list(ui, "kb_categories", vec2(left, 170.0), &cats, &f.category) {
f.category = c;
f.command = commands(app, &f.category).first().map(|(id, _)| (*id).to_string()).unwrap_or_default();
f.filter.clear();
f.command = matching_commands(app, &f.category, &f.filter).first().map(|(id, _)| id.clone()).unwrap_or_default();
f.current.clear();
}
});
@@ -432,6 +469,7 @@ mod tests {
delta: egui::vec2(0.0, dy),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
});
h.step();
}
@@ -450,4 +488,36 @@ mod tests {
assert_eq!(h.state().1.command, first_of_home.0);
assert!(row_in_view(&h, &first_of_home.1), "the new selection {} is in view", first_of_home.0);
}
/// #1845: the command filter searches every command by label or id, ignoring the category.
#[test]
fn command_filter_searches_all_commands() {
use super::{matching_commands, select_in};
let app = || WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
let a = app();
// Empty query keeps the category scope.
let file_cmds = matching_commands(&a, "File", "");
assert!(!file_cmds.is_empty());
assert!(
file_cmds
.iter()
.all(|(id, _)| crate::i18n::location(a.session.registry.get(id).map(|s| s.location).unwrap_or_default()).starts_with("File"))
);
assert!(file_cmds.iter().any(|(id, _)| id == "file.save"));
// A label finds its command from another category's scope.
let bold = matching_commands(&a, "File", "bold");
assert!(bold.iter().any(|(id, _)| id == "format.bold"));
// Matching is case-insensitive and covers ids.
assert!(matching_commands(&a, "File", "SAVE").iter().any(|(id, _)| id == "file.save"));
assert!(matching_commands(&a, "File", "format.b").iter().any(|(id, _)| id == "format.bold"));
// Nothing matches: empty rows and an empty selection.
let none = matching_commands(&a, "File", "zzz-no-such-command");
assert!(none.is_empty());
assert_eq!(select_in(&none, "file.save"), "");
// The selection survives when still listed, else falls back to the first row.
assert_eq!(select_in(&bold, "format.bold"), "format.bold");
let first = bold.first().map(|(id, _)| id.clone()).unwrap_or_default();
assert_eq!(select_in(&bold, "file.save"), first);
}
}
+1 -1
View File
@@ -434,7 +434,7 @@ fn choice(ui: &mut Ui, id: &str, value: &mut String, options: &[(&str, &str)]) {
/// hex.
fn color(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String, none_label: &str, auto: Option<wordcraft_doc::Rgb>) {
ui.horizontal(|ui| {
let id = ui.id().with("colorField");
let id = ui.scope_id().with("colorField");
crate::colors::take_field(ui.ctx(), id, value);
let c = wordcraft_doc::Rgb::parse(value);
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
+47 -12
View File
@@ -231,10 +231,12 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool {
// Position, edited in place: the interface unit by default, any
// unit spelled out, either decimal separator (`.measure`).
let mut pv = t.pos / k;
let r = ui.add_sized(
vec2(58.0, 18.0),
egui::DragValue::new(&mut pv).speed(0.05).range(-1584.0 / k..=1584.0 / k).measure(u).max_decimals(2),
);
let r = ui
.add_sized(
vec2(58.0, 18.0),
egui::DragValue::new(&mut pv).speed(0.05).range(-1584.0 / k..=1584.0 / k).measure(u).max_decimals(2),
)
.accessible_name(format!("{} {}", tl!("Tab stop position:"), i + 1));
if r.changed() {
f.set_pos(i, pv * k);
}
@@ -253,7 +255,9 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool {
for v in crate::ruler_tabs::KINDS {
ui.selectable_value(&mut a, v, tl!(crate::ruler_tabs::kind_label(v)));
}
});
})
.response
.accessible_name(format!("{} {}", tl!("Alignment:"), i + 1));
if a != t.align {
f.set_align(i, a);
pick = Some(i);
@@ -265,11 +269,16 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool {
.find(|(v, _, _)| *v == l)
.map(|(_, _, fill)| if fill.is_empty() { tl!("None").to_string() } else { (*fill).to_string() })
.unwrap_or_default();
egui::ComboBox::from_id_salt("tab_leader").width(64.0).selected_text(shown).show_ui(ui, |ui| {
for (v, label, _) in LEADERS {
ui.selectable_value(&mut l, v, tl!(label));
}
});
egui::ComboBox::from_id_salt("tab_leader")
.width(64.0)
.selected_text(shown)
.show_ui(ui, |ui| {
for (v, label, _) in LEADERS {
ui.selectable_value(&mut l, v, tl!(label));
}
})
.response
.accessible_name(format!("{} {}", tl!("Leader"), i + 1));
if l != t.leader {
f.set_leader(i, l);
pick = Some(i);
@@ -313,8 +322,9 @@ pub fn tabs(app: &mut WordApp, ui: &mut Ui, f: &mut TabsForm) -> bool {
ui.add_space(12.0);
ui.vertical(|ui| {
ui.set_min_width(130.0);
ui.label(tl!("Default tab stops:"));
ui.add(egui::DragValue::new(&mut f.default_tab).speed(0.05).range(1.0 / k..=1584.0 / k).measure(u).max_decimals(2));
let default_label = ui.label(tl!("Default tab stops:"));
ui.add(egui::DragValue::new(&mut f.default_tab).speed(0.05).range(1.0 / k..=1584.0 / k).measure(u).max_decimals(2))
.labelled_by(default_label.id);
ui.add_space(8.0);
ui.label(tl!("Tab stops to be cleared:"));
let list = if f.clear_all {
@@ -914,6 +924,31 @@ mod tests {
assert_eq!(f.stops[0].pos, 36.0);
}
/// The inline tab-stop rows are named by their column, and the default interval by its label.
#[test]
fn tab_stop_fields_have_semantic_names() {
use egui_kittest::kittest::Queryable;
let mut app = app_with("a\tb");
app.run("para.tabs", json!({"set": [{"pos": 72.0}]})).unwrap();
app.dialog = crate::dialogs::Dialog::open("tabs", &mut app);
assert!(matches!(app.dialog, Some(crate::dialogs::Dialog::Tabs { .. })));
let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
for _ in 0..4 {
h.step();
}
h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Tab stop position: 1");
h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Alignment: 1");
h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Leader 1");
h.get_by_role_and_label(egui::accesskit::Role::SpinButton, "Default tab stops:");
}
/// #3031: a paragraph with no stops — the default — says so in the list box instead of
/// showing a blank frame.
#[test]
+78 -50
View File
@@ -149,14 +149,15 @@ fn heading(ui: &mut Ui, s: &str) {
ui.label(egui::RichText::new(tl!(s)).font(semibold(12.5)));
}
/// A choice from `options` ((value, label)) in a drop-down.
fn choice<T: PartialEq + Clone>(ui: &mut Ui, id: &str, value: &mut T, options: &[(T, &str)]) {
let label = options.iter().find(|(v, _)| v == value).map(|(_, l)| tl!(l).to_string()).unwrap_or_default();
egui::ComboBox::from_id_salt(id).selected_text(label).width(150.0).show_ui(ui, |ui| {
/// A choice from `options` ((value, label)) in a drop-down, named by its visible label.
fn choice<T: PartialEq + Clone>(ui: &mut Ui, id: &str, value: &mut T, options: &[(T, &str)], label: &egui::Response) {
let shown = options.iter().find(|(v, _)| v == value).map(|(_, l)| tl!(l).to_string()).unwrap_or_default();
let combo = egui::ComboBox::from_id_salt(id).selected_text(shown).width(150.0).show_ui(ui, |ui| {
for (v, l) in options {
ui.selectable_value(value, v.clone(), tl!(l));
}
});
combo.response.labelled_by(label.id);
}
const LEADERS: [(&str, &str); 4] = [("none", "(none)"), ("dot", "........"), ("hyphen", "--------"), ("underscore", "________")];
@@ -311,25 +312,28 @@ impl CaptionForm {
}
}
/// Draws the Caption dialog's preview, text, label, position and numbering controls, with New
/// Label, Delete Label and Numbering jumps, and inserts the caption on OK.
fn caption_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CaptionForm) -> Next {
ui.label(tl!("Caption:"));
let caption_label = ui.label(tl!("Caption:"));
ui.horizontal(|ui| {
ui.label(f.preview());
ui.add(egui::TextEdit::singleline(&mut f.text).desired_width(220.0).hint_text(tl!("Caption text")));
ui.add(egui::TextEdit::singleline(&mut f.text).desired_width(220.0).hint_text(tl!("Caption text"))).labelled_by(caption_label.id);
});
ui.add_space(6.0);
let mut then = None;
egui::Grid::new("cap_opts").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Label:"));
let label_label = ui.label(tl!("Label:"));
let labels: Vec<(String, &str)> = f.labels.iter().map(|l| (l.clone(), l.as_str())).collect();
egui::ComboBox::from_id_salt("cap_label").selected_text(tl!(&f.label)).width(150.0).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("cap_label").selected_text(tl!(&f.label)).width(150.0).show_ui(ui, |ui| {
for (v, l) in &labels {
ui.selectable_value(&mut f.label, v.clone(), tl!(l));
}
});
combo.response.labelled_by(label_label.id);
ui.end_row();
ui.label(tl!("Position:"));
choice(ui, "cap_pos", &mut f.position, &[("below".to_string(), "Below the item"), ("above".to_string(), "Above the item")]);
let position_label = ui.label(tl!("Position:"));
choice(ui, "cap_pos", &mut f.position, &[("below".to_string(), "Below the item"), ("above".to_string(), "Above the item")], &position_label);
ui.end_row();
});
ui.checkbox(&mut f.exclude_label, tl!("Leave the label out of the caption"));
@@ -418,17 +422,20 @@ impl CrossRefForm {
}
}
/// Draws the Cross-reference dialog's reference-type and insert-reference-to selectors and the
/// target list, reloading the form when the type changes, and runs `insert.crossReference` on
/// Insert.
fn cross_ref_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CrossRefForm) -> Next {
let was = f.to.clone();
egui::Grid::new("xref_opts").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Reference type:"));
let type_label = ui.label(tl!("Reference type:"));
let types = ref_types(app);
let types: Vec<(String, &str)> = types.iter().map(|(v, l)| (v.clone(), l.as_str())).collect();
choice(ui, "xref_type", &mut f.to, &types);
choice(ui, "xref_type", &mut f.to, &types, &type_label);
ui.end_row();
ui.label(tl!("Insert reference to:"));
let show_label = ui.label(tl!("Insert reference to:"));
let shows: Vec<(String, &str)> = f.shows().iter().map(|(v, l)| (v.to_string(), *l)).collect();
choice(ui, "xref_show", &mut f.show, &shows);
choice(ui, "xref_show", &mut f.show, &shows, &show_label);
ui.end_row();
});
if f.to != was {
@@ -459,28 +466,31 @@ fn cross_ref_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CrossRefForm) -> Next {
if cancel { Next::Close } else { Next::Stay }
}
/// Draws the caption numbering format, chapter-number toggle and chapter level and separator
/// controls, returning to the caption dialog with the edited or original form.
fn numbering_ui(ui: &mut Ui, f: &mut CaptionForm, was: &CaptionForm) -> Next {
egui::Grid::new("cap_num").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Format:"));
let format_label = ui.label(tl!("Format:"));
let opts: Vec<(String, &str)> = NUMBER_FORMATS.iter().map(|(v, l)| (v.to_string(), *l)).collect();
choice(ui, "cap_fmt", &mut f.format, &opts);
choice(ui, "cap_fmt", &mut f.format, &opts, &format_label);
ui.end_row();
});
ui.checkbox(&mut f.chapter, tl!("Add the chapter number"));
ui.add_enabled_ui(f.chapter, |ui| {
egui::Grid::new("cap_chap").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Chapters begin at:"));
let chapter_label = ui.label(tl!("Chapters begin at:"));
let opts: Vec<(u8, String)> = (1..=9u8).map(|l| (l, crate::i18n::fmt(tl!("Heading {n}"), &[("n", &l.to_string())]))).collect();
let shown = opts.iter().find(|(l, _)| *l == f.chapter_level).map(|(_, s)| s.clone()).unwrap_or_default();
egui::ComboBox::from_id_salt("cap_level").selected_text(shown).width(150.0).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("cap_level").selected_text(shown).width(150.0).show_ui(ui, |ui| {
for (l, s) in &opts {
ui.selectable_value(&mut f.chapter_level, *l, s);
}
});
combo.response.labelled_by(chapter_label.id);
ui.end_row();
ui.label(tl!("Separator:"));
let separator_label = ui.label(tl!("Separator:"));
let opts: Vec<(String, &str)> = SEPARATORS.iter().map(|(v, l)| (v.to_string(), *l)).collect();
choice(ui, "cap_sep", &mut f.separator, &opts);
choice(ui, "cap_sep", &mut f.separator, &opts, &separator_label);
ui.end_row();
});
});
@@ -496,9 +506,11 @@ fn numbering_ui(ui: &mut Ui, f: &mut CaptionForm, was: &CaptionForm) -> Next {
Next::Stay
}
/// Draws the new caption label field with focus, sanitizes an accepted label, remembers custom
/// labels, and returns to the caption dialog.
fn new_label_ui(app: &mut WordApp, ui: &mut Ui, f: &mut CaptionForm) -> Next {
ui.label(tl!("Label:"));
let r = ui.add(egui::TextEdit::singleline(&mut f.new_label).desired_width(260.0));
let label = ui.label(tl!("Label:"));
let r = ui.add(egui::TextEdit::singleline(&mut f.new_label).desired_width(260.0)).labelled_by(label.id);
if !r.has_focus() {
r.request_focus();
}
@@ -562,6 +574,8 @@ impl IndexForm {
}
}
/// Draws the index preview and type, columns, right-alignment, tab-leader and heading controls,
/// jumps to Mark Entry, and runs `references.index` on OK.
fn index_ui(app: &mut WordApp, ui: &mut Ui, f: &mut IndexForm) -> Next {
let page = |p: &str| p.to_string();
let lines: Vec<(usize, String, String)> = {
@@ -587,16 +601,16 @@ fn index_ui(app: &mut WordApp, ui: &mut Ui, f: &mut IndexForm) -> Next {
ui.radio_value(&mut f.kind, "runIn".to_string(), tl!("Run-in"));
});
ui.end_row();
ui.label(tl!("Columns:"));
ui.add(egui::DragValue::new(&mut f.columns).range(1..=4));
let columns_label = ui.label(tl!("Columns:"));
ui.add(egui::DragValue::new(&mut f.columns).range(1..=4)).labelled_by(columns_label.id);
ui.end_row();
ui.label("");
ui.checkbox(&mut f.right_align, tl!("Page numbers at the right margin"));
ui.end_row();
ui.label(tl!("Tab leader:"));
let leader_label = ui.label(tl!("Tab leader:"));
ui.add_enabled_ui(f.right_align, |ui| {
let opts: Vec<(String, &str)> = LEADERS.iter().map(|(v, l)| (v.to_string(), *l)).collect();
choice(ui, "idx_leader", &mut f.leader, &opts);
choice(ui, "idx_leader", &mut f.leader, &opts, &leader_label);
});
ui.end_row();
ui.label("");
@@ -661,33 +675,35 @@ impl MarkForm {
}
}
/// Draws the index-entry fields and page, cross-reference or bookmark-range options, with Mark
/// and Mark All running `references.markEntry`.
fn mark_ui(app: &mut WordApp, ui: &mut Ui, f: &mut MarkForm) -> Next {
heading(ui, "Index");
egui::Grid::new("xe").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Main entry:"));
ui.add(egui::TextEdit::singleline(&mut f.entry).desired_width(220.0));
let entry_label = ui.label(tl!("Main entry:"));
ui.add(egui::TextEdit::singleline(&mut f.entry).desired_width(220.0)).labelled_by(entry_label.id);
ui.end_row();
ui.label(tl!("Subentry:"));
ui.add(egui::TextEdit::singleline(&mut f.subentry).desired_width(220.0));
let subentry_label = ui.label(tl!("Subentry:"));
ui.add(egui::TextEdit::singleline(&mut f.subentry).desired_width(220.0)).labelled_by(subentry_label.id);
ui.end_row();
});
heading(ui, "Options");
ui.horizontal(|ui| {
ui.radio_value(&mut f.option, "see".to_string(), tl!("Cross-reference:"));
ui.add_enabled(f.option == "see", egui::TextEdit::singleline(&mut f.see).desired_width(160.0));
let see_label = ui.radio_value(&mut f.option, "see".to_string(), tl!("Cross-reference:"));
ui.add_enabled(f.option == "see", egui::TextEdit::singleline(&mut f.see).desired_width(160.0)).labelled_by(see_label.id);
});
ui.radio_value(&mut f.option, "page".to_string(), tl!("This page"));
ui.horizontal(|ui| {
ui.add_enabled_ui(!f.bookmarks.is_empty(), |ui| {
ui.radio_value(&mut f.option, "range".to_string(), tl!("Pages of bookmark:"));
});
let bookmark_label =
ui.add_enabled_ui(!f.bookmarks.is_empty(), |ui| ui.radio_value(&mut f.option, "range".to_string(), tl!("Pages of bookmark:"))).inner;
ui.add_enabled_ui(f.option == "range", |ui| {
let opts: Vec<(String, &str)> = f.bookmarks.iter().map(|b| (b.clone(), b.as_str())).collect();
egui::ComboBox::from_id_salt("xe_bm").selected_text(f.bookmark.clone()).width(140.0).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("xe_bm").selected_text(f.bookmark.clone()).width(140.0).show_ui(ui, |ui| {
for (v, l) in &opts {
ui.selectable_value(&mut f.bookmark, v.clone(), *l);
}
});
combo.response.labelled_by(bookmark_label.id);
});
});
heading(ui, "Page number style");
@@ -811,6 +827,8 @@ impl TocForm {
}
}
/// Draws the TOC preview and page-number, right-alignment, tab-leader, hyperlink and level
/// controls, with Options and Modify jumps and `references.toc` on OK.
fn toc_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TocForm) -> Next {
let lines: Vec<(usize, String, String)> = (1..=f.levels.min(3))
.map(|l| {
@@ -824,17 +842,17 @@ fn toc_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TocForm) -> Next {
ui.add_enabled_ui(f.page_numbers, |ui| {
ui.checkbox(&mut f.right_align, tl!("Page numbers at the right margin"));
ui.horizontal(|ui| {
ui.label(tl!("Tab leader:"));
let leader_label = ui.label(tl!("Tab leader:"));
ui.add_enabled_ui(f.right_align, |ui| {
let opts: Vec<(String, &str)> = LEADERS.iter().map(|(v, l)| (v.to_string(), *l)).collect();
choice(ui, "toc_leader", &mut f.leader, &opts);
choice(ui, "toc_leader", &mut f.leader, &opts, &leader_label);
});
});
});
ui.checkbox(&mut f.hyperlinks, tl!("Link entries to their headings"));
ui.horizontal(|ui| {
ui.label(tl!("Levels:"));
ui.add(egui::DragValue::new(&mut f.levels).range(1..=9));
let levels_label = ui.label(tl!("Levels:"));
ui.add(egui::DragValue::new(&mut f.levels).range(1..=9)).labelled_by(levels_label.id);
});
ui.add_space(4.0);
let mut then = None;
@@ -856,6 +874,8 @@ fn toc_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TocForm) -> Next {
if ok || cancel { Next::Close } else { Next::Stay }
}
/// Draws the TOC sources: heading styles, per-style TOC levels, outline levels and TC entry
/// fields, with Reset. OK returns the edited form and Cancel the original.
fn toc_options_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TocForm, was: &TocForm) -> Next {
heading(ui, "Collect entries from:");
ui.checkbox(&mut f.heading_styles, crate::i18n::fmt(tl!("Heading styles (levels 1–{n})"), &[("n", &f.levels.to_string())]));
@@ -867,8 +887,9 @@ fn toc_options_ui(app: &mut WordApp, ui: &mut Ui, f: &mut TocForm, was: &TocForm
for (name, level) in f.style_levels.iter_mut() {
// Built-in styles read in the interface language; the document keeps the English name.
let shown = app.session.doc.styles.styles.iter().find(|st| st.name == *name).map_or_else(|| name.clone(), crate::i18n::style_name);
ui.label(shown);
ui.add(egui::DragValue::new(level).range(0..=9).custom_formatter(|v, _| if v < 0.5 { String::new() } else { format!("{v:.0}") }));
let style_label = ui.label(shown);
ui.add(egui::DragValue::new(level).range(0..=9).custom_formatter(|v, _| if v < 0.5 { String::new() } else { format!("{v:.0}") }))
.labelled_by(style_label.id);
ui.end_row();
}
});
@@ -958,6 +979,8 @@ impl FiguresForm {
}
}
/// Draws the table-of-figures preview and label, include-label, page-number, right-alignment and
/// tab-leader controls, running `references.tableOfFigures` on OK.
fn figures_ui(app: &mut WordApp, ui: &mut Ui, f: &mut FiguresForm) -> Next {
let lines: Vec<(usize, String, String)> = [(1, "1"), (2, "3")]
.iter()
@@ -969,23 +992,24 @@ fn figures_ui(app: &mut WordApp, ui: &mut Ui, f: &mut FiguresForm) -> Next {
sample(ui, &lines, f.right_align, leader_char(&f.leader));
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label(tl!("Label:"));
let label_label = ui.label(tl!("Label:"));
let labels: Vec<(String, &str)> = f.labels.iter().map(|l| (l.clone(), l.as_str())).collect();
egui::ComboBox::from_id_salt("tof_label").selected_text(tl!(&f.label)).width(150.0).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("tof_label").selected_text(tl!(&f.label)).width(150.0).show_ui(ui, |ui| {
for (v, l) in &labels {
ui.selectable_value(&mut f.label, v.clone(), tl!(l));
}
});
combo.response.labelled_by(label_label.id);
});
ui.checkbox(&mut f.include_label, tl!("Include label and number"));
ui.checkbox(&mut f.page_numbers, tl!("Include page numbers"));
ui.add_enabled_ui(f.page_numbers, |ui| {
ui.checkbox(&mut f.right_align, tl!("Page numbers at the right margin"));
ui.horizontal(|ui| {
ui.label(tl!("Tab leader:"));
let leader_label = ui.label(tl!("Tab leader:"));
ui.add_enabled_ui(f.right_align, |ui| {
let opts: Vec<(String, &str)> = LEADERS.iter().map(|(v, l)| (v.to_string(), *l)).collect();
choice(ui, "tof_leader", &mut f.leader, &opts);
choice(ui, "tof_leader", &mut f.leader, &opts, &leader_label);
});
});
});
@@ -1140,26 +1164,30 @@ impl SourceForm {
}
}
/// Draws a source's type and fields, optionally every bibliography field, and adds it as a
/// bibliography source or inserts a citation on OK.
fn source_ui(app: &mut WordApp, ui: &mut Ui, f: &mut SourceForm) -> Next {
egui::Grid::new("src").num_columns(2).spacing(vec2(10.0, 5.0)).show(ui, |ui| {
ui.label(tl!("Source type:"));
let kind_label = ui.label(tl!("Source type:"));
let shown = SOURCE_TYPES.iter().find(|(k, ..)| *k == f.source.kind).map(|(_, l, _)| tl!(l).to_string()).unwrap_or_default();
egui::ComboBox::from_id_salt("src_kind").selected_text(shown).width(200.0).show_ui(ui, |ui| {
let combo = egui::ComboBox::from_id_salt("src_kind").selected_text(shown).width(200.0).show_ui(ui, |ui| {
for (k, l, _) in SOURCE_TYPES {
ui.selectable_value(&mut f.source.kind, k.to_string(), tl!(l));
}
});
combo.response.labelled_by(kind_label.id);
ui.end_row();
let edit = f.edit;
for (key, label) in f.fields() {
ui.label(format!("{}:", tl!(label)));
let field_label = ui.label(format!("{}:", tl!(label)));
if let Some(v) = field_mut(&mut f.source, key) {
let hint = match key {
"author" => tl!("Last, First; Last, First"),
"tag" => tl!("Made from the author and year when empty"),
_ => "",
};
ui.add_enabled(!(edit && key == "tag"), egui::TextEdit::singleline(v).desired_width(260.0).hint_text(hint));
ui.add_enabled(!(edit && key == "tag"), egui::TextEdit::singleline(v).desired_width(260.0).hint_text(hint))
.labelled_by(field_label.id);
}
ui.end_row();
}
+51 -6
View File
@@ -356,6 +356,7 @@ impl AutoCorrectForm {
"borderLines": o.border_lines,
"tabIndent": o.tab_indent,
"headingStyles": o.heading_styles,
"doubleSpacePeriod": o.double_space_period,
"autoAddFirstLetter": o.auto_add_first_letter,
"autoAddInitialCaps": o.auto_add_initial_caps,
})
@@ -401,6 +402,7 @@ fn autocorrect_ui(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) -> bo
ui.checkbox(&mut o.bold_italic, tl!("*Bold* and _italic_ as real formatting"));
ui.checkbox(&mut o.dashes, tl!("Hyphens (--) as dashes (—)"));
ui.checkbox(&mut o.links, tl!("Internet addresses as links"));
ui.checkbox(&mut o.double_space_period, tl!("Add a period with double-space"));
heading(ui, "Apply as you type");
ui.checkbox(&mut o.bullets, tl!("Automatic bulleted lists"));
ui.checkbox(&mut o.numbering, tl!("Automatic numbered lists"));
@@ -423,6 +425,8 @@ fn autocorrect_ui(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) -> bo
cancel
}
/// Draws AutoCorrect's capitalization and replacement checkboxes, the replace list with its
/// filter and add, replace and delete actions, and the exceptions editor.
fn autocorrect_tab(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) {
let o = &mut f.options;
ui.checkbox(&mut o.two_initial_caps, tl!("Correct TWo INitial CApitals"));
@@ -442,11 +446,11 @@ fn autocorrect_tab(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) {
ui.checkbox(&mut o.caps_lock, tl!("Correct accidental usage of cAPS LOCK key"));
ui.checkbox(&mut o.replace_text, tl!("Replace text as you type"));
egui::Grid::new("ac_boxes").num_columns(2).spacing(vec2(8.0, 2.0)).show(ui, |ui| {
ui.label(tl!("Replace:"));
ui.label(tl!("With:"));
let replace_label = ui.label(tl!("Replace:"));
let with_label = ui.label(tl!("With:"));
ui.end_row();
ui.add(egui::TextEdit::singleline(&mut f.from).desired_width(110.0));
ui.add(egui::TextEdit::singleline(&mut f.to).desired_width(260.0));
ui.add(egui::TextEdit::singleline(&mut f.from).desired_width(110.0)).labelled_by(replace_label.id);
ui.add(egui::TextEdit::singleline(&mut f.to).desired_width(260.0)).labelled_by(with_label.id);
ui.end_row();
});
// The list, filtered to what's typed in Replace (as Word scrolls to it).
@@ -530,11 +534,12 @@ fn exceptions(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) {
});
}
/// Filters the math-symbol table by the typed prefix and lists each `\name` with its character.
fn math_tab(ui: &mut Ui, f: &mut AutoCorrectForm) {
ui.label(egui::RichText::new(tl!("In equations, typing a name after a backslash replaces it with its symbol.")).small().weak());
ui.horizontal(|ui| {
ui.label(tl!("Replace:"));
ui.add(egui::TextEdit::singleline(&mut f.math_filter).desired_width(140.0));
let label = ui.label(tl!("Replace:"));
ui.add(egui::TextEdit::singleline(&mut f.math_filter).desired_width(140.0)).labelled_by(label.id);
});
let filter = f.math_filter.trim().trim_start_matches('\\').to_string();
egui::ScrollArea::vertical().id_salt("ac_math").max_height(240.0).auto_shrink([false, true]).show(ui, |ui| {
@@ -897,4 +902,44 @@ mod tests {
}
assert!(texts(&h).iter().any(|t| t.contains("(none)")), "no matches: the hint shows");
}
/// #2848 through the real dialog: tick "Add a period with double-space" on the AutoFormat As
/// You Type tab, press OK, and a double Space typed in the page makes a period.
#[test]
fn the_double_space_option_is_set_in_the_dialog_and_works_when_typing() {
use egui_kittest::kittest::Queryable;
let mut app = app("");
app.run("tools.autocorrect", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
for _ in 0..4 {
h.step();
}
h.get_by_label("AutoFormat As You Type").click();
for _ in 0..3 {
h.step();
}
h.get_by_label("Add a period with double-space").click();
for _ in 0..3 {
h.step();
}
h.get_by_label("OK").click();
for _ in 0..4 {
h.step();
}
assert!(h.state().dialog.is_none(), "OK closes the dialog");
assert!(h.state().session.prefs.autocorrect.double_space_period, "the option is on");
h.state_mut().canvas.focused = true;
for t in ["Hello", " ", " ", "world"] {
h.event(egui::Event::Text(t.to_string()));
h.step();
}
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Hello. world");
}
}
+46 -3
View File
@@ -11,7 +11,7 @@ use serde_json::{Value, json};
use crate::WordApp;
use crate::dialogs::Dialog;
use crate::dialogs_keyboard::{categories, commands, list};
use crate::dialogs_keyboard::{categories, commands, list, matching_commands, select_in};
use crate::theme::semibold;
/// The dialog's two pages.
@@ -39,6 +39,9 @@ pub struct RibbonForm {
/// The left list: a category (empty for All Commands) and the command picked in it.
pub category: String,
pub command: String,
/// Filter-as-you-type query for the command list (#1845); UI-only dialog state, ignored
/// while empty. Never persisted and never a command parameter.
pub filter: String,
/// The item picked in the ribbon tree.
pub selected: TreeItem,
/// The command picked in the Quick Access Toolbar list.
@@ -97,16 +100,22 @@ pub fn customize_ribbon(app: &mut WordApp, ui: &mut Ui, f: &mut RibbonForm) -> b
});
ui.separator();
let (left, right, h) = (150.0, 230.0, 300.0);
ui.add(egui::TextEdit::singleline(&mut f.filter).hint_text(tl!("Search")).desired_width(left + right + 8.0));
let mut cats: Vec<(String, String)> = categories(app).into_iter().map(|c| (c.clone(), tl!(&c).to_string())).collect();
cats.push((String::new(), tl!("All Commands").to_string()));
let cmds: Vec<(String, String)> = commands(app, &f.category).into_iter().map(|(id, l)| (id.to_string(), l)).collect();
let cmds: Vec<(String, String)> = matching_commands(app, &f.category, &f.filter);
f.command = select_in(&cmds, &f.command);
if cmds.is_empty() {
ui.label(egui::RichText::new(tl!("No matching commands")).weak());
}
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.label(egui::RichText::new(tl!("Choose a command")).font(semibold(12.5)));
ui.horizontal_top(|ui| {
if let Some(c) = list(ui, "rb_categories", vec2(left, h), &cats, &f.category) {
f.category = c;
f.command = commands(app, &f.category).first().map(|(id, _)| (*id).to_string()).unwrap_or_default();
f.filter.clear();
f.command = matching_commands(app, &f.category, &f.filter).first().map(|(id, _)| id.clone()).unwrap_or_default();
}
if let Some(c) = list(ui, "rb_commands", vec2(right, h), &cmds, &f.command) {
f.command = c;
@@ -466,6 +475,40 @@ mod tests {
assert_eq!(a.ui.tab, "Insert", "a hidden tab isn't shown");
}
/// #1845: the shared command picker (Ribbon and Quick Access Toolbar pages) filters
/// as you type, and Add works from a filtered selection.
#[test]
fn command_filter_narrows_the_picker_and_add_works() {
use crate::dialogs_keyboard::{matching_commands, select_in};
let mut a = app();
a.run("tools.customizeRibbon", json!({})).unwrap();
assert!(matches!(a.dialog, Some(Dialog::CustomizeRibbon { .. })));
if let Some(Dialog::CustomizeRibbon { form }) = &mut a.dialog {
form.filter = "bold".to_string();
}
let (category, filter, current) = match &a.dialog {
Some(Dialog::CustomizeRibbon { form }) => (form.category.clone(), form.filter.clone(), form.command.clone()),
_ => panic!("expected the Customize Ribbon dialog"),
};
// Empty filter keeps the category scope.
let unfiltered = matching_commands(&a, &category, "");
assert!(!unfiltered.is_empty());
// Typing searches every command, ignoring the category.
let cmds = matching_commands(&a, &category, &filter);
assert!(cmds.iter().any(|(id, _)| id == "format.bold"));
assert!(cmds.len() < a.session.registry.all().len());
// The dialog's Add >> adds the filtered pick to the Quick Access Toolbar.
let picked = select_in(&cmds, &current);
assert!(!picked.is_empty());
if let Some(Dialog::CustomizeRibbon { form }) = &mut a.dialog {
form.command = picked.clone();
}
a.run("tools.customizeRibbon", json!({"qatAdd": picked})).unwrap();
assert!(a.session.ribbon.qat().contains(&picked.as_str()));
assert!(a.session.ribbon.qat().contains(&"format.bold"));
}
/// #376: the Quick Access Toolbar's … button opens its menu, and the toolbar shows the user's
/// commands.
#[test]
+39 -11
View File
@@ -295,7 +295,7 @@ pub fn frame_button(app: &mut WordApp, ctx: &egui::Context, layout: &wordcraft_l
return;
}
let t = Tokens::get(ctx);
egui::Area::new(egui::Id::new("smart_art_frame_button")).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| {
egui::Area::new(egui::Id::unique("smart_art_frame_button")).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| {
egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.border)).corner_radius(4).inner_margin(2).show(ui, |ui| {
if crate::widgets::icon_button(ui, "textPane", tl!("Text Pane")).clicked() {
let _ = app.run("smartArt.textPane", json!({"value": true}));
@@ -306,20 +306,45 @@ pub fn frame_button(app: &mut WordApp, ctx: &egui::Context, layout: &wordcraft_l
/// Text typed with a SmartArt graphic selected goes at the end of its current item (the first,
/// before any is picked), and the Text Pane opens, as Word does; it never replaces the graphic.
/// False when no graphic is selected.
pub fn type_into(app: &mut WordApp, typed: &str) -> bool {
let Some(g) = wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session) else { return false };
let Some(i) = app.smart_art_item.filter(|i| *i < g.items.len()).or((!g.items.is_empty()).then_some(0)) else { return true };
///
/// What typing into a selected SmartArt graphic did.
///
/// A selected graphic always consumes the input, even when it has no item to edit: the typing
/// must not fall through into the body. Only the absence of a SmartArt target lets the body type.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SmartArtTypeResult {
/// No SmartArt graphic is selected: the caller may type into the body.
NotHandled,
/// A selected graphic consumed the input without editing the document.
ConsumedNoChange,
/// A SmartArt item really changed.
Changed,
}
/// Appends typing to the selected SmartArt item, joining an active typing undo, and returns
/// whether the input was unhandled, consumed without a change, or changed an item.
pub(crate) fn type_into(app: &mut WordApp, typed: &str) -> SmartArtTypeResult {
let Some(g) = wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session) else {
return SmartArtTypeResult::NotHandled;
};
let Some(i) = app.smart_art_item.filter(|i| *i < g.items.len()).or((!g.items.is_empty()).then_some(0)) else {
// Selected but empty: consume, never insert into the body, never claim an edit.
return SmartArtTypeResult::ConsumedNoChange;
};
let text = g.items.get(i).map_or(String::new(), |it| it.text.clone()) + typed;
if app.smart_art_typing == Some(i) {
app.session.join_next_undo();
}
if app.run("smartArt.items", items_with(&g, i, &text)).is_ok() {
app.smart_art_item = Some(i);
app.smart_art_typing = Some(i);
if app.run("smartArt.items", items_with(&g, i, &text)).is_err() {
// A selected graphic that refuses the edit still consumes the event; an IME composition
// must start afresh rather than be applied to the body.
app.canvas.ime_interrupt_now = true;
return SmartArtTypeResult::ConsumedNoChange;
}
app.smart_art_item = Some(i);
app.smart_art_typing = Some(i);
let _ = app.run("smartArt.textPane", json!({"value": true}));
true
SmartArtTypeResult::Changed
}
/// The Text Pane: a row per item, indented by level. Typing changes the graphic as you go (one
@@ -330,7 +355,7 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(tl!("One line per item. Enter adds an item.")).color(t.text_dim));
ui.add_space(4.0);
let focus_id = egui::Id::new("smart_art_focus");
let focus_id = egui::Id::unique("smart_art_focus");
let want_focus: Option<usize> = ui.data_mut(|d| d.remove_temp(focus_id));
let mut typed = false;
app.smart_art_row_focused = false;
@@ -340,7 +365,10 @@ pub fn text_pane(app: &mut WordApp, ui: &mut Ui) {
ui.add_space(f32::from(it.level) * 16.0);
ui.label(egui::RichText::new("•").color(t.text_dim));
let mut text = it.text.clone();
let r = ui.add(egui::TextEdit::singleline(&mut text).id_salt(("smart_art_item", i)).desired_width(f32::INFINITY));
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);
if want_focus == Some(i) {
r.request_focus();
}
+20 -15
View File
@@ -470,11 +470,11 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool {
let base_name = styles.iter().find(|(i, _)| i == based_on).map(|(_, n)| n.clone()).unwrap_or_else(|| based_on.clone());
let mut rebased = false;
egui::Grid::new("tstyle").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Name:"));
ui.text_edit_singleline(name);
let name_label = ui.label(tl!("Name:"));
ui.text_edit_singleline(name).labelled_by(name_label.id);
ui.end_row();
ui.label(tl!("Style based on:"));
egui::ComboBox::from_id_salt("tstyle_base").width(200.0).selected_text(base_name).show_ui(ui, |ui| {
let base_label = ui.label(tl!("Style based on:"));
let base = egui::ComboBox::from_id_salt("tstyle_base").width(200.0).selected_text(base_name).show_ui(ui, |ui| {
for (sid, sname) in &styles {
if ui.selectable_label(sid == based_on, sname).clicked() && sid != based_on {
*based_on = sid.clone();
@@ -482,16 +482,18 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool {
}
}
});
base.response.labelled_by(base_label.id);
ui.end_row();
ui.label(tl!("Apply formatting to:"));
egui::ComboBox::from_id_salt("tstyle_region").width(200.0).selected_text(tl!(PARTS.get(*region).map_or("Whole Table", |(_, l)| *l))).show_ui(
ui,
|ui| {
let region_label = ui.label(tl!("Apply formatting to:"));
let part = egui::ComboBox::from_id_salt("tstyle_region")
.width(200.0)
.selected_text(tl!(PARTS.get(*region).map_or("Whole Table", |(_, l)| *l)))
.show_ui(ui, |ui| {
for (i, (_, n)) in PARTS.iter().enumerate() {
ui.selectable_value(region, i, tl!(*n));
}
},
);
});
part.response.labelled_by(region_label.id);
ui.end_row();
});
// A new style shows its new base's formatting; a modified one keeps its own.
@@ -512,7 +514,8 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool {
{
r.font = f;
}
ui.add(egui::TextEdit::singleline(&mut r.size).desired_width(36.0)).on_hover_text(tl!("Size:"));
let size = ui.add(egui::TextEdit::singleline(&mut r.size).desired_width(36.0));
size.on_hover_text(tl!("Size:")).accessible_name(tl!("Size:"));
ui.toggle_value(&mut r.bold, egui::RichText::new("B").strong()).on_hover_text(tl!("Bold"));
ui.toggle_value(&mut r.italic, egui::RichText::new("I").italics()).on_hover_text(tl!("Italic"));
ui.toggle_value(&mut r.underline, egui::RichText::new("U").underline()).on_hover_text(tl!("Underline"));
@@ -524,14 +527,15 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool {
// Borders: the pen (style, width, colour) and which edges; then the cell shading.
ui.horizontal(|ui| {
let current = LINE_STYLES.iter().find(|(s, _)| *s == pen.style).map_or("Single", |(_, n)| *n);
ui.label(tl!("Style:"));
egui::ComboBox::from_id_salt("tstyle_pen_style").selected_text(tl!(current)).width(90.0).show_ui(ui, |ui| {
let style_label = ui.label(tl!("Style:"));
let style = egui::ComboBox::from_id_salt("tstyle_pen_style").selected_text(tl!(current)).width(90.0).show_ui(ui, |ui| {
for (s, n) in LINE_STYLES {
ui.selectable_value(&mut pen.style, s, tl!(n));
}
});
ui.label(tl!("Width:"));
egui::ComboBox::from_id_salt("tstyle_pen_width")
style.response.labelled_by(style_label.id);
let width_label = ui.label(tl!("Width:"));
let width = egui::ComboBox::from_id_salt("tstyle_pen_width")
.selected_text(crate::i18n::fmt(tl!("{n} pt"), &[("n", &pen.width.to_string())]))
.width(70.0)
.show_ui(ui, |ui| {
@@ -539,6 +543,7 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, f: Form) -> bool {
ui.selectable_value(&mut pen.width, w, crate::i18n::fmt(tl!("{n} pt"), &[("n", &w.to_string())]));
}
});
width.response.labelled_by(width_label.id);
ui.push_id("tstyle_pen_color", |ui| {
ui.label(tl!("Color:"));
color_menu(ui, &theme, &mut pen.color);
+83 -7
View File
@@ -40,11 +40,59 @@ fn paint_math(app: &mut WordApp, ui: &Ui, r: Rect, key: &str, linear: &str, node
}
None => {
let t = Tokens::get(ui.ctx());
ui.painter().text(r.center(), Align2::CENTER_CENTER, linear, regular(11.0), t.text_dim);
// Math previews are asynchronous. Interface fonts need not cover the math font's
// repertoire, so use readable linear commands until the actual equation is ready.
let placeholder = math_placeholder(ui, linear);
ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, placeholder, regular(11.0), t.text_dim);
}
}
}
/// Builds a readable fallback for a math preview from the interface fonts' glyph coverage:
/// covered characters and whitespace stay, while a missing glyph becomes its UnicodeMath name
/// or a `U+XXXX` codepoint.
fn math_placeholder(ui: &Ui, linear: &str) -> String {
let font = regular(11.0);
math_placeholder_with(linear, |c| ui.fonts_mut(|fonts| fonts.has_glyph(&font, c)))
}
/// [`math_placeholder`] given which characters the interface fonts cover. A character they lack
/// becomes its UnicodeMath name (`┬` → `\below`), so the same template has a different
/// placeholder in a build without craft-fonts; the i18n audit recognizes the exact strings
/// (`placeholder_linears`) as math input rather than interface wording.
pub(crate) fn math_placeholder_with(linear: &str, covered: impl Fn(char) -> bool) -> String {
let mut result = String::new();
for c in linear.chars() {
if c.is_whitespace() || covered(c) {
result.push(c);
} else if let Some(name) = wordcraft_doc::math_symbols::name_of(c) {
result.push('\\');
result.push_str(name);
} else {
use std::fmt::Write as _;
let _ = write!(result, "U+{:04X}", c as u32);
}
}
result
}
/// The Equation tab's own big button picture.
const EQUATION_PICTURE: &str = "∑_(i=1)^n";
/// Every linear text the Equation tab can paint as a placeholder while a preview loads: the big
/// buttons' pictures, every template and built-in equation, and every symbol tile.
#[cfg(test)]
pub(crate) fn placeholder_linears() -> impl Iterator<Item = String> {
let pictures = STRUCTURES.iter().map(|g| button_picture(g.id).to_string());
let templates = wordcraft_engine::math_gallery::templates().map(|t| t.linear.to_string());
let symbols = SYMBOL_SETS.iter().flat_map(|(_, chars)| chars.chars()).map(|c| c.to_string());
std::iter::once(EQUATION_PICTURE.to_string())
.chain(pictures)
.chain(templates)
.chain(BUILT_INS.iter().map(|(_, _, linear)| (*linear).to_string()))
.chain(symbols)
}
/// A big ribbon button whose picture is an equation; opens `menu`.
fn math_menu_button(ui: &mut Ui, app: &mut WordApp, picture: &str, label: &str, menu: impl FnOnce(&mut Ui, &mut WordApp)) {
let label = tl!(label);
@@ -154,8 +202,8 @@ pub fn insert_button(ui: &mut Ui, app: &mut WordApp) {
let (r, _) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::hover());
let top = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + BIG_LABEL_Y - 2.0));
let bottom = Rect::from_min_max(pos2(r.min.x, r.min.y + BIG_LABEL_Y - 2.0), r.max);
let rt = ui.interact(top, ui.id().with("eq_insert_top"), Sense::click());
let rb = ui.interact(bottom, ui.id().with("eq_insert_bottom"), Sense::click());
let rt = ui.interact(top, ui.scope_id().with("eq_insert_top"), Sense::click());
let rb = ui.interact(bottom, ui.scope_id().with("eq_insert_bottom"), Sense::click());
for (rr, resp) in [(top, &rt), (bottom, &rb)] {
if resp.hovered() {
ui.painter().rect_filled(rr, 4.0, t.hover);
@@ -178,10 +226,10 @@ pub fn insert_button(ui: &mut Ui, app: &mut WordApp) {
/// The Symbols group: a strip of the current set's symbols, and a menu with every set.
fn symbols(ui: &mut Ui, app: &mut WordApp) {
let id = egui::Id::new("eq_symbol_set");
let id = egui::Id::unique("eq_symbol_set");
let set = ui.data(|d| d.get_temp::<usize>(id)).unwrap_or(0).min(SYMBOL_SETS.len().saturating_sub(1));
let Some((_, chars)) = SYMBOL_SETS.get(set) else { return };
let start_id = egui::Id::new("eq_symbol_start");
let start_id = egui::Id::unique("eq_symbol_start");
let per_row = 10usize;
let rows = 3usize;
let all: Vec<char> = chars.chars().collect();
@@ -247,6 +295,8 @@ fn symbols(ui: &mut Ui, app: &mut WordApp) {
});
}
/// Draws one equation-tab symbol button, paints its preview, shows the linear name and codepoint
/// in a tooltip, and returns whether it was clicked.
fn symbol_button(ui: &mut Ui, app: &mut WordApp, c: char, t: &Tokens) -> bool {
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
ui.painter().rect_filled(r, 2.0, t.input);
@@ -256,14 +306,16 @@ fn symbol_button(ui: &mut Ui, app: &mut WordApp, c: char, t: &Tokens) -> bool {
let s = c.to_string();
let nodes = vec![wordcraft_doc::math::MNode::Run(wordcraft_doc::math::MRun { text: s.clone(), lit: true, ..Default::default() })];
paint_math(app, ui, r, &format!("sym:{s}"), &s, Some(nodes), false, 11.0);
let name = wordcraft_doc::math_symbols::name_of(c).map(|n| format!("{s} \\{n}")).unwrap_or_else(|| format!("{s} U+{:04X}", c as u32));
let display = math_placeholder(ui, &s);
let name =
wordcraft_doc::math_symbols::name_of(c).map(|n| format!("{display} \\{n}")).unwrap_or_else(|| format!("{display} U+{:04X}", c as u32));
resp.on_hover_text(name).clicked()
}
/// The Equation tab.
pub fn show(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Tools", None, app, |ui, app| {
math_menu_button(ui, app, "∑_(i=1)^n", "Equation", builtin_menu);
math_menu_button(ui, app, EQUATION_PICTURE, "Equation", builtin_menu);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing = vec2(1.0, 0.0);
let latex = app.session.math_latex;
@@ -378,3 +430,27 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
});
});
}
#[cfg(test)]
mod placeholder_tests {
/// `∁` falls back to `\complement` in the placeholder while covered `x+y` is unchanged.
#[test]
fn a_missing_math_glyph_has_a_readable_linear_placeholder() {
let ctx = egui::Context::default();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
assert!(!ui.fonts_mut(|fonts| fonts.has_glyph(&super::regular(11.0), '∁')));
assert_eq!(super::math_placeholder(ui, "x+\u{2201}"), "x+\\complement");
assert_eq!(super::math_placeholder(ui, "x+y"), "x+y");
});
output.textures_delta.clear();
}
#[test]
fn a_missing_math_glyph_falls_back_to_its_linear_name() {
// Without craft-fonts the interface fonts may have no `┬`: the Equation button's picture
// then reads `lim\below(n→∞)`. That is still math input, not interface wording.
let covered = |c: char| c.is_ascii() || matches!(c, '∞' | '→');
assert_eq!(super::math_placeholder_with("lim┬(n→∞)", covered), "lim\\below(n→∞)");
assert!(super::placeholder_linears().any(|linear| linear == "lim┬(n→∞)"));
}
}
+12 -7
View File
@@ -310,7 +310,7 @@ pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) {
let t = Tokens::get(ctx);
let mut done: Option<Option<PathBuf>> = None;
let window = egui::Window::new(tl!(b.title()))
.id(egui::Id::new("builtin_file_browser"))
.id(egui::Id::unique("builtin_file_browser"))
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, vec2(0.0, -20.0));
@@ -348,7 +348,7 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option<Option<PathBuf>>
{
b.navigate(h);
}
let r = ui.add(egui::TextEdit::singleline(&mut b.location).desired_width(f32::INFINITY));
let r = ui.add(egui::TextEdit::singleline(&mut b.location).desired_width(f32::INFINITY)).accessible_name(tl!("Folder path"));
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
location_done = true;
let typed = b.location.clone();
@@ -440,11 +440,16 @@ fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option<Option<PathBuf>>
}
let before = b.filter;
let current = b.filters.get(b.filter).map(|(l, _)| tl!(l.as_str()).to_string()).unwrap_or_default();
egui::ComboBox::from_id_salt("builtin_file_browser_type").selected_text(current).width(150.0).show_ui(ui, |ui| {
for (i, (l, _)) in b.filters.iter().enumerate() {
ui.selectable_value(&mut b.filter, i, tl!(l.as_str()));
}
});
egui::ComboBox::from_id_salt("builtin_file_browser_type")
.selected_text(current)
.width(150.0)
.show_ui(ui, |ui| {
for (i, (l, _)) in b.filters.iter().enumerate() {
ui.selectable_value(&mut b.filter, i, tl!(l.as_str()));
}
})
.response
.accessible_name(tl!("File type"));
refilter = b.filter != before;
});
if refilter {
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more