Merge upstream/main into control-mcp-delivery-1326-1331

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76andClaude Sonnet 5.5 committed 2026-11-06 01:10:42 +01:00
commit 4e334166e5
119 files changed
+5994 -528

No files matched your search

+20 -1
View File
@@ -7,11 +7,30 @@ updates:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
# Minor and patch updates share one PR. A breaking bump gets its own PR so one API change
# doesn't hold the safe updates back (#2973). For cargo, Dependabot counts a 0.y bump of a
# 0.x crate as major, as Cargo does. A dependency goes to the first group it matches, so the
# crate families that must move together come before the catch-all.
groups:
egui:
patterns:
- "egui"
- "egui_*"
- "eframe"
- "winit"
fonts:
patterns:
- "skrifa"
- "harfrust"
hayro:
patterns:
- "hayro-*"
cargo-dependencies:
patterns:
- "*"
update-types:
- "minor"
- "patch"
commit-message:
prefix: "chore(deps)"
Generated
+12
View File
@@ -1157,6 +1157,15 @@ dependencies = [
"zip 9.1.0",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "craft-window"
version = "0.1.0"
@@ -6815,6 +6824,7 @@ dependencies = [
"wordcraft-engine",
"wordcraft-fonts",
"wordcraft-ui-egui",
"x11rb",
"zbus",
]
@@ -6880,6 +6890,7 @@ dependencies = [
"log",
"proptest",
"quick-xml 0.41.0",
"serde_json",
"sha1 0.11.0",
"sha2 0.11.1",
"thiserror 2.0.21",
@@ -7084,6 +7095,7 @@ dependencies = [
name = "wordcraft-web"
version = "0.15.1"
dependencies = [
"craft-web-host",
"eframe",
"egui",
"js-sys",
+12 -1
View File
@@ -299,10 +299,21 @@ adapter. The log file records the choice (lines starting with
RISC-V (riscv64): `wordcraft-<ver>-linux-riscv64.tar.gz`, a tarball cross-compiled for glibc 2.39+
(Ubuntu 24.04, Debian 13 and newer).
**Read Aloud** (Review › Read Aloud) speaks through a speech program installed on the system: it
tries `espeak-ng`, then `espeak`, and when neither is installed it talks to Speech Dispatcher.
The `.rpm` package recommends `espeak-ng`; the `.deb` recommends `espeak-ng` or
`speech-dispatcher`, so the package manager installs one of the two by default. With the
AppImage or the tarball, install one yourself if neither is there (`sudo apt install espeak-ng`,
`sudo dnf install espeak-ng`, `sudo pacman -S espeak-ng`). The Flatpak doesn't speak yet: it has
no audio access and doesn't include `espeak-ng`, `espeak` or Speech Dispatcher.
**Wayland:** on a Wayland desktop the app runs through XWayland when it is available, because
winit (the windowing library) supports dragging files into the window only on X11. To use the
native Wayland backend instead (for example for sharper text with fractional scaling, at the cost
of file drag-and-drop), start it with `WORDCRAFT_WAYLAND=1`:
of file drag-and-drop), choose **File › Options › Advanced › Linux display server › Wayland**; it
applies the next time WordCraft starts, and **X11 (XWayland)** or **Automatic** switches back. The
`WORDCRAFT_WAYLAND` environment variable overrides the setting: `1` for native Wayland, `0` for
XWayland:
```sh
WORDCRAFT_WAYLAND=1 wordcraft
+34 -1
View File
@@ -4,8 +4,9 @@
**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-11-04 · **Change:** trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: ODT display settings removed); previously trivial (progress log: dialogs are modal except the five Word keeps modeless, and a press beside one shakes it, #3859); previously trivial (progress log: picture brightness, contrast and transparency, ODT element audit, ODT checked against 220 files from other applications, ODT display styles); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: ODT sections inside text boxes); previously trivial (progress log: ODT definitions sharing a name, sections around tables, stacking order); previously trivial (progress log: ODT forms and indexes written as read); previously trivial (progress log: ODT table styles saved, WordCraft-origin documents checked in LibreOffice); previously trivial (progress log: ODT interpretation profile); previously trivial (progress log: ODT controls, custom shapes, in-line placement); previously trivial (progress log: ODT charts kept, frame outlines, header height); previously trivial (progress log: ODT header rows); previously trivial (progress log: ODT layout setting, padding, columns); previously trivial (progress log: ODT tables, header spacing, picture paragraphs); previously trivial (progress log: ODT round trips keep their page count); previously trivial (progress log: emphasis marks, SET fields, ODT page backgrounds and tables); previously trivial (progress log: ODT checked against LibreOffice's templates); previously trivial (progress log: ODT at ~90%); previously trivial (progress log: ODT read and written nearly whole); previously trivial (progress log: ODT text boxes); previously trivial (progress log: ODT layout settings); previously trivial (progress log: ODT character styles); previously trivial (progress log: ODT custom styles); previously trivial (progress log: ODT equations saved); previously trivial (progress log: ODT floating pictures); previously trivial (progress log: ODT paragraph borders); previously trivial (progress log: ODT heading numbering); previously trivial (progress log: ODT table borders); previously trivial (progress log: ODT heading styles); previously trivial (progress log: ODT tab stops); previously trivial (progress log: ODT character formatting); previously trivial (progress log: ODT lists); previously trivial (progress log: ODT comments); previously trivial (progress log: ODT footnotes and endnotes); previously trivial (progress log: ODT headers, footers and fields); previously trivial (progress log: ODT paragraph spacing); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns forLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: the status bar's count can be words, characters or characters without spaces, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (progress log: Linux Read Aloud through Speech Dispatcher, #4576); previously trivial (progress log: a click just outside a floating table by turned cell text, #4181; 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 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); previously trivial (progress log: MHT quoted MIME parameters); previously trivial (progress log: CLI convert format options); previously trivial (MATH coLine truncated
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
> **Legacy form update:** 2026-10-22 · trivial: numeric XML references decode in field names and labels; readiness estimates unchanged.
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
agent control. This page is the summary; the assessment is
@@ -139,6 +140,10 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
## Progress log
- 2026-11-04: CLI `convert` rejects a missing or repeated `--format` value before opening the input or writing output, matching the existing page/scale option checks. Readiness estimates unchanged.
- 2026-11-04: MHT import keeps semicolons and escaped quotes inside quoted MIME parameters, so an unrelated parameter cannot supply a false multipart boundary and hide the document text. Readiness estimates unchanged.
- 2026-10-28: Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open.
- 2026-11-04: OpenDocument files have no display settings any more (gap 19): File › Options › Advanced › OpenDocument files, `file.options` `odf`, `WORDCRAFT_ODF`, the LibreOffice style and custom values are gone. WordCraft makes one choice for each case the standard leaves open, the one the default made, and still follows what a file states ([docs/odf-interpretation.md](docs/odf-interpretation.md)).
- 2026-10-31: Dialogs are modal, as in Word (#3859): while one is open, the ribbon, File, the page and the panes of its document window take no clicks, and typing and shortcuts (Ctrl+O…) do nothing until it closes; WordCraft's own Open/Save As window is modal too. A press beside a modal dialog shakes it a little, so it is clear why nothing behind it reacts (Word beeps); with animation effects off, its border flashes instead. Find and Replace, Symbol, Mark Index Entry, Cross-reference and Insert Merge Field stay modeless, as in Word for Mac 16.114.1 (checked on a newer build than the 16.113.4 the parity target names).
- 2026-11-04: OpenDocument files are shown in one of two styles (gap 19): Microsoft Word style, the default, or LibreOffice style (File › Options › Advanced › OpenDocument files; `file.options` `{"odf": {"preset": "word"}}` or `"libreoffice"`; `WORDCRAFT_ODF=word` or `WORDCRAFT_ODF=libreoffice`). Custom stays for one's own value per case; `wordcraft`, the earlier id of Word's style, is still taken.
@@ -235,6 +240,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-10-22: Recovery covers every open document. Each dirty document keeps its own copy, written on the same ten-second cadence whether it is shown or not; closing a document without saving deletes its copy, as saving does, and a clean exit deletes the copies of every document saved or closed since its last copy. File › Recover lists only copies no open document keeps, and Restore opens a copy as another document (or in place of an untouched blank one), so it never replaces work. After a crash with several unsaved documents, the next start lists all of them.
- 2026-10-22: The desktop window works with several open documents (one window per document comes next). View › Window › Switch Windows lists them and shows the one chosen, back where it was left; New, Open and the mailings open beside unsaved work instead of asking about it; File › Close shows the next document; quitting asks about every document with unsaved changes, one at a time, and Cancel keeps the app open.
- 2026-10-22: Several open documents in one session (engine part of one window per document). File › New opens a new document beside the open ones; File › Open opens beside them too, replaces a blank document nobody has used, and shows a file that is already open instead of opening it twice. Each document keeps its own text, selection, undo history, find state, view and recovery copy. View › Window › Switch Windows (`view.switchWindows`) lists the open documents and switches between them; `file.closeDocument` closes one where no window asks about unsaved changes (agents, headless sessions). Envelopes, Labels, Finish & Merge and New from Template open their result as a new document and leave the main document open as it was. Document ids are unique in the process. The desktop app still shows one document per window; the windows come next.
- 2026-10-22: Legacy form names and selected drop-down labels decode numeric XML references, including saved whitespace, without decoding escaped reference text twice.
- 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).
@@ -244,6 +250,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 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-24: A click just outside a floating table, within 2pt of a cell whose text is turned (Text Direction, or a section whose text runs down or up the page), goes to the text beside or after the table, as it already did by upright cell text, instead of putting the caret in that cell (#4181).
- 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.
@@ -293,10 +300,13 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| Date | What landed |
| 2026-11-04 | Pictures keep the brightness, contrast and transparency their file gives them (#1711, #1766): a "Washout" picture watermark (VML `gain`/`blacklevel`) and a washed-out letterhead picture behind the text (`a:lum`) are drawn faded on screen and in PDF instead of at full colour, with the pixel curve measured on Word's own PDF export; `a:alphaModFix` transparency too. Saved back as DrawingML (and as VML `gain`/`blacklevel` in Word 2003 XML, without the transparency). Picture Format › Corrections, Transparency and Color › Washout replace the file's values (other Color modes leave them alone), and Reset Picture discards them all, as in Word; the PDF embeds the corrected pixels once per picture |
|---|---|
| 2026-10-17 | Copies keep their formatting in programs that read RTF rather than HTML: Copy and Cut put RTF beside the text and HTML on the system clipboard on Windows, macOS and Wayland (an X11 copy stays text and HTML, since arboard owns the selection there; a copy of only pictures or shapes carries no HTML or RTF, so pasting it back keeps every detail), and Paste reads another program's RTF on macOS and X11 too, not only on Windows and Wayland |
| 2026-11-03 | **Arabic interface (#1195):** `ar` selectable (and every `ar-*` system locale); craft-i18n catalog; mirrored ribbon, panes, File page, dialogs and menus; Arabic UI font fallback; document direction stays independent. No kashida or Arabic proofing yet; Hebrew UI still open. |
| 2026-11-01 | **English spelling reads SCOWL 2026 (CP2, #40, #520, #1281, #1045):** en-US, en-GB (also en-AU, en-NZ, en-IE, en-IN…) and en-CA each check against their own SCOWL `rel-2026.02.25` Hunspell dictionary from craft-i18n, embedded and read by craft-proof's affix engine (which now understands `COMPOUNDRULE`, so ordinals like `21st` are words). Suggestions come from the dialect's own dictionary; our American/British policy (`en-variants.txt`) still decides the other dialect's spellings, because Word accepts more than SCOWL's one spelling per dialect (`organize` and `organise` in en-GB). Rare words the 1990s Moby list accepted (`aposiopesis`) are now flagged. The Moby list stays for hyphenation and as a fallback. The build carries 1.5 MB more Language data. |
| 2026-11-01 | Proofing moved to the shared libraries, with nothing users see changing: the code is `craft-proof` in storytold/craft-libs (with its history) and the spelling and hyphenation data (the Moby list, our English patterns and word lists, the German, Polish, Portuguese and Italian patterns, `ru-extra`) is craft-i18n's `language-data/`, embedded from `CRAFT_I18N_DIR` at the commit in `craft-i18n.rev`. Before/after `wordcraft-cli` runs on a proofing document give the same issues, suggestions and hyphenated PDF (CP1). |
| 2026-10-28 | Fonts a document uses that aren't installed are no longer silent: the font box shows a warning mark (hover names the font drawn instead) and the status bar says how many are missing, listing them (the first 20, then how many more) with the faces drawn instead and Add Font…. The list is what layout actually drew, so hidden text, unused styles and orphaned stories don't count; substitutes that measure the same (Carlito for Calibri, Liberation for Arial) and Symbol/Wingdings bullets aren't flagged |
| 2026-10-21 | Read Aloud on Linux speaks through Speech Dispatcher when neither `espeak-ng` nor `espeak` is installed (#4576). It talks to the server over its own socket (SSIP), so the text is never taken for a command and stopping cancels only WordCraft's own speech, not a screen reader's; each sentence goes in its language, region included, and a sentence a screen reader's announcement cuts off is said again after a short pause; if another program keeps stopping it, reading pauses on it with a message. With no speech program at all, Read Aloud names the package to install, and the `.deb` and `.rpm` packages recommend one. |
| 2026-10-28 | **Arabic diacritic editing (#4757):** Backspace removes the last stored Arabic combining mark, so its base letter can stay while the mark is replaced. Caret movement and forward Delete keep their cluster rules. Synthetic command, history, protection and native/DOCX fresh-reopen checks; no native Word comparison or readiness change. |
| 2026-10-29 | Insert › Shapes shows the basic shapes as a grid of code-drawn icon tiles, each with an accessible name and tooltip, instead of a text list (#4349) |
| 2026-11-03 | A password-protected file named on the command line (double-clicked in the file manager) asks for its password when the first blank document was replaced because File › Options chose a region; the window now follows that replacement at once, so the first frame no longer takes it for a document switch and drops the Password box (#5299) |
@@ -305,6 +315,8 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-29 | A NUL in a document title no longer reaches the native window title, which panicked on X11 (#3634): displayed titles and suggested file names drop NUL characters and fall back to the untitled name when empty; the stored title is unchanged |
| 2026-10-28 | The Traditional Chinese interface draws hanzi to the Taiwan standard: on the desktop an installed Traditional Chinese font (PingFang TC on macOS, Microsoft JhengHei on Windows, Noto Sans CJK TC on Linux) comes before the embedded Noto Sans CJK SC, which drew 說, 這, 為, 戶 in mainland shapes; without one, and on the web, nothing changes. |
| 2026-10-17 | macOS launch arguments (`-psn_*`, `-NS*`, `-Apple*`) are no longer opened as files, and a file that fails to open names the file and the real cause on the status bar and in the log (#1103). The reported "corrupt" error could not be reproduced. |
| 2026-10-19 | Linux: File › Options › Advanced › Linux display server picks Automatic (the default: XWayland where there's an X server, for file drag-and-drop), Wayland or X11 (XWayland), so people who don't start WordCraft from a terminal can switch without an environment variable (#2184). It's kept in `ui.json` and applies at the next launch; `WORDCRAFT_WAYLAND` still overrides it, and `WORDCRAFT_WAYLAND=0` now asks for X11. A choice that isn't available falls back to the other instead of failing to start, and the log's `display:` line says which ran and why. Hidden on Windows, macOS and the web; `ui.displayServer` for agents. |
| 2026-10-19 | The status bar's count is selectable (#2797): words (default), characters (with spaces) or characters (no spaces), from a right-click menu on the count itself or File › Options › Display › Status bar (`ui.statusCount`), kept in `ui.json` between runs; a selection shows "selected of total" in the chosen unit, counted exactly as the Word Count dialog counts them, whose numbers and meaning are unchanged. |
| 2026-10-14 | Insert › Date & Time and DATE, TIME and other date fields show the computer's local date, time and weekday, daylight saving included, as Word does (#1707); they showed UTC, so a user in New Zealand at noon on 31 October got 30 October 23:00. Timestamps saved in files (revisions, comments, document properties) stay UTC. Backstage's greeting follows the local hour too |
| 2026-10-23 | File > Print: N-up physical sheets and left/right-bound booklets, shared preview/PDF geometry and padded copies (#641); printer jobs keep upstream’s link-annotation fix. |
| 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) |
@@ -758,6 +770,9 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
Footer page-table height and body wrapping are corrected. Other pagination gaps remain open.
Section page-numbering settings preserve `w:chapStyle` values across the full unsigned-byte range.
MATH glyphs that reference the same construction now share its immutable variants and assembly data (#3630). Glyph mappings, values and the existing partial-record fallback stay unchanged. Repeated glyph IDs keep their last valid reference without retaining superseded constructions. Distinct overlapping constructions and assembly-only sharing remain separate.
## Revision history
> 2026-11-03 · Arabic dialogs read right to left (rtl::Table); recorded in the progress log, readiness unchanged.
@@ -773,12 +788,19 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | trivial | Control/MCP #2596 review fixes; readiness estimates unchanged |
| 2026-11-05 | trivial | PR #4545: eligible cell floats retain in-bounds offsets and touch the outer cell edge only on overflow, ignoring wrap distances for object placement. Front-of-text, turned-cell and outside-cell opt-outs remain. Synthetic geometry controls; native front/behind-text parity remains unqualified. |
| 2026-11-04 | trivial | Progress log: CLI convert format options; readiness estimates unchanged |
| 2026-11-04 | trivial | Progress log: MHT quoted MIME parameters; readiness estimates unchanged |
| 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` is preserved with true, false and absent values; OOXML properties follow schema order. Grid layout remains open. |
| 2026-10-28 | trivial | Per-page ink object lookups, #3614. Drawing order, geometry and saved data retain their behavior; no readiness estimate change |
| 2026-10-28 | trivial | Shared MATH construction records reuse variants and assembly data without changing glyph mappings or existing limits (#3630); readiness estimates unchanged |
| 2026-11-04 | trivial | Progress log: OpenDocument display settings removed (gap 19) |
| 2026-10-31 | trivial | Progress log: dialogs are modal except the five Word keeps modeless; a press beside one shakes it (#3859) |
| 2026-11-04 | trivial | Progress log: picture brightness, contrast and transparency (#1711, #1766) |
| 2026-11-04 | trivial | Progress log: ODT element audit (gap 19) |
| 2026-11-04 | trivial | Progress log: ODT checked against 220 files from other applications (gap 19) |
| 2026-11-04 | trivial | Progress log: OpenDocument files shown in Microsoft Word style or LibreOffice style (gap 19) |
| 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. |
| 2026-11-01 | trivial | Progress log: SEQ, STYLEREF and REF fields show fresh results in output |
| 2026-10-29 | trivial | Progress log: Print Markup balloon anchors use one object walk per paragraph (#3614); readiness estimates unchanged |
| 2026-10-28 | trivial | Print Markup text-copy comment discovery walks paragraph objects once (#3614); quotes, order, limits and readiness estimates unchanged |
@@ -786,6 +808,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-11-03 | trivial | Progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language (CP4.3) |
| 2026-11-03 | trivial | Progress log: Options › Language and Proofing redesign (CP4.3) |
| 2026-10-30 | trivial | Progress log: layout resource caps — repeated table headers are bounded by item counts, the wrap cache by decoded pixels and linked text box layout by its lines and items; PDF export, hit testing, tracked changes, clipped tables and long endnote selections cost linear time (#3867, #3866) |
| 2026-10-17 | trivial | Progress log: RTF on the system clipboard |
| 2026-11-03 | trivial | Progress log: craft-proof pin, French elision suggestions and Valencian (CP4.3) |
| 2026-11-03 | trivial | Progress log: a long comment's card and balloon stop at ten lines (#3382) |
| 2026-11-03 | trivial | Progress log: Replace and Remove Table of Contents with Word's title above a section break (#4041) |
@@ -803,6 +826,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-31 | trivial | Progress log: a list level above counts its first number at the first deeper item |
| 2026-11-01 | trivial | Progress log: paragraph side borders lie outside the text (#5036) |
| 2026-11-01 | trivial | Progress log: proofing moved to craft-proof (craft-libs) and its data to craft-i18n `language-data/` (CP1) |
| 2026-11-01 | trivial | #3915: preserve guarded whole-line ordinary-tab alignment with current main. Existing controls retained; source checks only, primary qualification pending. |
| 2026-10-31 | trivial | Progress log: a paragraph naming an undefined DOCX style is formatted as Normal (#5077) |
| 2026-10-31 | trivial | Progress log: TOC page numbers are PAGEREF fields (#5097) |
| 2026-10-31 | trivial | Progress log: Backstage keytips, Alt, F, P prints (#4960) |
@@ -810,6 +834,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-29 | trivial | Progress log: documents with links print (#4734) |
| 2026-10-31 | trivial | Synchronize the existing Proofing pane with main `eecbb8f`; preserve explicit-position lookup, implicit selection, navigation, undo and per-window state; fresh exact-SHA hosted validation pending, readiness unchanged |
| 2026-10-30 | trivial | Synchronize the existing Proofing pane and navigation/selection/undo regressions with main `3b7f7ed`; new-SHA validation pending, readiness unchanged |
| 2026-10-31 | trivial | Progress log: Linux Read Aloud through Speech Dispatcher (#4576) |
| 2026-10-28 | trivial | Aligned copies retain their locked document coordinate during middle-button scrolling and can start on secondary selected objects; existing Ctrl and Shift selection behavior stays (#4764) |
| 2026-10-28 | trivial | Ctrl/Command+Shift drags a selected floating object into an axis-aligned copy (#4764); synthetic Linux interaction and persistence controls, native desktop and Word untested; gap/readiness estimates unchanged |
| 2026-10-28 | trivial | Connector object lookups retain the typed column-selection inputs on current main (#3631); readiness estimates unchanged |
@@ -825,13 +850,17 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-29 | trivial | Progress log: window fallback geometry and equation symbol tooltips |
| 2026-10-29 | trivial | Progress log: window titles omit NUL characters (#3634) |
| 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. |
| 2026-10-19 | trivial | Progress log: Linux display server setting (#2184) |
| 2026-10-19 | trivial | Progress log: the status bar's count can be words, characters or characters without spaces (#2797) |
| 2026-10-27 | trivial | Progress log: Return on File › Print prints, as Word's default Print button does |
| 2026-10-27 | trivial | PR #4609: Numeric cross-references (#4533): preserve the numbered clause through field updates; adapted from @Alyosha-dot-shop's issue patch, with extra relative-context and cache guards. Synthetic checks only; readiness estimates unchanged. |
| 2026-10-26 | minor | Word-observed Proofing task pane uses shared review commands; clicking a suggestion corrects and advances; context, suggestion cards and ignore/dictionary links follow the Windows reference; inspected previews prompted a full-width context and scrollable three-card list; exact selected spelling takes priority over overlapping grammar, with command/pane regressions and integration workflow paths; readiness estimates unchanged |
| 2026-10-14 | trivial | Progress log: Insert Date and Time and DATE/TIME fields use local time (#1707) |
| 2026-10-22 | trivial | Progress log: one native window per open document |
| 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy |
| 2026-10-22 | trivial | Progress log: several open documents in the desktop window |
| 2026-10-22 | trivial | Progress log: several open documents in one session |
| 2026-10-22 | trivial | Decode numeric XML references once in legacy form names and drop-down labels |
| 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) |
@@ -847,6 +876,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 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-24 | trivial | Progress log: a click just outside a floating table by turned cell text goes to the text after it (#4181) |
| 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) |
@@ -1209,3 +1239,6 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-27 | trivial | Progress log: AutoCorrect Options with no document open (#4585) |
| 2026-10-28 | trivial | Progress log: RTF metafile pictures are imported (#4259) |
| 2026-10-28 | trivial | Progress log: Chinese and Japanese text counts a word per character (#4547) |
| 2026-10-27 | trivial | 2026-10-27 · #4533 review: numeric REF updates lazily index body bookmarks once per update, retaining the first body bookmark for duplicate names. Unsupported switches and ordinary REF/PAGEREF behavior are unchanged; a synthetic 64-reference regression covers repeated updates and duplicate names. Estimates unchanged. |
| 2026-10-27 | trivial | 2026-10-27 · #4533: numeric REF fields use paragraph list numbers for no/full/relative context, reusing bounded numbering. Shared context already embedded in a level label is removed; merged-away cells do not count. Unsupported separator/text/position switches and ambiguous contexts or delimiter-free joins retain cached results. Synthetic OOXML regressions; no native Word or real-document validation, estimates unchanged. |
> 2026-10-29 · Port #3915 to split layout modules: ordinary LTR tab groups follow paragraph alignment. Positional tabs, lists, RTL and display math retain their alignment paths. Source port only; no test execution or new native measurements.
+6 -1
View File
@@ -430,7 +430,12 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
"convert" => {
validate_options("convert", &rest, CONVERT_OPTIONS)?;
// `--format wordml2003` (or `flatopc`) picks the kind of `.xml` file; its value isn't a path.
let format = arg_value(&rest, "--format");
let format = match find_option_arg(&rest, "--format") {
OptionArg::Absent => None,
OptionArg::Duplicate => return Err("convert: duplicate --format option".into()),
OptionArg::MissingValue => return Err("convert: --format requires a value".into()),
OptionArg::Value(value) => Some(value),
};
let paths = positionals(&rest, CONVERT_OPTIONS);
let (i, o) = (paths.first().ok_or_else(missing_argument)?.to_string(), paths.get(1).ok_or_else(missing_argument)?.to_string());
let markup = rest.iter().any(|a| a == "--markup");
+16
View File
@@ -75,6 +75,22 @@ fn unknown_options_are_rejected_before_file_access() {
assert_eq!(run(&args(&["zotero", "refresh", "missing.docx", "--prot", "1"])), Err("zotero: unknown option --prot".into()));
}
#[test]
fn convert_rejects_malformed_format_options_before_file_access() {
for (options, error) in [
(vec!["--format"], "convert: --format requires a value"),
(vec!["--format", "--markup"], "convert: --format requires a value"),
(vec!["--format", "flatopc", "--format", "wordml2003"], "convert: duplicate --format option"),
] {
let mut argv = args(&["convert", "missing.docx", "out.xml"]);
argv.extend(args(&options));
assert_eq!(run(&argv), Err(error.into()), "{options:?}");
}
// A valid single value reaches file access, rather than a format-option error.
let error = run(&args(&["convert", "missing.docx", "out.xml", "--format", "flatopc"])).unwrap_err().to_string();
assert!(error.starts_with("missing.docx:"), "{error}");
}
#[test]
fn help_or_h_after_a_subcommand_asks_for_the_usage() {
for cmd in SUBCOMMANDS {
+3 -1
View File
@@ -16,6 +16,8 @@ path = "src/main.rs"
[dependencies]
wordcraft-engine = { workspace = true }
wordcraft-ui-egui = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
@@ -29,4 +31,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "BeforeUnloadEvent", "ClipboardEvent", "DataTransfer", "FileList", "File", "DomStringList", "IdbFactory", "IdbOpenDbRequest", "IdbRequest", "IdbDatabase", "IdbObjectStore", "IdbTransaction", "IdbTransactionMode", "MediaQueryList", "Storage", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
+65 -107
View File
@@ -1,14 +1,20 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! WordCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
//! same messages as GridCraft's and PdfCraft's bridges; each is a plain object with a `type`:
//! Host modes: a page that runs WordCraft (as an `<iframe>`, or in its own document) opens
//! documents in it and stores what it saves instead of the browser downloading. The messages and
//! the two transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the
//! other Craft apps' bridges, with the `wordcraft:` prefix. This module is WordCraft's part of it.
//!
//! - `wordcraft:ready` (to the parent): `{ version }`, sent once the app is running and
//! listening; send a file now.
//! - `wordcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`. It opens like a document picked with File › Open or dropped on the window.
//! - `wordcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
//! every file WordCraft would otherwise download. The parent stores it and reports failures
//! itself. `saveAs` says whether the file becomes the document's file:
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
//! `postMessage`; `data-host="page"` on the canvas (the page's own document, no iframe) uses
//! DOM `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
//! - `wordcraft:ready` once the app is running and listening; send a file now. WordCraft reads
//! bytes, so it says `file: false` and a host sends `bytes`.
//! - `wordcraft:open` (from the host): `{ name, bytes }`. It opens like a document picked with
//! File › Open or dropped on the window; a file over [`MAX_OPEN_BYTES`] is refused before it is
//! copied.
//! - `wordcraft:save` (to the host): `{ name, saveAs, bytes }` for every file WordCraft would
//! otherwise download. The host stores it and reports failures itself. `saveAs` says whether
//! the file becomes the document's file:
//! - Save: `false`. It writes the opened file under its own name and format, as the desktop
//! app does. A name the parent never sent (an untitled document, an imported .doc saved as
//! .docx) is a new file: `true`, as for Save As.
@@ -17,70 +23,57 @@
//! is now that file; the next Save sends its name with `false`.
//! - Save As or Export to a format that doesn't (PDF, Markdown, plain text), Export as PDF and
//! Export Page as PNG: `false`. They are copies; the document's file stays the same.
//! - `wordcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `wordcraft:failed` (to the parent): `{ error }` when the app couldn't start.
//! - `wordcraft:dirty` whenever unsaved changes appear or go away, `wordcraft:failed` when the
//! app couldn't start.
//!
//! Only the parent window on the page's own origin is heard or answered. In host mode the parent
//! guards leaving the page, and `?author=` names the user for comments and tracked changes.
//! In both host modes the host sends the document to edit, so no sample opens, saves are handed
//! to the host rather than downloaded, the host guards leaving the page, and `?author=` names the
//! user for comments and tracked changes.
use std::cell::Cell;
use std::rc::Rc;
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use craft_web_host::web::{FileData, Host, Message};
use wordcraft_ui_egui::{Inbox, Services, WordApp};
/// The app id in the host messages (`wordcraft:ready`).
const APP: &str = "wordcraft";
/// The name a document gets when the host sent none (the crate says `document` for a blank name).
const DEFAULT_NAME: &str = "Document.docx";
/// The largest document taken from the host page, refused before it is copied.
const MAX_OPEN_BYTES: u64 = 512 << 20;
/// The longest user name taken from `?author=`.
const MAX_AUTHOR_CHARS: usize = 200;
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
host: Rc<Host>,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
/// on another origin: messages only go to this origin, so the browser would drop every save
/// without an error while the document showed as saved.
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
if origin == "null" {
return None;
}
// Reading another origin's location throws, which is `None` too.
if parent.location().origin().ok()? != origin {
return None;
}
Some(Self { window, parent, origin })
/// The bridge for this page, or `None` when WordCraft isn't hosted: no `?host=parent` and no
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
/// with downloads), or a parent on another origin.
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
}
/// Hand saved and exported files to the parent instead of downloading them, report unsaved
/// Hand saved and exported files to the host instead of downloading them, report unsaved
/// changes to it, and hear the files it sends.
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
self.listen(inbox, ctx);
let bridge = self.clone();
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
// Anything else that would download goes to the parent too.
// Anything else that would download goes to the host too.
let bridge = self.clone();
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| bridge.post_save(name, bytes, false)));
// The browser's save picker is never used, so no save finishes later.
services.async_saves = None;
let bridge = self.clone();
let reported: Rc<Cell<Option<bool>>> = Rc::default();
services.on_dirty = Some(Box::new(move |dirty: bool| {
if reported.get() != Some(dirty) && bridge.send(&[("type", "wordcraft:dirty".into()), ("dirty", dirty.into())]) {
reported.set(Some(dirty));
}
}));
// The crate does not repeat a state it already reported.
let host = self.host.clone();
services.on_dirty = Some(Box::new(move |dirty: bool| host.post_dirty(dirty)));
}
/// `?author=`: the host's name for the user signs comments and tracked changes.
@@ -92,78 +85,43 @@ impl Bridge {
}
pub fn post_ready(&self) {
self.send(&[("type", "wordcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "wordcraft:failed".into()), ("error", error.into())]);
let _ = self.host.post_failed(error, None);
}
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let message = message(&[
("type", "wordcraft:save".into()),
("name", name.as_str().into()),
("saveAs", save_as.into()),
("bytes", buffer.clone().into()),
])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
self.host.post_save(&name, bytes, None, save_as)
}
/// Hear `wordcraft:open` from the parent and queue the file like a dropped one.
/// Hear `wordcraft:open` and queue the file like a dropped one.
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
self.host.listen(move |message| {
if let Message::Open { name, data } = message {
deliver_open(&inbox, &ctx, &name, &data);
}
let data = event.data();
if string(&data, "type").as_deref() != Some("wordcraft:open") {
return;
}
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Document.docx".to_string());
let Some(bytes) = bytes(&data) else { return };
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
/// Post a message to the parent; false if it couldn't be sent.
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
}
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(array.to_vec())
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(&bytes).to_vec())
} else {
None
/// The file an open message carries, pushed onto the inbox for the next frame, like a dropped
/// file. A host's file is untrusted input: an oversized one is refused before it is copied into
/// the app's memory (the data's own length, not the host's claim). The crate has cleaned the name
/// (and cut it to 255 characters); a blank one comes as `document` and becomes `Document.docx`.
fn deliver_open(inbox: &Inbox, ctx: &egui::Context, name: &str, data: &FileData) {
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
let FileData::Bytes(array) = data else {
log::warn!("host file {name} came as a File; WordCraft reads bytes only; not opened");
return;
};
if data.size() > MAX_OPEN_BYTES as f64 {
log::warn!("host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20);
return;
}
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, array.to_vec()));
ctx.request_repaint();
}
+4 -2
View File
@@ -9,8 +9,10 @@
//! Preferences (theme, interface size, editing options…) are kept in `localStorage` (`prefs`).
//! URL flags: `?webgl` forces WebGL2; `?sample` opens the sample document.
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! document to open and stores what WordCraft saves (`host`), and `&author=` names the user for
//! comments and tracked changes.
//! document to open and stores what WordCraft saves (`host`). A page can also host WordCraft in
//! its own document (no iframe) with `data-host="page"` on the canvas, talking over DOM
//! `CustomEvent`s on the canvas. `&author=` names the user for comments and tracked changes in
//! either host mode.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
+6 -3
View File
@@ -28,9 +28,12 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// `?host=parent`: a same-origin page framing WordCraft opens documents in it and stores
// what it saves (`host`). That page guards leaving it, so this one doesn't.
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing WordCraft, opens documents in
// it and stores what it saves (`host`); unframed there is no parent, so that URL on its
// own stays the normal app with downloads. `data-host="page"` on the canvas opts a page
// in instead: it runs WordCraft in its own document and talks over DOM `CustomEvent`s
// on the canvas (`host`). The host guards leaving the page, so this one doesn't.
let bridge = crate::host::Bridge::detect(&canvas);
let app_bridge = bridge.clone();
let dirty = Rc::new(Cell::new(false));
let saves = AsyncSaves::default();
+7 -3
View File
@@ -38,13 +38,14 @@ fmv-macos-events = "=0.1.0"
# The system's Reduce motion setting (System Settings › Accessibility › Display), read through
# NSWorkspace: a safe call in these bindings. Already in the build through eframe, rfd and arboard,
# at this version; this adds the `NSAccessibility` bindings and, to fit the title bar around the
# traffic lights, the window, button and view ones.
# traffic lights, the window, button and view ones; and `NSPasteboard` for the clipboard's RTF.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility", "NSApplication", "NSWindow", "NSButton", "NSControl", "NSView", "NSResponder", "NSPasteboard"] }
objc2 = "0.6"
# A copied equation's MathML goes on the pasteboard as `public.mathml.presentation` beside
# arboard's HTML and text (#1297), through NSPasteboard's safe `setString:forType:`. Already in
# the build through arboard, at this version; this adds only the NSString bindings.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString"] }
# the build through arboard, at this version; this adds only the NSString bindings, and NSData
# for the clipboard's RTF (`public.rtf`).
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSData"] }
# Closing the window must quit through NSApplication rather than by dropping the NSWindow
# inside the AppKit run loop (see `schedule_macos_terminate`).
dispatch2 = "0.3"
@@ -58,6 +59,9 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
# types that arboard doesn't read, and the formats offered. Already in the build through arboard's
# `wayland-data-control` feature, at this version.
wl-clipboard-rs = "0.9"
# Another program's RTF on X11 (`text/rtf`), which arboard doesn't read. Already in the build
# through arboard, at this version.
x11rb = "0.13"
[target.'cfg(target_os = "windows")'.dependencies]
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
+222 -15
View File
@@ -18,6 +18,11 @@
//! they paste math (learn.microsoft.com/office/math/mathml, "Clipboard support"). Pasting into
//! Word and LibreOffice is not yet verified. Linux and the BSDs have no Word, and arboard can't
//! add MIME types of other names, so there the HTML carries the MathML alone.
//!
//! RTF goes beside them where we can add it: Windows ("Rich Text Format") and macOS
//! (`public.rtf`) after arboard's HTML, Wayland (`text/rtf`) in one offer with the text and HTML.
//! On X11 arboard owns the selection and serves only its own targets, so a copy there carries text
//! and HTML only; reading another program's RTF works on all four.
use wordcraft_engine::cmd::paste::MAX_PASTE_BYTES;
use wordcraft_ui_egui::paste_picture::ClipboardPicture;
@@ -31,17 +36,24 @@ thread_local! {
static WRITER: std::cell::RefCell<Option<arboard::Clipboard>> = const { std::cell::RefCell::new(None) };
}
/// Put `html` on the clipboard with `text` as its plain-text alternative, and `mathml` (a copy of
/// one equation alone; empty for any other copy) in the platform's MathML formats. `false` when
/// the HTML and text couldn't be set (the caller copies the text alone), including a Wayland
/// session without the data-control protocol, where arboard would fall back to X11 and native
/// Wayland programs might not see it. MathML that can't be added is logged: the HTML still
/// carries it.
pub fn set_html(html: &str, text: &str, mathml: &str) -> bool {
/// Put a copy on the clipboard: `text`, with `html` and `rtf` beside it where the platform takes
/// them (an empty one isn't offered), and `mathml` (a copy of one equation alone; empty for any
/// other copy) in the platform's MathML formats. `false` when the text and HTML couldn't be set
/// (the caller copies the text alone), including a Wayland session without the data-control
/// protocol, where arboard would fall back to X11 and native Wayland programs might not see it.
/// RTF or MathML that can't be added is logged: the HTML still carries the copy.
pub fn set_rich(text: &str, html: &str, rtf: &str, mathml: &str) -> bool {
// Wayland: one offer with the text, HTML and RTF (no MathML formats there; see above).
if platform::set_rich(text, html, rtf) {
return true;
}
if !platform::can_set() {
return false;
}
let set = set_html_text(html, text);
if set && !rtf.is_empty() {
platform::add_rtf(rtf);
}
if set && !mathml.is_empty() && mathml.len() <= MAX_PASTE_BYTES {
platform::add_mathml(mathml);
}
@@ -54,7 +66,8 @@ fn set_html_text(html: &str, text: &str) -> bool {
if slot.is_none() {
*slot = open();
}
match slot.as_mut().map(|c| c.set().html(html, Some(text))) {
let set = slot.as_mut().map(|c| if html.is_empty() { c.set_text(text) } else { c.set().html(html, Some(text)) });
match set {
Some(Ok(())) => true,
Some(Err(e)) => {
log::warn!("clipboard: {e}");
@@ -76,8 +89,8 @@ pub fn html() -> Option<String> {
open()?.get().html().ok().filter(|h| h.len() <= MAX_PASTE_BYTES)
}
/// The clipboard's RTF: Windows ("Rich Text Format") and Wayland (`text/rtf`); not offered by
/// arboard, so read here.
/// The clipboard's RTF: Windows ("Rich Text Format"), macOS (`public.rtf`), Wayland and X11
/// (`text/rtf`); not offered by arboard, so read here.
pub fn rtf() -> Option<String> {
let bytes = platform::rtf()?;
if bytes.len() > MAX_PASTE_BYTES {
@@ -176,15 +189,51 @@ mod platform {
)
}
/// Put the copy on a Wayland clipboard in one offer: the text (with the usual plain-text
/// aliases), HTML and RTF, served from a background thread until something else is copied.
/// `false` outside Wayland (X11 goes through arboard) or without the data-control protocol.
pub fn set_rich(text: &str, html: &str, rtf: &str) -> bool {
use wl_clipboard_rs::copy::{MimeSource, MimeType, Options, Source};
if !wayland() {
return false;
}
let offer = |mime: &str, data: &str| MimeSource { source: Source::Bytes(data.as_bytes().into()), mime_type: MimeType::Specific(mime.into()) };
// The text first: the plain-text aliases serve the first text type offered.
let mut sources = vec![offer("text/plain;charset=utf-8", text)];
for (mime, data) in [("text/html", html), ("text/rtf", rtf)] {
if !data.is_empty() {
sources.push(offer(mime, data));
}
}
match Options::new().copy_multi(sources) {
Ok(()) => true,
Err(e) => {
log::warn!("clipboard: {e}");
false
}
}
}
/// Wayland copies carry their RTF already; X11 ones can't (see the module docs).
pub fn add_rtf(_rtf: &str) {}
/// RTF types a clipboard may offer, best first.
const RTF_MIMES: [&str; 3] = ["text/rtf", "application/rtf", "text/richtext"];
/// The RTF type to ask for among those `offered`.
fn rtf_target(offered: &[String]) -> Option<&'static str> {
RTF_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))
}
/// arboard serves its own MIME types only; Word, which reads the MathML formats, isn't here.
pub fn add_mathml(_mathml: &str) {}
pub fn rtf() -> Option<Vec<u8>> {
if !wayland() {
return None;
return std::env::var_os("DISPLAY").is_some().then(|| x11::rtf(super::MAX_PASTE_BYTES)).flatten();
}
let offered = formats();
let mime = ["text/rtf", "application/rtf", "text/richtext"].into_iter().find(|m| offered.iter().any(|o| o == m))?;
let mime = rtf_target(&formats())?;
read(mime, super::MAX_PASTE_BYTES as u64)
}
@@ -196,6 +245,125 @@ mod platform {
let mime = super::PICTURE_MIMES.into_iter().find(|m| offered.iter().any(|o| o == m))?;
read(mime, wordcraft_ui_egui::paste_picture::MAX_FILE_BYTES)
}
/// Whether a property read of `len` bytes, with `bytes_after` left unread, is the whole value
/// and at most `cap` bytes.
fn complete(len: usize, bytes_after: u32, cap: usize) -> bool {
bytes_after == 0 && len <= cap
}
/// Reading another program's RTF on X11, which arboard doesn't offer: ask the CLIPBOARD
/// selection's owner for its targets, then for its RTF, into a property of a hidden window.
mod x11 {
use std::time::{Duration, Instant};
use x11rb::connection::Connection;
use x11rb::protocol::Event;
use x11rb::protocol::xproto::{Atom, AtomEnum, ConnectionExt as _, CreateWindowAux, GetPropertyReply, Window, WindowClass};
use x11rb::rust_connection::RustConnection;
/// How long a paste waits for the selection's owner, in all.
const TIMEOUT: Duration = Duration::from_secs(1);
/// Most targets looked at.
const MAX_TARGETS: usize = 128;
struct Reader {
conn: RustConnection,
win: Window,
clipboard: Atom,
prop: Atom,
deadline: Instant,
}
fn atom(conn: &RustConnection, name: &str) -> Option<Atom> {
conn.intern_atom(false, name.as_bytes()).ok()?.reply().ok().map(|r| r.atom)
}
impl Reader {
fn new() -> Option<Self> {
let (conn, screen) = x11rb::connect(None).ok()?;
let root = conn.setup().roots.get(screen)?.root;
let win = conn.generate_id().ok()?;
conn.create_window(0, win, root, 0, 0, 1, 1, 0, WindowClass::INPUT_ONLY, x11rb::COPY_FROM_PARENT, &CreateWindowAux::new()).ok()?;
let clipboard = atom(&conn, "CLIPBOARD")?;
let prop = atom(&conn, "WORDCRAFT_PASTE")?;
Some(Self { conn, win, clipboard, prop, deadline: Instant::now() + TIMEOUT })
}
/// The selection converted to `target`, at most `cap` bytes; `None` when the owner
/// refuses or doesn't answer in time. A longer value is `None` when `whole`, else
/// its first `cap` bytes.
fn convert(&self, target: Atom, cap: usize, whole: bool) -> Option<GetPropertyReply> {
self.conn.convert_selection(self.win, self.clipboard, target, self.prop, x11rb::CURRENT_TIME).ok()?;
self.conn.flush().ok()?;
loop {
// Checked every time round, so a stream of other events can't keep us here.
if Instant::now() >= self.deadline {
return None;
}
match self.conn.poll_for_event().ok()? {
Some(Event::SelectionNotify(e)) if e.requestor == self.win => {
if e.property == x11rb::NONE {
return None;
}
break;
}
Some(_) => {}
None => std::thread::sleep(Duration::from_millis(5)),
}
}
// In 32-bit units; for a whole value one more than the cap, so a larger one shows
// as `bytes_after`.
let units = u32::try_from(cap / 4 + usize::from(whole)).unwrap_or(u32::MAX);
let r = self.conn.get_property(true, self.win, self.prop, AtomEnum::ANY, 0, units).ok()?.reply().ok()?;
(!whole || super::complete(r.value.len(), r.bytes_after, cap)).then_some(r)
}
}
impl Drop for Reader {
fn drop(&mut self) {
let _ = self.conn.destroy_window(self.win);
let _ = self.conn.flush();
}
}
/// The CLIPBOARD selection's RTF, at most `cap` bytes. Copies too large to send at once
/// arrive in pieces (INCR), which aren't read: `None` then.
pub fn rtf(cap: usize) -> Option<Vec<u8>> {
let r = Reader::new()?;
// The first targets only: a longer list is cut short, not refused.
let targets = r.convert(atom(&r.conn, "TARGETS")?, MAX_TARGETS.saturating_mul(4), false)?;
let cookies: Vec<_> = targets.value32()?.take(MAX_TARGETS).filter_map(|a| r.conn.get_atom_name(a).ok()).collect();
let names: Vec<String> =
cookies.into_iter().filter_map(|c| c.reply().ok()).map(|n| String::from_utf8_lossy(&n.name).into_owned()).collect();
let target = atom(&r.conn, super::rtf_target(&names)?)?;
let incr = atom(&r.conn, "INCR")?;
let reply = r.convert(target, cap, true)?;
(reply.type_ != incr).then_some(reply.value)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rtf_target_prefers_text_rtf() {
let names = |n: &[&str]| n.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(rtf_target(&names(&["TARGETS", "text/richtext", "application/rtf", "text/rtf"])), Some("text/rtf"));
assert_eq!(rtf_target(&names(&["text/richtext", "UTF8_STRING"])), Some("text/richtext"));
assert_eq!(rtf_target(&names(&["text/html", "text/rtf;charset=x"])), None);
assert_eq!(rtf_target(&[]), None);
}
#[test]
fn property_reads_must_be_whole_and_capped() {
assert!(complete(10, 0, 10));
assert!(!complete(11, 0, 10), "over the cap");
assert!(!complete(8, 4, 10), "more left to read");
assert!(complete(0, 0, 0));
}
}
}
#[cfg(target_os = "windows")]
@@ -251,6 +419,23 @@ mod platform {
true
}
/// arboard sets the text and HTML.
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
false
}
/// Add "Rich Text Format" to what arboard just set, NUL-terminated as is usual.
pub fn add_rtf(rtf: &str) {
let Some(format) = raw::register_format("Rich Text Format") else { return };
let Ok(_open) = Clipboard::new_attempts(10) else { return };
let mut bytes = Vec::with_capacity(rtf.len().saturating_add(1));
bytes.extend_from_slice(rtf.as_bytes());
bytes.push(0);
if let Err(e) = raw::set_without_clear(format.get(), &bytes) {
log::warn!("clipboard RTF: {e}");
}
}
/// Add the MathML to what arboard just set, as "MathML" and "MathML Presentation": UTF-16LE
/// text (with its terminating NUL, as Windows text formats have).
pub fn add_mathml(mathml: &str) {
@@ -276,14 +461,18 @@ const MATHML_FORMATS: [&str; 2] = ["MathML", "MathML Presentation"];
#[cfg(target_os = "macos")]
mod platform {
use objc2_app_kit::NSPasteboard;
use objc2_foundation::NSString;
use objc2_foundation::{NSData, NSString};
/// RTF's pasteboard type (`NSPasteboardTypeRTF`, whose extern static needs unsafe to read).
const RTF: &str = "public.rtf";
pub fn formats() -> Vec<String> {
Vec::new()
}
pub fn rtf() -> Option<Vec<u8>> {
None
let data = NSPasteboard::generalPasteboard().dataForType(&NSString::from_str(RTF))?;
(data.len() <= super::MAX_PASTE_BYTES).then(|| data.to_vec())
}
pub fn encoded_picture() -> Option<Vec<u8>> {
@@ -294,6 +483,18 @@ mod platform {
true
}
/// arboard sets the text and HTML.
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
false
}
/// Add RTF to the pasteboard item arboard just wrote (it cleared the pasteboard first).
pub fn add_rtf(rtf: &str) {
if !NSPasteboard::generalPasteboard().setData_forType(Some(&NSData::with_bytes(rtf.as_bytes())), &NSString::from_str(RTF)) {
log::warn!("clipboard: the RTF couldn't be added");
}
}
/// Add the MathML to the pasteboard item arboard just wrote, as the MathML types (W3C MathML 3
/// §6.2.4): presentation markup, and the generic type for readers that only know that one.
pub fn add_mathml(mathml: &str) {
@@ -329,5 +530,11 @@ mod platform {
true
}
pub fn set_rich(_text: &str, _html: &str, _rtf: &str) -> bool {
false
}
pub fn add_rtf(_rtf: &str) {}
pub fn add_mathml(_mathml: &str) {}
}
+50 -3
View File
@@ -15,9 +15,25 @@ use eframe::{
/// compiler aborts on them, the device is lost and eframe exits before the window opens. WordCraft
/// issues no indirect draws or dispatches, so Intel Macs skip them; every other GPU keeps wgpu's
/// default. Same fix as PhotoCraft's storytold/PhotoCraft#3171.
///
/// `x86_64` is the build's architecture. An x86_64 build also runs on Apple silicon under Rosetta
/// (#5199), so it skips only when the machine itself isn't Apple silicon.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool {
macos && intel
pub fn skips_indirect_validation(macos: bool, x86_64: bool) -> bool {
// Short-circuits: only x86_64 macOS builds ask the system.
macos && x86_64 && !runs_on_apple_silicon()
}
/// Whether the machine is Apple silicon, also when this process is translated by Rosetta:
/// `hw.optional.arm64` describes the hardware, not the process. Assumes not (an Intel Mac) when
/// the system can't tell.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn runs_on_apple_silicon() -> bool {
cfg!(target_os = "macos")
&& std::process::Command::new("/usr/sbin/sysctl")
.args(["-n", "hw.optional.arm64"])
.output()
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
}
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
@@ -181,7 +197,8 @@ mod tests {
#[test]
fn intel_macs_skip_the_indirect_validation_shaders() {
// #2652: Metal's Intel compiler aborted on them, losing the device before the window opened.
assert!(skips_indirect_validation(true, true));
// An x86_64 build on Apple silicon (Rosetta) isn't an Intel Mac (#5199).
assert_eq!(skips_indirect_validation(true, true), !host_is_apple_silicon());
// Apple silicon, Windows and Linux keep wgpu's validation.
for (macos, intel) in [(true, false), (false, true), (false, false)] {
assert!(!skips_indirect_validation(macos, intel), "macos={macos} intel={intel}");
@@ -205,6 +222,36 @@ mod tests {
}
}
/// Whether the machine running the tests is Apple silicon, whatever this build's architecture.
fn host_is_apple_silicon() -> bool {
cfg!(target_os = "macos")
&& std::process::Command::new("/usr/sbin/sysctl")
.args(["-n", "hw.optional.arm64"])
.output()
.is_ok_and(|out| out.status.success() && out.stdout.trim_ascii() == b"1")
}
/// #5199: an x86_64 build running under Rosetta on Apple silicon isn't an Intel Mac and keeps
/// wgpu's indirect-call validation.
#[test]
fn x86_64_builds_on_apple_silicon_keep_indirect_validation() {
if !host_is_apple_silicon() {
return;
}
// What main.rs passes from an x86_64 build.
assert!(!skips_indirect_validation(true, true), "Rosetta build on Apple silicon must keep validation");
let mut options = NativeOptions::default();
let WgpuSetup::CreateNew(before) = &options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
let before = before.instance_descriptor.flags;
configure_indirect_validation(&mut options, skips_indirect_validation(true, true));
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert_eq!(create.instance_descriptor.flags, before, "indirect-call validation kept");
}
fn configured_backends(backends: Backends) -> Backends {
let mut options = NativeOptions::default();
configure_backends(&mut options, backends);
+75 -14
View File
@@ -253,13 +253,45 @@ fn load_user_fonts() {
}
}
/// The saved window geometry, read before the window opens (`load_prefs` runs after).
fn saved_window() -> Option<WindowGeometry> {
/// The saved preferences, read before the window opens for its geometry and, on Linux and the
/// BSDs, the display server (`load_prefs` runs after).
fn saved_ui() -> Option<UiState> {
if !prefs_enabled() {
return None;
}
let bytes = std::fs::read(prefs_path()?).ok()?;
UiState::from_json(&bytes)?.window?.sanitized()
UiState::from_json(&bytes)
}
/// Whether the window runs through XWayland (X11) rather than native Wayland, and the log line
/// saying why. `WORDCRAFT_WAYLAND` wins (`0`, `false`, `no` or `off` asks for X11, any other value
/// for Wayland), then File › Options › Advanced › Linux display server (#2184). Automatic is X11
/// whenever there's an X server: winit has no file drag-and-drop on Wayland, so dropping files from
/// the file manager showed a "no" cursor. X11 is only forced with an X server (`DISPLAY`), and
/// unforced winit picks Wayland when it's there and X11 otherwise, so a choice that isn't
/// available falls back to the other instead of failing to start.
#[cfg(all(unix, not(target_os = "macos")))]
fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Option<std::ffi::OsString>) -> (bool, String) {
use wordcraft_ui_egui::DisplayServer;
let set = |name: &str| env(name).is_some_and(|v| !v.is_empty());
let (wanted, by) = match env("WORDCRAFT_WAYLAND") {
Some(v) if matches!(v.to_string_lossy().trim().to_ascii_lowercase().as_str(), "0" | "false" | "no" | "off") => {
(DisplayServer::X11, "WORDCRAFT_WAYLAND")
}
Some(_) => (DisplayServer::Wayland, "WORDCRAFT_WAYLAND"),
None => (setting, "the setting"),
};
let x11 = wanted != DisplayServer::Wayland && set("DISPLAY");
let runs = if x11 {
"X11"
} else if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
"native Wayland"
} else if set("DISPLAY") {
"X11 (no Wayland)"
} else {
"neither (no Wayland or X server found)"
};
(x11, format!("display: {runs}; {} asks for {}", by, wanted.code()))
}
fn save_prefs(app: &WordApp) {
@@ -312,7 +344,7 @@ fn services(file_dialog: file_dialogs::Hook, ctx: &egui::Context) -> Services {
animations_enabled: Some(animations::hook(ctx)),
clipboard_text: Some(Box::new(clipboard::text)),
clipboard_html: Some(Box::new(clipboard::html)),
set_clipboard_html: Some(Box::new(clipboard::set_html)),
set_clipboard_rich: Some(Box::new(clipboard::set_rich)),
clipboard_rtf: Some(Box::new(clipboard::rtf)),
clipboard_formats: Some(Box::new(clipboard::formats)),
..Default::default()
@@ -486,25 +518,28 @@ fn main() -> eframe::Result {
// FXC, which needs no DLL of ours (#603; see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652).
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (#2652). An x86_64
// build under Rosetta on Apple silicon keeps them (#5199).
#[cfg(target_os = "macos")]
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
load_user_fonts();
// Windows opened for further documents start like the first one, before its saved place.
let window_builder = options.viewport.clone();
let restored = saved_window();
let saved = saved_ui();
let restored = saved.as_ref().and_then(|ui| ui.window?.sanitized());
if let Some(window) = restored {
options.viewport = window.apply(options.viewport);
}
#[cfg(all(unix, not(target_os = "macos")))]
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; WORDCRAFT_WAYLAND=1
// keeps the native Wayland backend.
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("WORDCRAFT_WAYLAND").is_none() {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
{
let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name));
log::info!("{why}");
if x11 {
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
}
// Registered before the event loop starts, so it catches the Finder event that launched us as
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
@@ -618,6 +653,32 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
/// #2184: `WORDCRAFT_WAYLAND` beats the setting, Automatic keeps XWayland where there's an X
/// server, and a choice that isn't available falls back to the other.
#[test]
fn the_display_server_follows_the_variable_then_the_setting() {
use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11};
let x11 = |setting, vars: &[(&str, &str)]| {
uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v))).0
};
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
assert!(x11(Auto, &both), "Automatic keeps XWayland, for file drag-and-drop");
assert!(x11(X11, &both));
assert!(!x11(Wayland, &both));
for (value, wants_x11) in [("1", false), ("", false), ("yes", false), ("0", true), (" Off ", true), ("FALSE", true), ("no", true)] {
let vars = [both[0], both[1], ("WORDCRAFT_WAYLAND", value)];
for setting in [Auto, Wayland, X11] {
assert_eq!(x11(setting, &vars), wants_x11, "WORDCRAFT_WAYLAND={value:?} beats {setting:?}");
}
}
// Without an X server X11 isn't forced: winit connects to Wayland instead of failing.
let wayland_only = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")];
assert!(!x11(X11, &wayland_only) && !x11(Auto, &wayland_only));
// Without Wayland, Wayland isn't forced either: unforced winit picks X11.
let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()));
assert!(!forced && why.contains("X11 (no Wayland)"), "{why}");
}
/// A named pipe or a device under such a name is a warning, not a read: the pipe blocked
/// start-up until something wrote to it, and `/dev/zero` read until memory ran out.
#[test]
+5
View File
@@ -162,6 +162,8 @@ sync_github_name = true
[people.maxm11]
real_name = "Max Murphy"
display_name = "Max Murphy"
sync_github_name = false
[people.SlavenDj]
sync_github_name = true
@@ -192,3 +194,6 @@ sync_github_name = true
[people.entropybeater]
display_name = "entropybeater"
[people.helenkwok]
real_name = "Helen Kwok"
+2
View File
@@ -213,7 +213,9 @@ impl Document {
if !parent.len().is_multiple_of(3) {
return None;
}
crate::work::steps(1);
for ch in parent.chunks(3) {
crate::work::steps(1);
let [b, r, c] = ch else { return None };
let Block::Table(t) = &**bl.get(*b as usize)? else { return None };
bl = &t.rows.get(*r as usize)?.cells.get(*c as usize)?.blocks;
+2
View File
@@ -609,8 +609,10 @@ impl Document {
let mut at = at.into_iter().peekable();
let mut start = 0usize;
for r in &mut p.runs {
crate::work::steps(1);
let end = start.saturating_add(r.len);
while at.peek().is_some_and(|&o| o < start) {
crate::work::steps(1);
at.next();
}
if at.peek().is_some_and(|&o| o < end) {
+45 -2
View File
@@ -120,14 +120,57 @@ fn attr_in(tag: &str) -> Option<String> {
Some(unescape(rest.get(..end)?))
}
fn unescape(s: &str) -> String {
s.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
fn unescape(mut s: &str) -> String {
let mut out = String::with_capacity(s.len());
while let Some((text, rest)) = s.split_once('&') {
out.push_str(text);
if let Some(end) = rest.find(['&', ';'])
&& rest.as_bytes()[end] == b';'
{
let entity = &rest[..end];
let decoded = match entity {
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"amp" => Some('&'),
_ => entity.strip_prefix('#').and_then(|n| {
let value = if let Some(hex) = n.strip_prefix('x') {
(!hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit())).then(|| u32::from_str_radix(hex, 16).ok()).flatten()
} else {
(!n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())).then(|| n.parse::<u32>().ok()).flatten()
}?;
char::from_u32(value)
.filter(|c| matches!(*c, '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}'))
}),
};
if let Some(c) = decoded {
out.push(c);
s = &rest[end + 1..];
continue;
}
}
out.push('&');
s = rest;
}
out.push_str(s);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn drop_down_entities_decode_once_including_xml_whitespace() {
let xml = r#"<w:ffData><w:name w:val="N&#x61;me"/><w:ddList><w:listEntry w:val="A&#10;B&#9;C&#13;D&#x1F600;&amp;#10;&lt;&gt;&quot;&apos;"/></w:ddList></w:ffData>"#;
let f = LegacyForm::parse(xml).unwrap();
assert_eq!(f.name(), "Name");
assert_eq!(f.shown_text(), Some("A\nB\tC\rD😀&#10;<>\"'"));
let malformed = r#"<w:ffData><w:ddList><w:listEntry w:val="&#0;&#xD800;&#x110000;&#-1;&#xZZ;&unknown;&amp"/></w:ddList></w:ffData>"#;
assert_eq!(LegacyForm::parse(malformed).unwrap().shown_text(), Some("&#0;&#xD800;&#x110000;&#-1;&#xZZ;&unknown;&amp"));
}
#[test]
fn check_boxes_text_fields_and_drop_downs() {
let cb = |inner: &str| format!(r#"<w:ffData><w:name w:val="Box1"/><w:enabled/><w:checkBox>{inner}</w:checkBox></w:ffData>"#);
+1 -1
View File
@@ -90,8 +90,8 @@ pub use settings::{
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source,
THEME_COLORS, Watermark, Zoom, ZoomFit,
};
pub use text_walk::count_words;
pub(crate) use text_walk::{PARA_WALK_DEPTH, walk_between};
pub use text_walk::{count_chars, count_chars_no_spaces, count_words};
/// A document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+51 -2
View File
@@ -22,10 +22,29 @@ pub fn decimal_separator(lang: Option<&str>) -> char {
const SPACES: &[char] = &[' ', '\u{a0}', '\u{202f}', '\u{2009}'];
const CURRENCY: &[char] = &['$', '€', '£', '¥', '¢', '₽', '₴', '₺', '₹', '₩', '₪', '₫', '฿', '₱', '₸', '₼', '₾'];
/// Currency signs spelled with more than one character: the Panamanian balboa (`B/.`).
const CURRENCY_SYMBOLS: &[&str] = &["B/."];
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`) from either end.
/// Trim spaces and single-character currency signs from either end.
fn trim_currency(s: &str) -> &str {
s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c))
}
/// Strip a currency symbol or a short currency word (`zł`, `kr`, `PLN`, `B/.`) from either end.
/// As Word does, a multi-character sign is a word of its own (`B/. 1,250.50`, `1,250.50 B/.`);
/// `B/.1,250.50` and `B/ 1,250.50` are not numbers.
fn strip_currency(s: &str) -> &str {
let mut s = s.trim_matches(|c: char| c.is_whitespace() || CURRENCY.contains(&c));
let mut s = trim_currency(s);
if let Some(rest) =
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_prefix(sym).filter(|rest| rest.chars().next().is_some_and(char::is_whitespace)))
{
s = trim_currency(rest);
}
if let Some(rest) =
CURRENCY_SYMBOLS.iter().find_map(|sym| s.strip_suffix(sym).filter(|rest| rest.chars().next_back().is_some_and(char::is_whitespace)))
{
s = trim_currency(rest);
}
if let Some((head, word)) = s.rsplit_once(SPACES)
&& (1..=3).contains(&word.chars().count())
&& word.chars().all(|c| c.is_alphabetic() || c == '.')
@@ -137,6 +156,7 @@ mod tests {
assert_eq!(parse_number("1.5", pl), Some(1.5));
assert_eq!(parse_number("-2,25", pl), Some(-2.25));
assert_eq!(parse_number("PLN 12", pl), Some(12.0));
assert_eq!(parse_number("-B/. 1 250,50", pl), Some(-1250.5));
assert_eq!(parse_number("abc", pl), None);
assert_eq!(parse_number("1,2,3", pl), None);
// From review: ordinals, a leading zero, percentages and an en dash minus.
@@ -174,11 +194,40 @@ mod tests {
assert_eq!(format_number(5.75, Some("es-ES")), "5,75");
}
#[test]
fn balboa_numbers() {
let en = Some("en-US");
// Word 16.0 reads `B/.` as a word of its own, before or after the number.
assert_eq!(parse_number("B/. 1,234.50", en), Some(1234.5));
assert_eq!(parse_number("1,234.50 B/.", en), Some(1234.5));
assert_eq!(parse_number("B/.\u{a0}1,234.50", en), Some(1234.5), "a no-break space after the sign");
assert_eq!(parse_number("B/. 1,234.50 B/.", en), Some(1234.5), "the sign at both ends");
assert_eq!(parse_number("-B/. 1,234.50", en), Some(-1234.5), "a minus before the sign");
assert_eq!(parse_number("B/. 1,234.50", Some("es-PA")), Some(1234.5), "the balboa's own region");
// The sign has no space of its own in Word: touching the number is text.
assert_eq!(parse_number("B/.1,234.50", en), None);
assert_eq!(parse_number("1,234.50B/.", en), None);
assert_eq!(parse_number("B/ 1,234.50", en), None, "the balboa is B/. in Word");
assert_eq!(parse_number("B/.", en), None);
// The slash and the full stop make `B/.` ambiguous with a fraction: the sign is matched at
// the ends of the text only, so a fraction is never read as a balboa amount and a sign in
// the middle is not a sign.
assert_eq!(parse_number("1/.5", en), None, "a fraction is not a price");
assert_eq!(parse_number("1/.5", Some("es-PA")), None);
assert_eq!(parse_number("1/.5 B/.", en), None, "the sign at the end does not rescue a fraction");
assert_eq!(parse_number("B/. 1/.5", en), None);
assert_eq!(parse_number("2 B/. 1,234.50", en), None, "the sign sits before the number");
assert_eq!(parse_number("1,234.50 B/. 1,234.50", en), None, "the sign sits between two numbers");
assert_eq!(parse_number("1,234.50/2", en), None, "a division is not a balboa amount");
assert_eq!(parse_number("B/. 1,234.50,50", en), None, "a second decimal separator");
}
#[test]
fn english_numbers() {
let en = Some("en-US");
assert_eq!(parse_number("1,250.50", en), Some(1250.5));
assert_eq!(parse_number("$1,250", en), Some(1250.0));
assert_eq!(parse_number("PAB 1,250.50", en), Some(1250.5), "the ISO code");
assert_eq!(parse_number("1.5", en), Some(1.5));
assert_eq!(parse_number("12", None), Some(12.0));
assert_eq!(parse_number(".5", None), Some(0.5));
+6
View File
@@ -16,6 +16,12 @@ pub type Arg = Vec<MNode>;
/// Deepest structure nesting kept (parsers stop here; layout draws nothing deeper).
pub const MAX_DEPTH: usize = 64;
/// Most matrix columns kept (writers pad every row to the widest, so this bounds a row's cost).
pub const MAX_MATRIX_COLS: usize = 64;
/// Most matrix rows kept.
pub const MAX_MATRIX_ROWS: usize = 1000;
/// An equation's structure, plus what's needed to write it back.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
pub struct Math {
+137 -30
View File
@@ -294,6 +294,14 @@ pub struct Counters {
/// Per abstract list: whether each level has counted yet (a value of 0 can be a real count when a
/// level starts at 0).
flags: HashMap<u32, [LevelFlags; 9]>,
/// The last explicitly rendered label and template at each abstract-list level.
labels: HashMap<(u32, u8), CountedLabel>,
}
#[derive(Debug, Clone)]
struct CountedLabel {
label: Option<String>,
levels: Vec<usize>,
}
/// Whether a level has counted yet, and the value its next item takes if a start override says so.
@@ -320,6 +328,7 @@ impl Counters {
let def = level_def(lv)?.clone();
// A "none" level shows no label at all (Word's numFmt none) — not even its text.
if def.format == NumFormat::None {
self.labels.insert((abs.id, lv as u8), CountedLabel { label: None, levels: Vec::new() });
return None;
}
let st = self.state.entry(abs.id).or_insert([0; 9]);
@@ -366,42 +375,101 @@ impl Counters {
if restarts {
*deeper = 0;
*f = LevelFlags::default();
self.labels.remove(&(abs.id, d as u8));
}
}
let vals = *st;
let started = *fl;
if def.format == NumFormat::Bullet {
return Some((def.text.clone(), def));
}
let mut label = String::new();
// Word's level text is short; .docx and the lists command already cap it at 64.
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
{
chars.next();
let k = (d as usize).saturating_sub(1).min(8);
let val = vals.get(k).copied().unwrap_or(0);
let counted = started.get(k).is_some_and(|f| f.started);
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
// format (二 of a Chinese outline becomes 2 in "2.1").
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
NumFormat::DecimalZero => NumFormat::DecimalZero,
_ if def.legal => NumFormat::Decimal,
f => f,
};
label.push_str(&fmt.format(val));
if label.len() > MAX_LABEL_BYTES {
break;
}
} else {
label.push(c);
}
}
let numeric = def.format != NumFormat::Bullet;
let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() };
self.labels.insert((abs.id, lv as u8), CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) });
Some((label, def))
}
/// Which levels of `num`'s list have counted a paragraph yet (a level never used has no
/// number of its own to show in a reference).
pub fn started(&self, numbering: &Numbering, num: u32) -> Option<[bool; 9]> {
let abs = numbering.abstract_of(num)?;
Some(std::array::from_fn(|k| self.labels.get(&(abs.id, k as u8)).is_some_and(|last| last.label.is_some())))
}
/// The label `level` of `num`'s list shows at this point of the walk, without counting: what
/// the last paragraph at that level showed (`"14."` while walking clause 14's sub-clauses).
/// `None` for a bullet or "none" level, or a level the list doesn't define.
pub fn current_label(&self, numbering: &Numbering, num: u32, level: u8) -> Option<String> {
let abs = numbering.abstract_of(num)?;
if let Some(last) = self.labels.get(&(abs.id, level.min(8))) {
return last.label.clone();
}
// A deeper item can count a level implicitly without rendering a paragraph there.
let def = numbering.level(num, level.min(8))?;
if matches!(def.format, NumFormat::None | NumFormat::Bullet) {
return None;
}
let vals = self.state.get(&abs.id).copied().unwrap_or([0; 9]);
let started = self.flags.get(&abs.id).copied().unwrap_or_default();
Some(render_label(numbering, num, def, &vals, &started))
}
/// Levels shown by the last rendered label, or the current definition for an implicit level.
pub fn current_template_levels(&self, numbering: &Numbering, num: u32, level: u8) -> Vec<usize> {
if let Some(abs) = numbering.abstract_of(num)
&& let Some(last) = self.labels.get(&(abs.id, level.min(8)))
{
return last.levels.clone();
}
numbering.level(num, level.min(8)).map(|def| template_levels(&def.text)).unwrap_or_default()
}
}
/// The zero-based levels a label template shows (`"%1.%2"` → `[0, 1]`, `"(%3)"` → `[2]`).
pub fn template_levels(text: &str) -> Vec<usize> {
let mut out = Vec::new();
let mut chars = text.chars().take(MAX_LEVEL_TEXT).peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
{
chars.next();
let k = (d as usize).saturating_sub(1).min(8);
if !out.contains(&k) {
out.push(k);
}
}
}
out
}
/// Render a list label without advancing counters, retaining the existing hostile-input caps.
fn render_label(numbering: &Numbering, num: u32, def: &Level, vals: &[u32; 9], started: &[LevelFlags; 9]) -> String {
let level_def = |k: usize| numbering.level(num, k as u8);
let mut label = String::new();
// Word's level text is short; .docx and the lists command already cap it at 64.
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
while let Some(c) = chars.next() {
if c == '%'
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
{
chars.next();
let k = (d as usize).saturating_sub(1).min(8);
let val = vals.get(k).copied().unwrap_or(0);
let counted = started.get(k).is_some_and(|f| f.started);
let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) };
// Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own
// format (二 of a Chinese outline becomes 2 in "2.1").
let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) {
NumFormat::DecimalZero => NumFormat::DecimalZero,
_ if def.legal => NumFormat::Decimal,
f => f,
};
label.push_str(&fmt.format(val));
if label.len() > MAX_LABEL_BYTES {
break;
}
} else {
label.push(c);
}
}
label
}
/// Longest level text (in characters) expanded into a list label.
@@ -413,6 +481,45 @@ const MAX_LABEL_BYTES: usize = 256;
mod tests {
use super::*;
/// Read-only counter APIs retain the renderer's bounds and never advance a list.
#[test]
fn reference_labels_are_bounded_and_do_not_advance_counters() {
let mut n = Numbering::default();
let id = n.add_list(ListKind::Numbered);
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
num.level_overrides = vec![(0, Level { text: "%1".repeat(30_000), ..Level::default() })];
}
let mut c = Counters::default();
let label = c.next_label(&n, id, 0).unwrap().0;
let before = c.values(&n, id);
assert_eq!(c.current_label(&n, id, 0).as_deref(), Some(label.as_str()));
assert_eq!(c.values(&n, id), before);
assert!(label.len() <= 512);
assert_eq!(template_levels(&"%1%9".repeat(30_000)), [0, 8]);
assert!(c.started(&n, id).unwrap()[0]);
}
#[test]
fn last_ancestor_label_keeps_the_instance_that_rendered_it() {
let mut numbering = Numbering::default();
let first = numbering.add_list(ListKind::Numbered);
numbering.abstracts[0].levels[0].text = "Clause %1.".into();
let mut second = numbering.num(first).unwrap().clone();
second.id = first + 1;
second.level_overrides = vec![
(0, Level { text: "Part %1.%2.".into(), ..Level::default() }),
(1, Level { format: NumFormat::LowerLetter, text: "(%2)".into(), ..Level::default() }),
];
let second_id = second.id;
numbering.nums.push(second);
let mut counters = Counters::default();
assert_eq!(counters.next_label(&numbering, first, 0).unwrap().0, "Clause 1.");
assert_eq!(counters.next_label(&numbering, second_id, 1).unwrap().0, "(a)");
assert_eq!(counters.current_label(&numbering, second_id, 0).as_deref(), Some("Clause 1."));
assert_eq!(counters.current_template_levels(&numbering, second_id, 0), vec![0]);
assert_eq!(counters.started(&numbering, second_id).unwrap()[..2], [true, true]);
}
#[test]
fn a_huge_level_text_gives_a_bounded_label() {
let mut n = Numbering::default();
+7 -1
View File
@@ -305,6 +305,9 @@ pub struct TextFrame {
pub v_anchor: String,
/// `around`, `auto`, `notBeside`, `none`, `tight` or `through`.
pub wrap: String,
/// Whether the frame stays fixed to its page anchor when its paragraph moves.
#[serde(skip_serializing_if = "Option::is_none")]
pub anchor_lock: Option<bool>,
/// Distance from the text left and right, and above and below.
pub h_space: f32,
pub v_space: f32,
@@ -359,6 +362,9 @@ pub struct ParaProps {
/// outside ones, so they swap sides on even (left-hand) pages.
#[serde(skip_serializing_if = "Option::is_none")]
pub mirror_indents: Option<bool>,
/// `w:adjustRightInd`: automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
#[serde(skip_serializing_if = "Option::is_none")]
pub adjust_right_indent: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub keep_next: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -447,7 +453,7 @@ impl ParaProps {
fn overlay_layout(&mut self, patch: &ParaProps) {
overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars,
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, tabs, shading, borders);
space_before, space_after, space_before_auto, space_after_auto, line_spacing, contextual_spacing, mirror_indents, adjust_right_indent, tabs, shading, borders);
}
fn overlay_pagination(&mut self, patch: &ParaProps) {
+8
View File
@@ -153,6 +153,10 @@ impl ResolvedChar {
}
/// Concrete paragraph formatting.
fn default_adjust_right_indent() -> bool {
true
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ResolvedPara {
@@ -173,6 +177,9 @@ pub struct ResolvedPara {
pub contextual_spacing: bool,
/// Left/right indents are inside/outside: they swap on even pages.
pub mirror_indents: bool,
/// Automatically adjust the right indent for the document grid when enabled; keep the specified right indent when disabled.
#[serde(default = "default_adjust_right_indent")]
pub adjust_right_indent: bool,
pub keep_next: bool,
pub keep_lines: bool,
pub page_break_before: bool,
@@ -264,6 +271,7 @@ impl ResolvedPara {
},
contextual_spacing: p.contextual_spacing.unwrap_or(false),
mirror_indents: p.mirror_indents.unwrap_or(false),
adjust_right_indent: p.adjust_right_indent.unwrap_or(true),
keep_next: p.keep_next.unwrap_or(false),
keep_lines: p.keep_lines.unwrap_or(false),
page_break_before: p.page_break_before.unwrap_or(false),
+50
View File
@@ -115,6 +115,40 @@ pub enum NumFormat {
DollarText,
}
/// Chapter separator for page numbers (`w:chapSep`, ECMA-376 §17.18.12).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ChapterSep {
Hyphen,
Period,
Colon,
EmDash,
EnDash,
}
impl ChapterSep {
pub fn from_ooxml(value: &str) -> Option<Self> {
match value {
"hyphen" => Some(Self::Hyphen),
"period" => Some(Self::Period),
"colon" => Some(Self::Colon),
"emDash" => Some(Self::EmDash),
"enDash" => Some(Self::EnDash),
_ => None,
}
}
pub const fn ooxml(self) -> &'static str {
match self {
Self::Hyphen => "hyphen",
Self::Period => "period",
Self::Colon => "colon",
Self::EmDash => "emDash",
Self::EnDash => "enDash",
}
}
}
impl NumFormat {
/// Every format, in the order ST_NumberFormat lists them (which is also the order of the
/// binary .doc number format codes up to 0x27).
@@ -749,6 +783,9 @@ pub struct SectionProps {
pub title_page: bool,
pub page_num_start: Option<u32>,
pub page_num_format: NumFormat,
/// Chapter heading level and separator in `w:pgNumType`.
pub chapter_style: Option<u8>,
pub chapter_sep: Option<ChapterSep>,
/// The file carries a `w:pgNumType` element that sets nothing else (no start, decimal
/// format), so saving writes it back rather than dropping it.
#[serde(skip_serializing_if = "std::ops::Not::not")]
@@ -779,6 +816,13 @@ pub struct SectionProps {
pub footnote_pr: NoteProps,
#[serde(skip_serializing_if = "NoteProps::is_empty")]
pub endnote_pr: NoteProps,
/// First-page and other-page printer trays (`w:paperSrc`).
pub paper_src_first: Option<u16>,
pub paper_src_other: Option<u16>,
/// Section-specific suppression of endnotes (`w:noEndnote`).
pub no_endnote: Option<bool>,
/// Independent footnote column count (`w15:footnoteColumns`).
pub footnote_columns: Option<u32>,
/// Whether its columns end balanced (an ODF section's `text:dont-balance-text-columns`):
/// `None` is Word's rule, balanced when a continuous section follows; `Some(true)` also at
/// the end of the document, `Some(false)` never.
@@ -809,6 +853,8 @@ impl Default for SectionProps {
title_page: false,
page_num_start: None,
page_num_format: NumFormat::Decimal,
chapter_style: None,
chapter_sep: None,
page_num_set: false,
line_numbers: None,
valign: PageVAlign::Top,
@@ -821,6 +867,10 @@ impl Default for SectionProps {
doc_grid: None,
footnote_pr: NoteProps::default(),
endnote_pr: NoteProps::default(),
paper_src_first: None,
paper_src_other: None,
no_endnote: None,
footnote_columns: None,
balance_columns: None,
}
}
+25
View File
@@ -966,6 +966,31 @@ fn table_styles() -> Vec<Style> {
#[cfg(test)]
mod tests {
use super::*;
use crate::resolve::ResolvedPara;
#[test]
fn adjust_right_indent_inherits_and_can_be_overridden() {
let resolved = StyleSheet::empty().resolve_para(&ParaProps::default());
assert!(resolved.adjust_right_indent);
let mut older_json = serde_json::to_value(&resolved).unwrap();
older_json.as_object_mut().unwrap().remove("adjustRightIndent");
assert!(serde_json::from_value::<ResolvedPara>(older_json).unwrap().adjust_right_indent);
let mut s = StyleSheet::empty();
s.styles.push(Style {
id: "Normal".into(),
name: "Normal".into(),
para: ParaProps { adjust_right_indent: Some(true), ..Default::default() },
..Default::default()
});
assert!(s.resolve_para(&ParaProps::default()).adjust_right_indent);
assert!(!s.resolve_para(&ParaProps { adjust_right_indent: Some(false), ..Default::default() }).adjust_right_indent);
let json = serde_json::to_value(ParaProps { adjust_right_indent: Some(false), ..Default::default() }).unwrap();
assert_eq!(json["adjustRightIndent"], false);
assert_eq!(serde_json::from_value::<ParaProps>(json).unwrap().adjust_right_indent, Some(false));
}
#[test]
fn builtins_present() {
+19
View File
@@ -19,6 +19,25 @@ fn word_count() {
assert_eq!(count_words(" -- "), 0);
}
/// #2797: the status bar's character counts, worded like the Word Count dialog's: characters
/// with spaces leave the paragraph breaks out, characters without spaces leave every
/// whitespace character out.
#[test]
fn character_counts() {
assert_eq!(count_chars(""), 0);
assert_eq!(count_chars("Hello"), 5);
// Paragraph breaks join the paragraphs but aren't characters.
assert_eq!(count_chars("Hello\nworld\r\n"), 10);
assert_eq!(count_chars("a\tb"), 3, "a tab is a character");
// Every kind of whitespace goes, spaces, tabs and (non-ASCII) spaces between words.
assert_eq!(count_chars_no_spaces("Hello world"), 10);
assert_eq!(count_chars_no_spaces("a\tb c\u{a0}d"), 4, "including a non-breaking space");
assert_eq!(count_chars_no_spaces(" \n\r\t "), 0);
// Accented letters and emoji count as one character each, as they are.
assert_eq!(count_chars("café 🚀"), 6);
assert_eq!(count_chars_no_spaces("café 🚀"), 5);
}
/// #4547: each Han ideograph and kana counts as a word; punctuation and Hangul spacing don't change.
#[test]
fn word_count_counts_each_chinese_and_japanese_character() {
+9 -26
View File
@@ -102,18 +102,6 @@ fn dense(n: usize) -> Document {
d
}
// Thread CPU excludes scheduler delays. Missing or nonadvancing counters provide no timing
// evidence; the semantic assertions still run on those platforms.
fn cpu_cost(f: impl FnOnce()) -> Option<f64> {
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
let before = ran();
f();
match (before, ran()) {
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
_ => None,
}
}
#[test]
fn conversion_work_scales_with_reference_and_formatting_runs() {
let (small, big) = (dense(2_000), dense(16_000));
@@ -128,18 +116,13 @@ fn conversion_work_scales_with_reference_and_formatting_runs() {
};
convert(&small);
convert(&big);
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
for _ in 0..3 {
let a = cpu_cost(|| (0..8).for_each(|_| convert(&small)));
let b = cpu_cost(|| convert(&big));
if let (Some(a), Some(b)) = (a, b) {
t1 = t1.min(a / 8.0);
t8 = t8.min(b);
}
}
if t1.is_finite() && t8.is_finite() {
let ratio = t8 / t1;
eprintln!("note conversion CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
assert!(ratio < 24.0, "8x the references and runs cost {ratio:.2}x CPU");
}
// The characters and runs the conversion walks (`crate::work`), not a timing: about 8x for 8x
// the references and runs when they are walked together, about 64x when each run rescans the
// references.
let s1 = crate::work::count(|| convert(&small));
let s8 = crate::work::count(|| convert(&big));
let ratio = s8 as f64 / s1.max(1) as f64;
eprintln!("note conversion steps: {s1} -> {s8}, {ratio:.1}x for 8x references and runs");
assert!(s1 > 0, "no steps counted, so the walk this test watches no longer counts them (crate::work)");
assert!(ratio <= 12.0, "8x the references and runs cost {ratio:.1}x the steps ({s1} -> {s8})");
}
+13
View File
@@ -35,6 +35,18 @@ fn is_cjk_word(c: char) -> bool {
) && c.is_alphanumeric()
}
/// Characters in `s`, as the Word Count dialog counts them ("Characters (with spaces)"):
/// paragraph breaks aren't characters (#2797).
pub fn count_chars(s: &str) -> usize {
s.chars().filter(|c| *c != '\n' && *c != '\r').count()
}
/// Characters in `s` ignoring every whitespace character, as the Word Count dialog counts them
/// ("Characters (no spaces)"; #2797).
pub fn count_chars_no_spaces(s: &str) -> usize {
s.chars().filter(|c| !c.is_whitespace()).count()
}
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
pub(crate) const PARA_WALK_DEPTH: usize = 16;
@@ -51,6 +63,7 @@ pub(crate) fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, dept
return;
}
for (i, b) in bl.iter().enumerate() {
crate::work::steps(1);
prefix.push(i as u32);
match &**b {
Block::Para(_) => out.push(Path(prefix.clone())),
+9 -7
View File
@@ -1,8 +1,9 @@
//! A count of the characters a paragraph's object lookups walk, for the linear-time tests of the
//! crates that use them: an input 8x the size must cost about 8x the steps, which a busy machine
//! can't skew the way it skews a timing. Counted only in tests (this crate's, and other crates'
//! through the `test-support` feature, which they turn on as a dev-dependency); in the app the
//! count is nothing.
//! A count of the characters a paragraph's object lookups walk, the blocks
//! [`crate::Document::para_paths`] visits, and each block lookup by path with the table levels it
//! descends, for the linear-time tests of the crates that use them: an input 8x the size must cost
//! about 8x the steps, which a busy machine can't skew the way it skews a timing. Counted only in
//! tests (this crate's, and other crates' through the `test-support` feature, which they turn on
//! as a dev-dependency); in the app the count is nothing.
#[cfg(any(test, feature = "test-support"))]
thread_local! {
@@ -10,9 +11,10 @@ thread_local! {
static STEPS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// Count `n` steps of a walk (nothing outside tests).
/// Count `n` steps of a walk (nothing outside tests). Public for the walks other crates do over a
/// paragraph's text themselves (Markdown and HTML comment export, for one).
#[inline(always)]
pub(crate) fn steps(n: usize) {
pub fn steps(n: usize) {
#[cfg(any(test, feature = "test-support"))]
STEPS.with(|s| s.set(s.get().saturating_add(n as u64)));
#[cfg(not(any(test, feature = "test-support")))]
+106 -19
View File
@@ -51,6 +51,14 @@ const MAX_PARTS: usize = 50_000;
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
const MAX_TOTAL_CHARS: u64 = 64 << 20;
/// Most paragraph and cell marks across every story walk of one document. The character
/// budget does not see them: a mark is one byte of the file but about 1 KB of document, so a
/// file of nothing but marks would grow memory about a thousand times faster than its size.
/// The cap bounds a flood to about 2 GB while reading (about 4 GB at the very cap, when the
/// blocks are assembled). Past it the read fails with [`DocbinError::Limit`]. Real documents stay far below it:
/// 50 paragraphs on each of 10,000 pages is 500,000, and a 10,000-page one-column table (46
/// rows, each a cell mark plus a row mark) is about 920,000.
const MAX_PARAGRAPHS: usize = 2_000_000;
/// Bytes of repeated work per document beyond the text itself: piece Prm grpprls (Prcs, up
/// to 64 KB each) interpreted (#3692), hyperlink targets (#3708), picture alt text (#3709)
/// and floating objects (#3710) copied into every run or anchor. Past it Prms are skipped,
@@ -195,6 +203,12 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
/// verifier ([`DocbinError::WrongPassword`] when it does not match) and then decrypted. The
/// password is ignored for an unprotected file.
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Document, DocbinError> {
read_limited(bytes, password, MAX_PARAGRAPHS)
}
/// [`read_with_password`] with the paragraph cap as a parameter, so tests need not build a
/// file of [`MAX_PARAGRAPHS`] marks.
fn read_limited(bytes: &[u8], password: Option<&str>, max_paragraphs: usize) -> Result<Document, DocbinError> {
let mut comp = open_container(bytes)?;
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
if word.len() as u64 > MAX_STREAM {
@@ -270,6 +284,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
// tight against aliasing — and never past the absolute ceiling.
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
let work = Work::new(MAX_WORK);
let paragraphs = ParaBudget { left: std::cell::Cell::new(max_paragraphs), exceeded: std::cell::Cell::new(false) };
let ctx = WalkCtx {
word: &word,
@@ -285,6 +300,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
drawn: &[],
sect_ends: &[],
budget: &budget,
paragraphs: &paragraphs,
work: &work,
};
@@ -304,7 +320,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
if doc.parts.len() >= MAX_PARTS || tb_stories.contains_key(&spid) {
continue;
}
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
tb_stories.insert(spid, doc.add_part(PartKind::TextBox, blocks));
}
}
@@ -329,7 +345,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
0 | 1 | 4 => PartKind::Header,
_ => PartKind::Footer,
};
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
if !blocks.is_empty() {
let id = doc.add_part(kind, blocks);
hf_ids.push((si, k, id));
@@ -342,7 +358,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
let mut note_links: Vec<(u32, NoteKind, u32)> = Vec::new();
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_FND_REF, fib::pair::PLCF_FND_TXT) {
let Some((a, b)) = story(ftn_base, fib.ccp.ftn, ab) else { continue };
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
let id = doc.add_part(PartKind::Footnote, blocks);
note_links.push((cp, NoteKind::Footnote, id));
@@ -350,7 +366,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
}
for (cp, ab) in notes::parse_notes(&table, &fib, fib::pair::PLCF_END_REF, fib::pair::PLCF_END_TXT) {
let Some((a, b)) = story(edn_base, fib.ccp.edn, ab) else { continue };
let blocks = table::assemble(walk(&ctx, a, b, &mut pics));
let blocks = story_blocks(&ctx, a, b, &mut pics, max_paragraphs)?;
if !blocks.is_empty() && doc.parts.len() < MAX_PARTS {
let id = doc.add_part(PartKind::Endnote, blocks);
note_links.push((cp, NoteKind::Endnote, id));
@@ -393,6 +409,9 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
doc.last_section = l;
}
if paragraphs.exceeded.get() {
return Err(paragraph_limit(max_paragraphs));
}
doc.media.extend(pics.media);
doc.body = table::assemble(paras);
doc.ensure_nonempty();
@@ -400,6 +419,20 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
Ok(doc)
}
fn paragraph_limit(max_paragraphs: usize) -> DocbinError {
DocbinError::Limit(format!("the document has more than {max_paragraphs} paragraphs and table cells"))
}
/// Walks one story into blocks. A walk that spent the shared paragraph budget is refused before
/// its partial list is assembled, so a flood does not build blocks it will throw away.
fn story_blocks(ctx: &WalkCtx<'_>, a: u32, b: u32, pics: &mut media::Pictures, max_paragraphs: usize) -> Result<wordcraft_doc::Blocks, DocbinError> {
let paras = walk(ctx, a, b, pics);
if ctx.paragraphs.exceeded.get() {
return Err(paragraph_limit(max_paragraphs));
}
Ok(table::assemble(paras))
}
/// A story's CP range `[a, b)`, relative to a subdocument starting at `base` with `len`
/// CPs, as absolute CPs clamped to that subdocument; `None` when empty.
fn story(base: u32, len: u32, (a, b): (u32, u32)) -> Option<(u32, u32)> {
@@ -433,10 +466,35 @@ struct WalkCtx<'a> {
/// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of
/// them could otherwise materialise the same text thousands of times; this bounds the total.
budget: &'a std::cell::Cell<u64>,
/// Paragraph and cell marks still allowed across every story walk ([`MAX_PARAGRAPHS`]).
paragraphs: &'a ParaBudget,
/// Repeated-work bytes left ([`MAX_WORK`]), and what they left out.
work: &'a Work,
}
/// The paragraph and cell marks still allowed in this document, and whether a walk ran into
/// the end of them (the read then fails).
struct ParaBudget {
left: std::cell::Cell<usize>,
exceeded: std::cell::Cell<bool>,
}
impl ParaBudget {
/// Spends one mark; false (and the overrun noted) when none are left.
fn spend(&self) -> bool {
match self.left.get().checked_sub(1) {
Some(left) => {
self.left.set(left);
true
}
None => {
self.exceeded.set(true);
false
}
}
}
}
/// State while assembling one paragraph from per-CP formatting.
#[derive(Default)]
struct ParaBuild {
@@ -527,6 +585,9 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
let section_mark = c == '\u{C}' && ctx.sect_ends.binary_search(&cp.saturating_add(1)).is_ok();
match c {
'\r' | '\u{7}' | '\u{C}' if c != '\u{C}' || section_mark => {
if !ctx.paragraphs.spend() {
break;
}
// Paragraph mark: its PAPX covers the FC range of the paragraph it ends, so
// querying at the mark's own FC finds that paragraph's properties.
let fc_mark = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
@@ -536,6 +597,9 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
}
let mut row = table::decode(papx);
row.cell_mark = c == '\u{7}';
// Only table marks keep their table context; a plain paragraph would carry
// a `RowInfo` (about 300 bytes) it never uses.
let row = row.is_table().then(|| Box::new(row));
let mut ilvl = 0u8;
let mut ilfo = 0i32;
for prl in sprm::iter(papx) {
@@ -589,7 +653,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
&& f.depth == 1
&& f.sep.is_none()
{
f.sep = Some(Vec::new());
f.sep = Some(FieldResult::default());
}
cp = cp.saturating_add(1);
continue;
@@ -669,7 +733,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
out.push(ParaOut {
para: Paragraph { text: done.text, runs: done.runs, props: done.props, mark: done.mark, objects: done.objects, ..Default::default() },
end_cp: cp_end,
row: table::RowInfo::default(),
row: None,
});
}
out
@@ -680,7 +744,28 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic
struct FieldBuild {
depth: u32,
instr: String,
sep: Option<Vec<(char, CharProps)>>,
sep: Option<FieldResult>,
}
/// A field's cached result: its text and, as runs, the formatting of that text. A run is added
/// only when the formatting changes, so a result of millions of equally formatted characters
/// costs one `CharProps`, not one per character (365 bytes each).
#[derive(Default)]
struct FieldResult {
text: String,
/// (byte length in `text`, formatting) in order; adjacent runs differ.
runs: Vec<(usize, CharProps)>,
}
impl FieldResult {
fn push(&mut self, c: char, props: CharProps) {
let len = c.len_utf8();
match self.runs.last_mut() {
Some((n, last)) if *last == props => *n += len,
_ => self.runs.push((len, props)),
}
self.text.push(c);
}
}
/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all
@@ -705,24 +790,26 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
let result = f.sep.unwrap_or_default();
if let Some(target) = hyperlink_target(&instr) {
let cost = target.len() as u64;
let mut run: Option<CharProps> = None;
for (c, props) in result {
if run.as_ref() == Some(&props) {
pb.extend(c);
continue;
}
let mut linked = props.clone();
let mut chars = result.text.chars();
for (len, props) in result.runs {
let mut linked = props;
if work.charge(cost, Cost::LinkTarget) {
linked.link = Some(target.clone());
}
pb.push(c, linked);
run = Some(props);
let mut left = len;
let Some(first) = chars.next() else { break };
pb.push(first, linked);
left = left.saturating_sub(first.len_utf8());
while left > 0 {
let Some(c) = chars.next() else { break };
pb.extend(c);
left = left.saturating_sub(c.len_utf8());
}
}
return;
}
let result_text: String = result.iter().map(|(c, _)| *c).collect();
pb.push('\u{FFFC}', CharProps::default());
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
pb.objects.push(InlineObject::Field { instr, result: result.text, locked: false, dirty: false, code: false, form: String::new() });
}
/// `sprmCPicLocation` from a CHPX grpprl: the fc of the picture in the Data stream.
@@ -806,7 +893,7 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f
Some(f) => match f.sep.as_mut() {
// Inside a field, characters are recorded for the field object instead of shown;
// `finish_field` emits the visible text (hyperlink runs or one object anchor).
Some(result) => result.push((c, props)),
Some(result) => result.push(c, props),
None => f.instr.push(c),
},
None => pb.push(c, props),
+9 -6
View File
@@ -83,6 +83,11 @@ pub(crate) struct RowInfo {
/// Read the table context out of a paragraph mark's PAPX grpprl.
impl RowInfo {
/// Does the mark belong to a table: inside one, ending a row, or ending a cell?
pub(crate) fn is_table(&self) -> bool {
self.in_table || self.ttp || self.cell_mark
}
/// The row height from `sprmTDyaRowHeight`, for tests.
#[cfg(test)]
pub(crate) fn height(&self) -> Option<f32> {
@@ -263,8 +268,8 @@ pub(crate) struct ParaOut {
pub(crate) para: Paragraph,
/// CP just past the terminating mark.
pub(crate) end_cp: u32,
/// Table context from the mark's PAPX (empty default = not in a table).
pub(crate) row: RowInfo,
/// Table context from the mark's PAPX; `None` for a paragraph outside a table.
pub(crate) row: Option<Box<RowInfo>>,
}
/// Group consecutive in-table paragraphs into [`Table`] blocks; the rest pass through.
@@ -369,9 +374,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
}
for p in paras {
let info = p.row;
let is_table = info.in_table || info.ttp || info.cell_mark;
if !is_table {
let Some(info) = p.row else {
if open {
flush_table(&mut out, &mut rows, &mut cells, &mut cell, &grid, &props, depth_seen > 1);
grid.clear();
@@ -386,7 +389,7 @@ pub(crate) fn assemble(paras: Vec<ParaOut>) -> Blocks {
}
out.push(para_block(p.para));
continue;
}
};
if !open {
open = true;
}
+136 -3
View File
@@ -116,6 +116,12 @@ fn piece_bytes(p: &Piece) -> Vec<u8> {
/// A document whose main text is the given pieces (extra subdocument text may follow).
fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec<u8> {
text_doc_with(main, extra, FibSpec { ccp_hdd, ..Default::default() }, &[])
}
/// [`text_doc`] with the FIB knobs given (`ccp_text` is filled in from `main`; `pairs` must be empty) and extra
/// Table-stream data placed after the piece table: `(pair index, bytes)` for each.
fn text_doc_with(main: &[Piece], extra: &[Piece], spec: FibSpec, table_data: &[(usize, &[u8])]) -> Vec<u8> {
let all: Vec<Piece> = main.iter().chain(extra.iter()).cloned().collect();
let ccp_text: u32 = main.iter().map(|p| p.text.chars().count() as u32).sum();
let mut word = vec![0u8; TEXT_FC as usize];
@@ -128,7 +134,13 @@ fn text_doc(main: &[Piece], extra: &[Piece], ccp_hdd: u32) -> Vec<u8> {
clx.extend_from_slice(&plc);
let mut table = vec![0u8; CLX_AT as usize];
table.extend_from_slice(&clx);
let spec = FibSpec { ccp_text, ccp_hdd, pairs: vec![(33, CLX_AT, clx.len() as u32)], ..Default::default() };
let mut pairs = vec![(33, CLX_AT, clx.len() as u32)];
for (pair, bytes) in table_data {
pairs.push((*pair, table.len() as u32, bytes.len() as u32));
table.extend_from_slice(bytes);
}
debug_assert!(spec.pairs.is_empty(), "text_doc_with sets the pairs itself");
let spec = FibSpec { ccp_text, pairs, ..spec };
word[..fib_bytes(&spec).len()].copy_from_slice(&fib_bytes(&spec));
cfb_file(&[("WordDocument", &word), ("0Table", &table)])
}
@@ -247,6 +259,77 @@ fn empty_paragraphs_kept() {
assert_eq!(body_texts(&doc), ["a", "", "b"]);
}
/// A document of `n` copies of `mark` as one compressed piece.
fn marks_doc(mark: char, n: usize) -> Vec<u8> {
text_doc(&[Piece { compressed: true, text: mark.to_string().repeat(n) }], &[], 0)
}
/// Paragraph marks are one byte each in the file but about 1 KB each in memory, so they have
/// their own cap ([`super::MAX_PARAGRAPHS`]); a file past it is refused instead of growing
/// until the machine runs out. A document exactly at the cap still opens.
#[test]
fn a_flood_of_paragraph_marks_is_refused() {
let at_cap = super::read_limited(&marks_doc('\r', 1000), None, 1000).expect("opens");
assert_eq!(at_cap.body.len(), 1000);
let over = super::read_limited(&marks_doc('\r', 1001), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
}
/// The paragraph cap is one budget for the whole document, not one per story: marks in the
/// body and in a footnote add up. (A walk that got its own budget would open both over-cap files.)
#[test]
fn the_paragraph_cap_is_shared_by_the_body_and_a_footnote() {
// One footnote at CP 0 whose text is `note` marks, stored right after the body in the stream.
let doc_of = |body: usize, note: usize| {
let cps = |v: &[u32]| v.iter().flat_map(|c| c.to_le_bytes()).collect::<Vec<u8>>();
let mut refs = cps(&[0, 1]);
refs.extend_from_slice(&[0, 0]); // the one reference's data
let txt = cps(&[0, note as u32, note as u32]); // story 0..note, then the limit
let spec = FibSpec { ccp_ftn: note as u32, ..Default::default() };
text_doc_with(
&[Piece { compressed: true, text: "\r".repeat(body) }],
&[Piece { compressed: true, text: "\r".repeat(note) }],
spec,
&[(2, &refs), (3, &txt)],
)
};
let at_cap = super::read_limited(&doc_of(600, 400), None, 1000).expect("opens");
assert_eq!(at_cap.body.len(), 600);
assert_eq!(at_cap.parts.len(), 1);
let over = super::read_limited(&doc_of(600, 401), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
let over = super::read_limited(&doc_of(601, 400), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
}
/// Cell marks are paragraph marks too: a never-closed row of them is refused the same way.
#[test]
fn a_flood_of_cell_marks_is_refused() {
let over = super::read_limited(&marks_doc('\u{7}', 5000), None, 1000);
assert!(matches!(over, Err(crate::DocbinError::Limit(_))), "{over:?}");
}
/// The budget counts down across spends and notes the overrun (`walk` receives one shared by
/// every story of the document).
#[test]
fn the_paragraph_budget_counts_down_and_notes_the_overrun() {
let budget = super::ParaBudget { left: std::cell::Cell::new(2), exceeded: std::cell::Cell::new(false) };
assert!(budget.spend() && budget.spend());
assert!(!budget.exceeded.get());
assert!(!budget.spend());
assert!(budget.exceeded.get());
}
/// The cap is far above any real document (10,000 pages of 50 paragraphs is 500,000), and a
/// paragraph outside a table no longer carries a table context (a `ParaOut` was 1,224 bytes).
#[test]
fn the_paragraph_cap_leaves_room_and_a_paragraph_stays_small() {
const { assert!(super::MAX_PARAGRAPHS >= 1_000_000) };
assert!(std::mem::size_of::<crate::table::ParaOut>() < 1000, "{}", std::mem::size_of::<crate::table::ParaOut>());
let doc = read(&marks_doc('\r', 10)).expect("opens");
assert_eq!(doc.body.len(), 10);
}
#[test]
fn empty_document_gets_a_paragraph() {
let f = text_doc(&[Piece { compressed: false, text: "".into() }], &[], 0);
@@ -2660,7 +2743,8 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
assert_eq!(linked.first().map(|r| r.len), Some(n));
// One copy of the target for the one run, not one per character (~2.5 s here): counted by
// what the copies charge to the work budget, which a busy machine can't skew.
let result = vec![('y', wordcraft_doc::props::CharProps::default()); n];
let mut result = super::FieldResult::default();
(0..n).for_each(|_| result.push('y', wordcraft_doc::props::CharProps::default()));
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) };
let work = super::Work::new(u64::MAX);
super::finish_field(&mut super::ParaBuild::default(), f, &work);
@@ -2673,7 +2757,8 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
fn hyperlink_target_copies_are_charged_to_the_work_budget() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let result: Vec<(char, CharProps)> = (0..1000).map(|i| ('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })).collect();
let mut result = super::FieldResult::default();
(0..1000).for_each(|i| result.push('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() }));
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) };
let mut pb = super::ParaBuild::default();
let budget = super::Work::new(10_000);
@@ -2747,3 +2832,51 @@ fn prm_grpprls_past_the_work_budget_are_skipped() {
assert_eq!(work.left.get(), 1);
assert_eq!(work.dropped(), [super::Cost::Formatting]);
}
/// A field's cached result keeps one `CharProps` per formatting run, not one per character
/// (365 bytes each: 4 million result characters took 1.4 GB).
#[test]
fn a_field_result_stores_one_props_per_run() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut r = super::FieldResult::default();
for _ in 0..200_000 {
r.push('x', CharProps::default());
}
assert_eq!(r.runs.len(), 1);
assert_eq!(r.runs.first().map(|(n, _)| *n), Some(200_000));
r.push('\u{e9}', bold.clone());
r.push('y', bold);
r.push('z', CharProps::default());
assert_eq!(r.runs.iter().map(|(n, _)| *n).collect::<Vec<_>>(), [200_000, 3, 1], "a two-byte char counts its bytes");
assert_eq!(r.text.len(), 200_004);
}
/// A hyperlink whose result changes formatting, with multi-byte text, keeps every run's text and
/// links all of it; a plain field keeps its result text whole.
#[test]
fn field_results_keep_their_text_and_runs() {
use wordcraft_doc::props::CharProps;
let bold = CharProps { bold: Some(true), ..Default::default() };
let mut result = super::FieldResult::default();
for (c, p) in
[('a', CharProps::default()), ('\u{e9}', CharProps::default()), ('\u{20ac}', bold.clone()), ('b', bold), ('c', CharProps::default())]
{
result.push(c, p);
}
let f = super::FieldBuild { depth: 1, instr: "HYPERLINK \"http://x\"".into(), sep: Some(result) };
let mut pb = super::ParaBuild::default();
super::finish_field(&mut pb, f, &super::Work::new(u64::MAX));
assert_eq!(pb.text, "a\u{e9}\u{20ac}bc");
assert_eq!(pb.runs.iter().map(|r| r.len).collect::<Vec<_>>(), [3, 4, 1]);
assert!(pb.runs.iter().all(|r| r.props.link.as_deref() == Some("http://x")));
let doc = read(&text_doc(&[Piece { compressed: false, text: "\u{13} PAGE \u{14}1\u{e9}2\u{15}\r".into() }], &[], 0)).expect("opens");
let p = doc.body.first().and_then(|b| b.as_para()).expect("paragraph");
assert!(
p.objects
.iter()
.any(|o| matches!(o, wordcraft_doc::para::InlineObject::Field { instr, result, .. } if instr == "PAGE" && result == "1\u{e9}2")),
"{:?}",
p.objects
);
}
+1
View File
@@ -32,6 +32,7 @@ getrandom = { workspace = true, features = ["wasm_js"] }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
serde_json = { workspace = true }
proptest = { workspace = true }
image = { workspace = true }
zip = { workspace = true }
+20
View File
@@ -33,6 +33,7 @@ fn text_frame(k: &El) -> wordcraft_doc::props::TextFrame {
h_anchor: word("w:hAnchor", &["text", "margin", "page"], "page"),
v_anchor: word("w:vAnchor", &["text", "margin", "page"], "page"),
wrap: word("w:wrap", &["around", "auto", "notBeside", "none", "tight", "through"], "auto"),
anchor_lock: k.attr("w:anchorLock").map(|v| !matches!(v.trim(), "0" | "false" | "off" | "False" | "FALSE")),
h_space: len("w:hSpace").unwrap_or(0.0).max(0.0),
v_space: len("w:vSpace").unwrap_or(0.0).max(0.0),
}
@@ -333,6 +334,7 @@ impl PropCtx {
"w:widowControl" => p.widow_control = Some(on_off(k)),
"w:contextualSpacing" => p.contextual_spacing = Some(on_off(k)),
"w:mirrorIndents" => p.mirror_indents = Some(on_off(k)),
"w:adjustRightInd" => p.adjust_right_indent = Some(on_off(k)),
"w:suppressAutoHyphens" => p.suppress_hyphens = Some(on_off(k)),
"w:suppressLineNumbers" => p.suppress_line_numbers = Some(on_off(k)),
"w:bidi" => p.bidi = Some(on_off(k)),
@@ -763,8 +765,26 @@ pub fn sectpr(pc: &PropCtx, e: &El) -> (SectionProps, Vec<HfRef>) {
if let Some(f) = k.attr("w:fmt") {
s.page_num_format = NumFormat::from_ooxml(f);
}
s.chapter_style = k.attr("w:chapStyle").and_then(u32_of).filter(|v| *v <= u8::MAX as u32).map(|v| v as u8);
s.chapter_sep = k.attr("w:chapSep").and_then(wordcraft_doc::section::ChapterSep::from_ooxml);
s.page_num_set = s.page_num_start.is_none() && s.page_num_format == NumFormat::Decimal;
}
"w:paperSrc" => {
let tray = |name| {
let value = k.attr(name)?.trim();
let digits = value.strip_prefix('+').unwrap_or(value);
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
u32_of(digits).filter(|v| *v <= u16::MAX as u32).map(|v| v as u16)
};
s.paper_src_first = tray("w:first");
s.paper_src_other = tray("w:other");
}
"w:noEndnote" => s.no_endnote = Some(on_off(k)),
"w15:footnoteColumns" => {
s.footnote_columns = k.attr("w:val").and_then(u32_of).filter(|v| (1..=45).contains(v));
}
"w:lnNumType" => {
let mut l = LineNumbering::default();
if let Some(v) = k.attr("w:countBy").and_then(u32_of) {
+6 -2
View File
@@ -1,7 +1,8 @@
//! [`wordcraft_doc::math`] tree → Office Math (OMML).
use wordcraft_doc::math::{
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, ScriptKind, parse_linear,
Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MAX_MATRIX_COLS, MAX_MATRIX_ROWS, MNode, MRun, MScr, MSty, Math, MathJc, MathProps,
ScriptKind, parse_linear,
};
use wordcraft_doc::props::Rgb;
@@ -181,7 +182,10 @@ fn char_prop(w: &mut W, name: &str, c: Option<char>) {
fn write_matrix(w: &mut W, n: &MNode, depth: usize) {
let MNode::Matrix { rows, col_jc } = n else { return };
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).max(1);
// Every row is padded to the widest one, so a ragged matrix is clamped to the reader's
// caps rather than written as rows × widest cells (#3643).
let rows = rows.get(..rows.len().min(MAX_MATRIX_ROWS)).unwrap_or_default();
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).clamp(1, MAX_MATRIX_COLS);
w.open("m:m", &[]);
w.open("m:mPr", &[]);
w.open("m:mcs", &[]);
+4
View File
@@ -326,6 +326,9 @@ fn write_frame(w: &mut W, p: &ParaProps, framed: bool) {
a.push(("w:vSpace", &vs));
}
a.push(("w:wrap", &f.wrap));
if let Some(anchor_lock) = f.anchor_lock {
a.push(("w:anchorLock", if anchor_lock { "1" } else { "0" }));
}
a.push(("w:vAnchor", &f.v_anchor));
a.push(("w:hAnchor", &f.h_anchor));
match &f.x_align {
@@ -373,6 +376,7 @@ fn write_text_flow(w: &mut W, p: &ParaProps) {
toggle(w, "w:autoSpaceDE", p.auto_space_de);
toggle(w, "w:autoSpaceDN", p.auto_space_dn);
toggle(w, "w:bidi", p.bidi);
toggle(w, "w:adjustRightInd", p.adjust_right_indent);
toggle(w, "w:snapToGrid", p.snap_to_grid);
}
+45 -8
View File
@@ -87,6 +87,7 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
("w:gutter", &twips(s.gutter.max(0.0))),
],
);
paper_source(w, s);
if let Some(b) = &s.page_borders {
borders(w, "w:pgBorders", b, None, &[("w:offsetFrom", "text")]);
}
@@ -107,14 +108,7 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:lnNumType", &refs);
}
if s.page_num_set || s.page_num_start.is_some() || s.page_num_format != wordcraft_doc::section::NumFormat::Decimal {
let mut a = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
if let Some(st) = s.page_num_start {
a.push(("w:start", st.to_string()));
}
let refs: Vec<(&str, &str)> = a.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:pgNumType", &refs);
}
page_number_type(w, s);
let c = &s.columns;
let count = c.count.clamp(1, 45);
let mut ca = vec![("w:space", twips(c.space.max(0.0))), ("w:num", n(count as i64))];
@@ -134,6 +128,9 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
let refs: Vec<(&str, &str)> = ca.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:cols", &refs);
}
if let Some(count) = s.footnote_columns.filter(|count| *count > 0) {
w.empty("w15:footnoteColumns", &[("w:val", &count.clamp(1, 45).to_string())]);
}
if let Some(on) = s.form_prot {
w.empty("w:formProt", if on { &[] } else { &[("w:val", "0")] });
}
@@ -143,6 +140,9 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
wordcraft_doc::section::PageVAlign::Justify => w.val("w:vAlign", "both"),
wordcraft_doc::section::PageVAlign::Bottom => w.val("w:vAlign", "bottom"),
}
if let Some(on) = s.no_endnote {
w.empty("w:noEndnote", if on { &[] } else { &[("w:val", "0")] });
}
if s.title_page {
w.empty("w:titlePg", &[]);
}
@@ -167,6 +167,43 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
}
}
fn paper_source(w: &mut W, s: &SectionProps) {
let mut attrs = Vec::new();
if let Some(first) = s.paper_src_first {
attrs.push(("w:first", first.to_string()));
}
if let Some(other) = s.paper_src_other {
attrs.push(("w:other", other.to_string()));
}
if !attrs.is_empty() {
let refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:paperSrc", &refs);
}
}
fn page_number_type(w: &mut W, s: &SectionProps) {
if !(s.page_num_set
|| s.page_num_start.is_some()
|| s.page_num_format != wordcraft_doc::section::NumFormat::Decimal
|| s.chapter_style.is_some()
|| s.chapter_sep.is_some())
{
return;
}
let mut attrs = vec![("w:fmt", s.page_num_format.ooxml().to_string())];
if let Some(start) = s.page_num_start {
attrs.push(("w:start", start.to_string()));
}
if let Some(style) = s.chapter_style {
attrs.push(("w:chapStyle", style.to_string()));
}
if let Some(separator) = s.chapter_sep {
attrs.push(("w:chapSep", separator.ooxml().to_string()));
}
let refs: Vec<(&str, &str)> = attrs.iter().map(|(k, v)| (*k, v.as_str())).collect();
w.empty("w:pgNumType", &refs);
}
/// The children of a `w:footnotePr` / `w:endnotePr` in schema order (`w:pos`, `w:numFmt`,
/// `w:numStart`, `w:numRestart`; ECMA-376 §17.11.11), for the fields that are set.
pub(in super::super) fn note_pr_inner(w: &mut W, pr: &NoteProps) {
+29
View File
@@ -80,3 +80,32 @@ fn automatic_spacing_reads_and_round_trips() {
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(props(&again)[..3], p[..3]);
}
#[test]
fn adjust_right_indent_is_preserved() {
let body = r#"<w:p><w:pPr><w:adjustRightInd w:val="0"/><w:snapToGrid w:val="0"/><w:spacing w:after="120"/><w:ind w:right="120"/></w:pPr><w:r><w:t>false</w:t></w:r></w:p>
<w:p><w:pPr><w:adjustRightInd w:val="1"/></w:pPr><w:r><w:t>true</w:t></w:r></w:p>
<w:p><w:r><w:t>absent</w:t></w:r></w:p>"#;
let mut d = wordcraft_docx::read(&docx(body)).unwrap();
assert_eq!(props(&d).iter().map(|p| p.adjust_right_indent).collect::<Vec<_>>(), [Some(false), Some(true), None]);
for _ in 0..2 {
let out = wordcraft_docx::write(&d).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(out.clone())).unwrap();
let mut xml = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap();
assert!(xml.contains(r#"<w:adjustRightInd w:val="0""#), "explicit false was lost: {xml}");
assert!(xml.contains("<w:adjustRightInd/>"), "explicit true was lost: {xml}");
let tags = ["<w:adjustRightInd", "<w:snapToGrid", "<w:spacing", "<w:ind"];
let pprs = xml.split("<w:pPr>").skip(1).filter_map(|rest| rest.split("</w:pPr>").next()).collect::<Vec<_>>();
let first = pprs.first().expect("paragraph properties must be present");
for &tag in &tags {
assert!(first.contains(tag), "{tag} missing: {xml}");
}
for ppr in pprs {
let order = tags.iter().filter_map(|tag| ppr.find(*tag)).collect::<Vec<_>>();
assert!(order.windows(2).all(|pair| pair[0] < pair[1]), "paragraph properties are out of schema order: {xml}");
}
d = wordcraft_docx::read(&out).unwrap();
assert_eq!(props(&d).iter().map(|p| p.adjust_right_indent).collect::<Vec<_>>(), [Some(false), Some(true), None]);
}
}
+40
View File
@@ -247,6 +247,46 @@ fn frame_zero_side_is_automatic() {
}
}
#[test]
fn locked_frame_anchor_survives_two_saves() {
for (lock, expected) in [("", None), (" w:anchorLock=\"1\"", Some(true)), (" w:anchorLock=\"0\"", Some(false))] {
let mut document = read_body(&format!(r#"<w:p><w:pPr><w:framePr w:w="1440" w:h="720"{lock}/></w:pPr><w:r><w:t>Locked</w:t></w:r></w:p>"#));
let paragraph = paras(&document)[0];
assert_eq!(paragraph.props.frame.as_ref().and_then(|f| f.anchor_lock), expected);
if expected == Some(true) {
let Some(wordcraft_doc::Block::Para(paragraph)) = document.body.get_mut(0).map(std::sync::Arc::make_mut) else {
panic!("first block is the framed paragraph");
};
let frame = paragraph.props.frame.as_mut().unwrap();
frame.x = 36.0;
frame.y = 72.0;
frame.w = Some(108.0);
}
let json = serde_json::to_vec(&document).unwrap();
document = serde_json::from_slice(&json).unwrap();
if expected == Some(true) {
let frame = paras(&document)[0].props.frame.as_ref().unwrap();
assert_eq!((frame.x, frame.y, frame.w), (36.0, 72.0, Some(108.0)));
}
for pass in 0..2 {
let bytes = wordcraft_docx::write(&document).unwrap();
let xml = text(&unzip(&bytes), "word/document.xml");
let expected_xml = match expected {
Some(true) => "w:anchorLock=\"1\"",
Some(false) => "w:anchorLock=\"0\"",
None => "w:anchorLock=",
};
assert_eq!(xml.contains(expected_xml), expected.is_some(), "lock changed on save {}: {xml}", pass + 1);
document = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(paras(&document)[0].props.frame.as_ref().and_then(|f| f.anchor_lock), expected);
if expected == Some(true) {
let frame = paras(&document)[0].props.frame.as_ref().unwrap();
assert_eq!((frame.x, frame.y, frame.w), (36.0, 72.0, Some(108.0)));
}
}
}
}
#[test]
fn omitted_paragraph_defaults_can_be_edited() {
let styles =
+1
View File
@@ -29,6 +29,7 @@ mod roundtrip;
mod row_property_exceptions;
mod run_props;
mod section_form_protection;
mod section_metadata_preservation;
mod section_page_number_type;
mod section_text_direction;
mod section_valign;
+2
View File
@@ -176,6 +176,7 @@ fn every_para_prop_round_trips() {
line_spacing: Some(LineSpacing::Multiple(1.5)),
contextual_spacing: Some(true),
mirror_indents: Some(true),
adjust_right_indent: Some(false),
keep_next: Some(true),
keep_lines: Some(false),
page_break_before: Some(true),
@@ -209,6 +210,7 @@ fn every_para_prop_round_trips() {
h_anchor: "margin".into(),
v_anchor: "text".into(),
wrap: "around".into(),
anchor_lock: Some(true),
h_space: 9.0,
v_space: 0.0,
}),
+24
View File
@@ -29,6 +29,30 @@ fn part(bytes: &[u8], name: &str) -> String {
xml
}
#[test]
fn paragraph_adjust_right_indent_inherits_from_style() {
let styles =
r#"<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:pPr><w:adjustRightInd/></w:pPr></w:style>"#;
let body = r#"<w:p><w:pPr><w:adjustRightInd w:val="0"/></w:pPr><w:r><w:t>off</w:t></w:r></w:p>
<w:p><w:r><w:t>inherited</w:t></w:r></w:p>"#;
let mut d = wordcraft_docx::read(&docx(body, styles)).unwrap();
let paragraphs: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
assert_eq!(paragraphs[0].props.adjust_right_indent, Some(false));
assert!(!d.styles.resolve_para(&paragraphs[0].props).adjust_right_indent);
assert!(d.styles.resolve_para(&paragraphs[1].props).adjust_right_indent);
for _ in 0..2 {
let out = wordcraft_docx::write(&d).unwrap();
assert!(part(&out, "word/document.xml").contains(r#"<w:adjustRightInd w:val="0""#));
assert!(part(&out, "word/styles.xml").contains("<w:adjustRightInd/>"));
d = wordcraft_docx::read(&out).unwrap();
let paragraphs: Vec<_> = d.body.iter().filter_map(|b| b.as_para()).collect();
assert!(!d.styles.resolve_para(&paragraphs[0].props).adjust_right_indent);
assert!(d.styles.resolve_para(&paragraphs[1].props).adjust_right_indent);
assert_eq!(paragraphs[0].props.adjust_right_indent, Some(false));
}
}
#[test]
fn run_and_style_snap_to_grid_and_web_hidden_survive_two_saves() {
let styles = r#"
@@ -0,0 +1,66 @@
//! Section printer, note and chapter-number metadata survives DOCX and native JSON saves.
use std::io::{Read, Write};
fn docx(body: &str) -> Vec<u8> {
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="w15"><w:body>{body}</w:body></w:document>"#
);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(bytes: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut s = String::new();
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
s
}
#[test]
fn section_metadata_survives_native_json_and_two_docx_save_reopen_cycles() {
let input = docx(
r#"<w:p><w:pPr><w:sectPr><w:paperSrc w:first=" +261 " w:other=" 260 "/><w:noEndnote/><w15:footnoteColumns w:val="2"/><w:pgNumType w:fmt="decimal" w:chapStyle="1" w:chapSep="hyphen"/></w:sectPr></w:pPr><w:r><w:t>First section</w:t></w:r></w:p><w:p><w:r><w:t>Second section</w:t></w:r></w:p><w:sectPr><w:paperSrc w:first="12" w:other="34"/><w:noEndnote w:val="0"/><w15:footnoteColumns w:val="3"/><w:pgNumType w:fmt="upperRoman" w:chapStyle="10" w:chapSep="colon"/></w:sectPr>"#,
);
let mut document = wordcraft_docx::read(&input).unwrap();
let json = serde_json::to_vec(&document).unwrap();
document = serde_json::from_slice(&json).unwrap();
for cycle in 0..2 {
let bytes = wordcraft_docx::write(&document).unwrap();
let xml = document_xml(&bytes);
assert!(xml.contains(r#"<w:paperSrc w:first="261" w:other="260"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"<w:paperSrc w:first="12" w:other="34"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains("<w:noEndnote/>"), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"<w:noEndnote w:val="0"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"<w15:footnoteColumns w:val="2"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"<w15:footnoteColumns w:val="3"/>"#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"w:chapStyle="1" w:chapSep="hyphen""#), "cycle {cycle}: {xml}");
assert!(xml.contains(r#"w:chapStyle="10" w:chapSep="colon""#), "cycle {cycle}: {xml}");
assert!(xml.contains("xmlns:w15=") && xml.contains("mc:Ignorable=\"w14 w15 wp14\""), "cycle {cycle}: {xml}");
document = wordcraft_docx::read(&bytes).unwrap();
let json = serde_json::to_vec(&document).unwrap();
document = serde_json::from_slice(&json).unwrap();
}
}
#[test]
fn invalid_section_metadata_is_ignored_and_missing_values_stay_missing() {
let bytes = docx(
r#"<w:p><w:r><w:t>Text</w:t></w:r></w:p><w:sectPr><w:paperSrc w:first="65536" w:other="-1"/><w15:footnoteColumns w:val="0"/><w:pgNumType w:chapStyle="256" w:chapSep="invalid"/></w:sectPr>"#,
);
let mut d = wordcraft_docx::read(&bytes).unwrap();
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
assert!(!xml.contains("paperSrc"), "{xml}");
assert!(!xml.contains("footnoteColumns"), "{xml}");
assert!(!xml.contains("chapStyle") && !xml.contains("chapSep"), "{xml}");
assert!(!xml.contains("noEndnote"), "{xml}");
d.last_section.footnote_columns = Some(0);
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
assert!(!xml.contains("footnoteColumns"), "{xml}");
}
+23
View File
@@ -0,0 +1,23 @@
//! A ragged equation matrix doesn't make the saved `document.xml` grow with rows × widest row
//! (#3643): one wide row plus many empty rows used to be padded out to a full grid.
use wordcraft_doc::math::{MNode, MRun, Math};
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, Paragraph, para_block};
#[test]
fn ragged_matrix_save_stays_small() {
let n = 5000;
let mut rows = vec![vec![vec![MNode::Run(MRun::new("a"))]; n]];
rows.extend(std::iter::repeat_with(Vec::new).take(n));
let math = Math { nodes: vec![MNode::Matrix { rows, col_jc: Vec::new() }], ..Default::default() };
let mut p = Paragraph::new();
p.insert_object(0, InlineObject::Equation { linear: String::new(), display: true, math }, &CharProps::default()).unwrap();
let mut doc = Document::new();
doc.body = vec![para_block(p)];
let bytes = wordcraft_docx::write(&doc).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let size = zip.by_name("word/document.xml").unwrap().size();
assert!(size < 1_000_000, "document.xml is {size} bytes");
}
+1
View File
@@ -52,6 +52,7 @@ chrono = { workspace = true, features = ["clock"] }
libc = { workspace = true }
[dev-dependencies]
wordcraft-doc = { workspace = true, features = ["test-support"] }
encoding_rs = { workspace = true }
zip = { workspace = true }
cfb = { workspace = true }
+9 -3
View File
@@ -26,6 +26,9 @@ use super::ink_shape::{ArrowParts, InkShape, arrow_parts, pair_parts, recognise}
use super::pos_json;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
mod page_objects;
use page_objects::PageObjects;
/// What dragging on the page does (Draw tab).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -325,12 +328,13 @@ impl PlacedInk {
/// turn and geometry on the page.
pub fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec<PlacedInk> {
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
let mut objects = PageObjects::new(&s.doc);
pg.items
.iter()
.filter_map(|it| match it {
Placed::Object { rect, spin, story, path, off, .. } => {
let pos = Pos { story: *story, path: path.clone(), off: *off };
match s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) {
match objects.get(&pos) {
Some(InlineObject::Shape { freeform: Some(f), stroke_width, stroke, .. }) if f.is_ink() => {
Some(PlacedInk { pos, rect: *rect, spin: *spin, freeform: f.clone(), color: *stroke, width: *stroke_width })
}
@@ -577,10 +581,11 @@ fn lasso(s: &mut Session, v: &Value) -> CmdResult {
return Err(CmdError::Params("a lasso loop needs at least three points".into()));
}
let mut items = Vec::new();
let mut objects = PageObjects::new(&s.doc);
for it in &pg.items {
let Placed::Object { rect, spin, story, path, off, .. } = it else { continue };
let pos = Pos { story: *story, path: path.clone(), off: *off };
let Some(obj) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
let Some(obj) = objects.get(&pos) else { continue };
let picked = match obj.ink() {
Some(_) if s.view.hide_ink => false,
Some(f) => {
@@ -771,13 +776,14 @@ fn near(a: Rect, b: Rect) -> bool {
fn arrow_pieces(s: &Session, layout: &DocLayout, page: usize, keep: impl Fn(&Pos, &Rect) -> bool) -> Vec<ArrowPiece> {
let Some(pg) = layout.pages.get(page) else { return Vec::new() };
let mut out = Vec::new();
let mut objects = PageObjects::new(&s.doc);
for it in &pg.items {
let Placed::Object { rect, spin, story, path, off, .. } = it else { continue };
let pos = Pos { story: *story, path: path.clone(), off: *off };
if !spin.is_identity() || !keep(&pos, rect) {
continue;
}
let Some(InlineObject::Shape { kind, freeform: Some(f), float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue };
let Some(InlineObject::Shape { kind, freeform: Some(f), float, .. }) = objects.get(&pos) else { continue };
let Some(fp) = f.paths.first().filter(|_| f.paths.len() == 1) else { continue };
let pen = matches!(f.ink, Some(InkTool::Pen | InkTool::Pencil)) && !s.view.hide_ink;
let shape = f.ink.is_none() && *kind == ShapeKind::Freeform && matches!((fp.pts.len(), fp.closed), (2, false) | (3, true));
@@ -0,0 +1,49 @@
//! Per-call object lookups for drawing-order page walks, which can revisit a paragraph's
//! offsets out of text order. Scan only as far as the furthest requested object, once.
use std::collections::HashMap;
use wordcraft_doc::para::OBJ;
use wordcraft_doc::{Document, InlineObject, Path, Pos, StoryRef};
#[derive(Default)]
struct ParagraphObjects<'a> {
scanned: usize,
ordinal: usize,
objects: Vec<(usize, &'a InlineObject)>,
}
pub(super) struct PageObjects<'a> {
doc: &'a Document,
paragraphs: HashMap<(StoryRef, Path), ParagraphObjects<'a>>,
}
impl<'a> PageObjects<'a> {
pub(super) fn new(doc: &'a Document) -> Self {
Self { doc, paragraphs: HashMap::new() }
}
pub(super) fn get(&mut self, pos: &Pos) -> Option<&'a InlineObject> {
let p = self.doc.para_at(pos)?;
if !p.text.get(pos.off..)?.starts_with(OBJ) {
return None;
}
let end = pos.off.checked_add(OBJ.len_utf8())?;
let cached = self.paragraphs.entry((pos.story, pos.path.clone())).or_default();
if end > cached.scanned {
// Start after the last scanned marker, not at the paragraph's start. In particular,
// a lone object near the start never scans a long text suffix it doesn't need.
for (off, c) in p.text.get(cached.scanned..end)?.char_indices() {
wordcraft_doc::work::steps(1);
if c == OBJ {
if let Some(object) = p.objects.get(cached.ordinal) {
cached.objects.push((cached.scanned + off, object));
}
cached.ordinal += 1;
}
}
cached.scanned = end;
}
let i = cached.objects.binary_search_by_key(&pos.off, |(off, _)| *off).ok()?;
cached.objects.get(i).map(|(_, object)| *object)
}
}
+2
View File
@@ -5,6 +5,8 @@ use wordcraft_doc::para::{InlineObject, ShapeKind, Wrap};
use crate::Session;
mod page_lookup;
fn session() -> Session {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("document.setText", &json!({"text": "First paragraph of text.\nSecond paragraph of text."})).unwrap();
@@ -0,0 +1,106 @@
use super::super::{PlacedInk, ink_on};
use crate::Session;
use std::sync::Arc;
use wordcraft_doc::freeform::{Freeform, InkTool};
use wordcraft_doc::para::{Float, InlineObject, OBJ, ShapeKind, Wrap};
use wordcraft_doc::{CharProps, Document, Paragraph, Path, Pos, StoryRef, para_block};
use wordcraft_geom::{Rect, Spin};
use wordcraft_layout::{DocLayout, Page, Placed};
fn stroke(order: u32) -> InlineObject {
InlineObject::Shape {
kind: ShapeKind::Freeform,
w: 20.0,
h: 10.0,
fill: None,
stroke: Some(wordcraft_doc::Rgb(12, 34, 56)),
stroke_width: 2.0,
float: Float { wrap: Wrap::InFrontOfText, ..Default::default() },
story: None,
freeform: Some(Arc::new(Freeform { order: Some(order), ..Freeform::ink(InkTool::Pen, 20.0, 10.0, vec![[1.0, 1.0], [19.0, 9.0]]) })),
effects: Default::default(),
extra: Default::default(),
}
}
fn placed(story: StoryRef, path: Path, off: usize, x: f32) -> Placed {
Placed::Object {
rect: Rect::new(x, 30.0, 20.0, 10.0),
spin: Spin::default(),
story,
path,
off,
text_box: None,
wrap: Wrap::InFrontOfText,
origin: Default::default(),
}
}
fn orders(ink: &[PlacedInk]) -> Vec<u32> {
ink.iter().map(|k| k.freeform.order.unwrap()).collect()
}
#[test]
fn page_ink_keeps_item_order_duplicates_story_paths_and_unmatched_storage() {
let mut d = Document::new();
let mut p = Paragraph::new();
p.text = format!("é{OBJ}{OBJ}{OBJ}漢{OBJ}");
p.runs = vec![wordcraft_doc::Run { len: p.text.len(), props: CharProps::default() }];
p.objects = vec![InlineObject::BookmarkStart { name: "original".into() }, stroke(11), stroke(22)];
d.body = vec![para_block(p.clone())];
let h = d.add_part(wordcraft_doc::PartKind::Header, vec![para_block(p.clone())]);
let mut table = wordcraft_doc::Table::new(1, 1, 100.0);
p.objects.push(stroke(33));
p.objects.push(stroke(99)); // Surplus storage has no character and is not placed.
table.rows[0].cells[0].blocks = vec![para_block(p)];
d.body.push(Arc::new(wordcraft_doc::Block::Table(table)));
let cell = Path(vec![1, 0, 0, 0]);
let mut items = vec![
placed(StoryRef::Body, Path::top(0), 8, 50.0),
placed(StoryRef::Part(h), Path::top(0), 5, 60.0),
placed(StoryRef::Body, cell.clone(), 14, 70.0),
placed(StoryRef::Body, Path::top(0), 5, 80.0),
placed(StoryRef::Body, Path::top(0), 8, 90.0),
];
items.extend([0, 1, 2, 3, 4, 11, 14, usize::MAX].map(|off| placed(StoryRef::Body, Path::top(0), off, 0.0)));
items.push(placed(StoryRef::Part(999), Path::top(0), 0, 0.0));
let l = DocLayout { pages: vec![Page { items, ..Default::default() }], ..Default::default() };
let mut s = Session::new(d);
let original = s.doc.clone();
let got = ink_on(&s, &l, 0);
assert_eq!(orders(&got), [22, 11, 33, 11, 22]);
assert_eq!(got.iter().map(|k| k.rect.x).collect::<Vec<_>>(), [50.0, 60.0, 70.0, 80.0, 90.0]);
assert_eq!(got[1].pos, Pos::new(StoryRef::Part(h), Path::top(0), 5));
assert_eq!(got[2].pos.path, cell);
assert!(got.iter().all(|k| k.width == 2.0 && k.color == Some(wordcraft_doc::Rgb(12, 34, 56))));
assert!(ink_on(&s, &l, 1).is_empty());
s.view.hide_ink = true;
assert_eq!(orders(&ink_on(&s, &l, 0)), orders(&got), "discovery retains hidden ink; callers decide visibility");
assert_eq!(s.doc, original);
}
#[test]
fn page_ink_lookup_work_does_not_multiply_with_paragraph_objects() {
fn fixture(n: usize) -> (Session, DocLayout) {
let mut d = Document::new();
let mut p = Paragraph::new();
p.text = OBJ.to_string().repeat(n);
p.runs = vec![wordcraft_doc::Run { len: p.text.len(), props: CharProps::default() }];
p.objects = (0..n).map(|i| stroke(i as u32)).collect();
d.body = vec![para_block(p)];
let items = (0..n).map(|i| placed(StoryRef::Body, Path::top(0), i * OBJ.len_utf8(), 50.0)).collect();
(Session::new(d), DocLayout { pages: vec![Page { items, ..Default::default() }], ..Default::default() })
}
let (small, a) = fixture(250);
let (big, b) = fixture(2000);
assert_eq!(ink_on(&small, &a, 0).len(), 250);
assert_eq!(ink_on(&big, &b, 0).len(), 2000);
// The characters the object lookups walk (`wordcraft_doc::work`), not a timing: about 8x for
// 8x the objects when each paragraph is scanned once, about 64x when every lookup rescans it.
let s1 = wordcraft_doc::work::count(|| drop(std::hint::black_box(ink_on(&small, &a, 0))));
let s8 = wordcraft_doc::work::count(|| drop(std::hint::black_box(ink_on(&big, &b, 0))));
let ratio = s8 as f64 / s1.max(1) as f64;
eprintln!("page ink steps: {s1} -> {s8}, {ratio:.1}x for 8x objects");
assert!(s1 > 0, "no steps counted, so the walk this test watches no longer counts them (wordcraft_doc::work)");
assert!(ratio <= 12.0, "page ink lookup repeats paragraph-prefix work: {ratio:.1}x the steps for 8x objects ({s1} -> {s8})");
}
+12 -7
View File
@@ -199,16 +199,21 @@ fn copy_with(s: &mut Session, whole: Option<&Path>) -> CmdResult {
}
/// What Copy and Cut return: the clipboard's text, and the copy as HTML (equations as MathML,
/// #816) for the host to put on the system clipboard too, so other programs keep the formatting;
/// and for a copy of one equation alone, its MathML (#1297).
/// #816) and as RTF for the host to put on the system clipboard too, so other programs keep the
/// formatting; and for a copy of one equation alone, its MathML (#1297).
pub(crate) fn copy_result(s: &mut Session) -> Value {
s.clipboard_html = match &s.clipboard {
Some(f) => wordcraft_formats::html::export_fragment(&f.to_document()),
None => String::new(),
(s.clipboard_html, s.clipboard_rtf) = match &s.clipboard {
Some(f) => {
let d = f.to_document();
(wordcraft_formats::html::export_fragment(&d), wordcraft_formats::rtf::export(&d))
}
None => (String::new(), String::new()),
};
let mut r = json!({"text": s.clipboard_text});
if !s.clipboard_html.is_empty() {
r["html"] = json!(s.clipboard_html);
for (k, v) in [("html", &s.clipboard_html), ("rtf", &s.clipboard_rtf)] {
if !v.is_empty() {
r[k] = json!(v);
}
}
// A copy of one equation alone, as MathML for the clipboard formats equation editors read
// ("MathML" on Windows, `public.mathml.presentation` on macOS; #1297). Word looks for them
+1 -1
View File
@@ -18,7 +18,7 @@ mod styles;
mod tabs_borders;
use format::{align, asian_typography, cur, direction, line_spacing, ls, set_align, set_props, tog};
pub use format::{fmt, indent, outdent};
pub use format::{cur as selection_para, fmt, indent, outdent};
pub use lists::{ListButtons, list_buttons, manage_list, touch_all};
use lists::{list, restart_numbering};
pub(crate) use outline::{ensure_heading_style, set_heading};
+11 -2
View File
@@ -1,7 +1,8 @@
use super::{Align, CmdError, CmdResult, LineSpacing, NumRef, ParaProps, Pos, Session, Value, json, p, sel_result};
/// Resolved paragraph props at the caret.
pub(super) fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
/// Resolved paragraph props of the selection: those of the paragraph the caret is in, or of the
/// last paragraph a selection covers when it ends at the next paragraph's start.
pub fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara {
// A selection ending at the next paragraph's start doesn't format that paragraph (#418), so
// its state comes from the last paragraph it does cover.
let (a, b) = s.sel.ordered();
@@ -122,8 +123,16 @@ pub(super) fn set_props(s: &mut Session, v: &Value) -> CmdResult {
for path in crate::cmd::selected_paths(s) {
let Some(para) = s.doc.para(story, &path) else { continue };
let rtl = s.doc.styles.resolve_para(&para.props.clone().overlaid(&props)).bidi;
let anchor_lock = props
.frame
.as_ref()
.filter(|frame| frame.anchor_lock.is_none())
.and_then(|_| para.props.frame.as_ref().and_then(|frame| frame.anchor_lock));
let para = s.doc.para_mut(story, &path)?;
para.props.overlay(&props);
if let (Some(anchor_lock), Some(frame)) = (anchor_lock, para.props.frame.as_mut()) {
frame.anchor_lock = Some(anchor_lock);
}
if let Some(a) = align {
para.props.align = Some(a.visual(rtl));
}
+1
View File
@@ -12,6 +12,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
mod fields;
mod notes;
mod numbers;
mod properties;
mod toc;
@@ -1,3 +1,4 @@
use super::numbers::{body_bookmark_paths, para_numbers, ref_number};
use super::properties::{
bookmark_ends_in_its_paragraph, bookmark_text, bookmark_text_at, count_text, date_picture, doc_property, doc_property_arg, new_property_value,
set_summary_property, summary_property, text_result,
@@ -335,6 +336,9 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
// The document properties as the fields read and set them (`SUBJECT "New subject"`): a field
// after one that sets a property shows the new value.
let mut core = s.doc.core.clone();
// Build each body lookup once, only when a supported numeric REF needs it.
let mut numbers = None;
let mut bookmark_paths = None;
let paths: Vec<(StoryRef, Path)> = stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect();
// SET <name> "<text>" (ECMA-376 §17.16.5.58): the values it gives, which REF <name> shows; a
// REF before the SET shows the last value set, as Word's bookmark does after an update.
@@ -374,6 +378,14 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
let r = if name == "REF" {
if has_switch(instr, 'p') {
None
} else if let Some(sw) = ['w', 'r', 'n'].into_iter().find(|c| has_switch(instr, *c)) {
if has_switch(instr, 'd') || has_switch(instr, 't') {
continue; // Preserve cached results for unsupported delimiter/text switches.
}
let numbers = numbers.get_or_insert_with(|| para_numbers(&s.doc));
let from = (story == StoryRef::Body).then_some(&path);
let paths = bookmark_paths.get_or_insert_with(|| body_bookmark_paths(&s.doc));
paths.get(target).and_then(|tp| ref_number(numbers, tp, from, sw))
} else {
bookmark_text(&s.doc, target).or_else(|| {
let key = target.to_lowercase();
+135
View File
@@ -0,0 +1,135 @@
//! Numbered REF results, adapted from @Alyosha-dot-shop's patch in #4533.
use super::{Counters, NumFormat, Path, StoryRef};
/// What a numbered body paragraph shows: the counter value of each level down to its own, the
/// label each of those levels shows at that point (`["14.", "(a)"]` for 14(a)), and the levels
/// each label's template shows (`"%1.%2"` shows levels 0 and 1).
#[derive(Clone, Debug, Default)]
pub(super) struct ParaNumber {
/// Relative context is shared only within the same numbering instance.
list: u32,
values: Vec<u32>,
/// Whether each level has counted a paragraph yet: a level the list never used (an `(a)` item
/// with no clause above it in its list) shows no number in a reference.
started: Vec<bool>,
labels: Vec<Option<String>>,
shows: Vec<Vec<usize>>,
}
/// Whether the body paragraph at `path` sits in a table cell merged into the one above
/// (`w:vMerge` continue), at any nesting depth. The layout shows nothing of such a cell, so its
/// paragraphs take no list number.
fn in_merged_cell(doc: &wordcraft_doc::Document, path: &Path) -> bool {
let p = &path.0;
let mut j = 0usize;
// [table, row, cell, block, row, cell, block, …]: a table at p[..=j], its row and cell next.
while j.saturating_add(3) < p.len() {
let (Some(tp), Some(&r), Some(&c)) = (p.get(..=j), p.get(j + 1), p.get(j + 2)) else { return false };
let merged = doc
.table(StoryRef::Body, &Path(tp.to_vec()))
.and_then(|t| t.rows.get(r as usize))
.and_then(|row| row.cells.get(c as usize))
.is_some_and(|cell| cell.props.vmerge == wordcraft_doc::props::VMerge::Continue);
if merged {
return true;
}
j = j.saturating_add(3);
}
false
}
/// Every numbered body paragraph's number, by path, walking the lists in document order as the
/// layout does (cells merged into the one above count nothing). Bullets have no number.
pub(super) fn para_numbers(doc: &wordcraft_doc::Document) -> std::collections::HashMap<Path, ParaNumber> {
let mut counters = Counters::default();
let mut out = std::collections::HashMap::new();
for path in doc.para_paths(StoryRef::Body) {
if in_merged_cell(doc, &path) {
continue;
}
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
let Some(n) = p.props.numbering.or_else(|| doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0) else { continue };
let Some((_, def)) = counters.next_label(&doc.numbering, n.num, n.level) else { continue };
if def.format == NumFormat::Bullet {
continue;
}
let level = usize::from(n.level.min(8));
let values = counters.values(&doc.numbering, n.num).unwrap_or_default();
let started = counters.started(&doc.numbering, n.num).unwrap_or_default();
let mut num = ParaNumber { list: n.num, ..Default::default() };
for k in 0..=level {
num.values.push(values.get(k).copied().unwrap_or(0));
num.started.push(started.get(k).copied().unwrap_or(false));
num.labels.push(counters.current_label(&doc.numbering, n.num, k as u8));
num.shows.push(counters.current_template_levels(&doc.numbering, n.num, k as u8));
}
out.insert(path, num);
}
out
}
/// A paragraph number as a cross-reference shows it: without the trailing period ("14." → "14"),
/// keeping a closing bracket ("(a)").
fn ref_part(label: &str) -> &str {
label.trim().trim_end_matches('.').trim_end()
}
/// The number `REF \n`, `\r` or `\w` shows for the bookmarked paragraph `target` (ECMA-376 Part 1
/// §17.16.5.51), as Word computes it:
/// - `\n` (no context): the paragraph's own label.
/// - `\w` (full context): the own label, with the label of each level above it put in front when
/// no label already in the result shows that level: under `%1.` / `%1.%2` / `(%3)`, clause (a)
/// of 1.1 is `1.1(a)`, not `1.1.1(a)`.
/// - `\r` (relative context): like `\w`, but leaving out the leading levels whose counters the
/// field's own paragraph (`from`) shares with the target, compared level by level within the same list. From inside clause 14, 14(a) is `(a)`; from clause 5 or an unnumbered paragraph it
/// is `14(a)`. Context already displayed by the own or a needed ancestor label stays.
///
/// A level above the target that its list never used adds nothing (Word shows `(a)` for an item
/// whose list has no clause above it, not a made-up `1.1(a)`). `None` when the target paragraph
/// has no number.
pub(super) fn ref_number(numbers: &std::collections::HashMap<Path, ParaNumber>, target: &Path, from: Option<&Path>, switch: char) -> Option<String> {
let t = numbers.get(target)?;
let level = t.values.len().checked_sub(1)?;
let own = t.labels.get(level)?.as_deref()?;
let mut out = ref_part(own).to_string();
if switch == 'n' {
return Some(out);
}
let used = |k: usize| t.started.get(k).copied().unwrap_or(false);
let first = match (switch, from.and_then(|p| numbers.get(p))) {
('r', Some(f)) if f.list == t.list => {
(0..level).take_while(|&k| !used(k) || (f.values.get(k).is_some() && f.values.get(k) == t.values.get(k))).count()
}
_ => 0,
};
// Relative context suppresses added ancestors, never text already rendered in a label.
// Word's measured legal-template cases retain 2.2.2 even when 2 or 2.2 is shared.
let mut shown = t.shows.get(level).cloned().unwrap_or_default();
for k in (first..level).rev() {
if shown.contains(&k) || !used(k) {
continue;
}
if t.labels.get(k).is_some_and(Option::is_some) {
let prefix = ref_part(t.labels.get(k)?.as_deref()?);
// Without a delimiter, joining separate numeric labels would invent a number.
if prefix.chars().next_back().is_some_and(char::is_alphanumeric) && out.chars().next().is_some_and(char::is_alphanumeric) {
return None;
}
out.insert_str(0, prefix);
shown.extend(t.shows.get(k).into_iter().flatten().copied());
}
}
Some(out)
}
/// Index the first body paragraph for each bookmark name, retaining document-order lookup semantics.
pub(super) fn body_bookmark_paths(doc: &wordcraft_doc::Document) -> std::collections::HashMap<String, Path> {
let mut paths = std::collections::HashMap::new();
for (name, pos) in doc.bookmarks() {
if pos.story == StoryRef::Body {
paths.entry(name).or_insert(pos.path);
}
}
paths
}
+4 -8
View File
@@ -4,20 +4,16 @@ pub(super) fn word_count(s: &mut Session, v: &Value) -> CmdResult {
if let Some(b) = p::bool(v, "includeTextBoxes") {
s.prefs.count_notes = b;
}
let stories = if s.prefs.count_notes { s.doc.counted_stories() } else { vec![StoryRef::Body] };
let stories = s.counted_stories();
// A selected picture, shape or text box isn't a text selection: count the document.
let text = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() {
stories.iter().map(|st| s.doc.plain_text(*st)).collect::<Vec<_>>().join("\n")
} else {
s.selected_text()
};
let text = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() { s.counted_text() } else { s.selected_text() };
let words = if s.sel.is_collapsed() || crate::cmd::objects::object_selection(s).is_some() {
stories.iter().map(|st| s.doc.story_word_count(*st)).sum()
} else {
wordcraft_doc::count_words(&text)
};
let chars = text.chars().filter(|c| *c != '\n' && *c != '\r').count();
let chars_no_spaces = text.chars().filter(|c| !c.is_whitespace()).count();
let chars = wordcraft_doc::count_chars(&text);
let chars_no_spaces = wordcraft_doc::count_chars_no_spaces(&text);
let paragraphs = text.split('\n').filter(|l| !l.trim().is_empty()).count();
let lines: usize = {
let l = s.layout();
+27
View File
@@ -924,6 +924,33 @@ mod tests {
#[test]
fn numbers_read_currency_and_separators_but_not_letters() {
assert_eq!(parse_number("$1,200", None), Some(1200.0));
assert_eq!(parse_number("B/. 1,234.50", None), Some(1234.5), "the Panamanian balboa");
assert_eq!(parse_number("1,234.50 B/.", None), Some(1234.5));
assert_eq!(parse_number("(B/. 1,234.50)", None), Some(-1234.5), "an accounting negative");
assert_eq!(parse_number("B/.1,234.50", None), None, "Word needs a space after the sign");
assert_eq!(parse_number("1/.5", None), None, "a fraction is not a balboa amount");
// The formula reads the cell in the document's language. With the document's default text
// as es-PA and the editing language as pl-PL the choice is observable: `1,250.50` is a
// number under es-PA and not under pl-PL (whose decimal separator is `,`).
let balboa = table(&[&["B/. 1,234.50"], &["(B/. 1,234.50)"], &[""]]);
let mut doc = wordcraft_doc::Document::new();
std::sync::Arc::make_mut(&mut doc.styles).default_chr.lang = Some("es-PA".into());
assert_eq!(
evaluate_with(&balboa, Some(&doc), Some("pl-PL"), 2, 0, "=SUM(ABOVE)"),
Ok(0.0),
"a balboa cell sums in the document's language, and its negative in parentheses cancels it",
);
assert_eq!(evaluate_with(&balboa, Some(&doc), Some("pl-PL"), 2, 0, "=COUNT(ABOVE)"), Ok(2.0), "both balboa cells are numbers",);
// And the other way around: a balboa cell written with Polish separators is read only when
// the document language reaches the parser, not when it falls back to English.
let mut polish = wordcraft_doc::Document::new();
std::sync::Arc::make_mut(&mut polish.styles).default_chr.lang = Some("pl-PL".into());
let balboa_pl = table(&[&["B/. 1 250,50"], &[""]]);
assert_eq!(
evaluate_with(&balboa_pl, Some(&polish), Some("en-US"), 1, 0, "=SUM(ABOVE)"),
Ok(1250.5),
"the cell's language selects the separators; English does not read `1 250,50`",
);
assert_eq!(parse_number(" 12% ", None), Some(12.0));
assert_eq!(parse_number("(5)", None), Some(-5.0));
assert_eq!(parse_number("x9", None), None);
+3
View File
@@ -71,6 +71,9 @@ pub struct Session {
/// HTML for the system clipboard beside [`Self::clipboard_text`]: the copy's formatting for
/// programs that read HTML, equations as MathML (#816); empty when nothing was copied.
pub clipboard_html: String,
/// RTF for the system clipboard beside [`Self::clipboard_html`], for programs that read RTF
/// rather than HTML; empty when nothing was copied.
pub clipboard_rtf: String,
/// Items collected by Copy and Cut this session, for the Clipboard pane.
pub clip_history: crate::cmd::edit::ClipHistory,
pub find: FindState,
+1
View File
@@ -244,6 +244,7 @@ impl Session {
clipboard: _,
clipboard_text: _,
clipboard_html: _,
clipboard_rtf: _,
clip_history: _,
registry: _,
author: _,
+1
View File
@@ -98,6 +98,7 @@ impl Session {
pending_move,
clipboard_text: String::new(),
clipboard_html: String::new(),
clipboard_rtf: String::new(),
clip_history: Default::default(),
find,
goal_x,
+12
View File
@@ -268,6 +268,18 @@ impl Session {
if self.prefs.count_notes { self.doc.word_count_including_notes() } else { self.doc.word_count() }
}
/// The stories the Word Count dialog and the status bar count: the body plus notes and text
/// boxes when [`Prefs::count_notes`] is on ([`Document::counted_stories`]).
pub fn counted_stories(&self) -> Vec<StoryRef> {
if self.prefs.count_notes { self.doc.counted_stories() } else { vec![StoryRef::Body] }
}
/// The whole document's text the counts cover (#2797): the stories [`Session::counted_stories`]
/// names, joined with paragraph breaks, as the Word Count dialog counts them.
pub fn counted_text(&self) -> String {
self.counted_stories().iter().map(|st| self.doc.plain_text(*st)).collect::<Vec<_>>().join("\n")
}
/// Selected plain text.
pub fn selected_text(&self) -> String {
// A column block or whole cells that aren't one run of the text: each piece's own text,
+1
View File
@@ -80,6 +80,7 @@ impl Session {
pending_move: _,
clipboard_text: _,
clipboard_html: _,
clipboard_rtf: _,
clip_history: _,
find: _,
goal_x: _,
+141 -11
View File
@@ -10,12 +10,15 @@
//! letters (Cyrillic, Greek, Arabic, Hebrew, CJK, Thai…), so a Russian sentence gets a Russian voice
//! where one is installed instead of being spelled out letter by letter. The system's own voice
//! stays whenever it already speaks the language (English text on an English system keeps the
//! voice the user chose), and without a voice for the language the default voice speaks.
//! voice the user chose), and without a voice for the language the default voice speaks. Speech
//! Dispatcher is the exception: it can't be asked for its default language, so it is told the
//! language of every sentence.
//!
//! The speech itself runs on a worker thread (desktop only): it starts `say` (macOS), `espeak`
//! / `spd-say` (Linux, BSD) or the Windows speech synthesizer (PowerShell) for the current
//! sentence, and moves on when it finishes. Commands only change the shared state and wake the
//! worker, which stops the running sentence when the state changed under it.
//! The speech itself runs on a worker thread (desktop only): it starts `say` (macOS), `espeak-ng`
//! / `espeak` (Linux, BSD) or the Windows speech synthesizer (PowerShell) for the current sentence,
//! or talks to Speech Dispatcher over its socket when neither espeak is installed, and moves on when
//! the sentence ends. Commands only change the shared state and wake the worker, which stops the
//! running sentence when the state changed under it.
use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError};
@@ -402,6 +405,90 @@ fn mac_default_voice_language() -> &'static str {
})
}
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
mod ssip;
/// What Read Aloud says when the system has none of the speech programs it knows.
#[cfg(not(target_arch = "wasm32"))]
const MISSING_PROGRAM: &str = if cfg!(all(unix, not(target_os = "macos"))) {
"Read Aloud needs a speech program: install espeak-ng or Speech Dispatcher from your distribution's packages"
} else {
"Read Aloud couldn't find the system's speech program"
};
/// Read Aloud's speed (1 = normal) as Speech Dispatcher's rate, -100 to 100 with 0 the voice's
/// normal speed: the slowest speed is -50 and the fastest 100.
#[cfg(any(all(unix, not(target_os = "macos")), test))]
fn spd_rate(rate: f32) -> i32 {
let rate = if rate.is_finite() { rate.clamp(MIN_RATE, MAX_RATE) } else { 1.0 };
let scaled = if rate >= 1.0 { (rate - 1.0) / (MAX_RATE - 1.0) * 100.0 } else { (rate - 1.0) * 100.0 };
scaled.round().clamp(-100.0, 100.0) as i32
}
/// Say one sentence through Speech Dispatcher ([`ssip`]), in the sentence's language whenever it
/// has one, region included (its voices are per region: `en-GB`, `pt-BR`). Its default language is
/// its own setting (`en-US` unless configured), which SSIP can't ask for, so English is named too.
/// `None` when Speech Dispatcher isn't installed.
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
fn speak_dispatcher(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> bool) -> Option<Spoken> {
let stream = match ssip::connect(changed) {
ssip::Connection::Open(s) => s,
ssip::Connection::Missing => return None,
ssip::Connection::Interrupted => return Some(Spoken::Interrupted),
ssip::Connection::Unreachable(why) => return Some(Spoken::Failed(format!("Read Aloud couldn't reach Speech Dispatcher: {why}"))),
};
let lang = dispatcher_language(lang);
let req = ssip::Request { text, rate: spd_rate(rate), lang: lang.as_deref() };
Some(dispatcher_spoken(ssip::converse(&stream, &req, changed)))
}
/// How a sentence Speech Dispatcher handled ended, for the worker.
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
fn dispatcher_spoken(outcome: std::io::Result<ssip::Outcome>) -> Spoken {
match outcome {
Ok(ssip::Outcome::Done) => Spoken::Done,
Ok(ssip::Outcome::Interrupted) => Spoken::Interrupted,
Ok(ssip::Outcome::Halted) => Spoken::Halted(HALTED_BY_OTHER.into()),
Ok(ssip::Outcome::NotStarted) => Spoken::Halted(NOT_STARTED.into()),
Err(e) => Spoken::Failed(format!("Read Aloud couldn't use Speech Dispatcher: {e}")),
}
}
/// Why reading paused when another program cancelled or paused the sentence.
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
const HALTED_BY_OTHER: &str =
"Speech Dispatcher kept stopping the sentence for another program (a screen reader, or a stop for every program): press Play to go on";
/// Why reading paused when Speech Dispatcher never began the sentence.
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
const NOT_STARTED: &str =
"Speech Dispatcher didn't start the sentence (other speech may be queued before it, or its speech module may be stuck): press Play to try again";
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
thread_local! {
/// Neither espeak command was found during this reading (the worker thread lives as long as
/// one reading): the next sentences go straight to Speech Dispatcher.
static NO_ESPEAK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// The language tag Speech Dispatcher is given for a sentence in `lang`: its language and region,
/// as SSIP's `SET LANGUAGE` takes them (`sr-Latn-RS` gives `sr-RS`, `zh-Hant-TW` gives `zh-TW`),
/// when it names a language (Word's `x-none` doesn't).
#[cfg(any(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")), test))]
fn dispatcher_language(lang: Option<&str>) -> Option<String> {
let tag = lang.and_then(plain_tag)?;
let primary = primary_tag(tag)?;
let region = tag
.split('-')
.skip(1)
.take(2)
.find(|t| (t.len() == 2 && t.chars().all(|c| c.is_ascii_alphabetic())) || (t.len() == 3 && t.chars().all(|c| c.is_ascii_digit())));
Some(match region {
Some(r) => format!("{primary}-{}", r.to_ascii_uppercase()),
None => primary,
})
}
/// Should `speak` say the sentence again with the default voice? When it gave the program a voice
/// and the program then exited with a failure: espeak-ng does that, silently, for a voice it
/// doesn't know, and a missing voice must never mean silence. `exited_ok` is `None` when the
@@ -571,6 +658,10 @@ struct Shared {
/// A worker thread is running (cleared under the lock as it exits).
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
running: bool,
/// Bumped when a reading starts: a worker thread can outlive a reading (a pause, then a new
/// Read Aloud), and what it learnt about the system's speech programs is per reading.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
readings: u64,
}
/// The Read Aloud player.
@@ -591,7 +682,16 @@ impl Default for ReadAloud {
let off = cfg!(test) || std::env::var("WORDCRAFT_SPEECH").is_ok_and(|v| v == "off");
ReadAloud {
shared: Arc::new((
Mutex::new(Shared { queue: Vec::new(), index: 0, state: State::Stopped, rate: 1.0, generation: 0, error: None, running: false }),
Mutex::new(Shared {
queue: Vec::new(),
index: 0,
state: State::Stopped,
rate: 1.0,
generation: 0,
error: None,
running: false,
readings: 0,
}),
Condvar::new(),
)),
backend: if off { Backend::Hold } else { Backend::System },
@@ -627,6 +727,7 @@ impl ReadAloud {
s.queue = queue;
s.index = 0;
s.error = None;
s.readings = s.readings.wrapping_add(1);
});
}
@@ -640,6 +741,7 @@ impl ReadAloud {
if s.index >= s.queue.len() {
s.index = 0;
}
s.error = None;
State::Playing
}
};
@@ -753,14 +855,18 @@ impl Drop for ReadAloud {
enum Spoken {
Done,
Interrupted,
/// Another program stopped the sentence: reading pauses on it, saying why.
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
Halted(String),
Failed(String),
}
#[cfg(not(target_arch = "wasm32"))]
fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {
let (m, cv) = shared;
let mut reading = None;
loop {
let (text, lang, generation, rate) = {
let (text, lang, generation, rate, readings) = {
let mut s = lock(m);
while s.state == State::Paused {
s = cv.wait(s).unwrap_or_else(PoisonError::into_inner);
@@ -770,7 +876,7 @@ fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {
return;
}
match s.queue.get(s.index) {
Some(u) => (u.text.clone(), u.lang.clone(), s.generation, s.rate),
Some(u) => (u.text.clone(), u.lang.clone(), s.generation, s.rate, s.readings),
None => {
s.state = State::Stopped;
s.running = false;
@@ -778,6 +884,12 @@ fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {
}
}
};
// A new reading asks the system afresh which speech programs it has.
if reading != Some(readings) {
reading = Some(readings);
#[cfg(all(unix, not(target_os = "macos")))]
NO_ESPEAK.set(false);
}
let changed = || lock(m).generation != generation;
let outcome = match backend {
Backend::System => speak(&text, lang.as_deref(), rate, &changed),
@@ -804,6 +916,10 @@ fn worker(shared: &(Mutex<Shared>, Condvar), backend: Backend) {
}
}
Spoken::Interrupted => {}
Spoken::Halted(why) => {
s.state = State::Paused;
s.error = Some(why);
}
Spoken::Failed(e) => {
s.state = State::Stopped;
s.error = Some(e);
@@ -906,7 +1022,8 @@ fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> b
}
#[cfg(all(unix, not(target_os = "macos")))]
{
for prog in ["espeak-ng", "espeak"] {
let programs: &[&str] = if NO_ESPEAK.get() { &[] } else { &["espeak-ng", "espeak"] };
for prog in programs {
let args = espeak_args(&wpm, lang);
let voiced = args.iter().any(|a| a == "-v");
let mut c = Command::new(prog);
@@ -916,6 +1033,7 @@ fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> b
}
let _ = (&wpm, lang);
let mut child = None;
let mut not_found = true;
let mut last_err = String::from("no speech program on this system");
for (mut c, voiced) in tries {
match c.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() {
@@ -923,11 +1041,23 @@ fn speak_with(text: &str, lang: Option<&str>, rate: f32, changed: &dyn Fn() -> b
child = Some((ch, voiced));
break;
}
Err(e) => last_err = e.to_string(),
Err(e) => {
not_found &= e.kind() == std::io::ErrorKind::NotFound;
last_err = e.to_string();
}
}
}
let Some((mut child, voiced)) = child else {
return (Spoken::Failed(format!("Read Aloud couldn't start the speech program: {last_err}")), false, None);
// Speech Dispatcher, which many desktops install without the espeak command.
#[cfg(all(unix, not(target_os = "macos")))]
if not_found {
NO_ESPEAK.set(true);
if let Some(spoken) = speak_dispatcher(text, lang, rate, changed) {
return (spoken, false, None);
}
}
let message = if not_found { MISSING_PROGRAM.to_string() } else { format!("Read Aloud couldn't start the speech program: {last_err}") };
return (Spoken::Failed(message), false, None);
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(text.as_bytes());
+927
View File
@@ -0,0 +1,927 @@
//! Speech Dispatcher through its own protocol (SSIP) on the user's socket, for systems that have
//! Speech Dispatcher but not the `espeak-ng` command.
//!
//! Talking SSIP directly, instead of starting `spd-say`, keeps two promises `spd-say` can't: the
//! sentence is always data (pipe mode runs a line starting with `!-!` as an SSIP command, and a
//! document must never send commands), and stopping cancels only WordCraft's own speech
//! (`CANCEL self`), where `spd-say -C` would silence every client, a screen reader included.
//!
//! One connection speaks one sentence: it names the client, asks for the begin, end, cancel and
//! pause events, sets the rate and language, sends the text, and waits for the sentence's end
//! event. A sentence another client's message cancels (a screen reader's announcement) is sent
//! again after a pause, twice at most; a sentence cancelled once more or paused by another
//! client halts reading on it ([`Outcome::Halted`]), and so does one that never began
//! ([`Outcome::NotStarted`]).
use std::io::{ErrorKind, Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::time::{Duration, Instant};
/// How often a wait for the end event looks at whether reading moved on.
const POLL: Duration = Duration::from_millis(30);
/// Longest wait for a reply to a command (the server answers at once; this only bounds a stuck one).
const REPLY_TIMEOUT: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(5) };
/// Longest reply line kept (a server sends short lines; this bounds a broken one).
const MAX_LINE: usize = 4096;
/// Speech Dispatcher's TCP port when `SPEECHD_ADDRESS` names none.
const DEFAULT_PORT: u16 = 6560;
/// How long a freshly started server gets to open its socket (it loads its speech modules first).
const START_WAIT: Duration = Duration::from_secs(5);
/// Longest wait for a TCP connection to one address.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(1);
/// The end of a sentence is awaited this long after it began, plus [`SENTENCE_PER_CHAR`] for each
/// character: far more than the slowest reading takes, so only a stuck server reaches it.
const SENTENCE_BASE: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(30) };
const SENTENCE_PER_CHAR: Duration = Duration::from_millis(400);
/// Longest wait for a queued sentence to begin (behind other clients' speech, or paused before it
/// began, which sends no pause event).
const QUEUE_WAIT: Duration = if cfg!(test) { Duration::from_secs(1) } else { Duration::from_secs(120) };
/// The pauses before a sentence another client cancelled is sent again: the other client's
/// message gets time to end, and the server holds ours back while a higher-priority one speaks.
const RESEND_AFTER: [Duration; 2] =
if cfg!(test) { [Duration::from_millis(50), Duration::from_millis(50)] } else { [Duration::from_secs(1), Duration::from_secs(3)] };
/// What to say and how.
pub(super) struct Request<'a> {
pub text: &'a str,
/// Speech Dispatcher's rate, -100 to 100.
pub rate: i32,
/// A plain language tag (`hu`, `pt-BR`); anything else is left out.
pub lang: Option<&'a str>,
}
/// How a sentence ended.
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Outcome {
/// Spoken to the end.
Done,
/// Reading moved on: the sentence was cancelled.
Interrupted,
/// Another client stopped it: cancelled it again after it was sent again, or paused it.
/// Reading halts on it instead of fighting the other client.
Halted,
/// The sentence never began: queued too long, behind other clients' speech, paused before it
/// began (which sends no event), or with a speech module that doesn't start. Reading halts on it.
NotStarted,
}
/// Where Speech Dispatcher listens.
#[derive(Debug, PartialEq, Eq)]
pub(super) enum Address {
Unix(PathBuf),
/// Host and port.
Inet(String, u16),
}
/// The server's address, found the way Speech Dispatcher's own client library finds it:
/// `SPEECHD_ADDRESS` when set (`unix_socket[:path]`, `inet_socket[:host[:port]]`), else the socket
/// `speech-dispatcher/speechd.sock` in the runtime folder, else in the cache folder
/// (`XDG_CACHE_HOME`, else `~/.cache`). `None` for an address method it doesn't know, a port that
/// isn't a number, or no folder to look in.
pub(super) fn address(spec: Option<&str>, runtime_dir: Option<PathBuf>, cache_home: Option<PathBuf>, home: Option<PathBuf>) -> Option<Address> {
let default_socket = || {
let set = |p: Option<PathBuf>| p.filter(|p| !p.as_os_str().is_empty());
set(runtime_dir)
.or_else(|| set(cache_home))
.or_else(|| set(home).map(|h| h.join(".cache")))
.map(|d| d.join("speech-dispatcher").join("speechd.sock"))
};
let Some(spec) = spec.map(str::trim).filter(|a| !a.is_empty()) else { return default_socket().map(Address::Unix) };
let (method, rest) = spec.split_once(':').unwrap_or((spec, ""));
match method {
"unix_socket" => Some(Address::Unix(if rest.is_empty() { default_socket()? } else { PathBuf::from(rest) })),
"inet_socket" => {
let mut p = rest.split(':');
let host = p.next().filter(|h| !h.is_empty()).unwrap_or("127.0.0.1");
let port = p.next().filter(|p| !p.is_empty()).map_or(Some(DEFAULT_PORT), |p| p.parse().ok())?;
Some(Address::Inet(host.to_string(), port))
}
_ => None,
}
}
/// Is `host` this machine (the only place a server may be started)?
fn is_local(host: &str) -> bool {
host.eq_ignore_ascii_case("localhost") || host.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback())
}
/// A connection to Speech Dispatcher.
pub(super) enum Stream {
Unix(UnixStream),
Tcp(TcpStream),
}
impl Stream {
fn try_clone(&self) -> std::io::Result<Stream> {
Ok(match self {
Stream::Unix(s) => Stream::Unix(s.try_clone()?),
Stream::Tcp(s) => Stream::Tcp(s.try_clone()?),
})
}
/// Reads give up after [`POLL`] (so a wait can look at whether reading moved on), writes after
/// [`REPLY_TIMEOUT`].
fn set_timeouts(&self) -> std::io::Result<()> {
match self {
Stream::Unix(s) => {
s.set_read_timeout(Some(POLL))?;
s.set_write_timeout(Some(REPLY_TIMEOUT))
}
Stream::Tcp(s) => {
s.set_read_timeout(Some(POLL))?;
s.set_write_timeout(Some(REPLY_TIMEOUT))
}
}
}
}
impl Read for Stream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
match self {
Stream::Unix(s) => s.read(buf),
Stream::Tcp(s) => s.read(buf),
}
}
}
impl Write for Stream {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
match self {
Stream::Unix(s) => s.write(buf),
Stream::Tcp(s) => s.write(buf),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
Stream::Unix(s) => s.flush(),
Stream::Tcp(s) => s.flush(),
}
}
}
/// Connect to `address`; between the addresses a host name resolves to, give up once `changed()`.
fn open(address: &Address, changed: &dyn Fn() -> bool) -> std::io::Result<Stream> {
match address {
Address::Unix(path) => UnixStream::connect(path).map(Stream::Unix),
Address::Inet(host, port) => {
let mut last = std::io::Error::from(ErrorKind::NotFound);
for a in (host.as_str(), *port).to_socket_addrs()? {
if changed() {
return Err(moved_on());
}
match TcpStream::connect_timeout(&a, CONNECT_TIMEOUT) {
Ok(s) => return Ok(Stream::Tcp(s)),
Err(e) => last = e,
}
}
Err(last)
}
}
}
/// How [`connect`] went.
pub(super) enum Connection {
Open(Stream),
/// Speech Dispatcher isn't installed.
Missing,
/// It is installed but can't be reached: why.
Unreachable(String),
/// Reading moved on while the server was starting.
Interrupted,
}
/// Connect to the user's Speech Dispatcher, starting it once if it isn't running (as `spd-say`
/// does), unless the address is another machine's.
pub(super) fn connect(changed: &dyn Fn() -> bool) -> Connection {
let env = |k: &str| std::env::var_os(k).map(PathBuf::from);
let spec = std::env::var("SPEECHD_ADDRESS").ok().filter(|s| !s.trim().is_empty());
let Some(address) = address(spec.as_deref(), env("XDG_RUNTIME_DIR"), env("XDG_CACHE_HOME"), env("HOME")) else {
return if spec.is_some() { Connection::Unreachable("SPEECHD_ADDRESS isn't an address it understands".into()) } else { Connection::Missing };
};
match open(&address, changed) {
Ok(s) => return Connection::Open(s),
Err(e) if e.kind() == ErrorKind::Interrupted => return Connection::Interrupted,
Err(_) => {}
}
let mut start = std::process::Command::new("speech-dispatcher");
start.arg("--spawn");
// A server started for an address of its own must open it there.
match (&address, spec.is_some()) {
(_, false) => {}
(Address::Unix(path), true) => {
start.args(["--communication-method", "unix_socket", "--socket-path"]).arg(path);
}
(Address::Inet(host, port), true) if is_local(host) => {
start.args(["--communication-method", "inet_socket", "--port", &port.to_string()]);
}
(Address::Inet(..), true) => return Connection::Unreachable("nothing answers at SPEECHD_ADDRESS".into()),
}
// `--spawn` exits once it has forked the server; the wait for it is still bounded and gives way
// to Stop, so a start that hangs can't keep the reading thread (and Read Aloud with it) busy.
let until = Instant::now() + START_WAIT;
match start_server(&mut start, until, changed) {
Started::Exited(true) => {}
Started::Exited(false) => {
return Connection::Unreachable("it isn't running and didn't start (its configuration may turn starting on demand off)".into());
}
Started::Missing => return Connection::Missing,
Started::Failed(e) => return Connection::Unreachable(format!("it couldn't be started: {e}")),
Started::Interrupted => return Connection::Interrupted,
Started::TimedOut => {
return open(&address, changed).map_or_else(|_| Connection::Unreachable("starting it didn't finish in time".into()), Connection::Open);
}
}
// The server opens its socket a moment after it starts.
loop {
if changed() {
return Connection::Interrupted;
}
if let Ok(s) = open(&address, changed) {
return Connection::Open(s);
}
if Instant::now() >= until {
return Connection::Unreachable("it started but doesn't answer".into());
}
std::thread::sleep(Duration::from_millis(50));
}
}
/// How running the server's start command went.
#[derive(Debug)]
enum Started {
/// It exited, successfully or not.
Exited(bool),
/// The command isn't installed.
Missing,
/// It couldn't be run or waited for.
Failed(std::io::Error),
/// Reading moved on before it exited; it was stopped.
Interrupted,
/// It was still running at the deadline; it was stopped.
TimedOut,
}
/// Run `command` (output discarded) and wait for it to exit, at most until `until` and only while
/// reading stays put; a command still running then is stopped and reaped.
fn start_server(command: &mut std::process::Command, until: Instant, changed: &dyn Fn() -> bool) -> Started {
use std::process::Stdio;
let mut child = match command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() {
Ok(child) => child,
Err(e) if e.kind() == ErrorKind::NotFound => return Started::Missing,
Err(e) => return Started::Failed(e),
};
let stopped = loop {
match child.try_wait() {
Ok(Some(status)) => return Started::Exited(status.success()),
Ok(None) if changed() => break Started::Interrupted,
Ok(None) if Instant::now() >= until => break Started::TimedOut,
Ok(None) => std::thread::sleep(Duration::from_millis(20)),
Err(e) => break Started::Failed(e),
}
};
let _ = child.kill();
let _ = child.wait();
stopped
}
/// The sentence as SSIP message data: one line (a line break is a space when read aloud anyway),
/// a leading `.` doubled so it can't end the message, and the terminating `.` line.
pub(super) fn message_data(text: &str) -> String {
let line: String = text.chars().map(|c| if c == '\r' || c == '\n' { ' ' } else { c }).collect();
let dot = if line.starts_with('.') { "." } else { "" };
format!("{dot}{line}\r\n.\r\n")
}
/// Reads reply lines. Bytes wait in `pending` until their line is complete, so a character split
/// across reads stays whole, and a line longer than [`MAX_LINE`] is an error, never a growing buffer.
struct Lines<R> {
reader: R,
pending: Vec<u8>,
}
impl<R: Read> Lines<R> {
fn new(reader: R) -> Self {
Lines { reader, pending: Vec::new() }
}
/// The next complete line without its line ending, or `Ok(None)` when none is complete after
/// one read (a timeout, or part of a line): the caller looks at its deadline and whether reading
/// moved on between calls, however slowly the bytes trickle in.
fn next(&mut self) -> std::io::Result<Option<String>> {
if let Some(line) = self.complete_line() {
return Ok(Some(line));
}
if self.pending.len() > MAX_LINE {
return Err(ErrorKind::InvalidData.into());
}
let mut buf = [0u8; 512];
match self.reader.read(&mut buf) {
Ok(0) => Err(ErrorKind::UnexpectedEof.into()),
Ok(n) => {
self.pending.extend_from_slice(buf.get(..n).unwrap_or_default());
Ok(self.complete_line())
}
Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut | ErrorKind::Interrupted) => Ok(None),
Err(e) => Err(e),
}
}
/// The first complete line in `pending`, taken out of it.
fn complete_line(&mut self) -> Option<String> {
let end = self.pending.iter().position(|&b| b == b'\n')?;
let line: Vec<u8> = self.pending.drain(..=end).collect();
Some(String::from_utf8_lossy(&line).trim_end_matches(['\r', '\n']).to_string())
}
}
/// The three-digit code of a final reply line (`225 OK MESSAGE QUEUED`); `None` for a
/// continuation line (`225-12`).
fn final_code(line: &str) -> Option<u16> {
let code = line.get(..3)?.parse().ok()?;
(line.as_bytes().get(3) == Some(&b' ') || line.len() == 3).then_some(code)
}
/// A `2xx` reply, or an error naming the code.
fn ok(code: u16) -> std::io::Result<()> {
if (200..300).contains(&code) { Ok(()) } else { Err(std::io::Error::other(format!("Speech Dispatcher refused a command ({code})"))) }
}
/// The error a conversation stops with when reading moved on.
fn moved_on() -> std::io::Error {
std::io::Error::new(ErrorKind::Interrupted, "reading moved on")
}
/// The begin (701), end (702), cancel (703) and pause (704) events.
const BEGIN: u16 = 701;
const END: u16 = 702;
const CANCEL: u16 = 703;
const PAUSE: u16 = 704;
/// One conversation with the server.
struct Conversation<'a, W, R> {
w: W,
lines: Lines<R>,
/// The last end, cancel or pause event, until it's taken.
event: Option<u16>,
/// The message just queued began.
begun: bool,
/// Whether reading moved on (checked between lines and at every read timeout).
changed: &'a dyn Fn() -> bool,
}
impl<W: Write, R: Read> Conversation<'_, W, R> {
/// The reply to the command just sent: its final code. An end, cancel or pause event that arrives
/// meanwhile is kept. Gives up after [`REPLY_TIMEOUT`], also when the server keeps sending
/// other lines, and as soon as reading moves on.
fn reply(&mut self) -> std::io::Result<u16> {
let start = Instant::now();
loop {
if let Some(line) = self.lines.next()? {
match final_code(&line) {
Some(BEGIN) => self.begun = true,
Some(code @ (END | CANCEL | PAUSE)) => self.event = Some(code),
Some(code) if (700..800).contains(&code) => {}
Some(code) => return Ok(code),
None => {}
}
}
if start.elapsed() > REPLY_TIMEOUT {
return Err(ErrorKind::TimedOut.into());
}
if (self.changed)() {
return Err(moved_on());
}
}
}
/// Send one command line and read its reply code.
fn command(&mut self, cmd: &str) -> std::io::Result<u16> {
self.w.write_all(cmd.as_bytes())?;
self.w.write_all(b"\r\n")?;
self.reply()
}
/// What became of the message just queued: its end, cancel or pause event, or
/// [`Fate::NotStarted`] when it didn't begin within [`QUEUE_WAIT`]. Once it began, its end is
/// waited for until `limit` has passed (a stuck server: an error). An error as soon as reading
/// moves on.
fn fate(&mut self, limit: Duration) -> std::io::Result<Fate> {
let queued = Instant::now();
let mut began = self.begun.then(Instant::now);
loop {
match self.event.take() {
Some(END) => return Ok(Fate::End),
Some(CANCEL) => return Ok(Fate::Cancelled),
Some(_) => return Ok(Fate::Paused),
None => {}
}
if (self.changed)() {
return Err(moved_on());
}
match began {
None if queued.elapsed() > QUEUE_WAIT => return Ok(Fate::NotStarted),
Some(t) if t.elapsed() > limit => return Err(std::io::Error::new(ErrorKind::TimedOut, "the sentence didn't end")),
_ => {}
}
if let Some(line) = self.lines.next()? {
match final_code(&line) {
Some(BEGIN) => {
began.get_or_insert_with(Instant::now);
}
Some(code @ (END | CANCEL | PAUSE)) => self.event = Some(code),
_ => {}
}
}
}
}
/// Wait `pause`, reading what the server sends meanwhile; an error as soon as reading moves on.
fn wait(&mut self, pause: Duration) -> std::io::Result<()> {
let start = Instant::now();
while start.elapsed() < pause {
if (self.changed)() {
return Err(moved_on());
}
self.lines.next()?;
}
Ok(())
}
/// Set up the client, say the sentence, and wait for it to end. Only [`converse`] cancels our
/// own speech, and it stops waiting first, so a cancel or pause event comes from another
/// client. A cancel is most often another client's message, a screen reader's announcement:
/// the sentence is sent again after a pause ([`RESEND_AFTER`]), when the announcement has
/// ended or the server holds ours back until it has. Cancelled again after that, paused, or
/// never begun, the sentence halts reading.
fn speak(&mut self, req: &Request<'_>) -> std::io::Result<Outcome> {
ok(self.command("SET SELF CLIENT_NAME user:wordcraft:read-aloud")?)?;
ok(self.command("SET SELF NOTIFICATION BEGIN on")?)?;
ok(self.command("SET SELF NOTIFICATION END on")?)?;
ok(self.command("SET SELF NOTIFICATION CANCEL on")?)?;
ok(self.command("SET SELF NOTIFICATION PAUSE on")?)?;
ok(self.command(&format!("SET SELF RATE {}", req.rate))?)?;
// A language the server doesn't know leaves its default: no error. The tag is checked
// again here, at the edge of the protocol: it goes on a command line.
if let Some(lang) = req.lang.and_then(super::plain_tag) {
self.command(&format!("SET SELF LANGUAGE {lang}"))?;
}
let chars = u32::try_from(req.text.chars().count()).unwrap_or(u32::MAX);
let limit = SENTENCE_BASE.saturating_add(SENTENCE_PER_CHAR.saturating_mul(chars));
let mut resends = RESEND_AFTER.iter();
loop {
self.event = None;
self.begun = false;
ok(self.command("SPEAK")?)?;
self.w.write_all(message_data(req.text).as_bytes())?;
ok(self.reply()?)?;
match self.fate(limit)? {
Fate::End => return Ok(Outcome::Done),
Fate::Cancelled => match resends.next() {
Some(pause) => self.wait(*pause)?,
None => return Ok(Outcome::Halted),
},
Fate::Paused => return Ok(Outcome::Halted),
Fate::NotStarted => return Ok(Outcome::NotStarted),
}
}
}
}
/// What became of a queued message.
enum Fate {
End,
Cancelled,
Paused,
NotStarted,
}
/// The last words after a sentence that didn't finish.
const CLOSE: &[u8] = b"\r\n.\r\nCANCEL self\r\nQUIT\r\n";
/// Speak `req` over `stream`, cancelling it when `changed()`.
pub(super) fn converse(stream: &Stream, req: &Request<'_>, changed: &dyn Fn() -> bool) -> std::io::Result<Outcome> {
stream.set_timeouts()?;
let mut c = Conversation { w: stream.try_clone()?, lines: Lines::new(stream.try_clone()?), event: None, begun: false, changed };
let result = c.speak(req);
// The last words wait for no answer: a server that stopped answering mustn't hold up Stop.
// After anything but a finished sentence our message may still be queued, or the server may
// be reading message data: an empty line and `.` end a message being sent (outside one they
// are two refused commands), and `CANCEL self` drops ours.
let done = matches!(result, Ok(Outcome::Done));
let _ = c.w.write_all(if done { b"QUIT\r\n" } else { CLOSE });
match result {
Err(e) if e.kind() == ErrorKind::Interrupted => Ok(Outcome::Interrupted),
r => r,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufRead, BufReader};
#[test]
fn the_address_follows_the_environment() {
let unix = |p: &str| Some(Address::Unix(PathBuf::from(p)));
let run = || Some(PathBuf::from("/run/user/1"));
assert_eq!(address(Some("unix_socket:/tmp/s.sock"), None, None, None), unix("/tmp/s.sock"));
assert_eq!(address(None, run(), None, None), unix("/run/user/1/speech-dispatcher/speechd.sock"));
// `unix_socket` alone is the default socket, as in Speech Dispatcher's client library.
assert_eq!(address(Some("unix_socket"), run(), None, None), unix("/run/user/1/speech-dispatcher/speechd.sock"));
assert_eq!(address(Some("unix_socket:"), None, None, Some("/home/u".into())), unix("/home/u/.cache/speech-dispatcher/speechd.sock"));
// Without a runtime folder: the cache folder, `XDG_CACHE_HOME` first.
assert_eq!(address(Some(" "), None, None, Some("/home/u".into())), unix("/home/u/.cache/speech-dispatcher/speechd.sock"));
assert_eq!(address(None, Some("".into()), Some("/c".into()), Some("/home/u".into())), unix("/c/speech-dispatcher/speechd.sock"));
// A TCP address, with the default host and port filled in.
assert_eq!(address(Some("inet_socket:localhost:6561"), run(), None, None), Some(Address::Inet("localhost".into(), 6561)));
assert_eq!(address(Some("inet_socket"), None, None, None), Some(Address::Inet("127.0.0.1".into(), DEFAULT_PORT)));
assert_eq!(address(Some("inet_socket:host"), None, None, None), Some(Address::Inet("host".into(), DEFAULT_PORT)));
assert_eq!(address(Some("inet_socket:host:port"), None, None, None), None);
assert_eq!(address(Some("inet_socket:host:99999"), None, None, None), None);
assert_eq!(address(Some("carrier_pigeon:x"), run(), None, None), None);
assert_eq!(address(None, None, None, None), None);
assert!(is_local("localhost") && is_local("127.0.0.1") && is_local("::1"));
assert!(!is_local("example.org") && !is_local("192.0.2.1"));
}
#[test]
fn document_text_is_always_message_data() {
// `spd-say -e` would run this line as an SSIP command; here it's spoken text.
assert_eq!(message_data("!-!SET SELF RATE 50"), "!-!SET SELF RATE 50\r\n.\r\n");
// A line of a single `.` would end the message early, and a line break would start a
// new line that could be one.
assert_eq!(message_data(".\r\nQUIT"), ".. QUIT\r\n.\r\n");
assert_eq!(message_data("a\n.\nb"), "a . b\r\n.\r\n");
assert_eq!(final_code("225 OK MESSAGE QUEUED"), Some(225));
assert_eq!(final_code("225-17"), None);
assert_eq!(final_code("x"), None);
}
/// Hands out the given reads one by one, then reports a timeout.
struct Reads(std::collections::VecDeque<Vec<u8>>);
impl Read for Reads {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
match self.0.pop_front() {
Some(chunk) if !chunk.is_empty() => {
let n = chunk.len().min(buf.len());
buf[..n].copy_from_slice(&chunk[..n]);
if n < chunk.len() {
self.0.push_front(chunk[n..].to_vec());
}
Ok(n)
}
_ => Err(ErrorKind::WouldBlock.into()),
}
}
}
#[test]
fn a_character_split_by_a_read_timeout_stays_whole() {
// `é` is C3 A9; the read times out (the empty read) between its two bytes.
let mut lines = Lines::new(Reads(vec![b"249-Caf\xC3".to_vec(), Vec::new(), b"\xA9\tfr\tnone\r\n702 END\r\n".to_vec()].into()));
assert_eq!(lines.next().unwrap(), None, "half a line");
assert_eq!(lines.next().unwrap(), None, "the timeout");
assert_eq!(lines.next().unwrap().as_deref(), Some("249-Café\tfr\tnone"));
assert_eq!(lines.next().unwrap().as_deref(), Some("702 END"));
assert_eq!(lines.next().unwrap(), None);
}
#[test]
fn a_line_without_an_end_is_an_error_not_a_growing_buffer() {
// A peer that never stops sending and never ends the line.
let mut lines = Lines::new(std::io::repeat(b'x'));
let mut calls = 0;
let err = loop {
calls += 1;
assert!(calls < 100, "never gave up");
match lines.next() {
Ok(None) => {}
Ok(Some(l)) => panic!("{l}"),
Err(e) => break e,
}
};
assert_eq!(err.kind(), ErrorKind::InvalidData);
assert!(lines.pending.len() <= MAX_LINE + 512);
}
#[test]
fn a_trickle_of_bytes_hands_control_back_after_each_read() {
// One byte per read and never a line end: every call returns, so the caller can look at
// its deadline and at whether reading moved on.
let mut lines = Lines::new(Reads(vec![b"2".to_vec(), b"0".to_vec(), b"0".to_vec(), b" OK\r\n".to_vec()].into()));
assert_eq!(lines.next().unwrap(), None);
assert_eq!(lines.next().unwrap(), None);
assert_eq!(lines.next().unwrap(), None);
assert_eq!(lines.next().unwrap().as_deref(), Some("200 OK"));
}
/// How the stand-in server ends a message.
#[derive(Clone, Copy)]
enum Ending {
/// The end event.
End,
/// Nothing until `CANCEL self`.
Hold,
/// Cancelled by another client every time.
CancelledByOther,
/// Cancelled by another client the first time only.
CancelledOnce,
/// Queued and never begun (paused before it began: no event at all).
NeverBegins,
/// Paused by another client (`PAUSE all`).
PausedByOther,
}
/// A Speech Dispatcher stand-in: answers every command with `2xx` and ends each message as
/// `ending` says. Returns the lines it received.
fn server(mut s: UnixStream, ending: Ending) -> std::thread::JoinHandle<Vec<String>> {
std::thread::spawn(move || {
let mut got = Vec::new();
let mut r = BufReader::new(s.try_clone().unwrap());
let mut in_data = false;
let mut messages = 0;
loop {
let mut line = String::new();
if r.read_line(&mut line).unwrap_or(0) == 0 {
return got;
}
let line = line.trim_end_matches(['\r', '\n']).to_string();
got.push(line.clone());
let reply: &[u8] = if in_data {
if line != "." {
continue;
}
in_data = false;
messages += 1;
match ending {
Ending::CancelledOnce if messages == 1 => {
b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n703-1\r\n703-7\r\n703 CANCELED\r\n"
}
Ending::CancelledOnce => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n702-1\r\n702-7\r\n702 END\r\n",
Ending::NeverBegins => b"225-1\r\n225 OK MESSAGE QUEUED\r\n",
Ending::CancelledByOther => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n703-1\r\n703-7\r\n703 CANCELED\r\n",
Ending::PausedByOther => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n704-1\r\n704-7\r\n704 PAUSED\r\n",
Ending::End => b"225-1\r\n225 OK MESSAGE QUEUED\r\n702-1\r\n702-7\r\n702 END\r\n",
Ending::Hold => b"225-1\r\n225 OK MESSAGE QUEUED\r\n701 BEGIN\r\n",
}
} else if line == "SPEAK" {
in_data = true;
b"230 OK RECEIVING DATA\r\n"
} else if line == "CANCEL self" {
b"703-1\r\n703-7\r\n703 CANCELED\r\n210 OK CANCELLED\r\n"
} else if line == "QUIT" {
let _ = s.write_all(b"231 HAPPY HACKING\r\n");
return got;
} else {
b"200 OK\r\n"
};
// The client doesn't wait for the answers to its last words.
let _ = s.write_all(reply);
}
})
}
fn speaks(got: &[String]) -> usize {
got.iter().filter(|l| *l == "SPEAK").count()
}
#[test]
fn a_sentence_is_spoken_to_its_end() {
let (client, srv) = UnixStream::pair().unwrap();
let h = server(srv, Ending::End);
let req = Request { text: "!-!QUIT", rate: 50, lang: Some("pt-BR") };
assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done);
let got = h.join().unwrap();
assert!(got.contains(&"SET SELF RATE 50".to_string()), "{got:?}");
// The region stays: Speech Dispatcher's voices are per region (`en-GB`, `pt-BR`).
assert!(got.contains(&"SET SELF LANGUAGE pt-BR".to_string()), "{got:?}");
let at = got.iter().position(|l| l == "SPEAK").unwrap();
assert_eq!(got[at + 1..at + 3], ["!-!QUIT".to_string(), ".".to_string()], "the text went as data");
assert_eq!(speaks(&got), 1);
assert!(!got.iter().any(|l| l.starts_with("CANCEL")), "nothing cancelled");
assert_eq!(got.last().map(String::as_str), Some("QUIT"));
}
#[test]
fn a_sentence_a_screen_reader_cuts_off_is_said_again_after_a_pause() {
// One announcement: the sentence is said again once it has had time to end.
let (client, srv) = UnixStream::pair().unwrap();
let h = server(srv, Ending::CancelledOnce);
let req = Request { text: "Interrupted by a screen reader.", rate: 0, lang: None };
let start = Instant::now();
assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done);
assert!(start.elapsed() >= RESEND_AFTER[0], "it waited before saying it again");
let got = h.join().unwrap();
assert_eq!(speaks(&got), 2, "{got:?}");
assert!(got.contains(&"SET SELF NOTIFICATION BEGIN on".to_string()), "{got:?}");
assert!(!got.iter().any(|l| l.starts_with("CANCEL")), "{got:?}");
}
#[test]
fn a_sentence_another_client_keeps_stopping_halts_reading() {
// Cancelled every time (another client stopping everything, a failing module): two
// tries more, then reading halts, never moving on silently. A pause halts at once.
for (ending, sent) in [(Ending::CancelledByOther, 1 + RESEND_AFTER.len()), (Ending::PausedByOther, 1)] {
let (client, srv) = UnixStream::pair().unwrap();
let h = server(srv, ending);
let req = Request { text: "Interrupted by a screen reader.", rate: 0, lang: None };
assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Halted);
let got = h.join().unwrap();
assert_eq!(speaks(&got), sent, "{got:?}");
assert!(got.contains(&"SET SELF NOTIFICATION PAUSE on".to_string()), "{got:?}");
// Only our own message is dropped.
assert!(got.contains(&"CANCEL self".to_string()) && !got.iter().any(|l| l.eq_ignore_ascii_case("CANCEL all")), "{got:?}");
}
}
#[test]
fn a_withheld_end_times_out_and_the_next_sentence_plays() {
// The server accepts the sentence, begins it, stays connected and never reports its end:
// the wait gives up with an error, drops the message (`CANCEL self`), and closes.
let (client, srv) = UnixStream::pair().unwrap();
let h = server(srv, Ending::Hold);
let req = Request { text: "Withheld.", rate: 0, lang: None };
let start = Instant::now();
let err = converse(&Stream::Unix(client), &req, &|| false).unwrap_err();
assert_eq!(err.kind(), ErrorKind::TimedOut, "{err}");
assert!(start.elapsed() < SENTENCE_BASE + SENTENCE_PER_CHAR * 9 + REPLY_TIMEOUT * 2, "{:?}", start.elapsed());
let got = h.join().unwrap();
assert_eq!(speaks(&got), 1);
assert!(got.contains(&"CANCEL self".to_string()) && got.last().map(String::as_str) == Some("QUIT"), "{got:?}");
// Playback recovers: the next sentence, on a new connection as every sentence gets,
// is spoken to its end.
let (client, srv) = UnixStream::pair().unwrap();
let h = server(srv, Ending::End);
assert_eq!(converse(&Stream::Unix(client), &Request { text: "Next.", rate: 0, lang: None }, &|| false).unwrap(), Outcome::Done);
assert_eq!(speaks(&h.join().unwrap()), 1);
}
#[test]
fn a_sentence_that_never_begins_halts_reading_instead_of_failing() {
// `PAUSE all` before our message began sends no event: the time queued has its own
// bound, and reaching it halts reading (Play carries on) rather than stopping with an error.
let (client, srv) = UnixStream::pair().unwrap();
let h = server(srv, Ending::NeverBegins);
let req = Request { text: "Queued behind another client.", rate: 0, lang: None };
let start = Instant::now();
assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::NotStarted);
assert!(start.elapsed() >= QUEUE_WAIT && start.elapsed() < QUEUE_WAIT + REPLY_TIMEOUT * 2, "{:?}", start.elapsed());
assert!(h.join().unwrap().contains(&"CANCEL self".to_string()));
}
#[test]
fn a_sentence_that_never_ends_times_out() {
// A stuck speech module: no end event ever. The wait is bounded by the sentence's length.
let (client, srv) = UnixStream::pair().unwrap();
let h = server(srv, Ending::Hold);
let req = Request { text: "x", rate: 0, lang: None };
let start = Instant::now();
assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap_err().kind(), ErrorKind::TimedOut);
assert!(start.elapsed() < SENTENCE_BASE + SENTENCE_PER_CHAR + REPLY_TIMEOUT * 2, "{:?}", start.elapsed());
assert!(h.join().unwrap().contains(&"CANCEL self".to_string()), "the stuck message is dropped");
}
#[test]
fn moving_on_cancels_only_our_own_speech() {
let (client, srv) = UnixStream::pair().unwrap();
let h = server(srv, Ending::Hold);
let req = Request { text: "Long sentence.", rate: 0, lang: None };
let start = Instant::now();
let changed = || start.elapsed() > Duration::from_millis(300);
assert_eq!(converse(&Stream::Unix(client), &req, &changed).unwrap(), Outcome::Interrupted);
let got = h.join().unwrap();
assert_eq!(speaks(&got), 1, "{got:?}");
assert!(got.contains(&"CANCEL self".to_string()), "{got:?}");
assert!(!got.iter().any(|l| l.eq_ignore_ascii_case("CANCEL all")));
assert!(!got.iter().any(|l| l.starts_with("SET SELF LANGUAGE")), "no language, no command");
}
#[test]
fn moving_on_stops_waiting_for_a_slow_server() {
// A server that reads but never answers: Stop acts at once, not after the reply timeout.
let (client, mut srv) = UnixStream::pair().unwrap();
let h = std::thread::spawn(move || {
let mut all = String::new();
let _ = srv.read_to_string(&mut all);
all
});
let req = Request { text: "x", rate: 0, lang: None };
let start = Instant::now();
assert_eq!(converse(&Stream::Unix(client), &req, &|| true).unwrap(), Outcome::Interrupted);
assert!(start.elapsed() < REPLY_TIMEOUT, "{:?}", start.elapsed());
// Stopped while the server may be about to read message data: the empty line and `.` end
// any message first, so `CANCEL self` is never taken for text.
let all = h.join().unwrap();
assert!(all.ends_with("\r\n\r\n.\r\nCANCEL self\r\nQUIT\r\n"), "{all:?}");
}
#[test]
fn a_server_that_only_sends_events_times_out() {
// Events and continuation lines are no reply: they can't keep a command waiting forever.
let (client, mut srv) = UnixStream::pair().unwrap();
let h = std::thread::spawn(move || while srv.write_all(b"701 BEGIN\r\n225-1\r\n").is_ok() {});
let req = Request { text: "x", rate: 0, lang: None };
let start = Instant::now();
let client = Stream::Unix(client);
assert_eq!(converse(&client, &req, &|| false).unwrap_err().kind(), ErrorKind::TimedOut);
assert!(start.elapsed() < REPLY_TIMEOUT * 3, "{:?}", start.elapsed());
drop(client);
h.join().unwrap();
}
#[test]
fn a_hostile_language_tag_is_never_sent() {
let (client, srv) = UnixStream::pair().unwrap();
let h = server(srv, Ending::End);
let req = Request { text: "x", rate: 0, lang: Some("hu\r\nCANCEL all") };
assert_eq!(converse(&Stream::Unix(client), &req, &|| false).unwrap(), Outcome::Done);
let got = h.join().unwrap();
assert!(!got.iter().any(|l| l.starts_with("SET SELF LANGUAGE") || l.starts_with("CANCEL")), "{got:?}");
}
#[test]
fn a_server_that_hangs_up_is_an_error_not_a_hang() {
let (client, mut srv) = UnixStream::pair().unwrap();
let h = std::thread::spawn(move || {
let mut buf = [0u8; 64];
let _ = srv.read(&mut buf);
});
let req = Request { text: "x", rate: 0, lang: None };
assert!(converse(&Stream::Unix(client), &req, &|| false).is_err());
h.join().unwrap();
}
#[test]
fn a_start_command_that_exits_reports_how() {
let far = Instant::now() + Duration::from_secs(30);
let run = |script: &str| start_server(std::process::Command::new("sh").args(["-c", script]), far, &|| false);
assert!(matches!(run("exit 0"), Started::Exited(true)));
assert!(matches!(run("exit 1"), Started::Exited(false)));
let missing = start_server(&mut std::process::Command::new("no-such-program-for-wordcraft"), far, &|| false);
assert!(matches!(missing, Started::Missing), "{missing:?}");
}
/// A start command that never exits, writing its process id to `pid` first (whole, by renaming).
fn hanging_start(pid: &std::path::Path) -> std::process::Command {
let mut c = std::process::Command::new("sh");
c.args(["-c", "echo $$ > \"$1.part\" && mv \"$1.part\" \"$1\" && exec sleep 30", "sh"]).arg(pid);
c
}
/// A fresh path for a start command's process id.
fn pid_file(name: &str) -> std::path::PathBuf {
let p = std::env::temp_dir().join(format!("wordcraft-ssip-{}-{name}.pid", std::process::id()));
let _ = std::fs::remove_file(&p);
let _ = std::fs::remove_file(p.with_extension("pid.part"));
p
}
/// The process id the start command wrote (whole, or still in its `.part` file when it was
/// stopped before renaming it); `None` when it was stopped before writing any.
fn written_pid(pid: &std::path::Path) -> Option<String> {
let part = pid.with_extension("pid.part");
let id = std::fs::read_to_string(pid).or_else(|_| std::fs::read_to_string(&part)).ok();
let _ = std::fs::remove_file(pid);
let _ = std::fs::remove_file(part);
id.map(|id| id.trim().to_string()).filter(|id| !id.is_empty())
}
/// Is process `id` gone: reaped, not merely killed and left a zombie (which `kill -0` still
/// finds)?
fn gone(id: &str) -> bool {
let probe = std::process::Command::new("sh").args(["-c", "kill -0 \"$1\" 2>/dev/null", "sh", id]).status().unwrap();
!probe.success()
}
#[test]
fn a_start_command_that_hangs_is_stopped_at_the_deadline() {
// A start stopped before the stand-in wrote its id is tried again with a longer deadline
// (a loaded machine may not run it at once); one that never wrote it left nothing to reap.
for deadline in [Duration::from_secs(1), Duration::from_secs(3), Duration::from_secs(10)] {
let pid = pid_file("deadline");
let begun = Instant::now();
let got = start_server(&mut hanging_start(&pid), begun + deadline, &|| false);
assert!(matches!(got, Started::TimedOut), "{got:?}");
assert!(begun.elapsed() < deadline + Duration::from_secs(4), "{:?}", begun.elapsed());
if let Some(id) = written_pid(&pid) {
assert!(gone(&id), "the start command was reaped");
return;
}
}
}
#[test]
fn stop_ends_the_wait_for_a_start_command_at_once() {
let pid = pid_file("stop");
let begun = Instant::now();
// Stop once the command is surely running.
let got = start_server(&mut hanging_start(&pid), begun + Duration::from_secs(30), &|| pid.exists());
assert!(matches!(got, Started::Interrupted), "{got:?}");
assert!(begun.elapsed() < Duration::from_secs(5), "{:?}", begun.elapsed());
let id = written_pid(&pid).expect("stopped only once the id was written");
assert!(gone(&id), "the start command was reaped");
}
}
+102
View File
@@ -186,6 +186,76 @@ fn player_finishes_on_its_own() {
assert_eq!(r.state(), State::Playing);
}
#[test]
fn every_reading_counts_as_a_new_one() {
// The worker forgets what it learnt about the speech programs when a reading starts, even
// when the same worker thread carries on (a pause, then Read Aloud again).
let mut r = ReadAloud::default();
r.backend = Backend::Hold;
let readings = |r: &ReadAloud| lock(&r.shared.0).readings;
let doc = Document::from_text("One. Two.");
r.start(sentences(&doc, &Pos::body(0, 0), None, true));
let first = readings(&r);
r.play_pause();
r.start(sentences(&doc, &Pos::body(0, 0), None, true));
assert_eq!(readings(&r), first + 1);
r.play_pause();
r.play_pause();
assert_eq!(readings(&r), first + 1, "pause and play are the same reading");
r.stop();
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn a_sentence_that_never_began_says_so_not_another_program() {
// No begin event within the queue bound: no other program need be involved, so the
// message names the sentence not starting, not another program stopping it.
let halted = |o| match dispatcher_spoken(Ok(o)) {
Spoken::Halted(m) => m,
_ => String::new(),
};
assert_eq!(halted(ssip::Outcome::NotStarted), NOT_STARTED);
assert!(!NOT_STARTED.contains("another program"));
assert_eq!(halted(ssip::Outcome::Halted), HALTED_BY_OTHER);
}
#[test]
fn play_after_a_failed_sentence_plays_it_again() {
// A sentence whose end never came stops reading with an error (as the worker does on a
// timeout); Play starts it again and the message goes.
let mut r = ReadAloud::default();
r.backend = Backend::Hold;
r.start(sentences(&Document::from_text("One. Two."), &Pos::body(0, 0), None, true));
r.skip(1);
{
let mut s = lock(&r.shared.0);
s.state = State::Stopped;
s.error = Some("Read Aloud couldn't use Speech Dispatcher: the sentence didn't end".into());
}
r.play_pause();
assert_eq!((r.state(), r.progress().0, r.error()), (State::Playing, 1, None));
r.stop();
}
#[test]
fn play_after_a_halt_carries_on_and_clears_the_message() {
// Another program stopped the sentence: reading paused on it with a message, and Play
// goes on from the same sentence.
let mut r = ReadAloud::default();
r.backend = Backend::Hold;
r.start(sentences(&Document::from_text("One. Two."), &Pos::body(0, 0), None, true));
{
let mut s = lock(&r.shared.0);
s.state = State::Paused;
s.error = Some("stopped by another program".into());
}
r.play_pause();
assert_eq!(r.state(), State::Playing);
assert_eq!(r.progress().0, 0);
assert_eq!(r.error(), None);
r.stop();
}
#[test]
fn empty_queue_does_not_play() {
let mut r = ReadAloud::default();
@@ -271,6 +341,38 @@ fn espeak_gets_the_language() {
assert_eq!(espeak_args("180", Some("uk")), ["-s", "180", "-v", "uk", "--stdin"]);
assert_eq!(espeak_args("180", None), ["-s", "180", "--stdin"]);
assert_eq!(espeak_args("180", Some("--evil")), ["-s", "180", "--stdin"]);
assert_eq!(espeak_args("180", Some("hu-HU")), ["-s", "180", "-v", "hu", "--stdin"]);
}
#[test]
fn speech_dispatcher_rate_follows_the_speed() {
assert_eq!(spd_rate(1.0), 0);
assert_eq!(spd_rate(MIN_RATE), -50);
assert_eq!(spd_rate(MAX_RATE), 100);
assert_eq!(spd_rate(2.0), 50);
assert_eq!(spd_rate(10.0), 100);
assert_eq!(spd_rate(0.0), -50);
assert_eq!(spd_rate(f32::NAN), 0);
assert_eq!(spd_rate(1.5), 25);
}
#[test]
fn speech_dispatcher_keeps_the_region() {
let tag = |t| dispatcher_language(Some(t));
assert_eq!(tag("en-GB").as_deref(), Some("en-GB"));
assert_eq!(tag(" pt-BR ").as_deref(), Some("pt-BR"));
assert_eq!(tag("zh-TW").as_deref(), Some("zh-TW"));
assert_eq!(tag("hu").as_deref(), Some("hu"));
assert_eq!(tag("HU-hu").as_deref(), Some("hu-HU"));
// SSIP takes language and region: script and variant subtags are left out.
assert_eq!(tag("sr-Latn-RS").as_deref(), Some("sr-RS"));
assert_eq!(tag("zh-Hant-TW").as_deref(), Some("zh-TW"));
assert_eq!(tag("es-419").as_deref(), Some("es-419"));
assert_eq!(tag("de-CH-1996").as_deref(), Some("de-CH"));
assert_eq!(tag("sr-Cyrl").as_deref(), Some("sr"));
assert_eq!(dispatcher_language(Some("x-none")), None);
assert_eq!(dispatcher_language(Some("hu\r\nQUIT")), None);
assert_eq!(dispatcher_language(None), None);
}
#[test]
+84
View File
@@ -1346,6 +1346,74 @@ fn copying_bold_text_puts_html_on_the_clipboard() {
assert_eq!(s.clipboard_html, html);
}
/// Bold and italic of a paragraph's character, in a document.
fn emphasis_at(d: &wordcraft_doc::Document, block: usize, off: usize) -> (bool, bool) {
match d.body.get(block).map(|b| &**b) {
Some(wordcraft_doc::Block::Para(p)) => {
let c = p.props_of_char(off);
(c.bold.unwrap_or(false), c.italic.unwrap_or(false))
}
_ => (false, false),
}
}
#[test]
fn copying_puts_rtf_on_the_clipboard() {
// gap-clipboard-rtf: a copy carries RTF beside its text and HTML, for programs that read RTF.
let mut s = s();
run(&mut s, "document.setText", json!({"text": "plain bold\nsome italic"}));
s.doc.format_range(&Pos::body(0, 6), &Pos::body(0, 10), &|c| c.bold = Some(true)).unwrap();
s.doc.format_range(&Pos::body(1, 5), &Pos::body(1, 11), &|c| c.italic = Some(true)).unwrap();
run(&mut s, "select.all", json!({}));
let r = run(&mut s, "edit.copy", json!({}));
let rtf = r["rtf"].as_str().unwrap_or_default().to_string();
assert!(rtf.starts_with("{\\rtf1"), "{r}");
assert_eq!(s.clipboard_rtf, rtf);
let d = wordcraft_formats::rtf::import(rtf.as_bytes()).unwrap();
let paras: Vec<String> =
d.body.iter().filter_map(|b| if let wordcraft_doc::Block::Para(p) = &**b { Some(p.text.clone()) } else { None }).collect();
assert_eq!(paras.get(..2), Some(&["plain bold".to_string(), "some italic".to_string()][..]), "{paras:?}");
assert_eq!(emphasis_at(&d, 0, 0), (false, false));
assert_eq!(emphasis_at(&d, 0, 7), (true, false));
assert_eq!(emphasis_at(&d, 1, 7), (false, true));
// Pasting it elsewhere keeps the formatting.
let mut t = Session::new(wordcraft_doc::Document::new());
run(&mut t, "edit.paste", json!({"rtf": rtf}));
assert!(text(&t).starts_with("plain bold\nsome italic"), "{}", text(&t));
assert_eq!(emphasis_at(&t.doc, 0, 7), (true, false));
assert_eq!(emphasis_at(&t.doc, 1, 7), (false, true));
// Cut carries it too.
run(&mut s, "select.range", json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 10}}));
let r = run(&mut s, "edit.cut", json!({}));
assert!(r["rtf"].as_str().is_some_and(|t| t.starts_with("{\\rtf1") && t.contains("bold")), "{r}");
assert_eq!(text(&s).lines().next(), Some("plain "));
// Nothing selected: nothing copied, no RTF.
run(&mut s, "caret.docEnd", json!({}));
let r = run(&mut s, "edit.copy", json!({}));
assert!(r.get("rtf").is_none(), "{r}");
}
#[test]
fn pasting_hostile_rtf_never_panics() {
let deep = format!("{{\\rtf1 {}x{}", "{\\b ".repeat(5000), "}".repeat(5000));
let junk = ["{\\rtf1", "{\\rtf1 {\\b bo", "{\\rtf1\\ansi\\u-99999999999? \\bin999999999 x}", "{\\rtf1 \\'zz\\fonttbl{", "{\\rtf\u{0}\u{FFFF}}"];
for rtf in junk.iter().map(|j| j.to_string()).chain([deep]) {
for id in ["edit.paste", "edit.pasteMerge", "edit.pasteSpecial"] {
let mut t = Session::new(wordcraft_doc::Document::new());
// `Session::run` turns a panic into an error: look for it.
for v in [json!({"rtf": rtf, "as": "rtf"}), json!({"rtf": rtf, "text": "fallback"})] {
if let Err(e) = t.run(id, &v) {
assert!(!e.to_string().contains("panicked"), "{id}: {e}");
}
let _ = t.layout();
}
}
}
}
#[test]
fn copying_symbol_font_text_puts_the_unicode_symbols_on_the_clipboard() {
let mut s = s();
@@ -6045,6 +6113,22 @@ fn props_colours_take_hex_strings() {
}
}
#[test]
fn para_set_frame_geometry_keeps_anchor_lock_unless_replaced() {
use wordcraft_doc::props::TextFrame;
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Framed text"}));
let paragraph = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
paragraph.props.frame = Some(TextFrame { anchor_lock: Some(true), ..TextFrame::default() });
run(&mut s, "para.set", json!({"props": {"frame": {"x": 36.0, "y": 72.0, "w": 108.0}}}));
let frame = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.frame.as_ref().unwrap();
assert_eq!((frame.x, frame.y, frame.w, frame.anchor_lock), (36.0, 72.0, Some(108.0), Some(true)));
run(&mut s, "para.set", json!({"props": {"frame": {"x": 54.0, "anchorLock": false}}}));
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.frame.as_ref().unwrap().anchor_lock, Some(false));
}
/// #555: Update Heading 2 to Match Selection (the styles pane's context menu) makes Heading 2
/// look like the selected text — its paragraph style and direct formatting — so every Heading 2
/// paragraph changes; the selected paragraph takes Heading 2, and it is one undo step.
+2
View File
@@ -15,3 +15,5 @@ mod rtf_sections;
#[cfg(not(target_arch = "wasm32"))]
mod safe_save;
mod three_column_headers;
mod ref_paragraph_numbers;
@@ -0,0 +1,404 @@
//! Synthetic numbered-reference regression adapted from the patch by @Alyosha-dot-shop in #4533.
//! Switch semantics: Microsoft MS-OI29500, Part 1 Section 17.16.5.51 (REF).
//! All OOXML packages and document text are constructed in memory; no external documents.
use std::io::Write;
use serde_json::json;
use wordcraft_doc::{InlineObject, StoryRef};
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
/// Build an OOXML ZIP entirely in memory.
fn zip(entries: &[(&str, &str)]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, body) in entries {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(body.as_bytes()).unwrap();
}
zw.finish().unwrap().into_inner()
}
/// One synthetic numbering level.
fn lvl(ilvl: u8, fmt: &str, text: &str) -> String {
format!(r#"<w:lvl w:ilvl="{ilvl}"><w:start w:val="1"/><w:numFmt w:val="{fmt}"/><w:lvlText w:val="{text}"/></w:lvl>"#)
}
/// Five independent list schemes for context and restart coverage.
fn numbering() -> String {
let schemes: [&[(&str, &str)]; 5] = [
&[("decimal", "%1."), ("decimal", "%1.%2"), ("lowerLetter", "(%3)")],
&[("decimal", "%1"), ("decimal", "%1.%2"), ("decimal", "%1.%2.%3")],
&[("decimal", "%1"), ("decimal", "%1.%2"), ("lowerLetter", "(%3)")],
&[("decimal", "%1."), ("lowerLetter", "(%2)")],
// Scheme 5: as scheme 1, a list of its own whose first two levels are never used.
&[("decimal", "%1."), ("decimal", "%1.%2"), ("lowerLetter", "(%3)")],
];
let mut out = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:numbering {W}>"#);
for (i, levels) in schemes.iter().enumerate() {
out.push_str(&format!(r#"<w:abstractNum w:abstractNumId="{i}"><w:multiLevelType w:val="multilevel"/>"#));
for (k, (fmt, text)) in levels.iter().enumerate() {
out.push_str(&lvl(k as u8, fmt, text));
}
out.push_str("</w:abstractNum>");
}
for i in 0..schemes.len() {
out.push_str(&format!(r#"<w:num w:numId="{}"><w:abstractNumId w:val="{i}"/></w:num>"#, i + 1));
}
out + "</w:numbering>"
}
/// A paragraph, numbered `(numId, level)` or not, holding `content`.
fn para(num: Option<(u32, u8)>, content: &str) -> String {
let ppr = num.map(|(n, l)| format!(r#"<w:pPr><w:numPr><w:ilvl w:val="{l}"/><w:numId w:val="{n}"/></w:numPr></w:pPr>"#)).unwrap_or_default();
format!("<w:p>{ppr}{content}</w:p>")
}
/// One synthetic text run.
fn text(t: &str) -> String {
format!(r#"<w:r><w:t xml:space="preserve">{t}</w:t></w:r>"#)
}
/// A bookmark surrounding a synthetic text run.
fn marked(name: &str, id: u32, t: &str) -> String {
format!(r#"<w:bookmarkStart w:id="{id}" w:name="{name}"/>{}<w:bookmarkEnd w:id="{id}"/>"#, text(t))
}
/// A complex REF field with an intentionally stale result.
fn field(instr: &str, cached: &str) -> String {
format!(
concat!(
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r>"#,
r#"<w:r><w:instrText xml:space="preserve"> {} </w:instrText></w:r>"#,
r#"<w:r><w:fldChar w:fldCharType="separate"/></w:r>"#,
r#"<w:r><w:t xml:space="preserve">{}</w:t></w:r>"#,
r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
),
instr, cached
)
}
/// Open a synthetic OOXML package through the real document reader.
fn open(body: &str) -> wordcraft_engine::Session {
let rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc_rels = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdN" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/></Relationships>"#;
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W}><w:body>{body}</w:body></w:document>"#);
let numbering = numbering();
let bytes =
zip(&[("_rels/.rels", rels), ("word/_rels/document.xml.rels", doc_rels), ("word/document.xml", &doc), ("word/numbering.xml", &numbering)]);
wordcraft_engine::Session::new(wordcraft_engine::io::open_bytes("d.docx", &bytes).expect("opens"))
}
/// Every body field, as (code, result), in document order.
fn fields(s: &wordcraft_engine::Session) -> Vec<(String, String)> {
let mut out = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
for o in &p.objects {
if let InlineObject::Field { instr, result, .. } = o {
out.push((instr.trim().to_string(), result.clone()));
}
}
}
out
}
/// Numeric REF switches replace cached values with list labels, while plain REF and position switches retain their behavior.
#[test]
fn ref_number_switches_show_the_paragraph_number_as_word_does() {
let body = [
// Scheme 1: %1. / %1.%2 / (%3) — clause (a) of 1.1.
para(Some((1, 0)), &text("Definitions")),
para(Some((1, 1)), &text("Defined terms")),
para(Some((1, 2)), &marked("_RefA", 1, "Synthetic target alpha.")),
// Scheme 2: the stock chapter scheme %1 / %1.%2 / %1.%2.%3.
para(Some((2, 0)), &text("Chapter")),
para(Some((2, 1)), &text("Section")),
para(Some((2, 2)), &marked("_RefB", 2, "Point")),
// Scheme 3: %1 / %1.%2 / (%3).
para(Some((3, 0)), &text("Part")),
para(Some((3, 1)), &text("Part point")),
para(Some((3, 2)), &marked("_RefC", 3, "Sub-point")),
// Scheme 4: %1. / (%2), clauses and sub-clauses as in most contracts.
para(Some((4, 0)), &text("Hire")),
para(Some((4, 1)), &text("first")),
para(Some((4, 0)), &marked("_RefD", 4, "Payment")),
para(Some((4, 1)), &text("a")),
para(Some((4, 1)), &marked("_RefE", 5, "b")),
// 2(c), inside clause 2: relative references drop the shared clause number.
para(Some((4, 1)), &format!("{}{}{}", text("See "), field(r"REF _RefE \r \h", "STALE"), field(r"REF _RefD \r \h", "STALE"))),
para(Some((4, 0)), &text("Third")),
// 3(a), another clause: the full number. _RefF's list has no clauses above its items.
para(Some((4, 1)), &format!("{}{}", field(r"REF _RefE \r \h", "STALE"), field(r"REF _RefF \r \h", "STALE"))),
// Scheme 5 items in a table whose second row's cell is merged into the first: the empty
// item in the merged-away cell takes no number, so the next item is (b), not (c).
format!(
concat!(
r#"<w:tbl><w:tblPr/><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid>"#,
r#"<w:tr><w:tc><w:tcPr><w:vMerge w:val="restart"/></w:tcPr>{}</w:tc></w:tr>"#,
r#"<w:tr><w:tc><w:tcPr><w:vMerge/></w:tcPr>{}</w:tc></w:tr></w:tbl>"#,
),
para(Some((5, 2)), &marked("_RefF", 6, "Target F")),
para(Some((5, 2)), ""),
),
para(Some((5, 2)), &marked("_RefG", 7, "Target G")),
// An unnumbered paragraph.
para(
None,
&[
field(r"REF _RefA \w \h", "STALE"),
field(r"REF _RefA \n \h", "STALE"),
field(r"REF _RefA \r \h", "STALE"),
field(r"REF _RefB \w \h", "STALE"),
field(r"REF _RefC \w \h", "STALE"),
field(r"REF _RefE \r \h", "STALE"),
field(r"REF _RefD \n \h", "STALE"),
field(r"REF _RefG \r \h", "STALE"),
// Unchanged: no number switch shows the text; \p (above/below) is left as it is.
field(r"REF _RefB \h", "STALE"),
field(r"REF _RefA \r \p", "KEEP"),
]
.concat(),
),
]
.concat();
let mut s = open(&body);
s.run("references.updateFields", &json!({"all": true})).expect("updates");
let got: Vec<(String, String)> = fields(&s);
let want = [
(r"REF _RefE \r \h", "(b)"),
(r"REF _RefD \r \h", "2"),
(r"REF _RefE \r \h", "2(b)"),
(r"REF _RefF \r \h", "(a)"),
(r"REF _RefA \w \h", "1.1(a)"),
(r"REF _RefA \n \h", "(a)"),
(r"REF _RefA \r \h", "1.1(a)"),
(r"REF _RefB \w \h", "1.1.1"),
(r"REF _RefC \w \h", "1.1(a)"),
(r"REF _RefE \r \h", "2(b)"),
(r"REF _RefD \n \h", "2"),
(r"REF _RefG \r \h", "(b)"),
(r"REF _RefB \h", "Point"),
(r"REF _RefA \r \p", "KEEP"),
];
let want: Vec<(String, String)> = want.iter().map(|(i, r)| (i.to_string(), r.to_string())).collect();
assert_eq!(got, want);
}
/// Word retains context already rendered by the target's own label for relative REF.
#[test]
fn relative_number_keeps_context_already_in_the_target_label() {
let body = [
para(Some((2, 0)), &text("Root")),
para(Some((2, 1)), &text("First section")),
para(Some((2, 2)), &marked("_Target", 20, "Synthetic target")),
para(Some((2, 2)), &field(r"REF _Target \r", "STALE")),
para(Some((2, 1)), &text("Other section")),
para(Some((2, 2)), &field(r"REF _Target \r", "STALE")),
para(None, &field(r"REF _Target \w", "STALE")),
]
.concat();
let mut s = open(&body);
s.run("references.updateFields", &json!({"all": true})).unwrap();
let got: Vec<_> = fields(&s).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["1.1.1", "1.1.1", "1.1.1"]);
}
/// Cached values of unsupported switches, missing targets and locked fields are not replaced with paragraph text.
#[test]
fn unsupported_or_unresolvable_numeric_refs_preserve_their_cache() {
let body = [
para(Some((1, 0)), &marked("_Root", 30, "Synthetic root")),
para(
None,
&[
field(r"REF _Root \w \d /", "KEEP DELIMITER"),
field(r"REF _Root \n \t", "KEEP TEXT"),
field(r"REF _Root \r \p", "KEEP POSITION"),
field(r"REF _Missing \w", "KEEP MISSING"),
field(r"REF _Plain \n", "KEEP UNNUMBERED"),
field(r"REF _Root \n", "KEEP LOCKED").replace(r#"w:fldCharType="begin""#, r#"w:fldCharType="begin" w:fldLock="true""#),
]
.concat(),
),
para(None, &marked("_Plain", 31, "Synthetic unnumbered target")),
]
.concat();
let mut s = open(&body);
let before = fields(&s);
s.run("references.updateFields", &json!({"all":true})).unwrap();
assert_eq!(fields(&s), before);
}
/// Updating, undoing and saving keep the field instructions and their numeric results intact.
#[test]
fn numeric_refs_survive_undo_redo_and_docx_round_trip() {
let body = [
para(Some((4, 0)), &text("Root")),
para(Some((4, 1)), &marked("_Item", 40, "Synthetic item șțăîâ")),
para(None, &field(r"REF _Item \w \h", "STALE")),
]
.concat();
let mut s = open(&body);
s.run("references.updateFields", &json!({"all":true})).unwrap();
assert_eq!(fields(&s), [(r"REF _Item \w \h".into(), "1(a)".into())]);
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(fields(&s)[0].1, "STALE");
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(fields(&s)[0].1, "1(a)");
let bytes = wordcraft_docx::write(&s.doc).unwrap();
let back = wordcraft_engine::Session::new(wordcraft_engine::io::open_bytes("roundtrip.docx", &bytes).unwrap());
assert_eq!(fields(&back), fields(&s));
}
/// Context already displayed by a needed parent label is retained as Word measured it.
#[test]
fn relative_number_from_the_root_keeps_the_parents_rendered_context() {
let body = [
para(Some((1, 0)), &field(r"REF _Target \r", "STALE")),
para(Some((1, 1)), &text("Synthetic section")),
para(Some((1, 2)), &marked("_Target", 50, "Synthetic target")),
]
.concat();
let mut s = open(&body);
s.run("references.updateFields", &json!({"all":true})).unwrap();
assert_eq!(fields(&s)[0].1, "1.1(a)");
}
/// A header can reference a body list without inheriting the body caret's relative context.
#[test]
fn numeric_refs_in_headers_use_the_body_target_number() {
use wordcraft_doc::{Paragraph, PartKind, para_block};
let mut s = open(&[para(Some((4, 0)), &text("Synthetic root")), para(Some((4, 1)), &marked("_Target", 60, "Synthetic target"))].concat());
let mut header = Paragraph::new();
header
.insert_object(
0,
InlineObject::Field {
instr: r"REF _Target \r \h".into(),
result: "STALE".into(),
locked: false,
dirty: false,
code: false,
form: String::new(),
},
&Default::default(),
)
.unwrap();
let id = s.doc.add_part(PartKind::Header, vec![para_block(header)]);
s.doc.last_section.headers.default = Some(id);
s.run("references.updateFields", &json!({"all":true})).unwrap();
let p = s.doc.para(StoryRef::Part(id), &wordcraft_doc::Path::top(0)).unwrap();
let InlineObject::Field { result, .. } = &p.objects[0] else { panic!() };
assert_eq!(result, "1(a)");
}
/// Separate bare numeric level labels cannot be concatenated without inventing a delimiter.
#[test]
fn ambiguous_numeric_label_join_keeps_the_cached_full_context() {
let mut s = open(
&[
para(Some((2, 0)), &text("Root")),
para(Some((2, 1)), &text("Section")),
para(Some((2, 2)), &marked("_Target", 70, "Synthetic target")),
para(None, &field(r"REF _Target \w", "1.1.1")),
para(None, &field(r"REF _Target \n", "STALE")),
]
.concat(),
);
let abs = std::sync::Arc::make_mut(&mut s.doc.numbering).abstracts.iter_mut().find(|a| a.id == 1).unwrap();
abs.levels[1].text = "%2".into();
abs.levels[2].text = "%3".into();
s.run("references.updateFields", &json!({"all":true})).unwrap();
let got: Vec<_> = fields(&s).into_iter().map(|(_, s)| s).collect();
assert_eq!(got, ["1.1.1", "1"]);
}
/// Reusing the lookup keeps the first body bookmark even with duplicate names and many updates.
#[test]
fn repeated_numeric_refs_keep_the_first_body_bookmark() {
use wordcraft_doc::{PartKind, Path};
let mut body = [
para(Some((4, 0)), &marked("_Duplicate", 80, "First synthetic target")),
para(Some((4, 0)), &marked("_Duplicate", 81, "Second synthetic target")),
]
.concat();
for _ in 0..64 {
body.push_str(&para(None, &field(r"REF _Duplicate \w \h", "STALE")));
}
body.push_str(&para(None, &field(r"REF _Duplicate \h", "STALE")));
let mut s = open(&body);
assert_eq!(s.doc.bookmarks().iter().filter(|(name, _)| name == "_Duplicate").count(), 2);
// A same-named bookmark outside the body cannot replace the body target.
let header = open(&para(None, &marked("_Duplicate", 82, "Header synthetic target"))).doc.body[0].clone();
let id = s.doc.add_part(PartKind::Header, vec![header]);
s.doc.last_section.headers.default = Some(id);
s.run("references.updateFields", &json!({"all": true})).unwrap();
let got = fields(&s);
assert_eq!(got.len(), 65);
assert!(got[..64].iter().all(|(_, value)| value == "1"));
assert_eq!(got[64].1, "First synthetic target");
assert_eq!(s.doc.bookmarks().iter().find(|(name, _)| name == "_Duplicate").unwrap().1.path, Path::top(0));
}
/// A target's instance must not invent a different label for its actual ancestor paragraph.
#[test]
fn full_context_keeps_the_actual_ancestor_instance_label() {
let mut s = open(
&[
para(Some((1, 0)), &text("Synthetic ancestor")),
para(Some((6, 1)), &marked("_ActualAncestor", 80, "Synthetic child")),
para(None, &field(r"REF _ActualAncestor \w", "STALE")),
]
.concat(),
);
let numbering = std::sync::Arc::make_mut(&mut s.doc.numbering);
numbering.abstracts.iter_mut().find(|a| a.id == 0).unwrap().levels[0].text = "Clause %1.".into();
let mut child = numbering.nums.iter().find(|n| n.id == 1).unwrap().clone();
child.id = 6;
child.level_overrides = vec![
(0, wordcraft_doc::numbering::Level { text: "Part %1.%2.".into(), ..Default::default() }),
(1, wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::LowerLetter, text: "(%2)".into(), ..Default::default() }),
];
numbering.nums.push(child);
s.run("references.updateFields", &json!({"all":true})).unwrap();
assert_eq!(fields(&s).into_iter().map(|(_, value)| value).collect::<Vec<_>>(), ["Clause 1(a)"]);
}
/// The six legal-list cases measured in Word for Windows by @pakiat:
/// https://github.com/storytold/wordcraft/pull/4609#issuecomment-6557062456
#[test]
fn relative_refs_keep_the_legal_template_in_the_six_measured_contexts() {
let refs = || [field(r"REF _Leaf \r", "STALE"), field(r"REF _Mid \r", "STALE")].concat();
let body = [
para(Some((2, 0)), &text("Synthetic first root")),
para(Some((2, 0)), &refs()),
para(Some((2, 1)), &text("Synthetic first section")),
para(Some((2, 1)), &marked("_Mid", 90, "Synthetic target section")),
para(Some((2, 2)), &text("Synthetic first leaf")),
para(Some((2, 2)), &marked("_Leaf", 91, "Synthetic target leaf")),
para(Some((2, 2)), &refs()),
para(Some((2, 1)), &refs()),
]
.concat();
let mut s = open(&body);
s.run("references.updateFields", &json!({"all":true})).unwrap();
let got: Vec<_> = fields(&s).into_iter().map(|(_, r)| r).collect();
assert_eq!(got, ["2.2.2", "2.2", "2.2.2", "2.2", "2.2.2", "2.2"]);
}
/// Equal counter values in another list do not provide shared relative context.
#[test]
fn relative_context_is_not_shared_by_different_lists() {
let mut s = open(
&[
para(Some((4, 0)), &text("Synthetic target root")),
para(Some((4, 1)), &marked("_OtherList", 92, "Synthetic target")),
para(Some((1, 0)), &text("Synthetic separate root")),
para(Some((1, 1)), &field(r"REF _OtherList \r", "STALE")),
]
.concat(),
);
s.run("references.updateFields", &json!({"all":true})).unwrap();
assert_eq!(fields(&s)[0].1, "1(a)");
}
+171 -32
View File
@@ -88,7 +88,7 @@ pub struct Construction {
pub parts: Vec<GlyphPart>,
}
/// A parsed `MATH` table. Values are font units.
/// A parsed `MATH` table. Values are font units; glyphs can share immutable constructions.
#[derive(Clone, Debug, Default)]
pub struct MathTable {
pub script_percent: f32,
@@ -100,8 +100,8 @@ pub struct MathTable {
pub italics: HashMap<u32, f32>,
pub top_accent: HashMap<u32, f32>,
pub min_connector_overlap: f32,
pub vert: HashMap<u32, Construction>,
pub horiz: HashMap<u32, Construction>,
pub vert: HashMap<u32, Arc<Construction>>,
pub horiz: HashMap<u32, Arc<Construction>>,
}
impl MathTable {
@@ -185,41 +185,59 @@ fn value_map(b: &[u8], at: usize) -> HashMap<u32, f32> {
m
}
fn constructions(b: &[u8], variants: usize, cov_at: usize, count: usize, offsets_at: usize) -> HashMap<u32, Construction> {
fn construction(b: &[u8], c: usize) -> Construction {
let mut con = Construction::default();
let nv = u16_at(b, c + 2).unwrap_or(0) as usize;
for k in 0..nv.min(256) {
let r = c + 4 + k * 4;
let (Some(vg), Some(adv)) = (u16_at(b, r), u16_at(b, r + 2)) else { break };
con.variants.push((vg as u32, adv as f32));
}
if let Some(a) = sub(b, c, c) {
let np = u16_at(b, a + 4).unwrap_or(0) as usize;
for k in 0..np.min(64) {
let r = a + 6 + k * 10;
let (Some(pg), Some(s), Some(e), Some(full), Some(fl)) =
(u16_at(b, r), u16_at(b, r + 2), u16_at(b, r + 4), u16_at(b, r + 6), u16_at(b, r + 8))
else {
break;
};
con.parts.push(GlyphPart {
gid: pg as u32,
start_connector: s as f32,
end_connector: e as f32,
full_advance: full as f32,
extender: fl & 1 != 0,
});
}
}
con
}
fn constructions(
b: &[u8],
variants: usize,
cov_at: usize,
count: usize,
offsets_at: usize,
cache: &mut HashMap<usize, Arc<Construction>>,
) -> HashMap<u32, Arc<Construction>> {
let mut m = HashMap::new();
let Some(cov) = sub(b, variants, cov_at) else { return m };
let glyphs = coverage(b, cov);
let mut offsets = HashMap::new();
for (i, g) in glyphs.iter().enumerate().take(count) {
let Some(c) = sub(b, variants, offsets_at + i * 2) else { continue };
let mut con = Construction::default();
let nv = u16_at(b, c + 2).unwrap_or(0) as usize;
for k in 0..nv.min(256) {
let r = c + 4 + k * 4;
let (Some(vg), Some(adv)) = (u16_at(b, r), u16_at(b, r + 2)) else { break };
con.variants.push((vg as u32, adv as f32));
}
if let Some(a) = sub(b, c, c) {
let np = u16_at(b, a + 4).unwrap_or(0) as usize;
for k in 0..np.min(64) {
let r = a + 6 + k * 10;
let (Some(pg), Some(s), Some(e), Some(full), Some(fl)) =
(u16_at(b, r), u16_at(b, r + 2), u16_at(b, r + 4), u16_at(b, r + 6), u16_at(b, r + 8))
else {
break;
};
con.parts.push(GlyphPart {
gid: pg as u32,
start_connector: s as f32,
end_connector: e as f32,
full_advance: full as f32,
extender: fl & 1 != 0,
});
}
}
if *g != u32::MAX {
m.insert(*g, con);
offsets.insert(*g, c);
}
}
// Malformed coverage may repeat a glyph. Decode only its last valid reference so
// superseded records cannot accumulate in the shared cache.
for (g, c) in offsets {
let con = cache.entry(c).or_insert_with(|| Arc::new(construction(b, c)));
m.insert(g, Arc::clone(con));
}
m
}
@@ -250,8 +268,9 @@ pub fn parse(b: &[u8]) -> Option<MathTable> {
t.min_connector_overlap = u16_at(b, v).unwrap_or(0) as f32;
let nv = u16_at(b, v + 6).unwrap_or(0) as usize;
let nh = u16_at(b, v + 8).unwrap_or(0) as usize;
t.vert = constructions(b, v, v + 2, nv, v + 10);
t.horiz = constructions(b, v, v + 4, nh, v + 10 + nv * 2);
let mut cache = HashMap::new();
t.vert = constructions(b, v, v + 2, nv, v + 10, &mut cache);
t.horiz = constructions(b, v, v + 4, nh, v + 10 + nv * 2, &mut cache);
}
Some(t)
}
@@ -425,6 +444,126 @@ mod tests {
assert!(!cached(&long), "absurdly long names must not be cached");
}
/// A small MathVariants table with two directions and shared or distinct records.
fn construction_bytes(count: u16, distinct: bool) -> Vec<u8> {
let cov = 10 + 4 * usize::from(count);
let con = cov + 10;
let mut b = vec![0; con];
let put = |b: &mut Vec<u8>, at: usize, value: u16| b[at..at + 2].copy_from_slice(&value.to_be_bytes());
put(&mut b, 0, 50);
put(&mut b, 2, cov as u16);
put(&mut b, 4, cov as u16);
put(&mut b, 6, count);
put(&mut b, 8, count);
for i in 0..2 * usize::from(count) {
put(&mut b, 10 + 2 * i, (con + if distinct && i % 2 == 1 { 1674 } else { 0 }) as u16);
}
for (i, v) in [2, 1, 0, count - 1, 0].into_iter().enumerate() {
put(&mut b, cov + 2 * i, v);
}
for shift in 0..if distinct { 2 } else { 1 } {
b.extend_from_slice(&1028u16.to_be_bytes());
b.extend_from_slice(&256u16.to_be_bytes());
for k in 0..256 {
b.extend_from_slice(&7u16.to_be_bytes());
b.extend_from_slice(&(500u16 + k + shift).to_be_bytes());
}
for v in [0u16, 0, 64] {
b.extend_from_slice(&v.to_be_bytes());
}
for _ in 0..64 {
for v in [8u16, 10, 20, 600 + shift, 1] {
b.extend_from_slice(&v.to_be_bytes());
}
}
}
b
}
fn construction_math(variants: &[u8]) -> Vec<u8> {
let v = 10 + 8 + VALUE_COUNT * 4 + 2;
let mut b = vec![0; v];
b[1] = 1;
b[5] = 10;
b[8..10].copy_from_slice(&(v as u16).to_be_bytes());
b.extend_from_slice(variants);
b
}
#[test]
fn shared_constructions_reuse_variant_and_assembly_storage() {
// 16,000 references per direction fit in the table's 16-bit offset domain.
let table = parse(&construction_math(&construction_bytes(16_000, false))).unwrap();
assert_eq!(table.vert.len(), 16_000);
assert_eq!(table.horiz.len(), 16_000);
let first = table.vert.get(&0).unwrap();
assert_eq!(first.variants.len(), 256);
assert_eq!(first.variants[255], (7, 755.0));
assert_eq!(first.parts.len(), 64);
assert_eq!(first.parts[0], GlyphPart { gid: 8, start_connector: 10.0, end_connector: 20.0, full_advance: 600.0, extender: true });
for con in table.vert.values().chain(table.horiz.values()) {
assert_eq!(con.variants.as_ptr(), first.variants.as_ptr(), "one source record must have one variant allocation");
assert_eq!(con.parts.as_ptr(), first.parts.as_ptr(), "one source record must have one assembly allocation");
}
}
#[test]
fn distinct_constructions_keep_values_and_direction_mappings() {
let table = parse(&construction_math(&construction_bytes(4, true))).unwrap();
for glyph in 0..4 {
let con = table.vert.get(&glyph).unwrap();
let other = table.horiz.get(&glyph).unwrap();
assert_eq!(con.variants[0], (7, 500.0 + (glyph % 2) as f32));
assert_eq!(con.parts[0].full_advance, 600.0 + (glyph % 2) as f32);
assert_eq!(con.variants.as_ptr(), other.variants.as_ptr());
}
assert_ne!(table.vert[&0].variants.as_ptr(), table.vert[&1].variants.as_ptr());
}
#[test]
fn shared_constructions_keep_truncated_records_and_absent_offsets() {
let mut variants = construction_bytes(4, false);
let con = u16_at(&variants, 10).unwrap() as usize;
variants.truncate(con + 4 + 10);
// An absent source reference remains absent, rather than aliasing record zero.
variants[10..12].copy_from_slice(&0u16.to_be_bytes());
let table = parse(&construction_math(&variants)).unwrap();
assert!(!table.vert.contains_key(&0));
let first = table.vert.get(&1).unwrap();
assert_eq!(first.variants, [(7, 500.0), (7, 501.0)]);
assert!(first.parts.is_empty());
for con in table.vert.values().chain(table.horiz.values()) {
assert_eq!(con.variants.as_ptr(), first.variants.as_ptr());
}
}
#[test]
fn duplicate_coverage_retains_only_surviving_constructions() {
let mut variants = construction_bytes(3, true);
let cov = u16_at(&variants, 2).unwrap() as usize;
// Format 1 accepts repeated glyph IDs. Each direction keeps its last valid offset.
for (i, value) in [1u16, 3, 7, 7, 7].into_iter().enumerate() {
variants[cov + i * 2..cov + i * 2 + 2].copy_from_slice(&value.to_be_bytes());
}
let mut cache = HashMap::new();
let vert = constructions(&variants, 0, 2, 3, 10, &mut cache);
assert_eq!(vert.len(), 1);
assert_eq!(vert[&7].variants[0], (7, 500.0));
assert_eq!(cache.len(), 1, "superseded records must not stay alive in the cache");
let horiz = constructions(&variants, 0, 4, 3, 16, &mut cache);
assert_eq!(horiz.len(), 1);
assert_eq!(horiz[&7].variants[0], (7, 501.0));
assert_eq!(cache.len(), 2, "each surviving direction can retain its own record");
// A later absent offset must not remove the preceding valid mapping.
variants[14..16].copy_from_slice(&0u16.to_be_bytes());
let table = parse(&construction_math(&variants)).unwrap();
assert_eq!(table.vert.len(), 1);
assert_eq!(table.vert[&7].variants[0], (7, 501.0));
assert_eq!(table.horiz[&7].variants[0], (7, 501.0));
assert!(Arc::ptr_eq(&table.vert[&7], &table.horiz[&7]));
}
#[test]
fn garbage_tables_never_panic() {
assert!(parse(&[]).is_none());
+1
View File
@@ -58,6 +58,7 @@ pub fn notes(doc: &Document) -> Vec<Note> {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
let mut objects = p.objects.iter();
for c in p.text.chars() {
wordcraft_doc::work::steps(1);
if c == OBJ {
match objects.next() {
Some(InlineObject::CommentStart { id }) => {
+8 -35
View File
@@ -64,45 +64,18 @@ fn dense_markers(n: usize) -> Document {
doc
}
// Ignore scheduler delays. An absent, failed, decreasing or nonadvancing counter supplies no
// performance evidence; the semantic assertions below still run on every platform.
fn cpu_cost(mut f: impl FnMut()) -> Option<f64> {
let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
let before = ran();
f();
match (before, ran()) {
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
_ => None,
}
}
/// Unrelated markers must add one walk, rather than a fresh paragraph-prefix scan per marker.
#[test]
fn comment_export_discovery_scales_with_unrelated_markers() {
let (small, big) = (dense_markers(2_000), dense_markers(16_000));
let expected = vec![Note { author: "Ada".into(), quote: "é漢 quote".into(), text: "Kept note".into(), replies: vec![] }];
assert_eq!(notes(&small), expected);
assert_eq!(notes(&big), expected);
let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY);
for _ in 0..3 {
let a = cpu_cost(|| {
for _ in 0..8 {
std::hint::black_box(notes(&small));
}
});
let b = cpu_cost(|| {
std::hint::black_box(notes(&big));
});
if let (Some(a), Some(b)) = (a, b) {
t1 = t1.min(a / 8.0);
t8 = t8.min(b);
}
}
if t1.is_finite() && t8.is_finite() {
let ratio = t8 / t1;
eprintln!("comment discovery CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}");
assert!(ratio < 24.0, "8x the markers cost {ratio:.2}x CPU ({t1:.6}s versus {t8:.6}s)");
} else {
eprintln!("comment discovery scaling skipped: no advancing thread CPU samples");
}
// The characters the discovery walks (`wordcraft_doc::work`), not a timing: about 8x for 8x
// the markers when the walk is linear, about 64x when it is quadratic. Timed, this failed on
// busy CI machines with a linear walk (58x for a 17 µs baseline).
let (s, b) = (wordcraft_doc::work::count(|| drop(notes(&small))), wordcraft_doc::work::count(|| drop(notes(&big))));
let ratio = b as f64 / s.max(1) as f64;
eprintln!("comment discovery steps: {s} -> {b}, {ratio:.1}x for 8x markers");
assert!(s > 0, "no steps counted, so the walk this test watches no longer counts them (wordcraft_doc::work)");
assert!(ratio <= 12.0, "8x the markers cost {ratio:.1}x the steps ({s} -> {b})");
}
+46 -4
View File
@@ -213,10 +213,32 @@ fn header<'a>(h: &'a [(String, String)], name: &str) -> Option<&'a str> {
/// A `; name=value` parameter of a header value (quotes removed), name case-insensitive.
fn param<'a>(value: &'a str, name: &str) -> Option<&'a str> {
value.split(';').skip(1).find_map(|p| {
let (k, v) = p.split_once('=')?;
k.trim().eq_ignore_ascii_case(name).then(|| v.trim().trim_matches('"'))
})
// Split outside quoted strings and comments (RFC 2045 §5.1, RFC 822 §3.3).
let mut quoted = false;
let mut escaped = false;
let mut comment_depth = 0usize;
value
.split(|c| {
if escaped {
escaped = false;
} else if (quoted || comment_depth > 0) && c == '\\' {
escaped = true;
} else if !quoted && c == '(' {
comment_depth = comment_depth.saturating_add(1);
} else if comment_depth > 0 {
if c == ')' {
comment_depth -= 1;
}
} else if c == '"' {
quoted = !quoted;
}
c == ';' && !quoted && comment_depth == 0
})
.skip(1)
.find_map(|p| {
let (k, v) = p.split_once('=')?;
k.trim().eq_ignore_ascii_case(name).then(|| v.trim().trim_matches('"'))
})
}
/// Gather the leaf parts of a (possibly multipart) entity.
@@ -400,6 +422,26 @@ mod tests {
}
}
#[test]
fn quoted_parameters_do_not_supply_a_false_boundary() {
for name in [r#"name="notes; boundary=wrong""#, r#"name="notes\"; boundary=wrong""#, r#"name="notes (10\" screen; boundary=wrong""#] {
let page = "Content-Type: text/html\r\n\r\n<p>Kept content</p>";
let message = message(page).replace("multipart/related; boundary=", &format!("multipart/related; {name}; boundary="));
let doc = import(message.as_bytes());
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Kept content", "{name}");
}
}
#[test]
fn quotes_in_comments_do_not_hide_the_boundary() {
for comment in [r#"(10" screen)"#, r#"(outer (10" screen); boundary=wrong)"#, r#"(10\)" screen; boundary=wrong)"#] {
let page = "Content-Type: text/html\r\n\r\n<p>Kept content</p>";
let message = message(page).replace("multipart/related; boundary=", &format!("multipart/related {comment}; boundary="));
let doc = import(message.as_bytes());
assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Kept content", "{comment}");
}
}
#[test]
fn the_part_charset_decides_the_text() {
let page = "Content-Type: text/html; charset=windows-1250\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n<p>=E8lanak</p>";
+2
View File
@@ -62,6 +62,7 @@ struct Card {
fn root(doc: &Document, id: u32) -> u32 {
let mut cur = id;
for _ in 0..MAX_PARENT_CHAIN {
crate::work::steps(1);
match doc.comments.get(&cur).and_then(|c| c.parent) {
Some(p) if p != cur && doc.comments.contains_key(&p) => cur = p,
_ => break,
@@ -79,6 +80,7 @@ pub fn anchors(doc: &Document) -> Vec<(u32, Pos)> {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
for (off, object) in p.objects_with_offsets() {
let InlineObject::CommentStart { id } = object else { continue };
crate::work::steps(1);
if !doc.comments.contains_key(id) || root(doc, *id) != *id || !seen.insert(*id) {
continue;
}
+55 -9
View File
@@ -49,18 +49,64 @@ fn dense_markers(n: usize) -> Document {
doc
}
/// Asserts `check` on `make(SCALE * n)` takes at most 1.5 * SCALE times the steps it takes on
/// `make(n)`: about 8x for 8x the input when the work is linear, about 64x when it is quadratic.
/// The steps, not a timing, are both counters' together: the paragraph walks, the paragraph list
/// and the path lookups (`wordcraft_doc::work`), and the comment lookups (`crate::work`).
fn assert_linear_anchor_work(what: &str, n: usize, make: impl Fn(usize) -> Document, check: impl Fn(&Document, usize)) {
let (small, big) = (make(n), make(crate::tests::SCALE * n));
let steps = |doc: &Document, n| {
let mut in_doc = 0;
let in_layout = crate::work::count(|| in_doc = wordcraft_doc::work::count(|| check(doc, n)));
(in_doc, in_layout)
};
let ((sd, sl), (bd, bl)) = (steps(&small, n), steps(&big, crate::tests::SCALE * n));
let (s, b) = (sd + sl, bd + bl);
let ratio = b as f64 / s.max(1) as f64;
eprintln!("{what}: {s} -> {b} steps ({sd} -> {bd} in wordcraft_doc, {sl} -> {bl} in layout), {ratio:.1}x for 8x the input");
assert!(sd > 0, "{what}: no steps counted in wordcraft_doc, so the walks this test watches no longer count them (wordcraft_doc::work)");
assert!(sl > 0, "{what}: no steps counted in layout, so the comment lookups this test watches no longer count them (crate::work)");
assert!(ratio <= 1.5 * crate::tests::SCALE as f64, "{what}: work grew {ratio:.1}x for 8x the input ({s} -> {b} steps)");
}
#[test]
fn anchors_walk_dense_unrelated_markers_in_linear_work() {
const N: usize = 2_000;
let (small, big) = (dense_markers(N), dense_markers(crate::tests::SCALE * N));
let check = |doc: &Document, n| assert_eq!(anchors(doc), vec![(0, Pos::body(0, n * wordcraft_doc::para::OBJ.len_utf8()))]);
// The characters the paragraph's object lookups walk (`wordcraft_doc::work`), not a timing:
// about 8x for 8x the markers when the walk is linear, about 64x when it is quadratic.
let (s, b) = (wordcraft_doc::work::count(|| check(&small, N)), wordcraft_doc::work::count(|| check(&big, crate::tests::SCALE * N)));
let ratio = b as f64 / s.max(1) as f64;
eprintln!("balloon anchor steps: {s} -> {b}, {ratio:.1}x for 8x markers");
assert!(s > 0, "no steps counted, so the walk this test watches no longer counts them (wordcraft_doc::work)");
assert!(ratio <= 1.5 * crate::tests::SCALE as f64, "balloon anchor work grew {ratio:.1}x for 8x markers ({s} -> {b} steps)");
assert_linear_anchor_work("balloon anchors, dense markers", 2_000, dense_markers, check);
}
/// `n` paragraphs, every other one in a table cell, each holding an unrelated marker, the start
/// of a reply, the start of the comment it answers twice and the start of a missing comment.
fn threads(n: usize) -> Document {
let mut doc = Document::new();
doc.body = (0..n as u32)
.map(|i| {
let (top, reply) = (2 * i, 2 * i + 1);
doc.comments.insert(top, Comment::default());
doc.comments.insert(reply, Comment { parent: Some(top), ..Default::default() });
let mut p = Paragraph::with_text("x", CharProps::default());
push(&mut p, InlineObject::BookmarkEnd { name: "unrelated".into() });
for id in [reply, top, top, u32::MAX] {
push(&mut p, InlineObject::CommentStart { id });
}
if i % 2 == 0 {
return para_block(p);
}
let mut table = Table::new(1, 1, 200.0);
table.rows[0].cells[0].blocks = vec![para_block(p)];
std::sync::Arc::new(Block::Table(table))
})
.collect();
doc
}
#[test]
fn anchors_take_linear_work_over_paragraphs_comments_and_replies() {
let check = |doc: &Document, n: usize| {
let ids: Vec<u32> = anchors(doc).into_iter().map(|(id, _)| id).collect();
assert_eq!(ids, (0..n as u32).map(|i| 2 * i).collect::<Vec<_>>());
};
assert_linear_anchor_work("balloon anchors, threads", 500, threads, check);
}
#[test]
+35 -6
View File
@@ -13,7 +13,14 @@ use super::{
/// Cell identity is independent of optional table-style formatting.
pub(crate) enum BoxContext<'a> {
Other,
Cell(Option<&'a para::CellText>),
/// Outer cell edges relative to its text origin (not including wrap distances).
Cell {
text: Option<&'a para::CellText>,
left: f32,
right: f32,
/// False for a turned text frame: its x axis becomes page y after rotation.
clamp: bool,
},
}
pub(crate) struct BoxInput<'a> {
@@ -32,7 +39,7 @@ impl<'a> BoxInput<'a> {
}
pub(crate) fn cell(story: StoryRef, blocks: &'a Blocks, prefix: &'a [u32], width: f32, cell: Option<&'a para::CellText>, depth: usize) -> Self {
Self { story, blocks, prefix, width, context: BoxContext::Cell(cell), depth, frame: None }
Self { story, blocks, prefix, width, context: BoxContext::Cell { text: cell, left: 0.0, right: width, clamp: false }, depth, frame: None }
}
}
@@ -191,9 +198,9 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
// inline (restored for the story around this box when it's done).
let outer_hide = std::mem::replace(&mut ctx.hide_deleted, ctx.opts.hides_deleted(story));
let empty_cell = para::CellText::default();
let (table, in_cell) = match context {
BoxContext::Other => (None, false),
BoxContext::Cell(table) => (Some(table.unwrap_or(&empty_cell)), true),
let (in_cell, table, cell_edges) = match context {
BoxContext::Other => (false, None, None),
BoxContext::Cell { text, left, right, clamp } => (true, Some(text.unwrap_or(&empty_cell)), clamp.then_some((left, right))),
};
// Word 16.113.3: the override applies in modes 11/12/14, but not mode 15.
let allow_same_style_space =
@@ -290,7 +297,16 @@ pub(crate) fn layout_box(ctx: &mut Ctx, input: BoxInput<'_>) -> BoxLayout {
// Only paragraph-relative placement excludes space-before. Line anchors
// and frameless page/margin fallbacks retain the text-top origin.
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 mut r = float_rect(frame, (0.0, width), float_y, w, h, float);
// Only move an offset-positioned float when its box crosses the outer
// cell edge. Cell text margins and wrap distances do not move the object.
if let Some((left, right)) = cell_edges
&& !float.outside_cell
&& float.h_align.is_none()
&& float.wrap != Wrap::InFrontOfText
{
r.x = keep_in_cell(r.x, r.w, left, right);
}
let outline = ctx.wrap_outline(o);
excl.extend(wrap_area(r, float, outline.as_deref().map(Vec::as_slice)));
floats.insert(oi, r);
@@ -507,6 +523,19 @@ pub(crate) fn clear_of_float_tables(mut r: Rect, f: &TableFloat, legacy: f32, te
r
}
/// Keep a float's box within the outer cell edges, relative to the cell's text origin.
/// Offsets that fit are unchanged, including those inside the text margins. Wrap distances
/// affect surrounding text only. An oversized object starts at the outer left edge.
pub(crate) fn keep_in_cell(x: f32, w: f32, left: f32, right: f32) -> f32 {
let left = if left.is_finite() { left } else { 0.0 };
let end = right - w;
if !end.is_finite() || end <= left {
return left;
}
// max/min also place a non-finite offset at a finite edge without panicking.
x.max(left).min(end)
}
/// A text box's default internal margins (Word's: 0.1" left/right, 0.05" top/bottom); a box's
/// own come from its [`wordcraft_doc::TextBody`].
#[cfg(test)]
+16 -9
View File
@@ -324,11 +324,11 @@ fn between(a: &Path, b: &Path, l: &Path) -> bool {
}
/// The position in turned text (a table cell's text direction) of `story` under page point
/// (x, y), if there is turned text there.
fn turned_hit(page: &Page, story: StoryRef, x: f32, y: f32) -> Option<Pos> {
/// (x, y), if there is turned text there in a paragraph `skip` doesn't leave out.
fn turned_hit(page: &Page, story: StoryRef, x: f32, y: f32, skip: &dyn Fn(&Path) -> bool) -> Option<Pos> {
items_of(page, story).find_map(|it| {
let Placed::Lines { story: s, path, para, l0, l1, x: ox, y: oy, turn } = it else { return None };
if *s != story || !it.turned_bounds()?.expand(2.0).contains(Point::new(x, y)) {
if *s != story || skip(path) || !it.turned_bounds()?.expand(2.0).contains(Point::new(x, y)) {
return None;
}
let (u, v) = crate::unturn_point(*turn, *ox, *oy, x, y);
@@ -402,22 +402,29 @@ impl DocLayout {
/// The position nearest to (x, y) on `page`, in `story`.
pub fn hit(&self, page: usize, x: f32, y: f32, story: StoryRef) -> Option<Pos> {
let p = self.pages.get(page)?;
if let Some(pos) = turned_hit(p, story, x, y) {
return Some(pos);
}
// Like Word, a click outside a floating table doesn't land in its cells while there's
// other text on the page: below one, it goes to the text beside or after it (#2038).
let floats = if story == StoryRef::Body { float_table_areas(p) } else { Vec::new() };
let away = |path: &Path| floats.iter().any(|(t, r)| path.0.len() > t.0.len() && path.0.starts_with(&t.0) && !r.contains(Point::new(x, y)));
// Turned cell text takes a click up to 2pt outside it, so outside a floating table too.
if let Some(pos) = turned_hit(p, story, x, y, &away) {
return Some(pos);
}
let lines = page_lines(p, story);
if lines.is_empty() {
// A page of turned text (a vertical section): the nearest turned line.
return nearest_turned(p, story, x, y, &away).or_else(|| nearest_turned(p, story, x, y, &|_: &Path| false));
}
let nearest = |a: &&LineHit, b: &&LineHit| score(a, x, y).total_cmp(&score(b, x, y));
let best = lines.iter().filter(|l| !away(l.path)).min_by(nearest).or_else(|| lines.iter().min_by(nearest))?;
let off = best.para.off_at_x(best.li, x - best.x);
Some(Pos { story: best.story, path: best.path.clone(), off })
let at = |l: &LineHit| Pos { story: l.story, path: l.path.clone(), off: l.para.off_at_x(l.li, x - l.x) };
if let Some(best) = lines.iter().filter(|l| !away(l.path)).min_by(nearest) {
return Some(at(best));
}
// Only a floating table's upright cells are left: other turned text first, then turned
// text under the point (the table's too), then the nearest of those cells.
nearest_turned(p, story, x, y, &away)
.or_else(|| turned_hit(p, story, x, y, &|_: &Path| false))
.or_else(|| lines.iter().min_by(nearest).map(at))
}
/// The story whose text is under a point (body, footnotes, endnotes, text boxes; not
+4 -1
View File
@@ -515,7 +515,10 @@ impl<'e> Breaker<'e> {
_ => 0.0,
};
if shift > 0.0 && slack > 0.0 {
for v in sc.xs.iter_mut().skip(align_from - c0) {
// Ordinary tabs move with paragraph alignment; positional tabs keep their anchors.
let whole_line = display.is_none() && pl.label.is_none() && !self.rtl_para && !pl.ptabs.iter().any(|(ci, ..)| *ci >= c0 && *ci < c1);
let shift_from = if whole_line { c0 } else { align_from };
for v in sc.xs.iter_mut().skip(shift_from - c0) {
*v += shift;
}
// After a tab the label stays, as the text before the tab does.
+8 -2
View File
@@ -8,6 +8,7 @@ use wordcraft_doc::styles::{PartSet, TablePart, TableStyleProps};
use wordcraft_doc::{Block, Blocks, Paragraph, Row, StoryRef, Table};
use wordcraft_geom::Rect;
use crate::boxes::BoxContext;
use crate::para::{CellText, ClKind, ParaLayout};
use crate::{BoxInput, BoxLayout, Ctx, Placed, layout_box};
@@ -621,8 +622,13 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
turned = Some(Turned { turn, len, across, snap, region, ci, top: margins[0] + band_t });
(items, len)
} else {
let BoxLayout { mut items, mut height, floats_bottom, last_top, .. } =
layout_box(ctx, BoxInput::cell(story, &cell.blocks, &cpath, cw, text, depth));
let BoxLayout { mut items, mut height, floats_bottom, last_top, .. } = layout_box(
ctx,
BoxInput {
context: BoxContext::Cell { text, left: -margins[1], right: x1 - x0 - margins[1], clamp: true },
..BoxInput::cell(story, &cell.blocks, &cpath, cw, text, depth)
},
);
// A hidden end-of-cell mark: an empty last paragraph takes no room (Word for
// Windows: a row of empty such cells is only its borders).
if cell.props.hide_mark && cell.blocks.last().and_then(|b| b.as_para()).is_some_and(|p| p.text.is_empty()) {
+318
View File
@@ -7,6 +7,7 @@ use wordcraft_doc::{Pos, Table};
mod autospace;
mod bidi;
mod budget;
mod cell_floats;
mod complex;
mod grid;
mod ligature;
@@ -1487,6 +1488,37 @@ fn right_tab_aligns_text_end() {
assert!((end - 472.0).abs() < 1.0, "{end}");
}
/// Native Word moves every ordinary-tab group with centered or right paragraph alignment.
#[test]
fn ordinary_tabs_move_as_line() {
use wordcraft_doc::props::{TabAlign, TabLeader, TabStop};
for stops in [
vec![(180.0, TabAlign::Left)],
vec![(234.0, TabAlign::Center)],
vec![(234.0, TabAlign::Center), (364.5, TabAlign::Right)],
vec![(234.0, TabAlign::Center), (468.0, TabAlign::Right)],
] {
let text = if stops.len() == 1 { "Label\tfilename" } else { "Label\tfilename\ttime" };
let mut d = Document::from_text(text);
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| {
p.tabs = Some(stops.iter().map(|(pos, align)| TabStop { pos: *pos, align: *align, leader: TabLeader::None }).collect());
})
.unwrap();
let left = lay(&d);
let end = left.caret(&Pos::body(0, text.len())).unwrap().x;
let slack = 540.0 - end;
for (align, fraction) in [(Align::Center, 0.5), (Align::Right, 1.0)] {
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.align = Some(align)).unwrap();
let aligned = lay(&d);
for off in [0, 6, text.len()] {
let before = left.caret(&Pos::body(0, off)).unwrap().x;
let after = aligned.caret(&Pos::body(0, off)).unwrap().x;
assert!((after - before - slack * fraction).abs() < 0.1, "{align:?} {stops:?} offset {off}: {before} -> {after}, slack {slack}");
}
}
}
}
/// A paragraph `before` + positional tab + `after`, with left and right indents and a custom
/// left tab stop at 50 pt that the positional tab must ignore.
fn ptab_doc(before: &str, after: &str, ptab: InlineObject, indents: (f32, f32)) -> Document {
@@ -2235,6 +2267,162 @@ fn turned_up_cell_text_reads_bottom_to_top() {
assert_eq!((hit.path, hit.off), (Path(vec![1, 0, 1, 0]), 0));
}
/// #2038 with turned cell text: a click just outside a floating table, in the 2pt around turned
/// text that still takes a click, goes to the text beside or after the table, not into a cell; a
/// click on the table's edge still lands in one. A table that doesn't float keeps such a click.
#[test]
fn a_click_just_outside_a_floating_table_skips_its_turned_cell_text() {
use TextDirection::{Down, Horizontal, Up};
use wordcraft_doc::para::Anchor;
use wordcraft_doc::props::TableFloat;
// (the section's direction, the cells' direction, whether the table floats)
let cases = [
(Horizontal, Down, true),
(Horizontal, Up, true),
(Down, Horizontal, true),
(Up, Horizontal, true),
(Horizontal, Down, false),
(Horizontal, Up, false),
];
for (section, cells, floats) in cases {
let case = format!("section {section:?}, cells {cells:?}, floating {floats}");
let mut d = Document::from_text("before\nafter");
d.last_section.text_direction = section;
let mut t = Table::new(2, 2, 200.0);
for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
c.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Turned", Default::default()))];
c.props.text_direction = cells;
}
if floats {
t.props.float = Some(TableFloat {
h_rel: Anchor::Column,
v_rel: Anchor::Paragraph,
x: 150.0,
dist: [9.0, 0.0, 9.0, 0.0],
overlap: true,
..Default::default()
});
}
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let l = lay(&d);
let page = &l.pages[0];
assert_eq!(page.float_tables.len(), usize::from(floats), "{case}");
let area = page
.items
.iter()
.filter_map(|it| if let Placed::Cell { rect, .. } = it { Some(*rect) } else { None })
.reduce(|a, b| a.union(&b))
.unwrap();
let turned: Vec<Rect> = page
.items
.iter()
.filter_map(|it| match it {
Placed::Lines { story: StoryRef::Body, path, .. } if path.0.len() > 1 => it.turned_bounds(),
_ => None,
})
.collect();
assert!(!turned.is_empty(), "{case}: the cells' text is turned");
// Points on each side of the table (on its edge, and 1 and 2pt outside) across from turned
// cell text, which takes a click up to 2pt from its bounds.
let near = |p: &Point| turned.iter().any(|r| r.expand(2.0).contains(*p));
let side = |r: &Rect, out: f32| {
let (cx, cy) = (r.x + r.w / 2.0, r.y + r.h / 2.0);
[Point::new(cx, area.y - out), Point::new(cx, area.bottom() + out), Point::new(area.x - out, cy), Point::new(area.right() + out, cy)]
};
let outside: Vec<Point> = turned.iter().flat_map(|r| [side(r, 1.0), side(r, 2.0)]).flatten().filter(near).collect();
let edge: Vec<Point> = turned.iter().flat_map(|r| side(r, 0.0)).filter(near).collect();
assert!(!outside.is_empty() && !edge.is_empty(), "{case}: turned text within 2pt of the edge {area:?}: {turned:?}");
for p in outside {
let hit = l.hit(0, p.x, p.y, StoryRef::Body).unwrap();
assert_eq!(hit.path.0.len() == 1, floats, "{case}: a click at {p:?} landed in {:?}", hit.path);
}
for p in edge {
let hit = l.hit(0, p.x, p.y, StoryRef::Body).unwrap();
assert!(hit.path.0.len() > 1, "{case}: a click on the edge at {p:?} landed in {:?}", hit.path);
}
}
}
/// The same rule when nothing upright but a floating table's cells is on the page (the page's
/// paragraphs taken off the laid-out page): a click outside the table goes to other turned text
/// there, and one by turned text of the table's own still lands in that cell, not in the nearest
/// upright one.
#[test]
fn a_click_by_a_floating_table_with_no_upright_text_beside_it_prefers_turned_text() {
use wordcraft_doc::para::Anchor;
use wordcraft_doc::props::TableFloat;
let table = |dirs: [TextDirection; 2], floats: bool| {
let mut t = Table::new(1, 2, 200.0);
for (c, dir) in t.rows[0].cells.iter_mut().zip(dirs) {
c.blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("Cell text", Default::default()))];
c.props.text_direction = dir;
}
if floats {
t.props.float = Some(TableFloat {
h_rel: Anchor::Column,
v_rel: Anchor::Paragraph,
x: 150.0,
dist: [9.0, 0.0, 9.0, 0.0],
overlap: true,
..Default::default()
});
}
wordcraft_doc::Block::Table(t)
};
let lay_bare = |d: &Document| {
let mut l = lay(d);
l.pages[0].items.retain(|it| !matches!(it, Placed::Lines { path, .. } if path.0.len() == 1));
l
};
let cell_area = |l: &DocLayout, t: u32| {
l.pages[0]
.items
.iter()
.filter_map(|it| match it {
Placed::Cell { rect, table, .. } if table.0 == [t] => Some(*rect),
_ => None,
})
.reduce(|a, b| a.union(&b))
.unwrap()
};
// A floating table with an upright cell and a turned one: a click 1pt above the turned
// text lands in it, as it did before the floating-table rule.
let mut d = Document::from_text("before\nafter");
d.insert_block(StoryRef::Body, &Path::top(1), table([TextDirection::Horizontal, TextDirection::Down], true)).unwrap();
let l = lay_bare(&d);
assert_eq!(l.pages[0].float_tables, [Path::top(1)]);
let area = cell_area(&l, 1);
let turned = turned_lines(&l).turned_bounds().unwrap();
let p = Point::new(turned.x + turned.w / 2.0, area.y - 1.0);
assert!(turned.expand(2.0).contains(p) && !area.contains(p), "{p:?} {turned:?} {area:?}");
assert_eq!(l.hit(0, p.x, p.y, StoryRef::Body).unwrap().path, Path(vec![1, 0, 1, 0]));
// An upright floating table and, below it, a table in the text flow with turned cells: a
// click below the floating one goes to that turned text, not to the floating table.
let mut d = Document::from_text("before\nbeside\nbeside\nbeside\nafter");
d.insert_block(StoryRef::Body, &Path::top(1), table([TextDirection::Horizontal; 2], true)).unwrap();
d.insert_block(StoryRef::Body, &Path::top(5), table([TextDirection::Down; 2], false)).unwrap();
let l = lay_bare(&d);
assert_eq!(l.pages[0].float_tables, [Path::top(1)]);
let (area, flow) = (cell_area(&l, 1), cell_area(&l, 5));
assert!(flow.y > area.bottom() + 12.0, "{area:?} {flow:?}");
let hit = l.hit(0, area.x + area.w / 2.0, area.bottom() + 6.0, StoryRef::Body).unwrap();
assert_eq!(hit.path.0.first(), Some(&5), "{hit:?}");
// Both: a click 1pt above the floating table's turned cell goes to the other turned text,
// which comes before the table's own.
let mut d = Document::from_text(&format!("before\n{}after", "beside\n".repeat(6)));
d.insert_block(StoryRef::Body, &Path::top(1), table([TextDirection::Horizontal, TextDirection::Down], true)).unwrap();
d.insert_block(StoryRef::Body, &Path::top(8), table([TextDirection::Down; 2], false)).unwrap();
let l = lay_bare(&d);
assert_eq!(l.pages[0].float_tables, [Path::top(1)]);
let (area, flow) = (cell_area(&l, 1), cell_area(&l, 8));
assert!(flow.y > area.bottom() + 12.0, "{area:?} {flow:?}");
let turned = turned_lines(&l).turned_bounds().unwrap();
let p = Point::new(turned.x + turned.w / 2.0, area.y - 1.0);
assert!(turned.expand(2.0).contains(p) && !area.contains(p), "{p:?} {turned:?} {area:?}");
let hit = l.hit(0, p.x, p.y, StoryRef::Body).unwrap();
assert_eq!(hit.path.0.first(), Some(&8), "{hit:?}");
}
#[test]
fn turned_text_wraps_in_an_exact_row() {
let d = turned_table(TextDirection::Down, &"word ".repeat(30), Some(72.0));
@@ -5843,6 +6031,110 @@ fn floats_in_headers_and_table_cells_are_drawn() {
assert!(c[0].x > 72.0 && c[0].x < 72.0 + 234.0, "inside the first cell: {:?}", c[0]);
}
/// A float laid out in its cell (`layoutInCell`) whose offset would take it out of the cell is
/// moved in to the cell's outer side (a letterhead logo anchored left of its header cell). One laid out on the page (`layoutInCell="0"`) keeps its offset.
#[test]
fn cell_floats_laid_out_in_the_cell_stay_in_it() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let cell_w = 234.0;
let place = |x: f32, outside_cell: bool, wrap: Wrap| {
let mut d = Document::from_text("Body text.");
let mut t = Table::new(1, 2, 468.0);
let mut cp = wordcraft_doc::Paragraph::with_text("cell", Default::default());
let f = Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, dist: 9.0, outside_cell, ..Default::default() };
cp.insert_object(0, rect_shape(80.0, 48.0, f), &Default::default()).unwrap();
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(cp)];
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let c = shapes(&lay(&d).pages[0].items);
assert_eq!(c.len(), 1, "cell float drawn");
c[0].x
};
// An offset inside the cell is kept: the cell's text starts 20 pt left of this one.
let text_left = place(20.0, false, Wrap::Tight) - 20.0;
assert!(text_left > 72.0 && text_left < 72.0 + 12.0, "the cell's text starts past its margin: {text_left}");
let cell_left = text_left - 5.4;
// Anchored 95 pt left of the column: moved in to the cell's outer left side.
let left = place(-95.0, false, Wrap::Tight);
assert!((left - cell_left).abs() < 0.5, "pulled in to the left side: {left} vs {text_left}");
// Past the right side: moved in to it.
let right = place(cell_w + 40.0, false, Wrap::Tight);
// (within a point: the cell's border room is not split evenly between its two sides)
assert!((right - (cell_left + cell_w - 80.0)).abs() < 1.0, "pulled in to the right side: {right}");
// Not laid out in the cell: the offset is kept, off the cell or not.
let out = place(-95.0, true, Wrap::Tight);
assert!((out - (text_left - 95.0)).abs() < 0.5, "layoutInCell=0 keeps the offset: {out}");
// Front-of-text shapes do not wrap and keep their offsets even with layoutInCell on.
for offset in [-95.0, cell_w + 40.0] {
let front = place(offset, false, Wrap::InFrontOfText);
assert!((front - (text_left + offset)).abs() < 0.5, "front-of-text offset preserved: {front}");
}
}
/// Cell confinement uses the finite, nonnegative width that float placement actually draws.
#[test]
fn cell_float_bounds_use_the_placed_width() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let place = |width, x, wrap, outside_cell| {
let mut d = Document::from_text("Body");
let mut t = Table::new(1, 2, 468.0);
let mut p = wordcraft_doc::Paragraph::with_text("cell", Default::default());
let f = Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, dist: 9.0, outside_cell, ..Default::default() };
p.insert_object(0, rect_shape(width, 48.0, f), &Default::default()).unwrap();
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)];
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
let objects = shapes(&lay(&d).pages[0].items);
assert_eq!(objects.len(), 1);
objects[0]
};
// Invalid stored widths are placed as zero-width objects; offsets at either side must
// match an explicitly zero-width object, rather than inventing extra room in the cell.
for offset in [-95.0, 300.0] {
let zero = place(0.0, offset, Wrap::Tight, false);
for stored in [-100.0, f32::NEG_INFINITY, f32::NAN, f32::INFINITY] {
let placed = place(stored, offset, Wrap::Tight, false);
assert_eq!(placed.w, 0.0);
assert!((placed.x - zero.x).abs() < 0.01, "stored width {stored}, offset {offset}: {placed:?} vs {zero:?}");
}
// Neither opt-out path gains confinement from the width normalization.
for (wrap, outside_cell) in [(Wrap::InFrontOfText, false), (Wrap::Tight, true)] {
let zero = place(0.0, offset, wrap, outside_cell);
let negative = place(-100.0, offset, wrap, outside_cell);
assert_eq!(negative.w, zero.w);
assert!((negative.x - zero.x).abs() < 0.01);
}
}
}
#[test]
fn keep_in_cell_bounds() {
use super::boxes::keep_in_cell;
assert_eq!(keep_in_cell(-95.0, 80.0, 0.0, 234.0), 0.0);
assert_eq!(keep_in_cell(300.0, 80.0, 0.0, 234.0), 234.0 - 80.0);
assert_eq!(keep_in_cell(20.0, 80.0, 0.0, 234.0), 20.0);
// Wider than the cell, or exactly its width: starts at the outer left side.
assert_eq!(keep_in_cell(50.0, 300.0, 0.0, 234.0), 0.0);
assert_eq!(keep_in_cell(50.0, 234.0, 0.0, 234.0), 0.0);
// Nearly full: no clearance is added to the object's position.
assert_eq!(keep_in_cell(-95.0, 230.0, 0.0, 234.0), 0.0);
assert_eq!(keep_in_cell(300.0, 230.0, 0.0, 234.0), 4.0);
// Cell edges include its margins; negative offsets can be wholly inside the cell.
assert_eq!(keep_in_cell(-5.0, 80.0, 0.0, 234.0), 0.0);
assert_eq!(keep_in_cell(-5.0, 80.0, -4.0, 234.0), -4.0);
assert_eq!(keep_in_cell(-5.15, 63.75, -5.4, 448.2), -5.15);
assert_eq!(keep_in_cell(395.95, 47.25, -5.4, 448.2), 395.95);
// Clamp limits stay finite and ordered even at small scales or with malformed geometry.
for width in [0.1_f32, 0.3, 1.0, 234.0] {
let object = width * 0.98;
for offset in [-width, 0.0, width, f32::NAN, f32::INFINITY] {
let x = keep_in_cell(offset, object, 0.0, width);
assert!(x.is_finite() && x >= 0.0 && x + object <= width + f32::EPSILON * width.max(1.0));
}
}
assert_eq!(keep_in_cell(10.0, 80.0, 0.0, f32::NAN), 0.0);
assert_eq!(keep_in_cell(10.0, f32::NAN, 0.0, 234.0), 0.0);
assert_eq!(keep_in_cell(10.0, 80.0, f32::NAN, 234.0), 10.0);
}
/// Paragraph anchors exclude space-before; line and frameless page/margin anchors retain it.
#[test]
fn box_float_origins_respect_vertical_anchor_with_space_before() {
@@ -13435,3 +13727,29 @@ fn a_table_moved_by_a_page_break_shows_its_new_page_number() {
.collect();
assert_eq!(shown, ["2"]);
}
/// Cell confinement must not change page-vertical placement through the rotated text axis.
#[test]
fn turned_cell_float_keeps_its_along_text_offset() {
use wordcraft_doc::para::{Anchor, Float, Wrap};
let place = |direction, outside_cell| {
let mut doc = Document::from_text("Synthetic body");
let mut table = Table::new(1, 2, 468.0);
table.rows[0].props.height = Some(100.0);
table.rows[0].props.height_rule = wordcraft_doc::props::HeightRule::Exact;
table.rows[0].cells[0].props.text_direction = direction;
let mut para = Paragraph::with_text("Synthetic cell", Default::default());
let float = Float { wrap: Wrap::Tight, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 150.0, outside_cell, ..Default::default() };
para.insert_object(0, rect_shape(20.0, 10.0, float), &Default::default()).unwrap();
table.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(para)];
doc.insert_block(StoryRef::Body, &Path::top(1), Block::Table(table)).unwrap();
let objects = shapes(&lay(&doc).pages[0].items);
assert_eq!(objects.len(), 1);
objects[0]
};
for direction in [TextDirection::Down, TextDirection::Up] {
let confined = place(direction, false);
let reference = place(direction, true);
assert!((confined.y - reference.y).abs() < 0.01, "{direction:?}: {confined:?} vs {reference:?}");
}
}
+130
View File
@@ -0,0 +1,130 @@
//! Synthetic letterhead geometry. No external documents or image assets are used.
use super::*;
use wordcraft_doc::para::{Anchor, Float, Wrap};
use wordcraft_doc::props::VAlign;
use wordcraft_doc::{Paragraph, PartKind};
fn picture(name: &str, w: f32, h: f32, x: f32, y: f32, wrap: Wrap, dist: f32) -> InlineObject {
InlineObject::Image {
media: name.into(),
w,
h,
alt: name.into(),
float: Float { wrap, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x, y, dist, ..Default::default() },
crop: [0.0; 4],
fx: Default::default(),
ole: None,
}
}
fn cell_rect(items: &[Placed]) -> Rect {
items.iter().find_map(|p| if let Placed::Cell { rect, .. } = p { Some(*rect) } else { None }).unwrap()
}
/// A body-table letterhead: a central front-of-text box and two pictures close to the cell's
/// outer edges. Both picture boxes fit; neither their offsets nor the box may move inward.
#[test]
fn cell_letterhead_keeps_picture_offsets_inside_the_outer_edges() {
let mut d = Document::from_text("Synthetic body");
d.last_section.page_w = 612.0;
d.last_section.page_h = 792.0;
d.last_section.margin_left = 70.9;
d.last_section.margin_right = 31.5;
d.last_section.margin_top = 31.2;
let mut t = Table::new(1, 1, 453.6);
t.props.style = None;
t.props.width = Some(453.6);
t.props.indent = Some(21.05);
let border = Some(Border { style: BorderStyle::Single, width: 0.5, ..Default::default() });
t.props.borders = Some(Borders { top: border, left: border, bottom: border, right: border, ..Default::default() });
t.rows[0].props.height = Some(63.75);
let mut p = Paragraph::default();
let box_story = d.add_part(PartKind::TextBox, vec![wordcraft_doc::para_block(Paragraph::with_text("Synthetic letterhead", Default::default()))]);
let mut central = rect_shape(332.25, 52.5, Float { wrap: Wrap::InFrontOfText, x: 59.9, y: 5.8, dist: 9.0, ..Default::default() });
if let InlineObject::Shape { story, .. } = &mut central {
*story = Some(box_story);
}
p.insert_object(0, central, &Default::default()).unwrap();
p.insert_object(0, picture("right", 47.25, 61.6, 395.95, 1.4, Wrap::Through, 9.0), &Default::default()).unwrap();
p.insert_object(0, picture("left", 63.75, 56.25, -5.15, 2.0, Wrap::Through, 9.0), &Default::default()).unwrap();
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)];
d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap();
let layout = lay(&d);
let items = &layout.pages[0].items;
let cell = cell_rect(items);
let mut checked = 0;
for item in items {
if let Placed::Object { rect, origin, text_box, .. } = item {
let (x, y) = if text_box.is_some() {
(59.9, 5.8)
} else if rect.w > 60.0 {
(-5.15, 2.0)
} else {
(395.95, 1.4)
};
assert!(rect.x >= cell.x - 0.01 && rect.right() <= cell.right() + 0.01, "the original box fits the outer cell: {rect:?}, {cell:?}");
assert!((rect.x - origin.x - x).abs() < 0.5, "offset {x} was moved: {rect:?}, origin {origin:?}");
assert!((rect.y - origin.y - y).abs() < 0.01, "vertical placement is unchanged");
checked += 1;
}
}
assert_eq!(checked, 3, "two pictures and one central text box");
}
/// A centred header table wider than the text column, with a picture anchored far to its left.
/// The picture touches the outer cell edge, not the text inset or the wrap-distance boundary.
#[test]
fn cell_letterhead_pulls_an_outside_picture_to_the_outer_left_edge() {
for dist in [0.0, 9.0, 24.0] {
let mut d = Document::from_text("Synthetic body");
d.last_section.page_w = 612.0;
d.last_section.page_h = 792.0;
d.last_section.margin_left = 70.85;
d.last_section.margin_right = 70.85;
d.last_section.header = 5.65;
let mut t = Table::new(1, 1, 498.05);
t.props.style = None;
t.props.width_pct = Some(105.9);
t.props.align = Some(Align::Center);
t.rows[0].props.height = Some(67.95);
t.rows[0].cells[0].props.valign = Some(VAlign::Center);
let mut p = Paragraph::with_text("Synthetic header", Default::default());
p.insert_object(0, picture("logo", 79.0, 47.55, -94.7, 2.3, Wrap::BehindText, dist), &Default::default()).unwrap();
t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(p)];
let header = d.add_part(PartKind::Header, vec![Arc::new(Block::Table(t))]);
d.last_section.headers.default = Some(header);
let layout = lay(&d);
let items = &layout.pages[0].header;
let cell = cell_rect(items);
assert!((cell.x - 56.976).abs() < 0.5, "the centred outer cell begins at {cell:?}");
let objects: Vec<_> =
items.iter().filter_map(|p| if let Placed::Object { rect, origin, .. } = p { Some((*rect, *origin)) } else { None }).collect();
assert_eq!(objects.len(), 1);
let (rect, origin) = objects[0];
assert!((rect.x - cell.x).abs() < 0.5, "wrap distance {dist}: picture {rect:?}, outer cell {cell:?}");
assert!((rect.y - origin.y - 2.3).abs() < 0.01, "vertical placement is unchanged");
}
}
/// Confinement follows the particular cell, including asymmetric margins mirrored for RTL.
#[test]
fn cell_float_outer_edges_include_asymmetric_and_mirrored_margins() {
for rtl in [false, true] {
for (offset, at_left) in [(-100.0, true), (300.0, false)] {
let mut d = Document::from_text("Body");
let mut t = Table::new(1, 2, 468.0);
t.props.bidi_visual = rtl;
t.props.cell_margins = Some([0.0, 12.0, 0.0, 3.0]);
let mut p = Paragraph::default();
p.insert_object(0, picture("logo", 40.0, 20.0, offset, 2.0, Wrap::BehindText, 9.0), &Default::default()).unwrap();
t.rows[0].cells[1].blocks = vec![wordcraft_doc::para_block(p)];
d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap();
let layout = lay(&d);
let items = &layout.pages[0].items;
let cell = items.iter().find_map(|p| if let Placed::Cell { rect, cell: 1, .. } = p { Some(*rect) } else { None }).unwrap();
let picture = items.iter().find_map(|p| if let Placed::Image { rect, .. } = p { Some(*rect) } else { None }).unwrap();
let edge = if at_left { picture.x - cell.x } else { picture.right() - cell.right() };
assert!(edge.abs() < 0.5, "rtl {rtl}, offset {offset}: picture {picture:?}, cell {cell:?}");
}
}
}
+4 -2
View File
@@ -54,7 +54,7 @@ impl WordApp {
sent_full_screen,
seen_full_screen,
escape_free,
word_count,
status_counts,
missing_fonts,
last_autosave,
equation_prev_tab,
@@ -169,7 +169,7 @@ impl WordApp {
sent_full_screen,
seen_full_screen,
escape_free,
word_count,
status_counts,
missing_fonts,
last_autosave,
equation_prev_tab,
@@ -443,6 +443,8 @@ impl WordApp {
// Keytips are read before layout so the frame paints the new state; badges paint after
// the ribbon so they sit on top of it.
crate::keytips::logic(self, &ctx);
// Alt codes in a text box are read before the box is drawn, so it types the character.
crate::keys::field_alt_codes(self, &ctx);
// A failed Recent open, or a failed Save As, is news only until File closes (#2938).
if !self.ui.backstage {
backstage::forget_open_error(&ctx);
+25
View File
@@ -658,6 +658,17 @@ impl WordApp {
self.read_system_animations();
json!({"animations": self.ui.animations.code(), "enabled": self.animations_on(), "system": self.system_animations})
}
"ui.statusCount" => {
// `words` (the default), `characters` or `charactersNoSpaces`: what the status
// bar's live count shows; no value reads it (#2797).
if let Some(v) = s("value") {
match crate::StatusCount::from_code(v) {
Some(c) => self.ui.status_count = c,
None => return Some(Err(format!("unknown status count {v:?}; use words, characters or charactersNoSpaces"))),
}
}
json!({"statusCount": self.ui.status_count.code()})
}
"ui.language" => {
// `auto` (follow the system) or a language code; anything else is an error.
if let Some(v) = s("value") {
@@ -779,6 +790,18 @@ impl WordApp {
}
json!({"startScreen": self.ui.start_screen})
}
"ui.displayServer" => {
// File › Options › Advanced › Linux display server (#2184): `value` `auto`,
// `wayland` or `x11`; no value reads it. The desktop app reads it at the next launch.
match p.get("value") {
None => {}
Some(v) => match v.as_str().and_then(crate::DisplayServer::from_code) {
Some(d) => self.ui.display_server = d,
None => return Some(Err(format!("unknown display server {v}; use auto, wayland or x11"))),
},
}
json!({"displayServer": self.ui.display_server.code()})
}
// Shape Format › Text › Create Link: the next click on an empty text box links the
// selected box to it (`shape.link` with that box as the target); Esc cancels.
// Picture Format › Size › Crop: crop mode on the selected picture, or off (`crop`).
@@ -1036,6 +1059,8 @@ pub(crate) fn works_without_document(id: &str) -> bool {
| "ui.discord"
| "ui.caretAnimation"
| "ui.startScreen"
| "ui.displayServer"
| "ui.statusCount"
| "ui.checkForUpdates"
) || id.starts_with("help.")
}
+80 -4
View File
@@ -257,6 +257,8 @@ pub struct CanvasState {
/// The character an Alt code just typed: if the platform also sends its own composed
/// character as text on the Alt release (Windows does), it isn't typed twice.
pub(crate) alt_code_echo: Option<crate::keys::AltCodeEcho>,
/// Alt+numpad entry in the focused text box, while the page doesn't have the keyboard (#4403).
pub(crate) field_alt_code: crate::keys::FieldAltCode,
/// Create Link is waiting for a click on the text box to link to: the linking box's object.
pub(crate) link_from: Option<wordcraft_doc::Pos>,
/// What screen readers were last told about the document (#488).
@@ -272,15 +274,23 @@ pub struct CanvasState {
/// The rows and columns hovered in Insert › Table's grid this frame: the page shows that
/// table where it would go (#3931). Set by the grid each frame, taken by [`show`].
pub(crate) table_preview: Option<(usize, usize)>,
/// A command (id, params) the page shows run on the selection, without running it: the
/// formatting button the floating mini toolbar is hovered over. Set each frame, taken by [`show`].
pub(crate) command_preview: Option<(String, serde_json::Value)>,
/// The command preview the page last drew, so the bar repaints only when its hover changes.
pub(crate) command_previewed: Option<(String, serde_json::Value)>,
/// The scroll offset held while a command preview is shown, so the page does not move as
/// the previewed text reflows.
pub(crate) preview_scroll: Option<egui::Vec2>,
/// The last previewed table's layout, the copy of the document it was laid out from and the
/// copy's selection, with
/// what it was laid out for (rows, columns, document id, revision, selection, and the
/// what it was laid out for (command, params, document id, revision, selection, and the
/// document's own layout, which a view change replaces), so a still pointer doesn't lay the
/// document out every frame. Set only while the page shows the preview.
table_preview_layout: Option<(TablePreviewKey, wordcraft_doc::Document, wordcraft_engine::Selection, std::sync::Arc<DocLayout>)>,
}
type TablePreviewKey = ((usize, usize, u64, u64, wordcraft_engine::Selection), std::sync::Arc<DocLayout>);
type TablePreviewKey = ((String, serde_json::Value, u64, u64, wordcraft_engine::Selection), std::sync::Arc<DocLayout>);
impl CanvasState {
/// Another document is shown in the window: everything about the page that was shown (drags,
@@ -403,6 +413,7 @@ impl Default for CanvasState {
ime_viewport: None,
alt_code: String::new(),
alt_code_echo: None,
field_alt_code: Default::default(),
wheel: Default::default(),
auto_scroll: Default::default(),
scroll_offset: egui::Vec2::ZERO,
@@ -425,6 +436,9 @@ impl Default for CanvasState {
tab_drag: None,
drawn_ibeam: crate::ibeam::DRAWN,
table_preview: None,
command_preview: None,
command_previewed: None,
preview_scroll: None,
table_preview_layout: None,
}
}
@@ -1074,6 +1088,23 @@ fn handle_primary_pointer(
geo: &Geometry,
middle_gesture: bool,
) {
// A press that began on a drop-down's list button opens the list (`content_controls::clicks`) and must
// not move the caret: the button hangs over what follows the control, and a caret placed
// there leaves the control, which closes the list in the frame it opens (#5389).
// A press and its release in one frame (a quick click on a slow frame) leave no `press_origin`;
// the press is still among the frame's events. A table tool owns the pointer instead: a stroke
// may start over the button.
let press = ui.input(|i| {
i.pointer.press_origin().or_else(|| {
i.events.iter().find_map(|e| match e {
egui::Event::PointerButton { pos, pressed: true, .. } => Some(*pos),
_ => None,
})
})
});
if app.canvas.table_tool.is_none() && press.is_some_and(|p| app.controls.on_button(p)) {
return;
}
// Double-click the space between two pages (or the line between them) to hide or show it.
if resp.double_clicked()
&& app.session.view.mode == wordcraft_layout::ViewMode::Print
@@ -1307,12 +1338,53 @@ fn finish_canvas_frame(finish: CanvasFinish<'_>, out: egui::scroll_area::ScrollA
/// Lays out and paints the document pages for print, Read and Web views, settles viewports, draw
/// rulers and overlays, anchors zoom and scrolling, and routes pointer, selection, IME and caret
/// input.
/// While the page shows a command preview the view stays where it was when the preview began:
/// no scrolling to the caret, and the offset is held against the previewed text's reflow.
fn hold_scroll_for_preview(app: &mut WordApp) -> bool {
if app.canvas.command_previewed.is_none() {
app.canvas.preview_scroll = None;
return false;
}
let at = *app.canvas.preview_scroll.get_or_insert(app.canvas.scroll_offset);
app.canvas.scroll_request = Some(at);
true
}
/// The live selection's rects on screen, from the document's own layout rather than a command
/// preview's, which can move the lines (Align Right) from under the mini toolbar's pointer.
fn live_selection_screen_rects(app: &mut WordApp, rects: &[Rect], scale: f32) -> Vec<Rect> {
let layout = app.session.layout();
let selected: Vec<_> = if crate::objects::selected(app).is_some() {
Vec::new()
} else if let Some(range) = app.session.cell_range() {
wordcraft_engine::cmd::table_mouse::cell_rects(&layout, &app.session.doc, range)
} else if !app.session.sel.is_collapsed() {
match app.session.column_segments() {
Some(segs) => segs.iter().flat_map(|(a, b)| layout.selection_rects(&app.session.doc, a, b, app.session.page_hint)).collect(),
None => {
let (a, b) = app.session.sel.ordered();
wordcraft_engine::cmd::table_mouse::selection_rects(&layout, &app.session.doc, &a, &b, app.session.page_hint)
}
}
} else {
Vec::new()
};
selected
.into_iter()
.filter_map(|(pi, r)| {
let pr = rects.get(pi)?;
let scale = layout.pages.get(pi).map_or(scale, |page| page_screen_scale(*pr, page, scale));
Some(Rect::from_min_size(pos2(pr.min.x + r.x * scale, pr.min.y + r.y * scale), vec2(r.w * scale, r.h * scale)))
})
.collect()
}
pub fn show(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let full = ui.available_rect_before_wrap();
let layout = prepare_layout(app, ui, full);
let view = prepare_view(app, ui, full, &layout);
let scroll_target = update_caret_scroll(app, &view);
let scroll_target = if hold_scroll_for_preview(app) { None } else { update_caret_scroll(app, &view) };
let motion = scroll_motion(app, ui, full, &view);
let scroll_area = configure_scroll_area(app, ui, &view, motion);
let CanvasView { area, hruler, vruler, paper, geo, dim_body, grid_story, caret, .. } = view;
@@ -1429,7 +1501,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
// Anchor only to visible selection geometry. A selection can span pages, and its
// document-first line may be scrolled offscreen; using it would pin the toolbar to an
// old screen position unrelated to the visible selection.
app.canvas.mini_anchor = crate::mini_toolbar::visible_anchor(screen_rects, visible_clip);
// Alignment previews can move the selected text while the pointer is still over a mini
// toolbar button. Keep the bar anchored to the live selection until the preview ends, or
// it can move out from under the pointer and flicker between preview and live layouts.
let anchor_rects = if app.canvas.command_previewed.is_some() { live_selection_screen_rects(app, &rects, geo.scale) } else { screen_rects };
app.canvas.mini_anchor = crate::mini_toolbar::visible_anchor(anchor_rects, visible_clip);
// Content controls: the frame of the one the caret is in (all of them in Design Mode).
crate::content_controls::paint(app, &painter, &t, &layout, &rects, geo.scale);
paint_read_aloud_highlight(app, &painter, &layout, &rects, &geo, &t);
+6 -3
View File
@@ -184,20 +184,23 @@ pub(super) fn drawing_grid_step(spacing: f32, scale: f32) -> f32 {
/// hovering over (#3931). The preview is laid out on a copy, so the document and its undo history
/// change only when a square is clicked.
pub(super) fn shown_layout(app: &mut WordApp) -> std::sync::Arc<DocLayout> {
let Some((rows, cols)) = app.canvas.table_preview.take() else {
let table = app.canvas.table_preview.take().map(|(rows, cols)| ("insert.table".to_string(), json!({"rows": rows, "cols": cols})));
let command = app.canvas.command_preview.take();
app.canvas.command_previewed = command.clone().filter(|_| table.is_none());
let Some((id, params)) = table.or(command) else {
app.canvas.table_preview_layout = None;
return app.session.layout();
};
// The caret can move, or the view change, with the grid still hovered: the table then goes
// somewhere else, or the page is laid out to another width.
let key = ((rows, cols, app.session.document_id(), app.session.rev(), app.session.sel.clone()), app.session.layout());
let key = ((id.clone(), params.clone(), app.session.document_id(), app.session.rev(), app.session.sel.clone()), app.session.layout());
if let Some((k, _, _, l)) = &app.canvas.table_preview_layout
&& k.0 == key.0
&& std::sync::Arc::ptr_eq(&k.1, &key.1)
{
return l.clone();
}
match app.session.preview_layout("insert.table", &json!({"rows": rows, "cols": cols})) {
match app.session.preview_layout(&id, &params) {
Some((doc, sel, l)) => {
app.canvas.table_preview_layout = Some((key, doc, sel, l.clone()));
l
+55 -12
View File
@@ -5,7 +5,7 @@ use serde_json::json;
use wordcraft_doc::{PartKind, StoryRef};
use crate::theme::{Tokens, TypeRung, fit_text, medium, paint_text, regular, semibold, text_width};
use crate::{WordApp, icons};
use crate::{StatusCount, StatusCounts, WordApp, icons};
/// Why the caret's language isn't spell-checked (dictionary unreadable or missing), if it isn't.
pub(crate) fn dictionary_problem(lang: &str, unreadable: &Option<(std::path::PathBuf, String)>, missing: Option<&str>) -> Option<String> {
@@ -627,15 +627,29 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
{
let _ = app.run("ui.dialog", json!({"name": "goto"}));
}
let words = app.cached_word_count();
// The live count: words (the default, as Word's status bar counts), characters
// with spaces, or characters without spaces — whichever the person chose (#2797).
let unit = app.ui.status_count;
let counts = app.cached_status_counts();
// A selected picture/shape/text box isn't a text selection: show the total.
let wtxt = if app.session.sel.is_collapsed() || crate::objects::selected(app).is_some() {
crate::i18n::fmt(tl!("{words} words"), &[("words", &words.to_string())])
status_count_text(unit, counts.in_unit(unit), None)
} else {
let sw = wordcraft_doc::count_words(&app.session.selected_text());
crate::i18n::fmt(tl!("{selected} of {words} words"), &[("selected", &sw.to_string()), ("words", &words.to_string())])
let selected = crate::count_in_unit(&app.session.selected_text(), unit);
status_count_text(unit, counts.in_unit(unit), Some(selected))
};
if st(ui, &wtxt).clicked() {
let counted = st(ui, &wtxt);
// Right-click, as Word's status bar offers: choose what the count shows (#2797).
counted.context_menu(|ui| {
ui.set_min_width(180.0);
for c in crate::StatusCount::ALL {
if ui.selectable_label(app.ui.status_count == c, tl!(c.label())).clicked() {
let _ = app.run("ui.statusCount", json!({"value": c.code()}));
ui.close();
}
}
});
if counted.clicked() {
let _ = app.run("ui.dialog", json!({"name": "wordCount"}));
}
if app.session.sel.focus.story != StoryRef::Body && !crate::canvas::in_text_box(app) {
@@ -903,14 +917,43 @@ impl WordApp {
wordcraft_engine::cmd::font::warns(font, &shown).then_some(shown)
}
/// Word count, recomputed only when the document changes.
pub fn cached_word_count(&mut self) -> usize {
// The count option changes the count without a document change.
/// The document's counts, recomputed only when the document (or the count option) changes:
/// words as the status bar and the Word Count dialog count them, and characters with and
/// without spaces as the dialog does (#2797).
pub fn cached_status_counts(&mut self) -> StatusCounts {
// The count option changes the counts without a document change.
let key = self.session.rev().wrapping_mul(2) | u64::from(self.session.prefs.count_notes);
if self.word_count.0 != key {
self.word_count = (key, self.session.word_count());
if self.status_counts.0 != key {
let text = self.session.counted_text();
self.status_counts = (
key,
StatusCounts {
words: self.session.word_count(),
chars: wordcraft_doc::count_chars(&text),
chars_no_spaces: wordcraft_doc::count_chars_no_spaces(&text),
},
);
}
self.status_counts.1
}
}
/// The status bar's count line (#2797): the whole document's `total`, or "selected of total" when
/// part of it is selected, worded as the Word Count dialog words the same counts.
pub(crate) fn status_count_text(unit: StatusCount, total: usize, selected: Option<usize>) -> String {
match (unit, selected) {
(StatusCount::Words, None) => crate::i18n::fmt(tl!("{words} words"), &[("words", &total.to_string())]),
(StatusCount::Words, Some(n)) => {
crate::i18n::fmt(tl!("{selected} of {words} words"), &[("selected", &n.to_string()), ("words", &total.to_string())])
}
(StatusCount::Characters, None) => crate::i18n::fmt(tl!("{chars} characters"), &[("chars", &total.to_string())]),
(StatusCount::Characters, Some(n)) => {
crate::i18n::fmt(tl!("{selected} of {chars} characters"), &[("selected", &n.to_string()), ("chars", &total.to_string())])
}
(StatusCount::CharactersNoSpaces, None) => crate::i18n::fmt(tl!("{chars} characters (no spaces)"), &[("chars", &total.to_string())]),
(StatusCount::CharactersNoSpaces, Some(n)) => {
crate::i18n::fmt(tl!("{selected} of {chars} characters (no spaces)"), &[("selected", &n.to_string()), ("chars", &total.to_string())])
}
self.word_count.1
}
}
+107
View File
@@ -91,6 +91,113 @@ fn status_bar_controls_hold_still_across_zoom_levels() {
}
}
/// #2797: the count line's wording per unit — words (the default), characters with spaces,
/// characters without spaces — whole document or "selected of total".
#[test]
fn the_count_line_words_each_unit() {
for (unit, total, selected, expected) in [
(StatusCount::Words, 12, None, "12 words"),
(StatusCount::Words, 12, Some(3), "3 of 12 words"),
(StatusCount::Characters, 23, None, "23 characters"),
(StatusCount::Characters, 23, Some(6), "6 of 23 characters"),
(StatusCount::CharactersNoSpaces, 21, None, "21 characters (no spaces)"),
(StatusCount::CharactersNoSpaces, 21, Some(6), "6 of 21 characters (no spaces)"),
] {
assert_eq!(status_count_text(unit, total, selected), expected, "{unit:?} with {selected:?} selected");
}
}
/// #2797: the status bar's totals are the Word Count dialog's numbers (words, characters with
/// spaces, characters without spaces), and its selection line counts the selection the same way.
#[test]
fn the_status_bar_totals_are_the_word_count_dialogs_numbers() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.run("text.insert", json!({"text": "Hello world.\nSecond line"})).unwrap();
// Straight from the engine: `a.run` would only open the Word Count dialog (#240).
let stats = a.session.run("review.wordCount", &json!({})).unwrap();
let counts = a.cached_status_counts();
assert_eq!(counts.words, stats["words"].as_u64().unwrap_or_default() as usize, "words, as the dialog counts them");
assert_eq!(counts.chars, stats["charactersWithSpaces"].as_u64().unwrap_or_default() as usize, "characters with spaces");
assert_eq!(counts.chars_no_spaces, stats["characters"].as_u64().unwrap_or_default() as usize, "characters without spaces");
// "world." selected: its own count of the chosen unit over the document's total.
a.run("select.text", json!({"text": "world."})).unwrap();
let line = |unit: StatusCount| {
let selected = crate::count_in_unit(&a.session.selected_text(), unit);
status_count_text(unit, counts.in_unit(unit), Some(selected))
};
assert_eq!(line(StatusCount::Words), "1 of 4 words");
assert_eq!(line(StatusCount::Characters), "6 of 23 characters");
assert_eq!(line(StatusCount::CharactersNoSpaces), "6 of 21 characters (no spaces)");
}
/// #2797: the status bar draws the unit the person chose, and right-clicking the count offers
/// the three units, the choice taking effect at once and kept between runs.
#[test]
fn the_status_bar_count_is_chosen_from_its_context_menu() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.run("text.insert", json!({"text": "Hello world.\nSecond line"})).unwrap();
let ctx = ctx();
ctx.enable_accesskit();
let mut n = 0;
// What the status bar drew last frame: each widget's label and where it sits.
let drawn = |a: &mut WordApp, n: &mut u32, events: Vec<egui::Event>| -> Vec<(String, egui::Rect)> {
*n += 1;
let input = egui::RawInput {
time: Some(f64::from(*n) / 60.0),
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 800.0))),
events,
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| status_bar(a, ui));
out.textures_delta.clear();
let update = out.platform_output.accesskit_update.take().unwrap();
update
.nodes
.into_iter()
.filter_map(|(_, node)| {
let b = node.bounds()?;
Some((node.label()?.to_string(), egui::Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32))))
})
.collect()
};
let at =
|seen: &[(String, egui::Rect)], label: &str| seen.iter().find(|(l, _)| l == label).unwrap_or_else(|| panic!("{label} is drawn")).1.center();
let button = |at: egui::Pos2, button: egui::PointerButton, pressed: bool| egui::Event::PointerButton {
pos: at,
button,
pressed,
modifiers: Default::default(),
};
// Words are counted: the default unit.
let mut seen = drawn(&mut a, &mut n, vec![]);
assert!(seen.iter().any(|(l, _)| l == "4 words"), "{seen:?}");
// Right-click the count: the menu lists the three units.
let count = at(&seen, "4 words");
drawn(&mut a, &mut n, vec![egui::Event::PointerMoved(count), button(count, egui::PointerButton::Secondary, true)]);
drawn(&mut a, &mut n, vec![button(count, egui::PointerButton::Secondary, false)]);
seen = drawn(&mut a, &mut n, vec![]);
for label in ["Words", "Characters (with spaces)", "Characters (no spaces)"] {
assert!(seen.iter().any(|(l, _)| l == label), "the menu lists {label}: {seen:?}");
}
// Pick characters with spaces: the count changes at once, with no document change.
let pick = at(&seen, "Characters (with spaces)");
drawn(&mut a, &mut n, vec![egui::Event::PointerMoved(pick), button(pick, egui::PointerButton::Primary, true)]);
drawn(&mut a, &mut n, vec![button(pick, egui::PointerButton::Primary, false)]);
assert_eq!(a.ui.status_count, StatusCount::Characters, "the menu's choice is taken");
assert_eq!(a.run("ui.statusCount", json!({})).unwrap()["statusCount"], "characters", "and answered to scripts");
seen = drawn(&mut a, &mut n, vec![]);
assert!(seen.iter().any(|(l, _)| l == "23 characters"), "{seen:?}");
// With a selection: "selected of total".
a.run("select.text", json!({"text": "world."})).unwrap();
seen = drawn(&mut a, &mut n, vec![]);
assert!(seen.iter().any(|(l, _)| l == "6 of 23 characters"), "{seen:?}");
// The choice comes back after a restart.
let mut b = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
let saved = serde_json::to_vec(&a.prefs()).unwrap();
b.apply_prefs(crate::UiState::from_json(&saved).unwrap());
assert_eq!(b.ui.status_count, StatusCount::Characters, "kept between runs");
}
/// #2402: a long message (the path of a file that isn't there) is shortened so that it stops
/// before the controls on the right, instead of being drawn over the Focus button.
#[test]
+92
View File
@@ -30,6 +30,11 @@ impl ControlUi {
pub(crate) fn popup_open(&self) -> bool {
self.popup.is_some()
}
/// Whether `p` (screen) is on the list/calendar button of the current control.
pub(crate) fn on_button(&self, p: egui::Pos2) -> bool {
self.button.as_ref().is_some_and(|(b, _)| b.contains(p))
}
}
struct Popup {
@@ -250,3 +255,90 @@ fn calendar(ui: &mut Ui, t: &Tokens, month: &mut (i64, u32)) -> Option<u32> {
});
picked
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{Event, PointerButton};
fn press(at: egui::Pos2) -> Event {
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() }
}
fn release(at: egui::Pos2) -> Event {
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Default::default() }
}
/// A harness showing a drop-down control followed by text, with the caret in the control and
/// its list button on screen.
fn dropdown_harness() -> egui_kittest::Harness<'static, WordApp> {
let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
app.run("text.insert", json!({"text": "tail text"})).unwrap();
app.run("caret.docStart", json!({})).unwrap();
app.run("developer.dropDown", json!({"items": ["A", "B"]})).unwrap();
app.run("view.commentsPane", json!({"value": false})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
for _ in 0..6 {
h.step();
}
let before = h.state().session.sel.focus.clone();
assert!(h.state().session.doc.control_at(&before).is_some(), "the caret starts in the control");
h
}
/// #5389: pressing a drop-down's list button opens the list and leaves the caret in the
/// control, even when text follows the control and the button hangs over it.
#[test]
fn pressing_the_list_button_keeps_the_caret_in_the_control() {
let mut h = dropdown_harness();
let at = h.state().controls.button.as_ref().expect("the list button is shown").0.center();
h.event(Event::PointerMoved(at));
h.event(press(at));
h.step();
h.event(release(at));
for _ in 0..3 {
h.step();
}
let after = h.state().session.sel.focus.clone();
assert!(h.state().session.doc.control_at(&after).is_some(), "the caret stays in the control");
assert!(h.state().controls.popup_open(), "the list is open");
}
/// The same click when press and release arrive in one frame: egui has no press origin left
/// by then.
#[test]
fn a_click_that_presses_and_releases_in_one_frame_keeps_the_caret_in_the_control() {
let mut h = dropdown_harness();
let at = h.state().controls.button.as_ref().expect("the list button is shown").0.center();
h.event(Event::PointerMoved(at));
h.step();
// `Harness::event` runs a frame per event, so both go into the frame's input directly.
h.input_mut().events.extend([press(at), release(at)]);
h.step();
for _ in 0..3 {
h.step();
}
let after = h.state().session.sel.focus.clone();
assert!(h.state().session.doc.control_at(&after).is_some(), "the caret stays in the control");
assert!(h.state().controls.popup_open(), "the list is open");
}
/// A table tool owns the pointer: a press over the list button starts a stroke.
#[test]
fn the_draw_table_tool_starts_a_stroke_over_the_list_button() {
let mut h = dropdown_harness();
h.state_mut().canvas.table_tool = Some(crate::table_pen::TableTool::Draw);
let at = h.state().controls.button.as_ref().expect("the list button is shown").0.center();
h.event(Event::PointerMoved(at));
h.event(press(at));
h.step();
assert!(h.state().canvas.table_stroke.is_some(), "the stroke started on the button");
}
}
+133 -16
View File
@@ -293,19 +293,20 @@ fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>)
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
/// give way, down to [`MIN_COLUMN`].
/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
let spare = room - natural.iter().sum::<f32>();
let weight = grow.iter().sum::<f32>();
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| *w).sum();
let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
natural
.iter()
.zip(grow)
.map(|(w, g)| {
if spare >= 0.0 && weight > 0.0 {
if *g > 0.0 && spare >= 0.0 {
w + spare * g / weight
} else if *g > 0.0 && flexible > 0.0 {
(w + spare * w / flexible).max(MIN_COLUMN)
w - (-spare).min(flexible) * slack(*w) / flexible
} else {
*w
}
@@ -314,26 +315,30 @@ fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
}
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
/// sideways. Each column can be dragged wider or narrower at its edge. The last column fills
/// whatever width is left but never shrinks below its opening width, so widening a column grows
/// the table, as in a spreadsheet, instead of crushing the last one: only a table dragged wider
/// than the window scrolls sideways. Text too long for its column is clipped.
/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
/// dropping any dragged widths.
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
let s = ui.spacing();
let gaps = s.item_spacing.x * natural.len() as f32;
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
// The table keeps its own column widths, so a change in what they were fitted to has to clear
// them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
// width, so this compares the natural widths and the clip rect, which scrolling never moves.
let fit = egui::Id::unique(("credits_fit", id));
let measured = (natural.to_vec(), ui.clip_rect().width());
let refit = ui.data_mut(|d| d.get_temp::<(Vec<f32>, f32)>(fit)).as_ref() != Some(&measured);
ui.data_mut(|d| d.insert_temp(fit, measured));
let mut t =
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
let last = widths.len().saturating_sub(1);
for (i, w) in widths.into_iter().enumerate() {
let c = if i == last {
egui_extras::Column::remainder().at_least(w.max(MIN_COLUMN))
} else {
egui_extras::Column::initial(w).at_least(MIN_COLUMN)
};
t = t.column(c.clip(true));
if refit {
t.reset();
}
for w in widths {
t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
}
t
}
@@ -579,4 +584,116 @@ mod tests {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
}
#[test]
fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
assert!((w.iter().sum::<f32>() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
}
const WINDOW: f32 = 640.5;
const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
const GROW: [f32; 3] = [1.0, 0.0, 0.0];
/// One frame of a table long enough to scroll; the rects of its header cells.
fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec<egui::Event>) -> Vec<egui::Rect> {
scrolled_frame(ctx, width, natural, 0.0, events)
}
/// [`table_frame`], scrolled `offset` points sideways.
fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec<egui::Event>) -> Vec<egui::Rect> {
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
let mut cells = Vec::new();
let out = ctx.run_ui(raw, |ui| {
cells.clear();
egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
fitted_table(ui, "test", natural, &GROW)
.header(20.0, |mut header| {
for _ in natural {
header.col(|ui| cells.push(ui.max_rect()));
}
})
.body(|mut body| {
for _ in 0..40 {
body.row(20.0, |mut row| {
for _ in natural {
row.col(|ui| {
ui.label("x");
});
}
});
}
});
});
});
out.drop_without_applying_deltas();
cells
}
/// A new context with the table open and settled, and the rects of its header cells.
fn opened_table() -> (egui::Context, Vec<egui::Rect>) {
let ctx = egui::Context::default();
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
(ctx, cells)
}
/// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
use egui::{Event, Modifiers, PointerButton};
let start = egui::pos2(cell.right() + 4.0, cell.center().y);
let halfway = start + egui::vec2(by / 2.0, 0.0);
let end = start + egui::vec2(by, 0.0);
let moved = Event::PointerMoved;
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
table_frame(ctx, WINDOW, &NATURAL, vec![event]);
}
}
#[test]
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
let (_, opened) = opened_table();
for (dragged, cell) in opened.iter().enumerate() {
let (ctx, _) = opened_table();
drag_edge(&ctx, *cell, -30.0);
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
}
}
}
#[test]
fn a_changed_fit_replaces_the_column_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], -30.0);
let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
}
#[test]
fn scrolling_sideways_keeps_dragged_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], 100.0);
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
for offset in [13.3, 27.77, 41.1, 0.6] {
scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
}
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ impl WordApp {
self.read_aloud_at = None;
self.info_editing = None;
// Caches keyed by the revision alone: another document's revision can be the same number.
self.word_count = crate::window_ui::NOT_COUNTED;
self.status_counts = crate::window_ui::NOT_COUNTED;
self.find_fonts_for = None;
self.previews.document_changed();
// The Start screen's placeholder (#1129) goes once a document is open beside it.
+1 -1
View File
@@ -12,7 +12,7 @@ mod canvas;
mod ime;
mod shortcuts;
pub(crate) use alt_code::AltCodeEcho;
pub(crate) use alt_code::{AltCodeEcho, FieldAltCode, field_alt_codes};
pub use canvas::canvas_events;
pub(crate) use ime::observe_ime;
#[cfg(test)]
+117 -1
View File
@@ -29,11 +29,15 @@ fn alt_code_digit(key: Key) -> Option<char> {
char::from_digit(n, 10)
}
/// Alt is the only modifier held: the one an Alt code's digits are typed with.
fn alt_only(m: &Modifiers) -> bool {
m.alt && !m.ctrl && !m.shift && !m.command && !m.mac_cmd
}
/// Alt+numpad entry (#961): digits typed with Alt alone held collect; releasing Alt types the
/// character their code names (`text.typeCode`: 0233 → é, 233 → Θ in code page 437). Returns
/// whether the event was part of it (and isn't handled otherwise).
pub(super) fn alt_code_event(app: &mut WordApp, e: &egui::Event) -> bool {
let alt_only = |m: &Modifiers| m.alt && !m.ctrl && !m.shift && !m.command && !m.mac_cmd;
match e {
egui::Event::Key { key, pressed: true, repeat, modifiers, .. } if alt_only(modifiers) => match alt_code_digit(*key) {
// A held digit's auto-repeat adds nothing (as on Windows).
@@ -131,3 +135,115 @@ pub(super) fn alt_code_echoed(app: &mut WordApp, echo: &AltCodeEcho, text: &str)
}
true
}
/// Alt+numpad entry in the focused text box (Find what, a dialog's fields, the ribbon's boxes);
/// the page has its own ([`alt_code_event`]), #4403.
#[derive(Clone, Debug, Default)]
pub(crate) struct FieldAltCode {
/// The digits typed while Alt is held.
code: String,
/// The last digit's own text (Linux sends it with Alt held) isn't typed.
swallow_text: bool,
/// What the last code typed, for the platform's own composed character that may follow.
echo: Option<AltCodeEcho>,
}
/// The character an Alt code names in the code pages the page uses for it: the system locale's
/// on Windows, else `text.typeCode`'s defaults (Windows-1252 and the locale's OEM page).
fn field_code_char(code: &str) -> Option<char> {
use wordcraft_engine::cmd::text::{alt_code_char, system_code_pages, system_oem_code_page};
let (ansi, oem) = if cfg!(target_os = "windows") { system_code_pages() } else { (1252, system_oem_code_page()) };
alt_code_char(code, ansi, oem)
}
/// Whether `e` lets go of Alt: its key's release, or modifiers without it.
fn alt_released(e: &egui::Event) -> bool {
match e {
egui::Event::Key { key: Key::AltLeft | Key::AltRight, pressed: false, .. } => true,
egui::Event::ModifiersChanged(m) => !m.alt,
_ => false,
}
}
/// Alt codes in the focused text box, as on the page (#4403). Rewrites this frame's input before
/// the box reads it: the digits typed with Alt held type nothing (Linux sends their text), and
/// letting go of Alt types the code's character. A platform that composes the code itself
/// (Windows) sends its character after that: the same character isn't typed twice, and for a
/// code up to 255 the system's character replaces ours, as on the page ([`alt_code_echoed`]).
pub(crate) fn field_alt_codes(app: &mut WordApp, ctx: &egui::Context) {
if !alt_codes_supported(ctx) || app.canvas.focused || !ctx.text_edit_focused() {
app.canvas.field_alt_code = FieldAltCode::default();
return;
}
let st = &mut app.canvas.field_alt_code;
let events = ctx.input(|i| i.events.clone());
let mut out = Vec::with_capacity(events.len() + 1);
let mut changed = false;
for e in events {
match &e {
egui::Event::Key { key, pressed: true, repeat, modifiers, .. } => {
st.swallow_text = false;
st.echo = None;
match alt_code_digit(*key).filter(|_| alt_only(modifiers)) {
Some(d) => {
// A held digit's auto-repeat adds nothing; past seven digits a code
// names nothing, so stop collecting rather than grow.
if !*repeat && st.code.len() < 8 {
st.code.push(d);
}
st.swallow_text = true;
}
// Another key (Alt itself, a letter, a digit with Ctrl) ends the entry.
None => st.code.clear(),
}
}
egui::Event::Text(_) if std::mem::take(&mut st.swallow_text) => {
changed = true;
continue;
}
egui::Event::Text(t) => {
if let Some(echo) = st.echo.take()
&& (echo.typed == *t || echo.system_composes)
{
changed = true;
if echo.typed != *t && echo.byte_code && t.chars().count() == 1 {
// Ours was typed just before, with no key or click since: take it back.
out.push(egui::Event::Key {
key: Key::Backspace,
physical_key: None,
pressed: true,
repeat: false,
modifiers: Modifiers::NONE,
});
out.push(e);
}
continue;
}
}
egui::Event::PointerButton { pressed: true, .. } => st.echo = None,
// Focus elsewhere (another window) ends an Alt code unfinished.
egui::Event::WindowFocused(false) => st.code.clear(),
_ => {}
}
let released = alt_released(&e);
out.push(e);
if released && !st.code.is_empty() {
let code = std::mem::take(&mut st.code);
let system_composes = cfg!(target_os = "windows");
// A digit's own text comes with its key, before Alt is let go: text after this is
// the system's composed character (Windows), also for a code we name no character
// for (a double-byte lead byte, say), or what is typed next. A digit that sent no
// text must not cost it.
st.swallow_text = false;
if let Some(c) = field_code_char(&code) {
let byte_code = code.trim_start_matches('0').parse::<u32>().is_ok_and(|v| v <= 255);
st.echo = Some(AltCodeEcho { typed: c.to_string(), system_composes, byte_code });
out.push(egui::Event::Text(c.to_string()));
changed = true;
}
}
}
if changed {
ctx.input_mut(|i| i.events = out);
}
}
+102
View File
@@ -376,6 +376,108 @@ fn altgr_types_its_characters_without_keytips() {
assert_eq!(a.ui.keytips, crate::keytips::Phase::Tabs);
}
/// The Find dialog's Find what box, after the frames that open the dialog and focus the box.
fn find_box_harness() -> egui_kittest::Harness<'static, WordApp> {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.dialog = crate::dialogs::Dialog::open("find", &mut a);
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
h.run_steps(4);
h
}
/// What the Find dialog's Find what box holds.
fn find_box(h: &egui_kittest::Harness<'static, WordApp>) -> String {
match &h.state().dialog {
Some(crate::dialogs::Dialog::Find { query, .. }) => query.clone(),
_ => panic!("the Find dialog is open"),
}
}
/// One frame: Alt held, `digits` pressed and released (with their text, as Linux sends it, when
/// `digit_text`), Alt let go; then `after`, which a platform may send once Alt is up.
fn alt_code_frame(h: &mut egui_kittest::Harness<'static, WordApp>, digits: &str, digit_text: bool, after: &[&str]) {
let alt = Modifiers { alt: true, ..Default::default() };
let key = |key, pressed, modifiers| egui::Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers };
h.event(egui::Event::ModifiersChanged(alt));
h.event(key(Key::AltLeft, true, alt));
for d in digits.chars() {
let k = Key::from_name(&d.to_string()).expect("a digit key");
h.event(key(k, true, alt));
if digit_text {
h.event(egui::Event::Text(d.to_string()));
}
h.event(key(k, false, alt));
}
h.event(key(Key::AltLeft, false, alt));
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
for t in after {
h.event(egui::Event::Text((*t).to_string()));
}
h.step();
}
/// #4403: an Alt code typed in a text box (here Find what) types its character there, as on the
/// page. Before, the box got the digits (Linux sends their text with Alt held), or on Windows only
/// what the system composes, a byte for a code above 255. The page's text is left alone.
#[test]
fn alt_code_types_its_character_in_a_text_box() {
if cfg!(target_os = "macos") {
return;
}
// The code pages the page's Alt codes use (`text.typeCode`).
let (ansi, oem) = if cfg!(target_os = "windows") {
wordcraft_engine::cmd::text::system_code_pages()
} else {
(1252, wordcraft_engine::cmd::text::system_oem_code_page())
};
let ch = |code| wordcraft_engine::cmd::text::alt_code_char(code, ansi, oem).expect("a character code").to_string();
let mut h = find_box_harness();
assert_eq!(find_box(&h), "");
// Linux: each digit's text arrives too; none of it is typed.
alt_code_frame(&mut h, "0233", true, &[]);
let mut want = ch("0233");
assert_eq!(find_box(&h), want);
// Above 255 the code is the Unicode character's (Word's €), and a platform's echo of the
// same character isn't typed twice.
alt_code_frame(&mut h, "8364", false, &["\u{20AC}"]);
want.push('\u{20AC}');
assert_eq!(find_box(&h), want);
// Windows composes a code itself: up to 255 its character replaces ours (it knows the code
// pages), above that ours stays (it reduces the code to a byte). Elsewhere text after the
// release is typed as it comes.
alt_code_frame(&mut h, "0199", false, &["\u{1E9E}"]);
alt_code_frame(&mut h, "9731", false, &["X"]);
if cfg!(target_os = "windows") {
want.push_str("\u{1E9E}\u{2603}");
} else {
want.push_str(&format!("{}\u{1E9E}\u{2603}X", ch("0199")));
}
assert_eq!(find_box(&h), want);
// A code we name no character for (past Unicode here; a double-byte lead byte on Windows)
// types nothing of ours, and the character the system composes for it is typed, not taken
// for a digit's text. Windows sends no digit text; Linux does.
assert_eq!(wordcraft_engine::cmd::text::alt_code_char("1114112", ansi, oem), None);
alt_code_frame(&mut h, "1114112", !cfg!(target_os = "windows"), &["Y"]);
want.push('Y');
assert_eq!(find_box(&h), want);
// Digits without Alt type themselves; a bare Alt tap types nothing and opens no keytips.
h.event(egui::Event::Key { key: Key::Num5, physical_key: Some(Key::Num5), pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.event(egui::Event::Text("5".into()));
h.step();
alt_code_frame(&mut h, "", false, &[]);
want.push('5');
assert_eq!(find_box(&h), want);
assert_eq!(h.state().ui.keytips, crate::keytips::Phase::Off);
assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim_end(), "", "the page typed nothing");
}
fn alt_frame_app() -> (egui::Context, WordApp) {
let a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
(egui::Context::default(), a)
+147 -1
View File
@@ -138,6 +138,142 @@ struct AwaitingSave {
rev: u64,
}
/// File › Options › Advanced › Linux display server (#2184, `ui.displayServer`): how the desktop
/// app connects on Linux and the BSDs. It's read before the window opens, so a change applies at
/// the next launch; `WORDCRAFT_WAYLAND` overrides it. An unknown saved value reads as Automatic.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum DisplayServer {
/// The app's own choice: XWayland whenever there's an X server, for file drag-and-drop.
#[default]
Auto,
Wayland,
X11,
}
impl DisplayServer {
/// In menu order.
pub const ALL: [DisplayServer; 3] = [DisplayServer::Auto, DisplayServer::Wayland, DisplayServer::X11];
/// The value used in `ui.json` and by the `ui.displayServer` command.
pub fn code(self) -> &'static str {
match self {
DisplayServer::Auto => "auto",
DisplayServer::Wayland => "wayland",
DisplayServer::X11 => "x11",
}
}
pub fn from_code(code: &str) -> Option<DisplayServer> {
DisplayServer::ALL.into_iter().find(|d| d.code().eq_ignore_ascii_case(code.trim()))
}
/// The English label; only Automatic is translated, the others are names.
pub fn label(self) -> &'static str {
match self {
DisplayServer::Auto => "Automatic",
DisplayServer::Wayland => "Wayland",
DisplayServer::X11 => "X11 (XWayland)",
}
}
}
/// What the status bar's live count shows (#2797): words (the default, as Word's status bar
/// counts), characters with spaces, or characters without spaces — the same three the Word Count
/// dialog reports, worded the same way. Right-click the count in the status bar, or File ›
/// Options › Display, to choose; kept between runs in `ui.json`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum StatusCount {
#[default]
Words,
Characters,
CharactersNoSpaces,
}
impl StatusCount {
/// In menu order.
pub const ALL: [StatusCount; 3] = [StatusCount::Words, StatusCount::Characters, StatusCount::CharactersNoSpaces];
/// The value used in `ui.json` and by the `ui.statusCount` command.
pub fn code(self) -> &'static str {
match self {
StatusCount::Words => "words",
StatusCount::Characters => "characters",
StatusCount::CharactersNoSpaces => "charactersNoSpaces",
}
}
pub fn from_code(code: &str) -> Option<StatusCount> {
StatusCount::ALL.into_iter().find(|c| c.code().eq_ignore_ascii_case(code.trim()))
}
/// The English label (translated with `tl!` where shown): the Word Count dialog's own words
/// for these counts.
pub fn label(self) -> &'static str {
match self {
StatusCount::Words => "Words",
StatusCount::Characters => "Characters (with spaces)",
StatusCount::CharactersNoSpaces => "Characters (no spaces)",
}
}
}
impl From<String> for DisplayServer {
fn from(s: String) -> Self {
DisplayServer::from_code(&s).unwrap_or_default()
}
}
impl From<DisplayServer> for String {
fn from(d: DisplayServer) -> Self {
d.code().to_string()
}
}
/// An unknown saved value reads as the default rather than discarding the whole `ui.json`.
impl From<String> for StatusCount {
fn from(s: String) -> Self {
StatusCount::from_code(&s).unwrap_or_default()
}
}
impl From<StatusCount> for String {
fn from(c: StatusCount) -> Self {
c.code().to_string()
}
}
/// Whole-document counts the status bar shows (#2797), computed together whenever the document
/// changes: words ([`wordcraft_engine::Session::word_count`]), characters and characters without
/// spaces (the Word Count dialog's [`wordcraft_doc::count_chars`] and [`wordcraft_doc::count_chars_no_spaces`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct StatusCounts {
pub words: usize,
pub chars: usize,
pub chars_no_spaces: usize,
}
impl StatusCounts {
/// The total in the unit the status bar is showing.
pub fn in_unit(self, unit: StatusCount) -> usize {
match unit {
StatusCount::Words => self.words,
StatusCount::Characters => self.chars,
StatusCount::CharactersNoSpaces => self.chars_no_spaces,
}
}
}
/// `s` (a selection, say) counted in the unit the status bar is showing.
pub fn count_in_unit(s: &str, unit: StatusCount) -> usize {
match unit {
StatusCount::Words => wordcraft_doc::count_words(s),
StatusCount::Characters => wordcraft_doc::count_chars(s),
StatusCount::CharactersNoSpaces => wordcraft_doc::count_chars_no_spaces(s),
}
}
/// UI state that persists between runs (and that agents can read and set).
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -283,6 +419,13 @@ pub struct UiState {
pub update_fields_before_print: bool,
/// The custom dictionary (File › Options › Proofing, Add to Dictionary), kept between runs.
pub dictionary: Vec<String>,
/// File › Options › Advanced › Linux display server (#2184): read by the desktop app before
/// the window opens.
pub display_server: DisplayServer,
/// What the status bar's live count shows (#2797): words (the default), characters with
/// spaces, or characters without spaces. Right-click the count in the status bar, or File ›
/// Options › Display. Junk saved values read as words.
pub status_count: StatusCount,
}
/// The ribbon tab the app opens on, and the one it falls back to when the tab that was showing
@@ -358,6 +501,8 @@ impl Default for UiState {
print_backgrounds: false,
update_fields_before_print: false,
dictionary: Vec::new(),
display_server: DisplayServer::Auto,
status_count: StatusCount::default(),
}
}
}
@@ -587,7 +732,8 @@ pub struct WordApp {
/// The document's fonts that aren't installed, for the status bar and the font box, and the
/// layout they were read from.
pub missing_fonts: crate::chrome::MissingFontsMemo,
pub word_count: (u64, usize),
/// The document's [`StatusCounts`] and the key they were computed for; see [`WordApp::cached_status_counts`].
pub status_counts: (u64, StatusCounts),
last_autosave: f64,
/// The tab shown before the Equation tab came up (restored when editing ends).
pub(crate) equation_prev_tab: Option<String>,
+102 -35
View File
@@ -165,6 +165,9 @@ pub fn track_dismissal(app: &mut WordApp, ctx: &egui::Context, viewport: Rect) {
/// canvas area it must stay inside.
pub fn show(app: &mut WordApp, ctx: &egui::Context, selection: Rect, viewport: Rect) {
if !visible(app) {
if app.canvas.command_previewed.is_some() {
ctx.request_repaint();
}
return;
}
let Some(bar) = anchor_rect(selection.min.y, selection.min.x, viewport) else { return };
@@ -192,7 +195,11 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context, selection: Rect, viewport: R
let inner = bar.shrink2(Vec2::new(PAD_X, PAD_Y));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(Layout::top_down(Align::Min)));
child.spacing_mut().item_spacing = Vec2::new(SPACING, ROW_GAP);
content(app, &mut child, &t);
content(app, &mut child, &t, true);
// The page was drawn before the bar this frame: another frame shows or clears the preview.
if app.canvas.command_preview != app.canvas.command_previewed {
ctx.request_repaint();
}
let used = child.min_rect();
ctx.data_mut(|d| d.insert_temp(shown_id(), [bar, used]));
});
@@ -219,7 +226,7 @@ pub fn show_in_context_menu(app: &mut WordApp, ui: &mut egui::Ui) {
ui.set_style(ui.ctx().global_style());
ui.set_width(ROW2_W);
ui.spacing_mut().item_spacing = Vec2::new(SPACING, ROW_GAP);
content(app, ui, &t);
content(app, ui, &t, false);
if app.dialog.is_some() || app.colors.is_some() {
ui.close();
}
@@ -229,7 +236,7 @@ pub fn show_in_context_menu(app: &mut WordApp, ui: &mut egui::Ui) {
/// The controls in two rows like Word's mini toolbar, reusing the ribbon's widgets so the bar
/// matches the ribbon exactly.
fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens, preview_on_hover: bool) {
let st: Value = app.session.run("format.state", &json!({})).unwrap_or_default();
let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
// As on the ribbon, the buttons show alignment as seen on the page (#3579).
@@ -237,8 +244,8 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
.session
.doc
.para_at(&app.session.sel.focus)
.map(|p| {
let r = app.session.doc.styles.resolve_para(&p.props);
.map(|_| {
let r = wordcraft_engine::cmd::para::selection_para(&app.session);
r.align.visual(r.bidi)
})
.unwrap_or_default();
@@ -256,6 +263,7 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
let fams = app.previews.families();
let recent: Vec<String> = app.ui.recent_fonts.iter().map(|f| app.previews.font_shown(f)).collect();
let prev = app.previews.font_preview_fn();
let _ = widgets::take_hover(ui.ctx());
let options = widgets::FontComboOptions {
id: "mini_font",
width: COMBO_FONT_W,
@@ -266,7 +274,16 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
tip: tl!("Font"),
shown_as: warning.as_deref(),
};
if let Some(f) = widgets::font_combo_with_recent_hint(ui, options) {
let picked = widgets::font_combo_with_recent_hint(ui, options);
if preview_on_hover
&& picked.is_none()
&& let Some(f) = widgets::take_hover(ui.ctx())
&& f != crate::widgets::ADD_FONT
&& f != crate::widgets::LOAD_SYSTEM_FONTS
{
app.canvas.command_preview = Some(("format.font".to_string(), json!({"name": app.previews.font_stored(&f)})));
}
if let Some(f) = picked {
let _ = if f == crate::widgets::ADD_FONT {
app.run("font.add", json!({}))
} else if f == crate::widgets::LOAD_SYSTEM_FONTS {
@@ -280,33 +297,34 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
st.get("size").and_then(Value::as_f64).map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).unwrap_or_default();
let sizes: Vec<String> =
wordcraft_engine::cmd::format::SIZES.iter().map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).collect();
if let Some(v) = widgets::combo_hint(ui, "mini_size", COMBO_SIZE_W, &size, &sizes, None, tl!("Font Size"))
let _ = widgets::take_hover(ui.ctx());
let picked = widgets::combo_hint(ui, "mini_size", COMBO_SIZE_W, &size, &sizes, None, tl!("Font Size"));
if preview_on_hover
&& picked.is_none()
&& let Some(x) = widgets::take_hover(ui.ctx()).and_then(|v| v.trim().parse::<f64>().ok())
{
app.canvas.command_preview = Some(("format.size".to_string(), json!({"size": x})));
}
if let Some(v) = picked
&& let Ok(x) = v.trim().parse::<f64>()
{
let _ = app.run("format.size", json!({"size": x}));
}
ui.add_space(GAP);
widgets::small(ui, app, "grow", None, "Increase Font Size", crate::widgets::ButtonAction::new("format.growFont", json!({}), false));
widgets::small(ui, app, "shrink", None, "Decrease Font Size", crate::widgets::ButtonAction::new("format.shrinkFont", json!({}), false));
hover_small(ui, app, "grow", "Increase Font Size", "format.growFont", false, preview_on_hover);
hover_small(ui, app, "shrink", "Decrease Font Size", "format.shrinkFont", false, preview_on_hover);
});
row(ui, app, &|ui, app| {
widgets::small(ui, app, "bold", None, "Bold", crate::widgets::ButtonAction::new("format.bold", json!({}), flag("bold")));
widgets::small(ui, app, "italic", None, "Italic", crate::widgets::ButtonAction::new("format.italic", json!({}), flag("italic")));
widgets::small(ui, app, "underline", None, "Underline", crate::widgets::ButtonAction::new("format.underline", json!({}), flag("underline")));
widgets::small(
ui,
app,
"strike",
None,
"Strikethrough",
crate::widgets::ButtonAction::new("format.strikethrough", json!({}), flag("strike")),
);
hover_small(ui, app, "bold", "Bold", "format.bold", flag("bold"), preview_on_hover);
hover_small(ui, app, "italic", "Italic", "format.italic", flag("italic"), preview_on_hover);
hover_small(ui, app, "underline", "Underline", "format.underline", flag("underline"), preview_on_hover);
hover_small(ui, app, "strike", "Strikethrough", "format.strikethrough", flag("strike"), preview_on_hover);
sep(ui, t);
let hl = app.canvas.last_highlight.clone();
let (icon, sw, color) = highlight_button_appearance(&hl);
widgets::split(
let main = widgets::split(
ui,
app,
icon,
@@ -314,12 +332,20 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
crate::widgets::ButtonAction::new("format.highlight", json!({"color": color}), false),
sw,
|ui, app| {
let _ = widgets::take_hover(ui.ctx());
let in_effect = st.get("highlight").and_then(Value::as_str).unwrap_or("none").to_string();
widgets::set_current_pick(ui.ctx(), Some(in_effect));
highlight_menu(ui, app);
widgets::set_current_pick(ui.ctx(), None);
if preview_on_hover && let Some(c) = widgets::take_hover(ui.ctx()) {
app.canvas.command_preview = Some(("format.highlight".to_string(), json!({"color": c})));
}
},
);
preview_on_hover_if(app, &main, "format.highlight", json!({"color": color}), preview_on_hover);
let fc = app.canvas.last_font_color.clone();
let sw = wordcraft_doc::Rgb::parse(&fc).map(crate::theme::c32);
widgets::split(
let main = widgets::split(
ui,
app,
"fontcolor",
@@ -327,26 +353,50 @@ fn content(app: &mut WordApp, ui: &mut egui::Ui, t: &Tokens) {
crate::widgets::ButtonAction::new("format.color", json!({"color": fc}), false),
sw,
|ui, app| {
let _ = widgets::take_hover(ui.ctx());
widgets::set_current_pick(ui.ctx(), st.get("color").and_then(Value::as_str).map(str::to_string));
font_color_menu(ui, app);
widgets::set_current_pick(ui.ctx(), None);
if preview_on_hover && let Some(c) = widgets::take_hover(ui.ctx()) {
app.canvas.command_preview = Some(("format.color".to_string(), json!({"color": c})));
}
},
);
preview_on_hover_if(app, &main, "format.color", json!({"color": fc}), preview_on_hover);
sep(ui, t);
use wordcraft_doc::Align as A;
widgets::small(ui, app, "alignLeft", None, "Align Left", crate::widgets::ButtonAction::new("para.alignLeft", json!({}), align == A::Left));
widgets::small(ui, app, "alignCenter", None, "Center", crate::widgets::ButtonAction::new("para.alignCenter", json!({}), align == A::Center));
widgets::small(
ui,
app,
"alignRight",
None,
"Align Right",
crate::widgets::ButtonAction::new("para.alignRight", json!({}), align == A::Right),
);
widgets::small(ui, app, "justify", None, "Justify", crate::widgets::ButtonAction::new("para.justify", json!({}), align == A::Justify));
hover_small(ui, app, "alignLeft", "Align Left", "para.alignLeft", align == A::Left, preview_on_hover);
hover_small(ui, app, "alignCenter", "Center", "para.alignCenter", align == A::Center, preview_on_hover);
hover_small(ui, app, "alignRight", "Align Right", "para.alignRight", align == A::Right, preview_on_hover);
hover_small(ui, app, "justify", "Justify", "para.justify", align == A::Justify, preview_on_hover);
});
}
/// A bar button that, while hovered, makes the page show its command run on the selection. The
/// preview runs on a copy, so the document, its undo history and the selection stay as they are.
fn hover_small(ui: &mut egui::Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, checked: bool, preview: bool) {
let action = crate::widgets::ButtonAction::new(id, json!({}), checked);
let r = widgets::small(ui, app, icon, None, tip, action);
preview_on_hover_if(app, &r, id, json!({}), preview);
}
/// Previews command `id` with `params` while its button `r` is hovered and not pressed. After a
/// click the preview stays off until the pointer leaves the button: a toggle would otherwise
/// preview its other state under the pointer and look as if it flashed.
fn preview_on_hover_if(app: &mut WordApp, r: &egui::Response, id: &str, params: Value, preview: bool) {
let hold = r.id.with("hold_preview");
if r.clicked() {
r.ctx.data_mut(|d| d.insert_temp(hold, true));
} else if !r.hovered() {
r.ctx.data_mut(|d| d.remove::<bool>(hold));
}
let held = r.ctx.data(|d| d.get_temp::<bool>(hold)).unwrap_or(false);
if preview && !held && r.hovered() && !r.is_pointer_button_down_on() && !r.clicked() && widgets::enabled(app, id) {
app.canvas.command_preview = Some((id.to_string(), params));
}
}
/// A hairline divider between the bar's groups.
fn sep(ui: &mut egui::Ui, t: &Tokens) {
let (r, _) = ui.allocate_exact_size(Vec2::new(SEP_W, 20.0), Sense::hover());
@@ -370,6 +420,13 @@ pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
let Some(c) = h.rgb() else { continue };
let (r, resp) = ui.allocate_exact_size(Vec2::new(18.0, 18.0), Sense::click());
ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c.0, c.1, c.2));
if resp.hovered() {
widgets::note_hover(ui.ctx(), h.ooxml().to_string());
}
if widgets::current_pick(ui.ctx()).is_some_and(|cur| cur.eq_ignore_ascii_case(&c.hex())) {
let accent = Tokens::get(ui.ctx()).accent;
ui.painter().rect_stroke(r.expand(1.0), 2.0, egui::Stroke::new(2.0, accent), egui::StrokeKind::Outside);
}
if resp.on_hover_text(tl!(h.name())).clicked() {
app.canvas.last_highlight = h.ooxml().to_string();
widgets::run_from_ribbon(app, "format.highlight", json!({"color": h.ooxml()}));
@@ -381,7 +438,12 @@ pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
}
});
let enabled = widgets::enabled(app, "format.highlight");
if ui.add_enabled(enabled, egui::Button::new(tl!("No Color")).min_size(Vec2::new(200.0, 0.0))).clicked() {
let none_in_effect = widgets::current_pick(ui.ctx()).is_some_and(|cur| cur == "none");
let no_color = ui.add_enabled(enabled, egui::Button::new(tl!("No Color")).selected(none_in_effect).min_size(Vec2::new(200.0, 0.0)));
if no_color.hovered() {
widgets::note_hover(ui.ctx(), "none".to_string());
}
if no_color.clicked() {
app.canvas.last_highlight = "none".into();
widgets::run_from_ribbon(app, "format.highlight", json!({"color": "none"}));
ui.close();
@@ -391,7 +453,12 @@ pub(crate) fn highlight_menu(ui: &mut egui::Ui, app: &mut WordApp) {
/// Font colour: Automatic, the colour grid, Recent Colors and More Colors… (#606).
fn font_color_menu(ui: &mut egui::Ui, app: &mut WordApp) {
egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| {
if ui.selectable_label(false, "Automatic").clicked() {
let auto_in_effect = widgets::current_pick(ui.ctx()).is_some_and(|cur| cur == "auto");
let automatic = ui.selectable_label(auto_in_effect, "Automatic");
if automatic.hovered() {
widgets::note_hover(ui.ctx(), "auto".to_string());
}
if automatic.clicked() {
widgets::run_from_ribbon(app, "format.color", json!({"color": "auto"}));
ui.close();
return;
+289 -1
View File
@@ -198,6 +198,211 @@ fn format_state(h: &mut egui_kittest::Harness<'static, WordApp>) -> Value {
h.state_mut().session.run("format.state", &json!({})).unwrap()
}
/// Hovering an entry of the bar's size list previews that size; the pick is not made.
#[test]
fn hovering_a_size_in_the_bars_list_previews_it() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let (doc, size) = (h.state().session.doc.clone(), format_state(&mut h).get("size").cloned());
let viewport = h.state().canvas.viewport.unwrap();
let size_box = h
.get_all_by_role(Role::TextInput)
.map(|n| n.rect())
.filter(|r| viewport.contains_rect(*r))
.max_by(|a, b| a.min.x.total_cmp(&b.min.x))
.expect("the bar has a size box");
click_at(&mut h, pos2(size_box.max.x + 8.0, size_box.center().y));
let twenty = h.get_by_label("20").rect().center();
h.event(egui::Event::PointerMoved(twenty));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, Some(("format.size".to_string(), json!({"size": 20.0}))));
assert_ne!(crate::canvas::page_doc(h.state()), &doc, "the page shows the 20 pt text");
assert_eq!(&h.state().session.doc, &doc);
assert_eq!(format_state(&mut h).get("size").cloned(), size);
}
/// Hovering a family of the bar's font list previews it; clicking it applies it.
#[test]
fn hovering_and_clicking_a_font_in_the_bars_list() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let doc = h.state().session.doc.clone();
let viewport = h.state().canvas.viewport.unwrap();
let font_box = h
.get_all_by_role(Role::TextInput)
.map(|n| n.rect())
.filter(|r| viewport.contains_rect(*r))
.min_by(|a, b| a.min.y.total_cmp(&b.min.y).then(a.min.x.total_cmp(&b.min.x)))
.expect("the bar has a font box");
click_at(&mut h, pos2(font_box.max.x + 8.0, font_box.center().y));
let family = h.state_mut().previews.families().into_iter().find(|f| f != "Aptos Display" && f != "Aptos").expect("another family is installed");
let at = h.get_all_by_label(&family).map(|n| n.rect().center()).find(|p| p.y > font_box.max.y).expect("the list shows the family");
h.event(egui::Event::PointerMoved(at));
steps(&mut h);
let previewed = h.state().canvas.command_previewed.clone();
assert!(previewed.is_some_and(|(id, _)| id == "format.font"), "hovering a family previews the font");
assert_eq!(&h.state().session.doc, &doc);
click_at(&mut h, at);
assert_ne!(&h.state().session.doc, &doc, "clicking the family applies the font");
}
/// A picked family joins Recently Used Fonts when the list next opens, not in the frame after the
/// pick, when the list is still on screen and would shift under the pointer.
#[test]
fn a_picked_font_joins_the_recent_section_on_the_next_open() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let viewport = h.state().canvas.viewport.unwrap();
let font_box = h
.get_all_by_role(Role::TextInput)
.map(|n| n.rect())
.filter(|r| viewport.contains_rect(*r))
.min_by(|a, b| a.min.y.total_cmp(&b.min.y).then(a.min.x.total_cmp(&b.min.x)))
.expect("the bar has a font box");
let arrow = pos2(font_box.max.x + 8.0, font_box.center().y);
click_at(&mut h, arrow);
let family = h.state_mut().previews.families().into_iter().find(|f| f != "Aptos Display" && f != "Aptos").expect("another family is installed");
let at = h.get_all_by_label(&family).map(|n| n.rect().center()).find(|p| p.y > font_box.max.y).expect("the list shows the family");
h.event(egui::Event::PointerMoved(at));
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.step();
h.step();
h.step();
assert_eq!(h.query_all_by_label("Recently Used Fonts").count(), 0, "the list does not shift under the pointer");
assert_eq!(h.query_all_by_label("Add Font…").count(), 0, "picking a family closes the list");
steps(&mut h);
click_at(&mut h, arrow);
assert_eq!(h.query_all_by_label("Recently Used Fonts").count(), 1, "the next open lists the family first");
}
/// Hovering the highlight and font colour buttons previews their last colour.
#[test]
fn hovering_the_colour_buttons_previews_their_colour() {
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let viewport = h.state().canvas.viewport.unwrap();
for (label, id) in [("Text Highlight Color", "format.highlight"), ("Font Color", "format.color")] {
let r = h.get_all_by_label(label).map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has the button");
h.event(egui::Event::PointerMoved(r.center()));
steps(&mut h);
let previewed = h.state().canvas.command_previewed.clone();
assert!(previewed.is_some_and(|(p, _)| p == id), "{label} previews {id}");
}
}
/// Hovering a colour of the bar's highlight list previews it on the selection.
#[test]
fn hovering_a_highlight_colour_previews_it() {
use egui_kittest::kittest::{By, Queryable};
let mut h = bar_over("brown fox");
let doc = h.state().session.doc.clone();
let [bar, _] = h.ctx.data(|d| d.get_temp::<[Rect; 2]>(shown_id())).expect("the bar is shown over a selection");
let arrow = h
.query_all(By::new().predicate(|n| n.raw_bounds().is_some_and(|r| ((r.x1 - r.x0) - 11.0).abs() < 0.5 && ((r.y1 - r.y0) - 22.0).abs() < 0.5)))
.map(|n| n.rect())
.filter(|r| bar.contains_rect(*r))
.min_by(|a, b| a.min.x.total_cmp(&b.min.x))
.expect("the bar has the Highlight split button");
click_at(&mut h, arrow.center());
let no_color = h.get_by_label("No Color").rect();
h.event(egui::Event::PointerMoved(pos2(no_color.min.x + 10.0, no_color.center().y - 30.0)));
steps(&mut h);
let previewed = h.state().canvas.command_previewed.clone();
assert!(previewed.is_some_and(|(id, _)| id == "format.highlight"), "a swatch previews the highlight");
assert_eq!(&h.state().session.doc, &doc);
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, None);
}
/// Hovering a bar button previews its command on the selection; leaving the bar restores the
/// page. The document, its selection and its revision stay as they were.
#[test]
fn hovering_a_bar_button_previews_it_on_the_selection() {
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let (doc, sel, rev) = (h.state().session.doc.clone(), h.state().session.sel.clone(), h.state().session.rev());
let viewport = h.state().canvas.viewport.unwrap();
let bold = h.get_all_by_label("Bold").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Bold");
h.event(egui::Event::PointerMoved(bold.center()));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, Some(("format.bold".to_string(), json!({}))));
assert_ne!(crate::canvas::page_doc(h.state()), &doc, "the page shows the bold text");
assert_eq!((&h.state().session.doc, &h.state().session.sel, h.state().session.rev()), (&doc, &sel, rev));
assert_eq!(format_state(&mut h).get("bold"), Some(&json!(false)));
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, None);
assert_eq!(crate::canvas::page_doc(h.state()), &doc, "off the bar the page is as it was");
// Alignment moves the previewed selection. The toolbar must stay anchored to the live
// selection so the hovered button remains under the pointer until the pointer leaves.
let anchor = h.state().canvas.mini_anchor.unwrap();
let align_right = h.get_all_by_label("Align Right").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Align Right");
h.event(egui::Event::PointerMoved(align_right.center()));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, Some(("para.alignRight".to_string(), json!({}))));
assert_eq!(h.state().canvas.mini_anchor, Some(anchor), "the alignment preview must not move the toolbar");
}
/// While a preview is shown the page holds its scroll position, though the previewed text
/// reflows and the caret would be scrolled into view.
#[test]
fn the_page_holds_its_place_while_a_preview_is_shown() {
let mut h = bar_over("brown fox");
h.state_mut().run("caret.docStart", json!({})).unwrap();
h.state_mut().run("text.insert", json!({"text": "x\n".repeat(150)})).unwrap();
h.state_mut().run("select.text", json!({"text": "brown fox"})).unwrap();
steps(&mut h);
let page = h.state().canvas.viewport.unwrap();
h.event(egui::Event::PointerMoved(pos2(page.min.x + 20.0, page.center().y)));
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -60.0),
phase: egui::TouchPhase::Start,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
});
steps(&mut h);
let held = h.state().canvas.scroll_offset;
for _ in 0..4 {
h.state_mut().canvas.command_preview = Some(("format.size".to_string(), json!({"size": 72.0})));
h.state_mut().canvas.scroll_to_caret = true;
h.step();
}
assert!(h.state().canvas.command_previewed.is_some());
assert_eq!(h.state().canvas.scroll_offset, held, "the preview does not move the page");
steps(&mut h);
assert_eq!(h.state().canvas.preview_scroll, None, "the hold ends with the preview");
}
/// With no preview shown the bar's anchor follows the selection as the page scrolls.
#[test]
fn the_bar_anchor_follows_the_selection_as_the_page_scrolls() {
let mut h = bar_over("brown fox");
h.state_mut().run("caret.docStart", json!({})).unwrap();
h.state_mut().run("text.insert", json!({"text": "x\n".repeat(150)})).unwrap();
h.state_mut().run("select.text", json!({"text": "brown fox"})).unwrap();
steps(&mut h);
let anchored = h.state().canvas.mini_anchor.unwrap();
let page = h.state().canvas.viewport.unwrap();
h.event(egui::Event::PointerMoved(pos2(page.min.x + 20.0, page.center().y)));
h.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, 120.0),
phase: egui::TouchPhase::Start,
modifiers: Default::default(),
source: egui::MouseWheelSource::Unknown,
});
steps(&mut h);
assert_ne!(h.state().canvas.mini_anchor, Some(anchored), "the toolbar anchor follows the live selection as the viewport scrolls");
}
/// #2959: formatting must be available alongside the text's right-click menu, using the
/// selection that was clicked. Its lists must remain usable inside the context popup.
#[test]
@@ -219,6 +424,11 @@ fn context_menu_toolbar_formats_the_selection_and_opens_its_size_list() {
assert!(bar_layer(&h).is_none(), "the standalone selection toolbar must not double up");
let viewport = h.state().canvas.viewport.unwrap();
let bold = h.get_all_by_label("Bold").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the context menu has Bold");
let doc = h.state().session.doc.clone();
h.event(egui::Event::PointerMoved(bold.center()));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, None, "the context toolbar does not start the hidden floating bar's preview repaint loop");
assert_eq!(&h.state().session.doc, &doc, "hovering a context-menu control does not preview a document change");
click_at(&mut h, bold.center());
assert_eq!(format_state(&mut h).get("bold"), Some(&json!(true)));
assert_eq!(h.state().session.sel, selection);
@@ -602,7 +812,7 @@ fn alignment_buttons_mark_the_alignment_seen_on_the_page_in_a_right_to_left_para
}
egui::Area::new(egui::Id::unique("mini_align_probe")).fixed_pos(pos2(10.0, 10.0)).show(ui.ctx(), |ui| {
let t = Tokens::get(ui.ctx());
content(app, ui, &t);
content(app, ui, &t, true);
});
},
a,
@@ -626,3 +836,81 @@ fn alignment_buttons_mark_the_alignment_seen_on_the_page_in_a_right_to_left_para
assert!(!is_marked(unlit), "rtl={rtl} {cmd:?}: {unlit} is not marked");
}
}
#[test]
fn the_alignment_buttons_align_the_paragraph() {
use egui_kittest::kittest::Queryable;
for (label, align) in [("Center", "center"), ("Align Right", "right"), ("Justify", "both"), ("Align Left", "left")] {
let mut h = bar_over("brown fox");
let viewport = h.state().canvas.viewport.unwrap();
let b = h.get_all_by_label(label).map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has the button");
h.event(egui::Event::PointerMoved(b.center()));
steps(&mut h);
click_at(&mut h, b.center());
let got = h.state().session.doc.para_at(&h.state().session.sel.focus).map(|p| format!("{:?}", p.props.align)).unwrap_or_default();
assert!(got.to_lowercase().contains(align) || (align == "both" && got.to_lowercase().contains("justif")), "{label}: {got}");
}
}
/// After a click the button's preview stays off while the pointer is on it, so a toggle does not
/// flash its other state; leaving and coming back previews again.
#[test]
fn a_clicked_button_holds_its_preview_until_the_pointer_leaves() {
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let viewport = h.state().canvas.viewport.unwrap();
let size = format_state(&mut h).get("size").cloned();
let grow = h.get_all_by_label("Increase Font Size").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Grow").center();
h.event(egui::Event::PointerMoved(grow));
steps(&mut h);
assert!(h.state().canvas.command_previewed.is_some(), "hovering previews");
click_at(&mut h, grow);
steps(&mut h);
assert_ne!(format_state(&mut h).get("size").cloned(), size, "the click grew the text");
assert_eq!(h.state().canvas.command_previewed, None, "no preview of the next step under the pointer");
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
steps(&mut h);
h.event(egui::Event::PointerMoved(grow));
steps(&mut h);
assert!(h.state().canvas.command_previewed.is_some(), "coming back previews again");
}
/// After a click the preview stays still while the pointer is on the button. Leaving and coming
/// back previews the undo: Bold over bold text shows it unbolded.
#[test]
fn coming_back_to_a_toggle_previews_its_undo() {
use egui_kittest::kittest::Queryable;
let mut h = bar_over("brown fox");
let viewport = h.state().canvas.viewport.unwrap();
let bold = h.get_all_by_label("Bold").map(|n| n.rect()).find(|r| viewport.contains_rect(*r)).expect("the bar has Bold").center();
click_at(&mut h, bold);
steps(&mut h);
assert_eq!(format_state(&mut h).get("bold"), Some(&json!(true)));
assert_eq!(h.state().canvas.command_previewed, None, "the page holds still after the click");
h.event(egui::Event::PointerMoved(pos2(5.0, 5.0)));
steps(&mut h);
h.event(egui::Event::PointerMoved(bold));
steps(&mut h);
assert_eq!(h.state().canvas.command_previewed, Some(("format.bold".to_string(), json!({}))), "coming back previews the undo");
let doc = h.state().session.doc.clone();
assert_ne!(crate::canvas::page_doc(h.state()), &doc, "the page shows the text unbolded");
}
/// A selection that ends at the next paragraph's start (Select Paragraph, a triple-click) shows
/// the alignment of the paragraph it covers, not the next one's.
#[test]
fn a_selected_paragraph_shows_its_own_alignment() {
let mut h = bar_over("brown fox");
h.state_mut().session = wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("First paragraph\nSecond paragraph"));
h.state_mut().session.sel.anchor = wordcraft_doc::Pos::body(0, 2);
h.state_mut().session.sel.focus = wordcraft_doc::Pos::body(0, 2);
h.state_mut().run("para.alignRight", json!({})).unwrap();
h.state_mut().session.sel.anchor = wordcraft_doc::Pos::body(0, 0);
h.state_mut().session.sel.focus = wordcraft_doc::Pos::body(1, 0);
steps(&mut h);
let s = &h.state().session;
let focus_align = s.doc.para_at(&s.sel.focus).map(|p| s.doc.styles.resolve_para(&p.props).align);
assert_eq!(focus_align, Some(wordcraft_doc::Align::Left), "the next paragraph is left aligned");
let r = wordcraft_engine::cmd::para::selection_para(s);
assert_eq!(r.align, wordcraft_doc::Align::Right, "the buttons read the selected paragraph");
}
+46
View File
@@ -313,6 +313,9 @@ fn display(app: &mut WordApp, ui: &mut Ui) {
}
numeral_picker(app, ui);
ui.add_space(8.0);
section(ui, "Status bar");
status_count_picker(app, ui);
ui.add_space(8.0);
section(ui, "Printing");
ui.checkbox(&mut app.ui.print_backgrounds, tl!("Print the page color"));
ui.checkbox(&mut app.ui.update_fields_before_print, tl!("Update fields before printing"));
@@ -645,6 +648,11 @@ fn advanced(app: &mut WordApp, ui: &mut Ui) {
{
let _ = app.run("ui.caretAnimation", json!({"value": caret}));
}
if cfg!(all(unix, not(target_os = "macos"))) {
ui.add_space(8.0);
section(ui, "Linux display server");
display_server_picker(app, ui);
}
// The web app has no folders to name.
if !cfg!(target_arch = "wasm32") {
ui.add_space(8.0);
@@ -718,6 +726,25 @@ fn theme_picker(app: &mut WordApp, ui: &mut Ui) {
});
}
/// What the status bar's live count shows (#2797): words (as Word's status bar counts), characters
/// with spaces, or characters without spaces. Right-clicking the count in the status bar offers
/// the same choices.
fn status_count_picker(app: &mut WordApp, ui: &mut Ui) {
use crate::StatusCount;
ui.horizontal(|ui| {
let label = ui.label(tl!("Status bar count:"));
let combo = craft_ui::ComboBox::from_id_salt("status_count").selected_text(tl!(app.ui.status_count.label())).width(220.0).show_ui(ui, |ui| {
for c in StatusCount::ALL {
if ui.selectable_label(app.ui.status_count == c, tl!(c.label())).clicked() {
let _ = app.run("ui.statusCount", json!({"value": c.code()}));
}
}
});
combo.response.labelled_by(label.id);
});
note(ui, "You can also right-click the count in the status bar to change it.");
}
/// Interface size (#475, #822): Auto, which keeps the system's display scale and shows the size it
/// draws at, or an absolute size, so "100%" means one screen pixel per point.
fn interface_size_picker(app: &mut WordApp, ui: &mut Ui) {
@@ -779,6 +806,25 @@ fn animations_picker(app: &mut WordApp, ui: &mut Ui) {
}
}
/// Linux display server (#2184): Automatic, Wayland or X11 (XWayland). The desktop app reads it
/// before the window opens, so a change applies at the next launch.
fn display_server_picker(app: &mut WordApp, ui: &mut Ui) {
use crate::DisplayServer;
let name = |d: DisplayServer| if d == DisplayServer::Auto { tl!(d.label()) } else { d.label() };
ui.horizontal(|ui| {
let label = ui.label(tl!("Display server:"));
let combo = craft_ui::ComboBox::from_id_salt("display_server").selected_text(name(app.ui.display_server)).width(220.0).show_ui(ui, |ui| {
for d in DisplayServer::ALL {
if ui.selectable_label(app.ui.display_server == d, name(d)).clicked() {
let _ = app.run("ui.displayServer", json!({"value": d.code()}));
}
}
});
combo.response.labelled_by(label.id);
});
note(ui, "Applies the next time WordCraft starts.");
}
/// Interface language: follow the system (the default) or pick one (#8).
fn language_picker(app: &mut WordApp, ui: &mut Ui) {
use crate::i18n::{AUTO, Lang};
+13 -1
View File
@@ -5,7 +5,7 @@ use wordcraft_geom::Unit;
use super::{decimal, length, parse_length, unit};
use crate::theme::Appearance;
use crate::{Services, UiState, WordApp};
use crate::{Services, StatusCount, UiState, WordApp};
#[test]
fn unknown_settings_keys_load_in_linear_time() {
@@ -155,6 +155,7 @@ fn options_survive_a_restart_and_junk_in_ui_json() {
.unwrap();
a.ui.recent_count = 30;
a.ui.print_backgrounds = true;
a.ui.status_count = StatusCount::CharactersNoSpaces;
let saved = serde_json::to_vec(&a.prefs()).unwrap();
let mut b = app();
b.apply_prefs(UiState::from_json(&saved).unwrap());
@@ -162,13 +163,16 @@ fn options_survive_a_restart_and_junk_in_ui_json() {
assert_eq!(b.session.prefs.units, Some(Unit::Centimeters));
assert_eq!(b.session.initials, "VK");
assert_eq!((b.ui.recent_count, b.ui.print_backgrounds), (30, true));
assert_eq!(b.ui.status_count, StatusCount::CharactersNoSpaces, "#2797: what the status bar counts");
assert!(wordcraft_engine::proof::user_dictionary().contains(&"zqxoptionsword".to_string()));
let junk = br#"{"theme": "dark", "recentCount": "lots", "printBackgrounds": 7, "optionsPane": ["x"], "dark": true,
"statusCount": "paragraphs",
"editing": {"measurementUnits": "furlongs", "typingReplacesSelection": false, "marks": {"tabs": "yes", "spaces": true}}}"#;
let ui = UiState::from_json(junk).unwrap();
assert_eq!(ui.theme, Appearance::Dark);
assert_eq!((ui.recent_count, ui.print_backgrounds, ui.options_pane.as_str()), (12, false, "general"));
assert_eq!(ui.status_count, StatusCount::Words, "#2797: a wrong value reads the default");
assert_eq!(ui.legacy_dark, Some(true));
assert_eq!(ui.editing.units, None);
assert!(!ui.editing.typing_replaces_selection);
@@ -246,6 +250,14 @@ fn options_panes_open_and_change_settings() {
h.get_by_label("Spaces").click();
h.run_steps(2);
assert!(h.state().session.prefs.marks.spaces, "Display › Spaces");
// #2797: what the status bar's count shows, chosen here as in its right-click menu. The
// Display pane's other combo box is Numerals, drawn above this one.
h.get_all_by_role(egui::accesskit::Role::ComboBox).last().expect("the status bar count picker").click();
h.run_steps(4);
h.get_by_label("Characters (with spaces)").click();
h.run_steps(2);
assert_eq!(h.state().ui.status_count, StatusCount::Characters, "Display › Status bar count");
assert_eq!(h.state_mut().run("ui.statusCount", json!({})).unwrap()["statusCount"], "characters", "the command answers the same");
h.get_by_label("Proofing").click();
h.run_steps(4);
assert_eq!(h.state().ui.options_pane, "proofing");
+34 -4
View File
@@ -23,10 +23,7 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) {
if ra.state() == State::Playing {
ctx.request_repaint_after(std::time::Duration::from_millis(120));
}
if let Some(e) = app.session.read_aloud.error()
&& app.read_aloud_error.as_deref() != Some(e.as_str())
{
app.read_aloud_error = Some(e.clone());
if let Some(e) = error_notice(&mut app.read_aloud_error, app.session.read_aloud.error()) {
app.status(e);
}
// The caret follows the reading only in the document being read.
@@ -41,6 +38,23 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) {
}
}
/// The notice for the player's error `now`, given the one last `shown`: each message is shown
/// once, and when the error clears (Play after a halt) the last one is forgotten, so the same
/// message coming back is shown again.
fn error_notice(shown: &mut Option<String>, now: Option<String>) -> Option<String> {
match now {
None => {
*shown = None;
None
}
Some(e) if shown.as_deref() == Some(e.as_str()) => None,
Some(e) => {
*shown = Some(e.clone());
Some(e)
}
}
}
/// The page rectangles (in page units) of the sentence being read, to highlight.
pub fn highlight(app: &mut WordApp) -> Option<(wordcraft_engine::doc::Pos, wordcraft_engine::doc::Pos)> {
if !app.session.read_aloud.open || !reads_shown_document(app) {
@@ -170,3 +184,19 @@ fn button(ui: &mut Ui, t: &Tokens, icon: &str, tip: &str, enabled: bool) -> bool
crate::icons::paint(ui.painter(), r, icon, c, if enabled { t.accent } else { Color32::TRANSPARENT });
enabled && resp.clicked()
}
#[cfg(test)]
mod tests {
use super::error_notice;
#[test]
fn the_same_error_after_play_is_shown_again() {
let mut shown = None;
let halt = || Some("stopped for another program".to_string());
assert_eq!(error_notice(&mut shown, halt()), halt(), "shown once");
assert_eq!(error_notice(&mut shown, halt()), None, "not on every frame");
// Play clears the error; the same halt later is a new notice.
assert_eq!(error_notice(&mut shown, None), None);
assert_eq!(error_notice(&mut shown, halt()), halt());
}
}
+1 -1
View File
@@ -169,7 +169,7 @@ pub(super) fn home(app: &mut WordApp, ui: &mut Ui) {
});
});
group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| {
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props));
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|_| wordcraft_engine::cmd::para::selection_para(&app.session));
// The buttons show alignment as seen on the page (Align Right is a right-to-left paragraph's start).
let align = rp.as_ref().map(|r| r.align.visual(r.bidi)).unwrap_or_default();
let rtl = rp.as_ref().is_some_and(|r| r.bidi);
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more